← Lock

第 5 章:第五章:星期四——大衛的眼淚

小琪是早上十點發現的。

她站在志明的位子旁邊,手上拿著一杯從便利商店買的拿鐵,臉色不是很好看。

「Zhi哥,你現在有空嗎?」

志明正在看大衛昨天晚上發的 PR——一個 migration 檔,要幫 orders 表加三個 index。他抬起頭,看見小琪的表情,把筆電蓋了一半。

「怎麼了?」

「我測到一個問題。」小琪坐下來,把他的筆電轉過去面對自己,打開自己手機上錄的螢幕錄影。「你看,我剛剛在測試結帳流程,用 sandbox 環境模擬。我按了一次結帳,然後在 response 回來之前,我又按了一次。」

影片裡面,結帳按鈕被按了兩次,中間間隔大概半秒多。前端有 debounce,但 300 毫秒的 debounce 在這種情況下不夠——第一次 click 觸發了 request,按鈕進入 loading 狀態,但 loading 狀態的 CSS 有一個 transition delay,在 transition 完成之前第二次 click 還是被接收了。

「兩個 request 都發出去了。」小琪說。

「然後呢?」

「然後我去看 database。」小琪切換到另一個截圖,是 phpMyAdmin 的介面。orders 表,同一個 user_id,同一個購物車內容,兩條 status 為 paid 的紀錄。

「兩筆訂單?」

「對。而且兩筆都觸發了 PaymentCompleted event,Queue worker 寄了兩封確認信給同一個用戶。」

志明沒有說話。他拿過手機,把影片又看了一次。按鈕、click、loading、transition、第二個 click、兩個 request。

他放下手機,打開自己的筆電,切到 Terminal。

mysql> SELECT id, order_number, status, created_at FROM orders WHERE user_id = 1042 ORDER BY created_at DESC LIMIT 5;
+----+---------------+--------+---------------------+
| id | order_number  | status | created_at          |
+----+---------------+--------+---------------------+
| 89 | ORD-20240613-01 | paid  | 2024-06-13 10:07:23 |
| 88 | ORD-20240613-01 | paid  | 2024-06-13 10:07:23 |
+----+---------------+--------+---------------------+

同一個 order_number。同一個 timestamp,精確到秒。兩個 process 幾乎同時寫入。

「Zhi哥,」小琪壓低聲音,「這就是你之前說的 Race Condition 嗎?」

志明把筆電轉回來,沒有回答她的問題。

「你怎麼想到要測這個?」

「因為……我之前在別的公司看過一個類似的 bug。那時候也是結帳,也是重複扣款。我記得那時候的 QA 說,用戶在網路慢的時候會一直按按鈕,覺得沒反應就會再按。」

「你什麼時候測的?」

「今天早上。我昨天晚上睡不著,想說早點來公司跑一些 edge case。」

志明看著小琪。她的眼睛下面有淡淡的青色,跟他的差不多。

「好,」志明說,「你先不要跟別人說。我來處理。」

小琪點點頭,站起來要走,又回頭:「Zhi哥,這個……嚴重嗎?」

「我來處理。」他重複了一次。

小琪走了之後,志明打開 Slack,找到大衛的對話框。

「大衛,你之前寫的那份測試報告,可以傳給我嗎?」

過了大概五分鐘,大衛回了一個 PDF 檔。檔名是 payment_gateway_test_report_v2.pdf。志明記得這份報告——大衛上個禮拜五傳到 Slack 群組裡的,他在手機上滑過一眼,看到有圖表和覆蓋率數字,想說看起來 OK 就沒有細看。

他打開 PDF。

前面十幾頁都是正常的測試案例:正常結帳流程、金流失敗處理、webhook signature 驗證、timeout 重試。覆蓋率 87%。看起來很完整。

他翻到最後一頁。

最後一頁的格式跟前面不同。前面每一頁都有 header、測試環境資訊、表格邊框。最後一頁什麼都沒有,只有一段文字,字型是 Times New Roman 11 號,跟前面報告用的字型不一樣。像是後來才加上去的,或者從另一個檔案複製貼上的。

> 注意事項:

>

