← Lock

第 4 章:第四章:星期三——老張的警告

咖啡機壞了。

志明站在茶水間裡,看著機器面板上閃爍的紅色燈號,按了三次按鈕都沒有反應。他把杯子放下,用手背敲了敲側板,像在對待一台十年前的 CRT 螢幕。機器發出一聲低頻的嗡嗡聲,然後歸於沉默。

現在是早上十一點二十三分。他記得時間,因為他剛看完小琪傳來的前端報錯截圖——Vue 的 axios interceptor 在金流 gateway 回傳 408 timeout 時沒有正確 rollback loading state,按鈕會卡在 disabled 狀態。小琪在 Slack 上說「Zhi哥這個要怎麼修」,後面貼了一個哭臉 emoji。志明回了「我看看」,但還沒看。因為他剛從一個 meeting 出來,那個 meeting 裡副總花了四十分鐘討論下季度的 team building 要去哪裡,然後用最後兩分鐘確認他的專案進度。

「後天。」副總說。

「嗯?」

「星期五上線,對吧?」

志明點了點頭,沒有說其實前後端的串接測試還沒完成,沒有說大衛的 migration 有一個 index 衝突還沒解,沒有說他自己連 Queue worker 的 retry 邏輯都還沒寫完。

他只是點頭。

現在他站在壞掉的咖啡機前面,覺得這整棟大樓都在跟他作對。

「壞了。」

身後傳來一個聲音。志明轉頭,是老張。

老張穿著一件洗到褪色的 polo 衫,左手拿著一個保溫杯——永遠都是那個不锈钢保溫杯,茶漬已經在裡面形成一圈棕色的環。他的頭髮比星期一會議時看起來更亂,但眼睛很清醒,那種 Code Review 時會把每一個括號都看清楚的清醒。

「昨天就壞了。」志明說。

「所以你剛才是在祈禱?」

志明愣了一下,然後嘴角動了算笑了。沒有聲音。

老張走到咖啡機旁邊,擰開保溫杯的蓋子,往裡面倒了大杯自己泡的茶。茶水是深褐色的,聞起來像鐵觀音。他倒完之後蓋上蓋子,看了志明一眼,然後轉身要離開。

「老張。」志明叫住了他。

不是因為他想叫。是因為他的身體比他的大腦快了零點五秒,而他的大腦現在需要這零點五秒來決定要說什麼。

老張停下腳步,沒有回頭。

「你的 PaymentService,」老張說,背對著他,「那個 webhook controller,有處理 Race Condition 嗎?」

志明的後頸突然有一種很細微的感覺,像有蟲在爬。不是因為這個問題本身——他當然知道 Race Condition 的存在,他的瀏覽器分頁還開著 GitHub Issue #247——而是因為問這個問題的是老張。

老張在星期一的會議上,一個字都沒有說。

「你怎麼知道?」志明問。

老張終於轉過身來。他靠在水槽的邊緣,雙臂交叉。

「我看了你的分支。週一晚上推的那個 commit——feat: integrate third-party payment gateway。」

「你怎麼會有存取權限?」

「我是 team 的一员。你設定的 repo 權限是 team-wide read。」老張停了一下,「而且你寫的那個 handleWebhook 方法,在 update 訂單狀態之前沒有 lock。我在 commit diff 裡看得出來。」

志明的手機震了一下。他知道是小琪又在 Slack 上問問題,但他沒有看。

「我打算之後補。」志明說。

「什麼時候?」

「上線後。」

老張看著他。那個眼神不是敵意,也不是輕蔑——是一種很安靜的疲倦。像一個在急診室裡看过太多同樣傷口的醫生。

「你為什麼不在會議上說?」志明突然問。

他知道這個問題不公平。他知道不公平,但他還是說了。因為他需要一個出口,而老張正好站在那個出口前面。

老張沒有馬上回答。他低頭喝了一口茶,然後把蓋子蓋上,發出一個輕輕的金屬聲。

「我說了,你聽嗎?」

茶水間安靜下來。大樓的冷氣管線在牆壁裡面發出低頻的嗡嗡聲,跟剛才壞掉的咖啡機一樣。

志明想反駁。他想說:你哪有說?你從頭到尾只是坐在那裡,連嘴巴都沒張開。但他記得——他確切地記得——星期一的會議上,大衛提出那個 Package 的 GitHub Issue 有 Race Condition 的報告時,他看向了老張。

老張微微搖了搖頭。

