← Lock

第 6 章:第六章:星期五——上線

鬧鐘響的時候,志明已經醒了。

不是被鬧鐘叫醒的——他比鬧鐘早了大概十分鐘,躺在那裡聽雅婷翻身、聽窗外的車聲、聽自己的心跳。六點十五分。天已經亮了,但不是那種乾淨的亮,是台北夏天那種灰白色的、帶著濕氣的亮,像隔了一層保鮮膜。

他關掉鬧鐘,坐起來。雅婷還在睡,被子蓋到肩膀,呼吸很輕。他看了她一下——七个月的肚子在被子底下隆起一個弧度——然後輕手輕腳地下床。

他沒有去公司。他在家裡打開筆電,坐在餐桌前面。昨晚他没有回卧室睡,在客廳沙發上躺了一夜,但沒有真正睡著。他的身體在凌晨三點到四點之間進入了一種很淺的休眠,腦子裡跑著一些斷斷續續的畫面:終端機、錯誤訊息、記者會的閃光燈。

筆電打開之後,他做的第一件事不是看 code。是看 Slack。

#project-payment-gateway

[昨天 23:47] 小琪:Zhi哥我上傳了前端的 fix, PR 在 #184,主要修了 loading state 跟 debounce 的問題
[昨天 23:52] 大衛:migration PR #185 更新好了,三個 index 都加了
[今天 00:03] 志明:好,我等一下 merge
[今天 00:15] 志明:副總,我們準備上線了
[今天 00:22] 副總:👍 辛苦了,今天上線沒問題吧?
[今天 00:25] 志明:OK 👌

志明看著自己傳的那個 OK 手勢。他的拇指跟其他四指形成一個圓圈,在螢幕上是一個黃色的 emoji。很輕。像他經常用來回覆任何不確定事情的符號。

他打開 GitHub, merge 了小琪的 PR #184 和大衛的 PR #185。兩個 merge 都很乾淨,沒有 conflict。

然後他打開 PaymentWebhookController.php

那個 // TODO: Race Condition fix pending 的 comment 還在那裡。他在星期三晚上加的。

他盯著那行 comment 看了大概三十秒。

然後他開始打字。不是刪除 comment,而是在 comment 下面加上新的 code:

// Temporary fix: add frontend debounce (300ms) to prevent duplicate clicks
// NOTE: This does NOT solve the race condition at the API level
// Gateway retries and manual API calls can still cause concurrent requests
// TODO: Implement DB lock or idempotency key before high-traffic events

他把 comment 從 // Status: Deferred to post-launch 改成了 // TODO: Implement DB lock or idempotency key before high-traffic events

然後他就停了。

他在星期四的凌晨做了一件事:沒有加 lock,沒有加 idempotency key,只是在 comment 裡面把「post-launch」改成了「before high-traffic events」。

這是一個很小很小的改動。小到在 diff 裡面只有一個綠色的加號跟一個紅色的減號。但它在他心裡代表了一種自我說服:我沒有忽略這個問題,我只是在等待一個更好的時機。

他把筆電關上,去浴室刷牙。鏡子裡的自己看起來比昨天晚上好一點,至少眼睛裡的血絲少了一些。他洗了臉,換了一件乾淨的 T 恤,出門。


公司裡已經有人到了。

不是早,是因為有人昨天加班到太晚,懶得回家,就在休息室的沙發上睡了一夜。志明經過休息室的時候看到小琪蜷在沙發上,身上蓋一件公司的 hoodie,頭髮亂的。他沒有叫她。

他走到自己的位子,發現桌上放了一杯便利商店的咖啡,旁邊貼了一張便利貼:

> Zhi哥,今天加油 💪 —— 小琪

志明把便利貼撕下來,看了兩秒,打開抽屜放進去。他不確定自己為什麼要留著。也許是因為害怕今天會出什麼事,而這杯咖啡可能是某個人對他最後的善意。

他打开另一台显示器,切到 production server 的 SSH session。

上线流程他在星期三晚上就写好了,写成一个 deploy script:

#!/bin/bash
cd /var/www/payment-gateway
git pull origin main
composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan queue:restart

六个步骤。他在脑子里跑过很多遍了。git pull 拉最新的 code,composer install 更新 dependencies(--no-dev 不装测试用的套件),migrate 跑 migration(--force 跳过 confirmation prompt,因为 production 不能 interactive),config:cacheroute:cache 做 cache 加速,queue:restart 重啟 Queue worker 讓新的 code 生效。

