← Lock

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

4682 字 •