不是什麼大幅度的動作。只是下巴收了一點,頭往左偏了大概五度,然後恢復原狀。當時志明把這個動作解讀成老張一貫的不配合態度,那種「反正我說了也沒用」的消極訊號。所以他選擇忽略,繼續推進議程。

但現在老張站在茶水間裡,背光的眼睛像兩顆深灰色的石子,志明才意識到那個搖頭的重量。

我說了。只是不是用你聽得進去的方式。

「小琪那裡,」志明換了個話題,因為他不知道怎麼接老張剛才那句話,「前端串接有問題,我可能要多花一天——」

「所以你打算上線後再補。」老張替他接完了。

「對。」

「我說了。你聽嗎?」志明剛才那句話現在聽起來像是回力鏢。

老張沒有理會。「那個 Package 我之前用過。」

「什麼時候?」

「上一家公司。2021 年。當時也是金流的案子,也是 webhook 回調更新訂單狀態。我們測了兩個禮拜沒問題,上線第三天,一個客人按了兩次結帳,被扣了兩次錢。」

「你們怎麼解?」

「用 SELECT FOR UPDATE。在更新訂單悲態之前先 lock 那筆訂單的 row。But 這個 Package 的 handler 是直接 Order::where('id', $id)->update(['status' => 'paid']),連 transaction 都沒有包。」

「那時候你怎麼跟主管說的?」

老張的眼神動了一下。很微小,但志明捕捉到了。

「我寫了一封 email。」老張說,「把 Race Condition 的情境、影響範圍、建議解法都寫進去了。CC 了當時的技術主管跟 PM。」

「然後?」

「然後我用兩行字核准了上線。」老張停了一下,「事後出了問題,那封 email 就是我的保命符。」

志明不知道該怎麼回應。他覺得老張在暗示什麼,但又不確定。老張從來不把話說清楚,這是他的風格——給你線索,讓你自己拼圖。志明有時候覺得這是一種聰明,更多的時候覺得這是一種逃避。

「我不是要看你出事。」老張說。他擰開保溫杯又喝了一口,語氣突然變得平淡,像在講今天天氣不錯。「我只是不想收拾殘局。」

「什麼意思?」

「如果你負責的模組出了問題,到時候要我加班幫你修?我上個月才因為加班太多去看中醫。」老張把保溫杯夾在腋下,「我的意思是,補洞這種事,谁挖的谁補。你别挖就行。」

老張說完往門口走。經過志明身側的時候,他停了一秒。

「那個 GitHub Issue #247,你應該看評論。不是看 issue 本體,是看 comment 裡面有一個 maintainer 在 14 個月前回的:We are aware of this issue but do not have immediate plans to address it。」

然後他走了。

茶水間只剩下志明一個人。咖啡機面板上的紅燈還在閃,像某種嘲諷的節奏。

志明拿起手機,打開 Slack。小琪又傳了訊息:「Zhi哥,前端這個loading state的問題,如果不處理的話,用戶一直按 disabled的按鈕,會request嗎?」

他回了:「按了 disabled 的 button 不會 trigger click event,但用戶看到的按鈕是灰色的,可能會覺得壞掉然後 reload page。」

小琪:「那reload之後怎麼樣呢?如果上一次的 request 其實已經處理了……」

志明沒有回。因為他知道小琪問到了一個真正的問題。如果用戶 reload,backend 的 webhook 已經處理了金流,但前端因為 timeout 沒有收到 response,用戶會看到一個空白畫面,然後可能會回到購物車再按一次結帳。

前端 debounce 可以擋掉快速連續點擊,但擋不了 reload。

他打開瀏覽器,找到 GitHub Issue #247,拉到 comment 區。老張說的那條留言在倒數第三頁。14 個月前,一個 maintainer 用很英文式的客氣回覆:

> We are aware of this issue but do not have immediate plans to address it. PRs are welcome in the meantime.

「PRs are welcome 」——這句話在 GitHub 上的涵義通常是「我們不想修,誰愛修誰修」。志明關掉分頁。

他走到自己的位子,打開 Terminal,切到 feature/payment-gateway 分支。

class PaymentWebhookController extends Controller
{
    public function handle(Request $request)
    {
        $orderId = $request->input('order_id');
        $status = $request->input('payment_status');

        $order = Order::find($orderId);

        if ($order && $order->status === 'pending') {
            $order->status = $status === 'success' ? 'paid' : 'failed';
            $order->save();

            event(new PaymentCompleted($order));
        }

        return response()->json(['ok' => true]);
    }
}

