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