昨天那條價格快照,寫錯的當下不會出事。今天換時間,而時間的錯更難抓:它們都發生在「那一刻」—— 開賣那一秒、保留到期那一毫秒。
今天有兩題。第一題,AI 寫的時間判定,會不會自己去讀時鐘。第二題,開賣瞬間一群人搶同一批座位,搶到的位子保留十分鐘,到期那一刻誰說了算 —— 而這個專案的資料庫 D1 是 SQLite,沒有 SELECT … FOR UPDATE,「先鎖起來再檢查」這條路不存在。
先講結論:時間當參數從外面傳進來,而且不給預設值 —— 預設值在測試裡演不出來,只能交給看寫法的靜態檢查。併發不靠「先查、再決定要不要改」,而是把「插座位、扣名額」放進同一個 batch,讓資料庫的約束來擋 —— 座位重複就撞上 UNIQUE 索引,名額扣到負數就撞上 CHECK,任何一個拋錯,整批一起回滾。兩題的 AI 版大方向都走對了:一題留下了測試看不到的時鐘,一題在一條我定好、卻沒寫成測試的優先順序上,做了跟我相反的選擇。
我原本要問的是「它會不會用客戶端時間判斷開賣」。這是老題目,開賽前的前置實驗就沒中 —— AI 在回覆的第一段就寫了「時間的唯一權威在後端」,程式註解裡還加了一句「絕對不接受」客戶端傳來的時間戳。
所以這次問的是另一件事:時間是不是從外面傳進去的。 無菌室的 AI 拿到四個函式的說明(能不能保留、是不是早鳥、保留過期沒、保留幾點到期),時間都是 epoch 毫秒。4 輪、$0.19、半分鐘,它交回來的第一個函式是這樣:
export function canHold(event, now = Date.now()) {
if (event.status !== 'on_sale') return { ok: false, reason: 'not_on_sale' };
if (now < event.opens_at) return { ok: false, reason: 'not_open_yet' };
if (now >= event.deadline_at) return { ok: false, reason: 'deadline_passed' };
return { ok: true };
}
幾乎完美。 它收 now 參數;檔頭註解寫明「含起點、不含終點」;狀態和時間兩個真相來源都檢查了,三種拒絕原因分得清清楚楚。拿同一份 15 個測試量:
Tests 1 failed | 14 passed (15)
紅的那一個是 holdExpiresAt 的參數順序:它寫 (minutes, now),測試傳 (now, ttl)。prompt 裡我沒講順序,這一紅算我的。其餘 14 個 —— 包括「剛好等於開賣時刻」「截止前 1 毫秒」「剛好等於到期」這些邊界 —— 全綠。
問題在那個預設值:now = Date.now()。
測試永遠抓不到它。 測試每次都會傳 now,預設值從來不會被用到,所以 14 個邊界測試全綠。但哪天有個呼叫端忘了傳,domain 就會安靜地自己讀時鐘 —— 到那時,「剛好等於開賣」就再也測不到了。
靜態檢查抓得到:check-time-injection.sh 在四個函式(第 18、31、43、53 行)都亮紅:❌ domain 層自己取了現在時間。

它檔頭的註解寫的是「每個函式都可傳入 now(預設 Date.now()),方便測試與在同一請求內使用一致的時間點」。這個好意剛好反了:預設值讓正式環境方便,卻讓「沒傳 now」這條路落在測試之外。
回頭看這一週,「時鐘藏在哪裡」已經出現三種樣子:
| 哪一天 | 誰 | 時鐘藏在 |
|---|---|---|
| Day 22 | AI 設計的 schema | SQL 裡:DEFAULT (unixepoch())、WHERE … <= unixepoch() |
| Day 24 | AI 寫的 JWT | 路由裡的 Date.now(),和函式庫 hono/jwt 的內部 |
| 今天 | AI 寫的時間判定 | 參數的預設值 |
| — | 我自己的 repo | 只有兩處:注入 now 的 app.js、跑排程的 worker.js |
三份 AI 版都知道不能信客戶端的時間,全部用伺服器時間 —— 然後各自留了一個自己讀時鐘的入口:SQL 自己取、交給函式庫、藏在參數預設值。沒有一個是錯的程式,卻都留了測試看不到的時間入口。Day 24 那天我把規則收緊成「讀時鐘只准在兩個入口」,檢查範圍擴到整個 src/,今天這個預設值在舊版檢查裡就會紅,新版也會。
單元測試裡「剛好等於開賣」那一格是綠的。那真的開賣呢?我在線上建了一個 20 秒後開賣的活動,然後對同一個座位連續送保留請求,記下每一次回應裡的 server_now:
第幾次 | server_now − opens_at | HTTP
36 | −154 ms | 409 not_on_sale
37 | −58 ms | 409 not_on_sale
38 | +39 ms | 201
邊界守住了:開賣前 58 毫秒被拒,開賣後 39 毫秒成功。

