用 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:
- 每個帳號一組 16 bytes 的隨機鹽值,同樣的密碼存起來也長得不一樣
- 迭代 10 萬次(這是 Workers 上 PBKDF2 的上限),讓暴力破解的成本變高
- 存的格式是
pbkdf2-sha256$100000$鹽值$雜湊,把演算法和次數一起記下來,未來要升級時可以辨認舊格式
比對時不洩漏線索
驗證密碼時,比對結果用的是 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 本身的設定也都鎖上了:
- HttpOnly:網頁上的 JavaScript 讀不到,就算有 XSS 也偷不走
- Secure:只在 HTTPS 傳送
- SameSite=Lax:從別的網站發出的表單送不出這個 Cookie,降低 CSRF 風險
另外兩個細節:重新登入時會先作廢舊的 Session,避免 Session 固定攻擊;每個請求都會重新從資料庫讀一次帳號資料,所以管理員把某人降級或停權,下一個請求就生效,不用等 Session 過期。
七、其他幾道防線
- 權限檢查做在 API:後台畫面會擋非管理員,但那只是方便。真正的檢查在 API,後台的每一支 API 都先驗證管理員身分,直接呼叫 API 也過不了
- 看不到別人的訂單:查別人的訂單一律回 404 而不是 403。403 等於告訴對方「這張訂單存在,只是你不能看」,訂單編號因此可能被拿來列舉
- 金額不信任前端:價格、運費、總額全部由伺服器依資料庫重算,改網頁上的數字沒有用
- 所有輸入都先驗證:每支 API 都用 zod 檢查參數結構,不合格直接回 400
- 防 SQL Injection:查詢全部參數化;搜尋關鍵字裡的
%、_也會先跳脫,避免被當成萬用字元 - 上傳白名單,而且不收 SVG:只收常見點陣圖格式、上限 5MB,檔名由伺服器產生。SVG 雖然是圖片,但可以夾帶 JavaScript,放在同網域就是 XSS,所以刻意排除;讀圖時也加上
X-Content-Type-Options: nosniff - 防止開放式重新導向:「登入後回到原本頁面」的參數只接受站內路徑,避免網址被拿來把人導去釣魚網站
- 錯誤不外洩內部細節:非預期的錯誤,API 只回一個錯誤代碼和追蹤編號;完整的錯誤訊息與堆疊只寫在伺服器日誌,用追蹤編號就能對回去
- 維運指令也防注入:把帳號設為管理員的指令會組 SQL,所以 Email 先用嚴格的格式檢查過才放行
八、測試要在真的環境裡跑
很多 Cloudflare 專案的測試是用假的物件模擬資料庫和儲存服務,但這樣測不出真正的併發行為。這個專案用 Cloudflare 官方的 Vitest 整合,51 個測試都跑在本機的 Workers 執行環境裡,D1、KV、Durable Objects、Workflows 都是真的:
- 30 個請求同時搶 5 件庫存、8 個人同時結帳搶 3 件
- 同一個冪等鍵重送,只會有一張訂單
- 保留過期後,庫存計時器會自動釋放
- 讓 Workflow 直接「逾時」,確認訂單被取消、庫存回來
- 別人的訂單查不到、非管理員呼叫後台 API 被拒
每次推送到 GitHub,CI 都會自動跑型別檢查、測試與建置。
九、還沒做完的部分
誠實列一下還沒完成的,這些都在規劃裡:
- 金流:串接綠界,處理付款通知的驗章與重送
- 後台再加一道門:用 Cloudflare Access 保護後台,加上雙重驗證
- 人機驗證:註冊與登入加上 Turnstile,擋自動化腳本
- 出貨與通知:出貨流程、Email 通知
小結:可以帶走的幾個原則
- 先找出「錯一次就賠錢」的地方,把正確性當需求,不是當優化
- 併發問題用設計解決:讓需要排隊的東西自己排隊(一個規格一個物件),不要指望「應該不會同時發生」
- 任何可能重送的請求都要冪等,任何可能同時發生的狀態轉換都要條件式更新
- 重要的清理工作要有第二道防線,不要只靠一個排程
- 把不該存明文的東西都雜湊起來,不只是密碼,Session 也是
- 失敗時不要洩漏線索:時間差、錯誤訊息、403 和 404 的差別,都是線索
- 權限檢查放在伺服器,畫面上的隱藏按鈕只是體驗
示範網站在這裡,可以實際逛逛、加入購物車、下單(目前還不能付款,30 分鐘後訂單會自動取消)。想做類似的系統,歡迎透過聯絡頁找我聊。