看起来很干净。没有遗漏。

他把 deploy script 上传好,然后坐在椅子上面等。等八点钟团队到齐,等副总的上线通知,等那段他排练了很久的 Slack 广播。


八点半,人到得差不多了。

志明站起来,走到辦公室的中間。

「大家,今天是上線日。流程我昨天晚上已经跑过一次 dry run,沒有問題。等一下十點正式切換 DNS,小琪你那邊的前端 build 好了嗎?」

小琪舉手。「好了,CI/CD pipeline 昨天半夜跑完了,dist 资料夹已經上傳到 S3。切換 DNS 之後就會自動拿到最新的版本。」

「好。大衛,你那一邊的 migration 在 staging 跑過了吧?」

「跑過了,「大衛點頭,「三個 index 都有正確建立,沒有 error。」

「Queue worker 呢?」

「Redis driver,五個 worker 在跑。retry delay 設 30 秒,最多 retry 三次。」

志明点頭。他看向老張的位子。老張坐在那裡,保溫杯打開著,正在喝茶。他没有看志明,但也沒有看別的地方——他就只是坐在那裡,像一個旁觀者。

「好,」志明說,「十點上線。如果有問題隨時在 Slack 上叫他。我的手機會開著。」

他走回位子,坐下來,看了一眼時間。八點三十二分。

他等。


九點四十五分,副總出現在辦公室。

這很不尋常。副總通常不會在上線日出現在工程部門的辦公室——他在自己的辦公室裡,透過 Slack 跟進。但今天他來了,穿著一件淺藍色的襯衫,手裡拿著一杯連鎖咖啡店的拿鐵,笑容可掬。

「志明啊,」副總走過來,「準備好了嗎?」

「好了。」

「沒問題吧?」

「沒問題。」

副總拍了拍他的肩膀。很輕,很短暫,但志明感覺得出來那隻手的力道——不是鼓勵,是確認。像在說:我信任你,所以你最好別讓我失望。

「那我等你的好消息。」副總說完,走了。

志明看著他的背影消失在走廊盡頭,然後打開 Slack,找到 #project-payment-gateway

他打了一行字:

> 各位,準備上線了。倒計時十五分鐘。

下面立刻跳出好幾個回應。大衛回了一個 minus 的表情(他們的暗號,表示收到)。小琪回了一個火箭。其他幾個同事回了讚。

老張沒有回。


十點整。

志明在 Terminal 執行 deploy script。

$ bash deploy.sh

第一行 output:

From github.com:company/payment-gateway
 * branch            main       -> FETCH_HEAD
   a3f2d1b..e8c942f  main       -> origin/main
Updating a3f2d1b..e8c942f
Fast-forward
 app/Http/Controllers/PaymentWebhookController.php |  3 +++
 app/Http/Resources/OrderResource.php             |  2 +-
 database/migrations/2024_06_13_add_order_indexes.php | 17 ++++++++
 resources/js/components/CheckoutButton.vue        | 11 +++++--
 4 files changed, 28 insertions(+), 5 deletions(-)

四個檔案的變動。PaymentWebhookController.php 的那三個加號是他加的 comment。CheckoutButton.vue 的變動是小琪修的 debounce 和 loading state。migration 是大衛的 index。

Installing dependencies from lock file (including require-dev)
Nothing to install, update or remove
Package operations: 0 installs, 0 updates, 0 removals
$ php artisan migrate --force
Nothing to migrate.

Migration 已經在 staging 跑過了,production 的 migration status 顯示不需要再跑。正常。

Configuration cache cleared!
Configuration cached successfully.
Route cache cleared!
Route cache cleared successfully.
Queue worker restarted.

最後一行。deploy script 結束。

整個過程大概四十秒。

志明在 Slack 上打:

> ✅ 上線完成。deploy 順利,migration 已跑,Queue worker 已重啟。

Slack 群組瞬間被 emoji 淹沒。鞭炮、煙火、拍手、讚。小琪傳了一個動畫 sticker 是一隻貓在放煙花。大衛回了一個「🎉🎉🎉」。

志明看著這些 emoji,嘴角動了一下。

然後他打開瀏覽器的分頁,切到 production 環境的訂單列表。空白。因為還沒有任何真實的訂單進來。