> 1. 在高併發情境下(多個 webhook 同時到達),PaymentWebhookControllerhandle 方法可能存在 Race Condition,導致同一筆訂單被處理兩次。建議在更新訂單狀態前加入 database lock(SELECT ... FOR UPDATE)或 distributed lock(Redis)。

> 2. 前端 debounce(300ms)可減少用戶重複點擊,但無法防止 gateway retry 或手動 API 呼叫造成的重複請求。建議後端加入 idempotency key 機制。

> 3. 以上測試案例未包含高併發壓力測試,因 sandbox 環境無法模擬。建議正式上線前使用工具(如 Apache JMeter 或 k6)進行併發測試。

志明把這段文字看了三遍。

然後他把 PDF 關上,靠在椅背上。

他想起上個禮拜五的晚上。他在家裡用手機看 Slack,大衛傳了這份報告,他滑了一下,看到前面幾頁的覆蓋率數字——87%,不錯——然後回了一個讚的 emoji。

他沒有看到最後一頁。

或者說,他看到了最後一頁的存在——他知道這份報告有十二頁,他滑到了第十二頁——但他看到那頁沒有圖表、沒有表格、只有文字,他的大腦自動把它歸類為「備註」或「免責聲明」,然後跳過了。

就像他在星期一的會議上,看到老張微微搖頭,然後跳過一樣。

志明拿起手機,想傳訊息給大衛,但不知道要打什麼。

「抱歉,我沒看到」?

「你為什麼不直接跟我說」?

「為什麼要用這麼小的字寫在最後一頁」?

每一句都不對。每一句都是把責任推回去給大衛。

他把手機放下,站起來,往大衛的位子走。

大衛正在看螢幕,耳朵戴著一副很大的降噪耳機,是那種可以把整個世界隔絕在外的型號。志明走到他旁邊,大衛沒有發現。志明敲了敲桌面,大衛嚇了一跳,把耳機拿下來。

「Zhi哥。」

「測試報告我看了。」

大衛的表情變了。不是驚慌,是一種很微妙的放鬆——像是一個背了很久的石頭終於被看到了。

「你看到了。」大衛說。不是問句。

「最後一頁。」

「嗯。」

「你為什麼不……」志明想了一下,把後面的話吞回去了。他本來想說「你為什麼不直接跟我說」,但這個問題不公平。大衛說了。他寫在報告裡了。用十二頁的報告,用 87% 的覆蓋率,用 Times New Roman 11 號的字。

他盡力了。

「你什麼時候寫的?」志明問。

「上個禮拜四。那時候我跑完所有 test case 之後,想說這個 Race Condition 的情境我沒有測到,因為我不太確定要怎麼在 sandbox 模擬併發請求。所以我就寫在最後一頁。」

「你有在 Slack 上說報告有注意事項嗎?」

大衛低下頭。「沒有。我……我不知道怎麼說。我怕你覺得我在找麻煩。」

志明沒有接話。

「Zhi哥,」大衛抬起頭,「我上個禮拜五傳報告的時候,其實有想過要不要單獨跟你說。但那天你在 meeting,我就想說……報告裡面有寫,你應該會看到。」

「嗯。」

「你看到了嗎?」

志明看著大衛的眼睛。二十八歲,年資兩年,是他帶進來的。剛進公司的時候連 Git 的 rebase 都不會,現在已經可以獨立寫 migration 和 test case 了。他的技術能力不差,只是習慣等指令。

這個「習慣等指令」是志明自己養出來的。因為志明是一個什麼都自己扛的主管,大衛學會了只要把東西交出來就好,不用擔心決策。

「我看到了。」志明說。

他沒有說「我沒有仔細看」。他沒有說「我滑過去就跳過了」。他選擇了最簡單的方式。

大衛的表情鬆了一口氣。「那……我們要怎麼處理?」

「我來處理。你先繼續你的 migration。」

「好。」大衛把耳機戴回去,轉回螢幕。

志明站在原地,看了大衛的背影三秒,然後走回自己的位子。

他坐下來,打開 VS Code,看著昨天寫的那段沒有 commit 的 lock code。

DB::transaction(function () use ($orderId, $status) {
    $order = Order::where('id', $orderId)->lockForUpdate()->first();
    if ($order && $order->status === 'pending') {
        $order->status = $status === 'success' ? 'paid' : 'failed';
        $order->save();
        event(new PaymentCompleted($order));
    }
});