他盯著螢幕。這段 code 他寫了三天了,每天看都覺得哪裡不對,但每天都有更緊急的事讓他沒空處理。

現在他知道哪裡不對了。

Order::find($orderId) 之後到 $order->save() 之間,如果有另一個 request 同時進來——gateway 的 timeout 重試、網路抖動造成的重複投遞——兩個 process 都會讀到 $order->status === 'pending'。然後兩個都會執行 $order->save()。兩條 PaymentCompleted event 被 dispatch,Queue worker 處理兩次,寄兩封確認信,扣兩次款。

解法不複雜。在 find 外面包一層 DB::transaction,加上 lockForUpdate()。或者用 Redis lock。兩種都不難,加起來大概一個小時。

一個小時。

志明把 Terminal 關上,打開 Slack,找到小琪的對話框。

「我下午有空,到時候一起看。」他打完,按了送出。

然後他打開行事曆,看著星期五那個被紅色框起來的「Go Live」,想著老張剛才說的話。

我只是不想收拾殘局。

志明把椅子往後靠,閉上眼睛。天花板上有一盞日光燈在閃,頻率很低,大概每三十秒閃一次。他以前從來沒注意過。

他想到一個問題:如果他現在花一個小時把 lock 加進去,然後小琪的問題花半天,大衛的 migration 花半天,那星期五無論如何都上不了線。他需要跟副總說「延期」。

「延期」這兩個字在他腦中成形的時候,他的胃收縮了一下。

不是因為副總會罵他。副總不會罵人。副總只會說:「嗯……這樣啊。那新的時程是什麼時候?」然後用一種很失望但很包容的語氣,讓他覺得自己對不起全公司。

志明睜開眼睛。

他想起上個禮拜五,他在手機上滑過大衛的測試報告,看到覆蓋率 87%,回了一個讚的 emoji。他沒有看到最後一頁。

他想起星期一的會議上,老張微微搖頭。他當時把那個動作解讀成消極的不配合。

他想起自己在這段 code 上面加的那行 // TODO: Add lock mechanism before launch——上個星期二加的,那時候他告訴自己「上線前會加」。

現在是星期三。星期五上線。

他打開 VS Code,把那段沒有 lock 的 controller 又看了一遍。

然後他關掉 VS Code,打開 Slack,開始回小琪的訊息。

上線後再補。

他這樣告訴自己。上線後流量不會太高,促銷活動是下個月的事,到時候再補來得及。

日光燈又閃了一下。


下午兩點,志明跟小琪在會議室裡面對她的筆電螢幕。

「你看,」小琪指著 Chrome DevTools 的 Network tab,「這裡,第一次 request 發出去之後,等了 408,然後 axios interceptor 把 loading state 設成 false,但按鈕的 disabled 沒有被移除,因為 disabled 是在 click event 裡面用 this.isLoading = true 直接設的,不是在 response handler 裡面設的。」

「所以按鈕卡在 disabled?」

「對。用戶看到的是灰色按鈕,可能會以為壞了,然後 reload。」

「reload 之後呢?」

「如果 webhook 其實已經處理了,那 reload 之後購物車應該是空的,因為訂單已經成立了。但如果 webhook 還沒處理完,購物車還是有東西,用戶可能會再按一次結帳。」

志明揉了揉眉心。「你在按鈕上加上 debounce 了嗎?」

「有,300 毫秒。但那只擋得了快速連點,擋不了 reload。」

「好,那我們在前端加上一個 flag,在 reload 的時候檢查 sessionStorage 裡面有沒有 checkout_in_progress,有的話就 disable 按鈕並顯示 loading spinner。」

小琪點點頭,開始打字。

志明看著她的側臉。小琪今年才來一年半,做事很認真,但常常把問題想得太簡單。sessionStorage 的 flag 可以解決 reload 的問題,但解決不了真正的問題——如果兩個 request 同時到達 server,前端的任何防護都是假的。

但他沒有說。

「Zhi哥,」小琪突然說,「你覺得我們星期五真的能上嗎?」

「為什麼這樣問?」

「因為……我前端的部分其實還沒完全測完。那個 loading state 的問題只是其中一個。還有金流 gateway 的 sandbox 環境有時候會回傳錯誤的 signature,我還沒寫 error handling。」

「sandbox 環境的 signature 問題?」