他打開另一個分頁,切到 Queue 監控。Horizon dashboard 上,五個 worker 的 status 都是 running,jobs processed 是 0,failed jobs 也是 0。

一切正常。

志明靠回椅背上,呼了一口氣。

他的手機震了一下。是雅婷傳的訊息:「今天上線對吧?加油 💪」

他回了一個 OK 的手勢。跟昨天晚上回副總的一樣。


下午兩點,第一筆真实订单进来了。

小琪在 Slack 上說:「第一筆訂單成功了!#ORD-20240614-001, sandbox 金流測試通過,webhook 有收到,訂單 status 變 paid。」

监视器上多了一筆纪录。OrderId: 101,UserId: 1(内部测试账号),金额: 350 元,状态: paid。

志明打开日志档,检查 webhook 的处理过程:

[2024-06-14 14:03:17] production.INFO: PaymentWebhookReceived {"order_id":101,"status":"success","signature":"valid"}
[2024-06-14 14:03:17] production.INFO: OrderStatusUpdated {"order_id":101,"from":"pending","to":"paid"}
[2024-06-14 14:03:18] production.INFO: PaymentCompletedEventDispatched {"order_id":101}
[2024-06-14 14:03:19] production.INFO: OrderConfirmationEmailQueued {"order_id":101}

每一行都乾乾淨淨。webhook 進來,驗證 signature,更新訂單狀態,dispatch event,Queue worker 非同步寄確認信。全部在兩秒內完成。

又過了半小時,第二筆、第三筆訂單陸續進來。都是內部測試,都用 sandbox 金流。全部成功。

小琪在下午三點的時候傳了一張截圖,是前端訂單確認頁面,上面顯示「您的訂單已成立」,下面有一個綠色的勾勾。

「Zhi哥,前端一切都正常!」

志明回了一個讚。


晚上七點,大部分同事都走了。辦公室暗了一半,只剩下幾盞燈還亮著。

志明還沒有走。他說不清楚為什麼。系統上線順利,訂單處理正常,沒有 error,沒有 failed job。他可以走了。副總在 Slack 說了「辛苦了,今天做得好」。他可以關掉筆電,回家,吃雅婷煮的飯,看女兒畫畫,洗澡,睡覺。

但他沒有走。

他坐在位子上,打開 Horizon dashboard,重新整理。

Jobs processed: 23。Failed: 0。Avg wait time: 1.2s。Avg runtime: 0.8s。

正常。

他關掉 Horizon,打開 production log,設定 tail -f。

[2024-06-14 19:05:43] production.INFO: OrderConfirmationEmailSent {"order_id":120}
[2024-06-14 19:07:12] production.INFO: OrderConfirmationEmailSent {"order_id":121}
[2024-06-14 19:09:56] production.INFO: OrderConfirmationEmailSent {"order_id":122}

每一筆都是独立的 INFO level log。沒有 warning。沒有 error。

志明把椅子往後靠,閉上眼睛。

他想起星期四在廁所裡,大衛眼睛紅紅的樣子。「Zhi哥,我不是故意的。」

他想起星期三在茶水間,老張說:「我只是不想收拾殘局。」

他想起星期二晚上,自己一個人坐在書桌前面,看著 GitHub Issue #247,看著那個 maintainer 14個月前的回覆:We are aware of this issue but do not have immediate plans to address it

他想起星期一的會議,副總說:「我相信你的判斷。」

所有的聲音在他腦中排列成一條線,從星期一到星期五,從那個搖頭的動作到那段 commit 的 comment,從大衛的測試報告到小琪的螢幕錄影。

每一環都在說同一句話。

他沒有聽。

志明睜開眼睛,看著螢幕上的 log。OrderConfirmationEmailSentOrderConfirmationEmailSentOrderConfirmationEmailSent。像一列沒有終點站的火車,每節車廂都長得一模一樣。


晚上十一點,他終於收拾東西,搭電梯下樓。

台北的夜晚還很熱。空氣裡面有機車排氣的味道跟某家鹹酥雞的香味。他走在人行道上,手機震了一下。是雅婷。

「還在公司?」

「剛走。」

「嗯。我留了飯在電鍋裡。」

「好。」

「志明。」

「嗯?」

「你還好嗎?」

他看了這則訊息三秒。然後回:「还好。今天上線順利。」

「那就好。快回來。」