但注意那個間距。兩次請求之間隔著大約 100 毫秒的網路往返,「剛好等於 opens_at 的那一毫秒」在線上根本打不到。 你永遠只能知道邊界落在 −58 和 +39 之間。真正驗證「等於」的那一格,只有時間是參數的單元測試做得到 —— 這就是為什麼預設值那一個後門值得一支靜態檢查。
開賣瞬間五百個人同時報名,聽起來是一個問題。實際上是三個:
| # | 規則 | 形狀 |
|---|---|---|
| 1 | 不超賣 | 計數:remaining 不能變成負的 |
| 2 | 座位不重複 | 唯一佔用:同一個位子不能兩人 |
| 3 | 保留 TTL | ⭐ 時間 × 併發:過期那一刻誰說了算 |
前兩條有現成的資料庫約束可以套。第三條缺的不只是技術,還有一條規則:到期那一刻,誰贏。 這是三條裡最難的一條。
(規格上「十分鐘」是預設值:hold_ttl_minutes 存在活動上,5–30 分鐘可設。)
教科書上的錯法長這樣:
const { remaining } = await db.prepare('SELECT remaining FROM ticket_types WHERE id=?').bind(id).first()
if (remaining < 1) return soldOut() // ← 檢查在應用層
await db.prepare('UPDATE ticket_types SET remaining = remaining - 1 WHERE id=?').bind(id).run()
兩個請求同時讀到 remaining = 1,兩個都通過檢查,兩個都減 → 變成 -1。
所以判斷交給資料庫自己做。座位用部分唯一索引 —— 過期的 hold 要留著當紀錄,所以索引只讓 holding 和 confirmed 狀態的列佔位:
CREATE UNIQUE INDEX ux_seat_active
ON seat_holds(event_id, seat_no)
WHERE status IN ('holding', 'confirmed');
名額用 CHECK (remaining >= 0)。一個 hold 可以鎖多座(seat_nos: ["A1", "A2"]),而且必須全有全無。這裡有兩個 D1 上的陷阱,是我 9/10 用 SQLite 實跑確認過的:
| 實測 | |
|---|---|
db.batch() |
是一個交易,但只在有語句拋錯時回滾。WHERE 沒命中、changes = 0 不算錯,後面照跑、整批 commit |
座位用 INSERT … ON CONFLICT DO NOTHING |
撞到不拋錯 → A2 被佔時,A1 照樣留下來 |
座位用裸 INSERT |
撞到 UNIQUE 拋錯 → 整批回滾,A1 沒留下 |
也就是說,在 batch 裡,「條件寫在 WHERE、看 changes」這一招不會讓整批退回。真正讓整批退回的只有拋錯。Day 21 spec-kit 在 plan 那一步列了三個候選,一個都沒選,留給我定。我定的(規格裡叫 R6)是最直接的那個:
一個 db.batch():
1. 還名額 —— 這個活動所有過期但還是 holding 的座位,名額加回去
2. 翻過期 —— 把它們翻成 expired(索引看 status 不看時間,不翻就搶不到)
3. 插座位 —— 裸 INSERT,撞索引 → 拋錯 → seat_taken
4. 扣名額 —— 直接扣,扣到負數由 CHECK 拋錯 → sold_out
連 WHERE remaining >= ? 的守衛都不加。 名額直接扣,不夠就讓 CHECK 拋錯、整批回滾。座位放在名額前面,所以兩種都衝突時回 seat_taken —— 這也是我定的。
要誠實講:這跟 CLAUDE.md 第三條硬規則「併發判斷必須在 SQL 的 WHERE 裡」的字面有落差。在 D1 的 batch 裡,判斷其實是 CHECK 和 UNIQUE 在做。spec-kit 的 plan 那一步把這件事標成 ⚠️,我接受,因為換成 WHERE 守衛反而會讓整批「看起來沒錯」地 commit 出半套結果。
規則我定,程式交給兩個 session 各寫一份:一個讀過規格,一個是無菌室。無菌室的 AI 拿到的是行為契約(FR-030–035:過期座位要能在同一次搶位裡被搶到、不靠排程、多座全有全無、名額不夠就失敗),加上四個函式的介面和兩張表的 DDL。沒給 R6、沒給「判斷寫在哪」、沒給錯誤的優先順序。
5 輪、$0.39、一分半,226 行。它的檔頭註解一開始就寫:
// D1 has no interactive transactions; `db.batch()` runs its statements as one
然後一個 batch、先還過期名額再翻過期、裸 INSERT 座位、直接扣名額讓 CHECK 兜底、把約束錯誤翻成錯誤代碼 —— 跟我定的 R6 一模一樣。 公平地說,DDL 裡的 CHECK 和部分唯一索引等於把答案的一半給了它;但它讀懂了,也用對了。
換進 repo 跑 concurrency、holds、orders 三檔測試(它交卷時是 31 個,後來陸續補到 36 個):
Tests 1 failed | 35 passed (36)
× 座位衝突與名額不足同時發生 → seat_taken(作者定:先檢查座位)
→ expected 'sold_out' to be 'seat_taken'
check-concurrency ✅
紅的原因是順序:它先扣名額、再插座位。 兩種衝突同時發生時,它先撞到 CHECK,回 sold_out。
重點在後面:這一條測試,是在它交卷之後才補的。它第一次跑的時候,那 31 個測試連跑 5 次全綠 —— 因為「座位優先」這條規則只寫在規格裡 —— 無菌室只拿到行為契約,裡面沒有這一條;而當時那 31 個測試,沒有一個檢查這個優先順序。補上那一條,它才紅。
規則沒寫成測試,就等於沒定。 在補那條測試之前,
這個優先順序在 AI 版和我自己那一版裡,都沒有被測試固定下來。
它還多做了一件事:還名額時包了一層 MIN(capacity, …),名額永遠不會還超過上限。這是防禦性寫法,但它也會把「還多了」這種 bug 安靜地蓋掉 —— 我寧可讓它爆出來。
保留十分鐘。到期那一刻,同時發生三件事:
先講一個不直覺的事實:索引不會自己過期。 部分唯一索引看的是 status,不是 expires_at —— hold 過了十分鐘但 status 還是 holding,B 照樣搶不到。所以 B 那個 batch 的前兩句就是「還名額、翻過期」,釋放和搶到在同一次操作裡成立;清掃排程(Cron 每五分鐘)反而是配角:有人來搶時,搶的人自己就會釋放;沒人來搶的座位,才靠它收回。
A 的確認也把「還沒過期」推進 WHERE:
UPDATE seat_holds SET status = 'confirmed'
WHERE hold_id = ? AND member_id = ? AND status = 'holding' AND expires_at > ?;
expires_at > now —— 到期那一毫秒就不成立。規則是我定的:到期那一刻,原持有人的確認一律輸。 跟「剛好等於 expires_at 就算過期」的時間判定同一條線,不留灰色地帶。
測試最後是這樣寫的(最早那版漏了一條斷言,後面「我自己也漏了三件」會講):三件事用閘門同時放行,重跑 5 次,每次都要是「A 拿到 409、B 拿到 201、座位歸 B、座位最多只屬於一個人」,而且 5 次結果完全一樣。(5 次都是清掃先翻掉過期的保留,B 接著搶到,跟圖裡一樣。)