「對,有時候 webhook 的 header 裡面 X-Signature 會少一個字元,可能是他們的 bug。我現在是跳過 signature verification 直接處理 payload,但這樣正式上線會有安全問題。」

志明沉默了三秒。

「sandbox 的 signature 問題,你有開 issue 嗎?」

「有,在他們的 GitHub 上開了一個,但沒有人回。」

「好,那正式環境的 webhook 不會經過 sandbox,應該不會有這個問題。你先把 error handling 加上,如果 signature 驗證失敗就回 401,不要處理 payload。」

「但 gateway 那邊如果因為 signature 錯誤而重試,會不會造成重複 webhook?」

小琪問出這個問題的時候,志明感覺到自己的心跳加速了。

「不會,」他說,「gateway 的 retry 機制是用相同的 payload 重試,signature 是一樣的。如果第一次驗證失敗,重試也會失敗,不會被處理。」

「哦,好。」小琪轉回去繼續打字。

志明看著她的螢幕,想說什麼,但最終什麼都沒說。

因為他剛才說的是錯的。

如果 gateway 在正式環境的 signature 跟 sandbox 不同——而通常正式環境會用不同的 secret——那 signature 驗證的邏輯本身就不應該有問題。但真正的問題不是 signature,是 gateway 在 timeout 時會用相同的 payload 重試,而這個重試的 request 跟第一次的 request 如果同時到達 server,就會觸發 Race Condition。

前端 debounce 擋不了 gateway 的 retry。

sessionStorage 的 flag 也擋不了。

他需要加 lock。

但他沒有時間了。


晚上七點,志明還在公司。雅婷傳了一張照片過來,是女兒用蠟筆畫的畫——一個藍色的人站在一個黃色的房子旁邊。

「這是爸爸。」雅婷傳訊息說,「她今天畫的。我問她爸爸在哪裡,她說『爸爸在電腦裡』。」

志明看了很久。

他回:「跟她說爸爸週末帶她去公園。」

雅婷回了一個 OK 的手勢,沒有多說什麼。

志明把手機翻過去,螢幕朝下,放在桌上。

他打開 Terminal,切到 feature/payment-gateway 分支,開始打字。

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,手指放在 Enter 鍵上。

只要 commit,只要 push,只要 merge 到 main,這個問題就解決了。一個小時的事。

但他的 Slack 跳出了大衛的訊息:「Zhi哥,migration 的 index 衝突我看不太懂,可以請你幫我看一下嗎?」

然後是小琪的訊息:「Zhi哥,我加了 error handling 但測試的時候發現一個新的問題……」

志明把 Terminal 關上。

他打開 Slack,先回大衛:「好,你發 PR 給我看。」再回小琪:「什麼問題?」

然後他站起來,走到茶水間。咖啡機還是壞的。他打開熱水壺,給自己泡了一杯即溶咖啡。

即溶咖啡很苦,但他沒有加糖。

他站在茶水間裡,透過玻璃門看到辦公室的燈光。大衛的位子亮著,小琪的位子也亮著。老張的位子暗著——他六點就走了,從來不加班。

志明端著咖啡走回位子,坐下來,打開 VS Code。

他看著那段沒有 lock 的 controller code,在檔案最上面加了一行 comment:

// TODO: Add DB lock to prevent race condition on concurrent webhook requests
// Known issue: https://github.com/xxx/payment-gateway/issues/247
// FIXME before high-traffic events

然後他把檔案存檔,關掉。

上線後再補。

他這樣告訴自己。

窗外的城市已經全暗了,只剩下遠處幾棟大樓的燈光,像是一些不肯睡的眼睛。志明喝了一口即溶咖啡,苦味從舌根蔓延到喉嚨。

他想起老張今天說的話。

我說了,你聽嗎?

志明閉上眼睛,在黑暗中看見星期一的會議室。老張坐在角落,微微搖頭。那個幅度大概只有五度。

他當時選擇了忽略。

現在他站在星期三的尾巴上,離上線還有兩天,離那個 Race Condition 被觸發還有——他不知道。也許永遠不會被觸發。也許 gateway 的 retry 機制有內建的去重邏輯。也許他的擔心是多餘的。

也許。

志明睜開眼睛,把咖啡喝完,打開 Slack 繼續回訊息。

他沒有 commit 那段 lock 的 code。

他把它留在了 Terminal 的 buffer 裡,像一個沒有說出口的話。

8464 字 •