他把手機收進口袋,繼續走。經過一家還在營業的超商時,他停下來,走進去買了一罐咖啡。不是即溶的,是罐裝的。他站在超商門口打開來喝,咖啡是溫的,不冰也不熱。

他看了一下手機。Slack 上面 #project-payment-gateway 的最後一則訊息是小琪在晚上九點傳的:

> 辛苦了大家~今天終於上線了 🎉🎉🎉 週末好好休息!

沒有人回應。因為大部分人都已經關掉 Slack 了。

志明把咖啡喝完,罐子丟進垃圾桶,往捷運站走。

他到家已經十二點多了。雅婷留的飯在電鍋裡,滷肉飯配滷蛋。他坐在餐桌前面吃,飯已經不熱了,但還是有味道。雅婷沒有出來,臥室的門關著。

他洗完澡,躺在床上,拿起手機,最後一滑 Slack。

然後他打開生產環境的 log,遠端 SSH 進去,跑了一次 tail。

[2024-06-14 23:58:17] production.INFO: OrderConfirmationEmailSent {"order_id":156}
[2024-06-14 23:59:02] production.INFO: OrderConfirmationEmailSent {"order_id":157}
[2024-06-15 00:01:44] production.INFO: OrderConfirmationEmailSent {"order_id":158}

正常。

他把手機放在枕頭旁邊,閉上眼睛。

三十秒之後,他拿起手機,又看了一次。

[2024-06-15 00:03:12] production.INFO: OrderConfirmationEmailSent {"order_id":159}
[2024-06-15 00:03:12] production.WARNING: QueueWorkerTimeout {"timeout":30,"connection":"redis","queue":"default"}

志明盯著第二行。

QueueWorkerTimeout。WARNING 級別。

他坐起來,打了 SSH session 檢查 Queue worker 狀態。Worker 還在跑。Horizon dashboard 上 Failed jobs: 0。他把 log 往上滾,找到那個 WARNING 的前後文——Queue worker 在處理 PaymentCompletedEventOrderConfirmationEmailQueued 之間超過了 30 秒 timeout,然後被 kill 並自動重啟。

他在 log 裡面搜尋 QueueWorkerTimeout,發現這不是第一次。從 23:47 到 00:03,三次。大約每十五到二十分鐘一次。

不頻繁。不密集。Failed jobs 為 0。

志明把 Terminal 關上,靠回枕頭。

他告訴自己:這只是 WARNING,不是 ERROR。Queue worker timeout 是 Laravel 正常的安全機制。SMTP server 有時候比較慢,或者 Redis 連線偶爾不穩定。

他告訴自己。

但他的心跳沒有慢下來。

他閉上眼睛。黑暗中,那些 log 的排列方式像一根刺,插在兩行正常的 INFO 之間:

PaymentWebhookReceived
OrderStatusUpdated
PaymentCompletedEventDispatched
**QueueWorkerTimeout**
OrderConfirmationEmailQueued
OrderConfirmationEmailSent

不致命。但拔不掉。

他想起自己在 Controller 上面寫的 comment:

> // TODO: Implement DB lock or idempotency key before high-traffic events

下個月的會員日。流量是平常的五倍。五倍的 webhook。五倍的 Queue worker 負載。五倍的 Race Condition 被觸發的機率。

如果 Queue worker 已經在凌晨有 timeout 的警告了,到時候會怎樣?

志明翻身,把枕頭對折,壓在耳朵旁邊。

「志明。」

雅婷的聲音從黑暗中傳來。她翻了身,沒有開燈。

「嗯?」

「怎麼了?」

安靜了三秒。志明可以感覺到雅婷在黑暗中注視著他。

「沒事。」他說。

雅婷沒有回答。過了大概十秒,她又翻身回去。

志明閉著眼睛,聽著雅婷的呼吸,聽著窗外台北的夜聲——遠處有一輛救護車的鳴笛,然後慢慢變弱,然後消失。

他的心跳還是很快。

他拿出手機,螢幕的亮光在黑暗中很刺眼。他打開 Horizon dashboard——Failed jobs: 0。他看了五次。

然後他把手機螢幕朝下,放在枕頭旁邊。

他告訴自己:明天再說。

但他知道他睡不著了。

他就那樣躺著,在黑暗中,聽著自己的心跳,像一個 count-down timer 在跑,而他不知道終點是什麼。

8914 字 •