iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0
Claude AI

盡信 Claude,不如無 Code — 心法與全端實戰系列 第 26 篇

Day 26 時間的那一刻:AI 寫對了判斷,卻留下測不到的時鐘,和一條我沒寫成測試的規則

  • 分享至 

  • xImage
  •  

昨天那條價格快照,寫錯的當下不會出事。今天換時間,而時間的錯更難抓:它們都發生在「那一刻」—— 開賣那一秒、保留到期那一毫秒。

今天有兩題。第一題,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 層自己取了現在時間。

https://ithelp.ithome.com.tw/upload/images/20261010/20103790vwBASUe5d5.png

它檔頭的註解寫的是「每個函式都可傳入 now(預設 Date.now()),方便測試與在同一請求內使用一致的時間點」。這個好意剛好反了:預設值讓正式環境方便,卻讓「沒傳 now」這條路落在測試之外。

時鐘藏過的三個地方:SQL、函式庫、預設值

回頭看這一週,「時鐘藏在哪裡」已經出現三種樣子:

哪一天 誰 時鐘藏在
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 毫秒成功。

https://ithelp.ithome.com.tw/upload/images/20261010/20103790qV2OrNdVIW.png

但注意那個間距。兩次請求之間隔著大約 100 毫秒的網路往返,「剛好等於 opens_at 的那一毫秒」在線上根本打不到。 你永遠只能知道邊界落在 −58 和 +39 之間。真正驗證「等於」的那一格,只有時間是參數的單元測試做得到 —— 這就是為什麼預設值那一個後門值得一支靜態檢查。

第二題:三條併發規則,不是一條

開賣瞬間五百個人同時報名,聽起來是一個問題。實際上是三個:

# 規則 形狀
1 不超賣 計數:remaining 不能變成負的
2 座位不重複 唯一佔用:同一個位子不能兩人
3 保留 TTL ⭐ 時間 × 併發:過期那一刻誰說了算

前兩條有現成的資料庫約束可以套。第三條缺的不只是技術,還有一條規則:到期那一刻,誰贏。 這是三條裡最難的一條。

(規格上「十分鐘」是預設值:hold_ttl_minutes 存在活動上,5–30 分鐘可設。)

前兩條:D1 沒有鎖,所以讓資料庫自己拒絕

教科書上的錯法長這樣:

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 安靜地蓋掉 —— 我寧可讓它爆出來。

第三條:過期那一刻

保留十分鐘。到期那一刻,同時發生三件事:

  1. A 送出確認 —— 他的保留剛好在這一刻到期
  2. B 來搶同一個位子 —— 在他看來這個位子已經釋放了
  3. 清掃排程跑過來,要把過期的保留收回去

先講一個不直覺的事實:索引不會自己過期。 部分唯一索引看的是 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 接著搶到,跟圖裡一樣。)

https://ithelp.ithome.com.tw/upload/images/20261010/201037902wJuLdnwWg.png

我自己也漏了三件

這一節的內容是 spec-kit 抓到的。9/27 規格改了一輪之後,我讓 spec-kit 重跑 analyze 和 converge,拿程式和規格對一次。它在併發這一塊抓到三件 —— 當時的測試裡都沒有:

  1. 三方競態的測試沒有斷言「原持有人輸」。 它當時只檢查了「座位不會同時屬於兩個人」—— 那是必要條件,但 A 贏或 B 贏都會綠。我定了規則,測試卻沒把它釘住。
  2. 清掃排程沒有自己的測試,Cron 觸發時有沒有真的呼叫它也沒有。 三方競態裡的清掃是測試直接呼叫的;它自己算得對不對、排程會不會叫到它,沒人檢查。
  3. 同一個 hold 的兩個確認同時到,輸的一方回 500。

第 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 門。


參考資料


上一篇
Day 25 早鳥加團體加優惠碼:規則改了,AI 不到一分鐘跟上,還問出一個我沒想到的問題
下一篇
Day 27 AI 寫 iOS:使用者的手機不是你的手機,而那支 UI 測試對著空畫面也會過
系列文
盡信 Claude,不如無 Code — 心法與全端實戰 共 28 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言