這一節的內容是 spec-kit 抓到的。9/27 規格改了一輪之後,我讓 spec-kit 重跑 analyze 和 converge,拿程式和規格對一次。它在併發這一塊抓到三件 —— 當時的測試裡都沒有:
第 3 件最值得講。我原本以為 changes = 0 只有兩種原因 —— 已經過期,或被 B 翻掉了 —— 兩種對 A 來說結果一樣,都回 409。沒想到的是第三種:A 自己按了兩次確認(網路慢,手指快)。第一個確認翻成功、建了訂單;第二個翻不到,但它的訂單 INSERT 照跑,撞上「一張 hold 只能一張訂單」的唯一索引 —— 500。
修法是兩件事:訂單的 INSERT 加上 WHERE changes() = 座位數,只有這一批真的翻成功才插;輸的一方重讀一次 hold,如果已經是 confirmed,就回 200 和同一張訂單。同一個人按兩次,拿到的是同一張票。
三件都先寫成紅測試,再修綠(906cc4c → 06f8fd2)。規格裡的規則我寫了;它們在測試裡不存在,是一個會讀規格、會讀測試的工具指出來的。
這是開賽前前置實驗裡最貴的教訓,而它是方法論,換題目也帶得走。
我花了三個版本才穩定重現一個 race:
| 版本 | 做法 | 結果 |
|---|---|---|
| v1 | Promise.all 打 N 個請求 |
測不出來 —— 同時送出,不等於同時抵達關鍵那一步 |
| v2 | 加隨機延遲 | 偶爾中,不可重現,等於沒有 |
| v3 | 閘門:全部到齊才放行 | 穩定重現,重跑 5 次都一樣 |
export function createGate(expected) {
let arrived = 0, release
const opened = new Promise((r) => { release = r })
return {
async arrive() {
arrived++
if (arrived >= expected) release()
await opened // 全部到齊之前,誰都不准過
},
}
}
不穩定的重現 = 沒有重現。 沒有穩定重現,你無法分辨「修好了」和「這次剛好沒中」。
但閘門只在一個 process 裡。真的多條連線打線上呢?scripts/race.sh 每次執行先註冊 20 個成員,每輪用 xargs -P 20 同時起跑:
# race.sh 2026-09-27 17:17:55 +0800,5 輪,每輪 20 條連線同時起跑
✅ 第 1 輪 | A 同座位: 1 201; 19 409 seat_taken; | B 名額 5: 5 201; 15 409 sold_out; remaining=0
…(第 2–5 輪相同)
✅ 5 輪全部一致:同座位恰 1 人、名額 5 恰 5 人,remaining 不為負
這支腳本自己也壞過三次,其中兩次是假綠燈 —— 失敗了,總判斷照樣綠。全都在第一次實跑前後抓到,記在帳本上。
| 狀態 | |
|---|---|
| 單一 process 內閘門重現競態 | ✅ npm run test:race 7/7,每條重跑 5 次一致 |
CHECK 約束擋得住超賣 |
✅ 名額 3、8 人搶 → 恰好 3 張訂單 |
| 座位唯一約束生效 | ✅ 8 人搶同一座 → 恰一個 201 |
多座 hold 全有全無(A2 被佔時 A1 不留下) |
✅ |
| TTL 三方競態行為被釘死(原持有人一律輸) | ✅ 重跑 5 次結果一樣 |
| 同一 hold 兩個確認同時到 | ✅ 一個 201、一個 200,同一張訂單 |
| 真實多連線打遠端 Worker | ✅ race.sh 5 輪 × 20 條連線 |
| 真實多連線下的 TTL 三方競態 | ❌ 沒跑。要在線上湊到「剛好到期那一毫秒」,跟上面開賣那一秒一樣,兩次請求隔著約 100 毫秒,那一毫秒打不到 |
早期的草稿裡,我有四篇寫了「壓測腳本當裁判」,而那支腳本根本不存在 —— 審稿時才發現。這一次它存在、跑過、輸出在 repo 裡 —— 而最後一格我照實留 ❌。
時間這一題,AI 用了伺服器時間、邊界含起點不含終點,行為是對的;但它留了一個預設值,讓程式可以自己讀時鐘。測試永遠會傳 now,線上又打不到那一毫秒,所以這一項只能交給一支看寫法的靜態檢查。
併發這一題,D1 沒有 FOR UPDATE,所以不靠鎖:把插座位、扣名額放進同一個 batch,座位重複撞 UNIQUE、名額不夠撞 CHECK,一拋錯整批回滾。一個拿到行為契約、介面和 DDL,卻沒拿到 R6 的 AI,自己走到了同一條路。真正讓測試變紅的差別,只有一條優先順序 —— 而那條規則在補成測試之前,沒有任何東西會替它紅。
兩題的共同點:AI 寫對了判斷,漏掉的是「能不能被驗證」。
這一篇留下的心法:
行為正確,不等於可驗證:程式自己去讀時鐘,「剛好那一毫秒」就永遠測不到。
規則沒寫成測試,就等於沒定 —— 對 AI 那一版,和對你自己那一版都一樣。
明天:換到 iOS。AI 寫的金額顯示,在德文語系的手機上會錯 —— 而測試在我的電腦上是綠的。最後蓋上 CI 門。
Date(Date.now() 回傳的 epoch 毫秒):developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date
Date.now() 在 Worker 中的語意):developers.cloudflare.com/workers/runtime-apis/web-standards
07a804b):github.com/n913239/event-signup-lab/tree/07a804b/devlog/raw/exp-03-time
04a9475,早於兩份實作):github.com/n913239/event-signup-lab/blob/04a9475/tests/domain/time-rules.test.js
batch() 的語意:developers.cloudflare.com/d1/worker-api/d1-database/#batch
holds.js 原文與 prompt(commit 0e768e6):github.com/n913239/event-signup-lab/tree/0e768e6/devlog/raw/exp-04-concurrency
createGate 與它的三個版本(註解裡,commit c24ccbb):github.com/n913239/event-signup-lab/blob/c24ccbb/tests/helpers/gate.js
race.sh):github.com/n913239/event-signup-lab/blob/073160b/devlog/raw/race-2026-09-27.txt