第 3 章:第三章:星期二——程式碼與沉默
凌晨兩點十七分,辦公室裡只剩志明一個人。
萤幕的光把他的臉照得發白。編輯器開著三個視窗:左邊是 PaymentService.php,中間是 WebhookController.php,右邊是終端機,跑著 php artisan queue:work --tries=3。
他在處理金流回調的流程。第三方金流——类比绿界或藍新——在使用者完成付款後,會 POST 一個 webhook 到公司的 API endpoint,帶上一組簽名參數。Controller 收到請求後,驗證簽名,然後把一個 Job 丟進 Queue,由 worker 異步處理订單狀態更新。
這是標準做法。Laravel 的 Queue 用 Redis driver,效能好,設定簡單。志明之前做過类似的案子,架構他很熟。
但他在寫 PaymentService::handleCallback() 的時候,卡住了。
public function handleCallback(array $payload): void
{
$order = Order::where('transaction_id', $payload->transaction_id)->first();
if ($order->status === 'paid') {
return;
}
$order->status = 'paid';
$order->save();
event(new OrderPaid($order));
}
邏輯很簡單:收到回調,找到订單,檢查是否已經付過,沒付過就更新狀態然後發事件。
但志明盯著 if ($order->status === 'paid') 這行看了很久。
如果——同一個 transaction_id 的 webhook 被同時發送了兩筆呢?
不是故意的。网路抖動、金流端的 retry mechanism、或者負載均衡器重復投遞——這些都有可能。兩筆請求同時到達,同時通過簽名驗證,同時找到同一筆订單,同時檢查 status——
都是 pending。
然後同時更新。
Race Condition。
用白話來說:兩個人同時伸手去拿同一張紙,兩個人都以為自己拿到了,結果那張紙被撕成了兩倍。在程式世界裡,這意味著同一筆訂單被標記為「已付款」兩次,而金流端也因此扣了兩次款。
志明把編輯器視窗切換到一個新分頁。他打開瀏覽器,輸入 GitHub 的网址,搜尋 pay-bridge/payment-kit。
Repository 頁面跳出來。1,400 stars,最後一次 commit 是三個月前,contributor 只有四個人。志明點進 Issues 頁,找到 #247。
大衛昨天給他看過的那個 Issue。
他這次仔細讀了。
發 Issue 的人叫做「kelly-chen」,是一個新加坡的工程師。他說他在 production 環境用這個 Package 串接當地的金流,在促銷時段(流量是平常的五倍)出現了重復扣款。他提供了 log:
[2024-03-15 10:23:45] Processing webhook for TXN-8827
[2024-03-15 10:23:45] Processing webhook for TXN-8827
[2024-03-15 10:23:46] Order #10234 status updated to 'paid'
[2024-03-15 10:23:46] Order #10234 status updated to 'paid'
同一秒,兩筆處理。
Issue 下面有六則留言,都是不同工程師遇到了类似的問題。有人說他用 UPDATE ... WHERE status = 'pending' 來解,有人說他在 Controller 層加了 Redis lock。
但 maintainer 的回應只有那一句:「我們會看。」
一年半了。
志明把瀏覽器關掉,回到編輯器。
他可以在 handleCallback() 裡加一個 SELECT ... FOR UPDATE,在交易完成前鎖住那一行。或者用 Redis 的 SETNX 做一個分散式鎖。兩個方法都不復雜,大概半天就能寫完。
但——
他看了一眼 Slack。團隊群組裡,小琪十五分鐘前發了一條訊息:
> 前端串接遇到 CORS 的問題,有人可以幫我看一下嗎?
沒有人回。
志明知道小琪已經卡了兩天了。如果他現在加 lock,測試要重跑,時程會再延一天。
一天。
副總的「六周」不是隨便說的。那是給客户的承諾,是業務部門已經簽進去的合約。延期意味著公司要賠違約金,意味著副總的信用會受損。
而副總的信用受損,意味著——
志明把游標放回 handleCallback() 的 save() 那一行。
他加了一行註解:
// TODO: Add lock mechanism before launch
然後繼續寫下一個 function。
下午三點,志明去茶水間倒咖啡。
大衛的座位在茶水間旁邊。志明端著咖啡走過去的時候,大衛正在寫一個 migration,萤幕上是一堆 Schema::create() 的程式碼。
「大衛。」
「嗯?」大衛沒抬頭。
「你那個 Race Condition 的 Issue——」
大衛的手停了。他轉過頭看志明。
「我看了,」志明說,「確實有這個問題。」
大衛的眼睛亮了一下,然後暗下去——他大概猜到志明接下來要說什麼。
「但時程——」
「我知道,」大衛打斷他,語氣很平,「你不用解釋。」
沉默。
「大衛——」
「Zhi哥,」大衛轉回去繼續寫 migration,「我先把這個栏位加完。」
志明站在那裡,端著咖啡,覺得自己應該說點什麼。比如「我會處理」、或者「上線前會加 lock」、或者「謝謝你提醒我」。
但他什麼都沒說。
他走回座位,繼續寫 code。
晚上七點,辦公室的人陸續離開了。小琪走的時候在 Slack 上說:「CORS 還是沒解,我先回家了。」大衛走的時候什麼都沒說,萤幕關了,背包背上就走了。
老張六點就走了。他從來不加班。
志明一個人坐在座位上,繼續刻 PaymentService 的另一個 method:refund()。退款邏輯比付款更復雜,要處理部分退款、退款失败的重試、還有金流端的非同步回應。
他寫到九點半,手機響了。
雅婷的訊息:
> 女兒今天問你什麼時候回家。
志明看著這行字,手指懸在鍵盤上方。
他本來要打「快了」,但犹豫了一下,改打:
> 大概十點半。
雅婷秒回:
> 好。我把飯熱著。
然後她又傳了一條:
> 她畫了一張畫要給你看。我拍給你。
一張照片傳過來。女兒用蜡筆畫的——一個大人坐在電腦前面,旁邊寫著歪歪扭扭的「爸爸」。
志明盯著那張照片看了很久。
他本來應該現在就關電腦回家的。十點半,他說的。
但他看著編輯器裡還沒寫完的 refund(),看著 PaymentService 裡那個 // TODO: Add lock mechanism before launch 的註解,看著終端機裡 queue worker 不斷跳出的 Processed 日志——
他繼續寫了。
晚上十一點零三分,志明終於把 refund() 寫完了。他跑了一下 php artisan test,看著測試全部通過。绿色的 OK (14 tests, 32 assertions) 在終端機裡跳出來。
他關掉筆電,站起來。
辦公室很安靜。日光灯管有一盏壞了,一閃一閃的。志明拿起手機,看著雅婷十點半之後傳的訊息:
> 她睡了。
> 我把畫放在你桌上。
> 晚安。
志明站在黑暗的辦公室裡,拿著手機,打了兩個字:
> 快了。
然後他删掉。
重新打:
> 對不起,刚忙完。現在回家。
他看著這行字,覺得「對不起」這三個字從昨天到現在大概傳了五次。
他按下發送。
然後他關掉辦公室的灯,走進電梯。電梯鏡面裡映出他的臉——眼睛下面有青黑色的陰影,頭發因為一整天抓來抓去而亂糟糟的,襯衫領口有一圈汗渍。
三十四歲。看起來像四十四。
電梯門打開,一樓大廳的警衛抬頭看了他一眼。
「陳先生,又這麼晚啊。」
「嗯,」志明笑了笑,「趕案子。」
他走出大樓,夜風比預期的涼。他站在骑樓下等 Uber,打開手機又看了一次女兒的那張畫。
蜡筆的線條很粗,顏色涂出了格。那個「爸爸」的頭很大,身體很小,坐在一臺方形的電腦前面。旁邊有一顆紅色的心。
志明把手機收起來。
Uber 來了。他坐進後座,报了地址。車子驶上高架橋,窗外的城市灯火往後飛。
他閉上眼睛,腦海裡浮現的是 handleCallback() 裡的那行 if ($order->status === 'paid')。
如果兩筆請求同時到達——
都是 pending。
同時更新。
他睁開眼睛,看著窗外。
就在這時候,手機震動了一下。他以為又是雅婷——不是。是一通來電。
雅婷。
志明愣了一下。雅婷很少在他加班的時候打電話來。
「喂?」
電話那頭的背景音很安靜,大概是在卧室。雅婷的聲音壓得很低,像是怕吵醒女兒。
「還在公司?」
「刚出來,在車上了。」
沉默了兩秒。
「志明。」
「嗯?」
「你不要把自己逼太緊。」
志明沒有說話。窗外的路灯一盏一盏往後退,在他的臉上投下忽明忽暗的光。
「我知道你在想什麼,」雅婷繼續說,語氣很平,不是那種溫柔的安慰,更像是一個護理師在陳述事實——她看多了撐不住的人,「你覺得這個案子不能延期、不能出錯、不能讓別人覺得你扛不住。對不對?」
「……對。」
「但你已經連續三天超過十一點了。」
「這個案子六周——」
「我知道六周。」雅婷打斷他,聲音依然很平,「我問的不是時程。我問的是你。」
志明握著手機,不知道該怎麼回答。
「你上次體檢,膽固醇又高了,」雅婷說,「你上次答應女兒周末去動物園,沒去成。你上次——」
「我知道。」
「你知道,但你不會改。」
這句話不重,但志明覺得被什麼東西堵住了。
電話那頭沉默了一會兒。然後雅婷嘆了口氣——不是那種失望的嘆氣,是一種很輕的、像是把什麼東西放下的嘆氣。
「回家路上小心。」
「好。」
「志明。」
「嗯。」
「不管怎樣,先回來。」
電話掛了。
志明握著手機,看著萤幕上雅婷的名字變成「通話時間 2:37」。
他想起他們刚結婚的時候,雅婷在急诊室輪夜班,他也在加班。兩個人常常只能在凌晨通電話,說的都是「回家路上小心」和「先回來」。那時候覺得辛苦,但至少——至少那些辛苦是兩個人一起的。
現在他覺得,那些辛苦好像只有他一個人在扛。
不。不對。雅婷也在扛。只是她扛的東西他看不見。
他閉上眼睛,把手機收起來。
「先生,到了。」司機說。
志明付了錢,下車,上樓,開門。
客廳的灯關著,桌上放著一張蜡畫,旁邊是一盒已經涼掉的卤肉飯。
他站在門口,看著那張畫。
然後他打開筆電。
萤幕亮起來,編輯器還停留在 PaymentService.php。游標在 // TODO: Add lock mechanism before launch 那一行閃爍著。
志明看著那行字。
他沒有删掉它。
他也沒有實現它。
他只是把游標往下移了一行,繼續寫了下一個 function。