前几天写了一篇《百万订单的架构演化》,有人留言「期待更多细节」。想了一下,确实踩了不少坑,写成系列应该对大家有帮助,所以就有了这个「百万订单」系列。
背景
我们是做花店 SaaS 的,业务是把各个外卖平台的订单聚合到一起。花店这个行业很特殊,节日单量特别大,像情人节、七夕、母亲节,都是高峰;平时单量又很少,大部分花店就靠节日活着。我们常戏称:人家过节,我们过劫。一到节假日,压力特别大,生怕系统崩掉,而且留给我们的恢复时间也很有限。
问题与解决
有一次过节,部分用户反馈某个操作特别慢,而这个操作大家用得还很频繁。操作慢,我们的第一反应就是数据库。看了一下表,数据已经 2000 多万行了,心想肯定是它了。于是大家开始瘦表,忙了一个多小时,表瘦下来 1000 多万行,觉得这次总该行了。结果用户还是反馈很慢,于是又做了第二轮排查:给接口代码加上日志,记录详细的执行时间,最后定位到一行代码:
Yii::$app->queue->remove($jobId);
把这行代码注释掉,发布之后,就再没有用户反馈慢了。
这行代码的作用就是删除队列里的 Job,告诉队列这个 Job 不用再执行了。翻开框架源码一看,结果大吃一惊。
/**
* Removes a job by ID.
*
* @param int $id of a job
* @return bool
* @since 2.0.1
*/
public function remove($id)
{
while (!$this->redis->set("$this->channel.moving_lock", true, 'NX', 'EX', 1)) {
usleep(10000);
}
if ($this->redis->hdel("$this->channel.messages", $id)) {
$this->redis->zrem("$this->channel.delayed", $id);
$this->redis->zrem("$this->channel.reserved", $id);
$this->redis->lrem("$this->channel.waiting", 0, $id);
$this->redis->hdel("$this->channel.attempts", $id);
return true;
}
return false;
}
看完源码发现,框架本身的设计就有几个问题:
- 自旋忙等无超时:拿不到锁就每 10ms 重试,没有上限。锁如果长期被占,调用方会无限等待。
- 锁不主动释放:靠 1 秒 TTL 自然过期,等价于全局串行约 1 次/秒,所有 remove 争用方排一条队。
- TTL 可能短于临界区:临界区内 5 次串行网络往返,网络抖动下耗时可超 1 秒,锁提前过期,互斥失效。
所以同时有 N 个请求调用 remove,排在最后的要等大约 N 秒。比如取消订单的高峰期同时来 50 个,最后一个要等约 50 秒。结果会连锁反应:
- PHP-FPM 进程被占满,其他正常接口也进不来,整站都会卡住。
- nginx 超时返回 502/504。
- 几十上百个进程同时空转,Redis 的 QPS 被打高。
反思
定位问题:先拿数据说话
现在回头看,第一反应就是数据库,确实浪费了很多时间,也错过了黄金抢救时间。第一次就应该拿数据说话:哪里慢,为什么慢。
另外,节假日当天大家都高度紧张,不敢随便发布代码,排查只能格外谨慎。
谁来背锅
写这个功能的人是照着业务逻辑写的,根本不会去翻框架源码。在他看来,调用底层接口不用考虑这些问题,框架底层肯定都支持得很好。结果却事与愿违。责怪框架的同时,我们是不是也该问问自己:对框架底层熟不熟悉,知不知道它适合哪些场景,在高并发下会不会出问题。这才是作为一个开发者应该关心的问题。
说白了,这就是一种迷信:默认「底层都支持得很好」。
那上游呢?我又去查了一下 GitHub 上的记录:
| 问题 | 上游现状 | 证据 |
|---|---|---|
| remove() 锁设计(自旋/限速/非原子/不幂等) | 未修,2017 年至今零实质改动 | master 源码逐字一致 |
| clear() SET NX 无 EX | 未修 | master 源码 |
| reserve() 幽灵 id 无容错 | 已修(2023-02) | 动机是 PHP 8.1 兼容,非队列一致性 |
| moveExpired 崩溃丢任务 | 已修(PR #516,2025-03) | 顺序对调,at-most-once 改 at-least-once |
九年里没人把 remove 会卡这件事报成 issue,毕竟静默变慢的问题,进不了报 bug、修 bug 的管道。
本质问题
更本质的问题,是没有一套贴近真实场景的压测体系,只能把线上当压测,出了问题全靠用户反馈。后来我们给高频接口加了调用时长监测,有点用,但没解决根本问题。真正需要的,是一套真实场景的自动化测试覆盖,加上一套接口监控体系,才能及时发现问题。
框架的其他问题
Laravel 的队列设计其实比 Yii2 好很多,但再好用的框架,也有你没看清的角落。说两个我们实际踩过的。
Laravel 的限流机制
'api' => [
'throttle:60,1',
'bindings',
],
意思是 1 秒钟只放行 60 个请求。这个坑我们不止踩过一次:老版本的默认配置都带着它,上线后一般都想不起来改,流量一起来,请求全被挡在门外。后来索性都把它去掉了。
Laravel 的中间件
Laravel 还有一些奇葩的中间件,比如把空字符串转成 null,还有递归去掉字符串两端的空格。平时没什么感觉,但跟外部系统对接、做签名校验的时候,这些中间件很容易让签名失败。出问题的时候,你查代码、对参数、翻文档,一圈下来也很难想到,是框架在你不知情的时候动了你的输入。
写在最后
说到底,这些坑的共同点只有一个:框架干了什么,你不去看,就不知道。它可能是 Yii2 里一把不声不响的锁,也可能是 Laravel 里一个没人改的默认配置、一个悄悄动你数据的中间件。
所以,少一点想当然。迷信框架,迟早是要还的。