← 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 字 •