這段 code 在他的 Terminal buffer 裡躺了超過二十四小時。他沒有 commit,沒有 push,甚至沒有寫進檔案裡。他只是打了出來,看了看,然後關掉。

現在他打開 PaymentWebhookController.php,把那段 code 複製貼上,取代原本沒有 lock 的版本。

他的手放在鍵盤上,游標在檔案裡閃爍。

只要 git add,只要 git commit,只要 git push

但他的 Slack 跳出了一個訊息。是副總。

> 志明,今天上線準備得怎麼樣了?明天沒問題吧?

志明看著這則訊息。他打了「沒問題」,然後刪掉。他打了「有一個小問題需要處理」,然後也刪掉。

他最後回:「差不多了,明天應該 OK。」

副總回了一個讚的手勢。

志明把 Slack 關掉,看著螢幕上的 Controller code。

他開始打字。不是 lock 的 code,而是一個 comment:

// TODO: Race Condition fix pending
// Issue: concurrent webhook requests may cause duplicate order processing
// Proposed fix: DB::transaction with lockForUpdate()
// Status: Deferred to post-launch

他把檔案存檔。

然後他站起來,說他要去倒水。


他沒有去茶水間。他去了廁所。

廁所的燈是自動感應的,他一走進去燈就亮了,日光燈的那種白,把所有東西都照得很乾淨。他站在洗手台前面,看著鏡子裡的自己。

三十四歲。眼角有了一些細紋,不是笑紋,是皺眉紋。他發現自己最近半年皺眉的時間比笑的時間多。

他打開水龍頭,水很冰。他捧了一把水澆在臉上,然後關掉水龍頭,抬頭看鏡子。

鏡子旁邊的隔間傳來一個聲音。

很輕。如果不是廁所很安靜,他不會聽到。

是吸鼻子的聲音。

志明站在原地,手還滴著水。

「大衛?」他叫了一聲。

安靜。然後是抽面紙的聲音。

「你還好嗎?」

隔間的門打開了。大衛走出來,眼睛紅紅的,鼻子也是。他看到志明,愣了一下,然後很快地用面紙把眼睛擦乾。

「Zhi哥。」

「你怎麼了?」

「沒有。」大衛走到洗手台旁邊,打開水龍頭洗臉。「沒有怎麼。」

志明看著他。水從水龍頭出來,沖過大衛的手指。大衛今年二十八歲,比志明小六歲。他剛進公司的時候,志明帶他認識 Laravel,帶他看 Service Container 怎麼運作,帶他理解 Queue 的架構。那時候大衛會問很多問題,有時候問到志明也答不出來,兩個人就一起查文件。

後來志明升了 Team Lead,開始有 meeting、有 deadline、有副總的期待。他不再有時間跟大衛一起查文件。大衛問問題的時候,他開始說「你先試試看」,開始說「這個應該不難」,開始說「我等等有空再跟你說」。

大衛後來就不問了。

「是因為測試報告的事?」志明問。

大衛關掉水龍頭,用面紙擦臉。他沒有看志明。

「不是。」

「那是什麼?」

大衛把面紙揉成一團,丟進垃圾桶。他終於轉過來看志明。

「Zhi哥,我昨天傳 migration PR 給你的時候,其實……我那天晚上睡不著。」

「為什麼?」

「因為我想到那個 Race Condition 的問題。我寫在報告裡了,但我不確定你有沒有看到。我昨天一直在想要不要直接跟你說,但又怕你覺得我在越級報告,或者覺得我在推責任。」

「你沒有推責任。」

「我知道。但我就是……」大衛的聲音變小了。「我怕你覺得我沒有做好。我寫了測試報告,但我沒有測到那個 edge case。我寫了注意事項,但我沒有用你看得懂的方式寫。我應該要直接跟你說的,不是寫在最後一頁。」

志明聽著。他想起自己昨天在茶水間跟老張的對話。老張說「我說了,你聽嗎」,他當時覺得老張在推責任。但現在大衛站在他面前,眼睛紅紅的,說著幾乎一模一樣的話——只是方向相反。

大衛在說:我說了,但我不敢說得夠大聲。

