百万订单踩坑记-迷信框架

前几天写了一篇《百万订单的架构演化》,有人留言「期待更多细节」。想了一下,确实踩了不少坑,写成系列应该对大家有帮助,所以就有了这个「百万订单」系列。

背景

我们是做花店 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;
    }

看完源码发现,框架本身的设计就有几个问题:

  1. 自旋忙等无超时:拿不到锁就每 10ms 重试,没有上限。锁如果长期被占,调用方会无限等待。
  2. 锁不主动释放:靠 1 秒 TTL 自然过期,等价于全局串行约 1 次/秒,所有 remove 争用方排一条队。
  3. 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 里一个没人改的默认配置、一个悄悄动你数据的中间件。

所以,少一点想当然。迷信框架,迟早是要还的。

欢迎关注公众号,第一时间更新