用 Cloudflare 從零打造電商:我怎麼處理超賣、重複下單與資安

· · Cloudflare / 架構設計 / 資訊安全 / 電商

藍白日常在作品集裡介紹的是它有哪些功能:逛商品、選規格、購物車、結帳、後台管理。這篇要講的是看不到的那一面:一個電商系統真正難的地方在哪裡,以及我怎麼處理。

電商的功能清單看起來很簡單,但它是少數「錯一次就直接賠錢」的系統:超賣要跟客人道歉、重複扣款要退款、密碼外洩更是信任危機。所以這個專案從第一天就把正確性和安全性當成主要需求,而不是做完功能再補。

一、整站都放在 Cloudflare 上

先交代一下舞台。整套系統沒有租任何一台主機,全部用 Cloudflare 的服務組起來:

需求 用的服務 用途
前台、後台、API Workers 三個程式各自部署,互相用 Service Binding 呼叫
會員、商品、訂單 D1(SQLite) 主要的關聯式資料
登入狀態 KV 存 Session,讀取快、可設到期時間
商品照片 R2 圖片儲存,沒有對外流量費
庫存、購物車 Durable Objects 需要「一次只處理一個請求」的地方
未付款訂單 Workflows 等候 30 分鐘、逾時自動取消

這樣的好處是流量小的時候幾乎不花錢,流量大的時候也不用自己擴充主機。代價是每個服務都有自己的限制,設計時要順著它們的特性走,下面會看到好幾次。

二、超賣:讓每件商品自己排隊

最後 1 件商品,兩個人同時按下單。如果程式是「先查庫存、再扣庫存」,兩個請求可能同時查到「還有 1 件」,然後都成功扣掉,庫存變成 -1。

一般解法是資料庫交易加鎖,但 D1 不支援這種互動式交易。這裡我改用 Durable Object:它的特性是同一個物件在全世界只有一份,而且同一時間只處理一個請求。於是我讓每一個商品規格(例如「白色 T 恤 M 號」)都有自己的庫存物件:

async reserve(orderId: string, qty: number): Promise<ReserveResult> {
  const existing = this.findReservation(orderId)
  if (existing) {
    // 重試:已經預留就視為成功;已釋放的訂單不能再預留
    return existing.status === 'released'
      ? { ok: false, available: this.getStock().available }
      : { ok: true }
  }

  const { available } = this.getStock()
  if (available < qty) return { ok: false, available }

  this.sql.exec(
    `INSERT INTO reservations (order_id, qty, status, expires_at)
     VALUES (?, ?, 'reserved', ?)`,
    orderId, qty, Date.now() + RESERVATION_TTL_MS,
  )
  // ...
}

「查庫存」和「寫入保留」之間沒有任何等待,所以不會被別的請求插隊。而且因為一個規格一個物件,賣 T 恤和賣帆布鞋互不干擾,不會變成全站排一條隊。

這不能只靠想的,所以有對應的測試:30 個請求同時搶 5 件庫存,必須剛好 5 個成功;另一個測試是 8 個人同時結帳搶最後 3 件,必須剛好成立 3 張訂單。

三、重複下單:同一個請求,只會有一個結果

顧客按下「確認下單」,網路卡了一下,又按一次。如果伺服器把兩次都當成新訂單,就會出現兩張一模一樣的訂單。

解法是冪等鍵(Idempotency Key):結帳頁打開時產生一組隨機編號,送出訂單時一起帶上。伺服器看到同一個使用者、同一組編號,就直接回傳第一次建立的那張訂單。資料庫上還有一道 UNIQUE (user_id, idempotency_key) 約束,就算兩個請求真的同時抵達,也只有一個寫得進去,另一個會改成去讀勝出的那張。

訂單狀態也有類似的問題。「顧客按取消」和「付款成功通知」可能同時到,誰先誰後?我的做法是所有狀態轉換都寫成條件式更新:

await db
  .update(orders)
  .set({ status: to, updatedAt: new Date() })
  .where(and(eq(orders.id, orderId), eq(orders.status, from)))
  .returning({ id: orders.id })

「只有在目前還是待付款時,才改成已取消」。兩個請求同時到,只有一個會真的改到資料,另一個拿到「0 筆被更新」就知道自己輸了。所以已付款的訂單不可能再被取消,也不會被取消兩次、多放回一次庫存。

四、不付款的訂單:兩道計時器