志明在說:你說了,但我沒有聽進去。

他們都在說同一件事,只是站在河的兩邊。

「大衛。」

「嗯。」

「不是你的錯。」

志明說這句話的時候,他的右手抬起來,拍了大衛的肩膀一下。不是很用力,就是那種前輩拍後輩的力度。

大衛低下頭,又抽了一張面紙。

「Zhi哥,我不是故意的。」

「我知道。」

「我真的不是故意不說的。我只是……不知道怎麼說。」

「我知道,不是你的錯。」

志明又說了一次。他的手掌還放在大衛的肩膀上,感覺到牛仔布下面的肩胛骨,很瘦。

大衛點點頭,把面紙丟掉,用雙手抹了一把臉。

「我沒事了。」他說。「謝謝 Zhi哥。」

他走了。廁所的門在他身後關上,日光燈在志明頭頂發出細微的電流聲。

志明站在洗手台前面,看著鏡子。

他剛才說「不是你的錯」。

但他心裡知道。

是他的錯。

是大衛寫了報告,他沒有看。是大衛在 Slack 上傳了檔案,他滑過去就跳過了。是大衛在報告最後一頁用 Times New Roman 11 號字寫了警告,他連那個字型不一樣都沒有注意到。

是他的錯。

志明打開水龍頭,又澆了一把水在臉上。水很冰,但他需要這種感覺。他需要某種具體的、物理性的刺激,來壓住胸口那一團說不出名字的東西。

他關掉水龍頭,擦乾臉,走出廁所。

走回位子的路上,他經過老張的位子。老張不在,椅子上放著一個背包,螢幕是暗的。志明想起老張昨天在茶水間說的話。

我只是不想收拾殘局。

志明走回位子,坐下來。

他打開 VS Code,看著那個寫了 // TODO: Race Condition fix pending 的 Controller。

然後他打開 Terminal。

git checkout -b hotfix/webhook-race-condition

他開始打字。不是 comment,是真正的 code。DB::transactionlockForUpdate()first()

他的手指在鍵盤上移動,但速度很慢。因為他知道,就算他現在把這段 code 寫完、commit、push、merge,也來不及了。

明天就是星期五。

副總在 Slack 上問「明天沒問題吧」,他回了「應該 OK」。

小琪的前端還沒測完。大衛的 migration 還沒 merge。他自己的 Queue worker retry 邏輯還沒寫完。

如果他現在說「我需要多兩天」,副總會說什麼?

副總不會罵他。副總只會說:「嗯……這樣啊。那新的時程是什麼時候?」然後用那種失望但包容的語氣,讓他覺得自己辜負了某種信任。

那種信任是副總在星期一的會議上給他的。「這件事你看著辦,我相信你的判斷。」

信任。

志明盯著 Terminal 游標。

他想到一個詞:idempotency key。大衛在報告最後一頁有提到。如果 webhook 的 payload 裡面帶一個 unique key,server 在處理之前先檢查這個 key 是否已經處理過,就可以避免重複處理。

這個解法比 lock 更乾淨。不需要 database lock,不需要 Redis,只需要在 orders 表加一個 idempotency_key 欄位,加上 unique index,然後在 controller 裡面檢查。

但這個解法需要改 migration,需要改 controller,需要改測試。加起來大概需要半天。

他沒有半天。

志明把 Terminal 關上。

他打開 Slack,找到副總的對話框。他的手指在鍵盤上停留了很久。

他打了一行字:「副總,關於明天的上線,有一個 Race Condition 的風險需要評估。」

他看了這行字五秒。

然後他一個字一個字地刪掉。

他重新打:「副總,明天上線準備好了。」

他按了送出。

然後他把 Slack 關上,把 VS Code 關上,把筆電蓋起來。

他坐在椅子上,看著天花板。日光燈在閃。每三十秒閃一次。

他想起女兒的畫。藍色的爸爸,黃色的房子。

「爸爸在電腦裡。」

志明閉上眼睛。

他告訴自己:明天上線後,第一件事就是修這個 bug。上線後流量不高,不會有問題的。促銷活動是下個月的事。他有一整個月的時間。

他告訴自己。

但他的心跳告訴他別的事情。

7336 字 •