下單當下就保留庫存,顧客馬上知道成功或缺貨,這是好體驗。但如果顧客下單後就消失,那件商品就會一直被佔著。

第一道防線是 Workflows:每張訂單建立時,同時啟動一個等候流程,等「已付款」或「取消」事件,最多等 30 分鐘。逾時就自動取消訂單、釋放庫存。

但如果那個流程本身出問題呢?所以庫存物件裡還有第二道計時器:每筆保留都記著到期時間(35 分鐘),用 Durable Object 的 alarm 定時清掉過期的保留。兩道防線各自獨立,任何一邊失效,庫存都不會永遠卡住。

五、帳號安全:密碼的每個細節

密碼雜湊

密碼絕對不能存明文,這大家都知道。但用什麼演算法,在 Workers 上有實際限制:bcrypt、argon2 需要大量 CPU 時間,不適合 Workers 的執行模型。最後選擇 Web Crypto 原生支援的 PBKDF2-SHA256:

比對時不洩漏線索

驗證密碼時,比對結果用的是 crypto.subtle.timingSafeEqual。一般的字串比較遇到第一個不同的字元就會停,攻擊者理論上可以從回應快慢推敲出答案;常數時間比較不管哪裡不同,花的時間都一樣。

還有一個容易被忽略的洞:帳號不存在時,回應比較快。因為不用算雜湊,伺服器馬上就能回「錯誤」,攻擊者就能藉此探測哪些 Email 有註冊。所以帳號不存在時,程式也會照樣跑一次完整的雜湊運算,讓兩種失敗的時間和訊息都一樣:

/** 帳號不存在時也跑一次雜湊,讓回應時間與「密碼錯誤」相同,避免被用來探測帳號是否存在 */
export async function burnPasswordCheck(password: string): Promise<void> {
  await derive(password, new Uint8Array(SALT_BYTES), ITERATIONS)
}

限流

登入和註冊都套用 Cloudflare 的 Rate Limiting:同一個 IP 每分鐘最多 10 次。暴力破解一個帳號原本可能一秒試幾百次,現在一小時只能試 600 次。

六、Session:就算資料外洩也不能用

登入成功後,伺服器產生一組 32 bytes 的密碼學亂數當 Session 代碼,放進 Cookie。這裡有一個我很喜歡的小設計:KV 裡存的不是代碼本身,而是代碼的 SHA-256。

/**
 * Session 存在 KV。KV 的 key 是 token 的 SHA-256,
 * 就算 KV 內容外洩,也拿不到可用的 cookie 值。
 */

這跟密碼不存明文是同一個道理:瀏覽器拿著原始代碼來,伺服器算一次雜湊再去查。萬一儲存區的內容外洩,拿到的只是一堆雜湊值,沒辦法拿來冒充任何人。

Cookie 本身的設定也都鎖上了:

另外兩個細節:重新登入時會先作廢舊的 Session,避免 Session 固定攻擊;每個請求都會重新從資料庫讀一次帳號資料,所以管理員把某人降級或停權,下一個請求就生效,不用等 Session 過期。

七、其他幾道防線

八、測試要在真的環境裡跑

很多 Cloudflare 專案的測試是用假的物件模擬資料庫和儲存服務,但這樣測不出真正的併發行為。這個專案用 Cloudflare 官方的 Vitest 整合,51 個測試都跑在本機的 Workers 執行環境裡,D1、KV、Durable Objects、Workflows 都是真的:

每次推送到 GitHub,CI 都會自動跑型別檢查、測試與建置。

九、還沒做完的部分

誠實列一下還沒完成的,這些都在規劃裡:

小結:可以帶走的幾個原則

  1. 先找出「錯一次就賠錢」的地方,把正確性當需求,不是當優化
  2. 併發問題用設計解決:讓需要排隊的東西自己排隊(一個規格一個物件),不要指望「應該不會同時發生」
  3. 任何可能重送的請求都要冪等,任何可能同時發生的狀態轉換都要條件式更新
  4. 重要的清理工作要有第二道防線,不要只靠一個排程
  5. 把不該存明文的東西都雜湊起來,不只是密碼,Session 也是
  6. 失敗時不要洩漏線索:時間差、錯誤訊息、403 和 404 的差別,都是線索
  7. 權限檢查放在伺服器,畫面上的隱藏按鈕只是體驗

示範網站在這裡,可以實際逛逛、加入購物車、下單(目前還不能付款,30 分鐘後訂單會自動取消)。想做類似的系統,歡迎透過聯絡頁找我聊。