3.3 排队设计:异步下单与消息队列

场景:同步下单让数据库排起了长队

限流做好之后,到达下单接口的都是相对真实的请求。但洪峰期每秒仍有数千次下单,MySQL 直接扛不住:订单插入与库存扣减在一起,同步处理时每个事务要几十毫秒,请求堆积后连接数告急,最终全部超时。本质矛盾是:用户秒级提交的洪峰,与数据库每秒只能处理数百个事务的吞吐,两者之间必须有个缓冲。这个缓冲就是消息队列。

方案:排队削峰的三种做法

异步下单的核心是接收请求、立即排队、慢慢处理。我们对比过三种落地形态:

方案 复杂度 可靠性 最终选择
直接入 MySQL 后慢慢处理 低 有脏数据风险 否
Redis 队列加消费者 低 中,需自己管可见性 是
专业消息队列 中 高,带 ACK 与重投 长期演进方向

我们选择 Redis 队列起步,原因是库存扣减本来就在同一个 Redis 实例上,用同一实例做队列不增加新组件,小团队的运维负担最轻。Laravel Queue 的 Redis 驱动自带可见性超时与重试机制,能满足秒杀需求。专业消息队列留作后续订单量再上一个量级时的演进方向。

整个流程对用户是这样的:提交下单请求后立即返回排队中,前端轮询订单状态,几秒内变成已创建或售罄。用户心理预期与系统实际处理速度之间,靠这个轮询过程完成和解。轮询的间隔设计也有讲究:前三次每秒轮询,之后逐渐拉长到三秒一次,既保证反馈及时又控制住查询压力。轮询的订单状态接口是轻量只读的,直接查 Redis 里的订单状态键,不落库,洪峰期几万次轮询对系统毫无压力。从监控数据看,九成以上的轮询在第二次或第三次就拿到终态了,用户等待感很弱。

配置:下单接口与消费者

下单接口本身的逻辑被压到极简,只做校验、扣库存 Lua 脚本、投递队列三件事,耗时控制在 50 毫秒内:

class PlaceOrderController
{
    public function __invoke(PlaceOrderRequest $request): JsonResponse
    {
        $userId = $request->user()->id;
        $code = app(StockService::class)->tryDeduct($request->sku_id, $userId);
        if ($code !== 1) {
            return $this->fail($code); // 0 售罄,-2 重复购买
        }

        $orderId = Order::generateNo($userId);
        CreateOrderJob::dispatch($orderId, $request->sku_id, $userId)
            ->onQueue('orders')
            ->delay(now()->addMilliseconds(100));

        return response()->json(['code' => 0, 'order_id' => $orderId, 'status' => 'queued']);
    }
}

消费者侧批量处理是吞吐的关键。单个消费一个订单是浪费,我们让消费者一次拉取一批、用一个事务批量插入:

class CreateOrderJob implements ShouldQueue
{
    public function handle(): void
    {
        $orders = Order::where('status', 'queued')
            ->limit(200)
            ->lockForUpdate()
            ->get();

        foreach ($orders as $order) {
            try {
                DB::transaction(fn () => $this->createOrder($order));
                $order->update(['status' => 'created']);
            } catch (Throwable $e) {
                $this->rollbackStock($order); // 失败回补库存
                $order->update(['status' => 'failed']);
                ClsLog::error('order_create_failed', ['order' => $order->id]);
            }
        }
    }
}

关键设计是失败必须回补库存并记录日志,库存不能因为订单创建失败而凭空消失。Cls 日志让失败可追溯,详情见 5.3 节。回补操作本身也可能失败,所以我们把回补失败的消息转入一个专用的死信队列,由值班同学每天定时处理,绝不让任何一笔库存悬空。库存靠谱是财务报表能平的基础,作为技术团队我们有责任给财务一个交代。

消费速率也要控制:消费者数量不是越多越好,并发消费者过多会把数据库连接打满。我们压测后的甜点是 4 个消费者进程并行,每个每秒处理约 100 单,合计每秒 400 单,与 MySQL 的安全水位吻合。这个数字的推导过程在 5.1 节压测演练里。消费者进程数的调整也做了热更新支持,无需重启服务即可加减进程,大促当晚根据队列深度灵活调配,比写死配置灵活得多。配置变更后生效速度在秒级,值班同学盯着队列深度曲线增减进程,这个画面后来成了我们大促值班的标志性场景。

队列深度是这套方案的命门,我们设定了三级告警线:正常水位低于 500,超过 2000 开始预警,超过 5000 直接触发扩容消费者进程的运维流程。大促时值班同学每十五分钟看一眼队列深度曲线,它比任何接口耗时都更早暴露问题。这里还有个小技巧:把队列深度做成前端大盘上的一个实时指标,所有人都能看到,不需要登录服务器查命令,沟通成本大幅降低。指标体系的设计在 5.2 节详细展开。

踩坑:排队设计的四个坑

坑一:用户重复提交。 排队中状态让用户焦虑,有人反复点提交,虽然 Lua 脚本有判重,但请求量还是被放大。前端按钮置灰加服务端的幂等令牌双重防护,幂等设计在 4.1 节详细展开。关于排队中的用户体验,我们还加了一个设计:前端轮询时带上预估位置信息,用户看到自己前面还有多少单在处理,焦虑感会明显下降。这个改动上线后客服咨询量降了三成,是投入产出比最高的一个小功能。

坑二:队列积压告警晚了一拍。 消费者处理速度跟不上时,积压是缓慢发生的,等告警时已经积压了几万单。监控队列深度并设置提前告警线,超过积压阈值的三分之一就开始告警,而不是等满了才响。有一次积压发生在凌晨的日常维护窗口,值班同学恰好不在电脑前,早上八点才发现积压了三万单,用户早就炸锅了。从那以后积压告警除了群消息还配了电话通知,夜间告警一律升级到电话级别。

坑三:消息可见性超时设得太短。 批量处理 200 单需要数秒,可见性超时只有 30 秒时偶尔有消息被重投,同一订单被处理两次。调大可见性超时到 5 分钟,同时订单状态机保证重复消息遇到已完成订单直接跳过。

坑四:消费失败的无界重试。 一次数据库抖动让某条消息重试了十几次,库存反复扣减回补。重试次数上限设为 3 次,超过就走人工处理队列并告警,绝不无限重试。无限重试是所有消息系统的隐形炸弹,它会在你最忙的时候把一次小故障放大成数据混乱,DDL 之外最该加的护栏就是重试上限。

小结

排队的本质是在用户与数据库之间加一个缓冲池。Redis 队列起步、批量消费、失败回补、监控积压,这四件事做到位,洪峰就被削成了平缓的小坡。用户侧的轮询是必要的体验设计,异步系统必须有同步接口的体感。最后再强调一遍:队列的可靠性主要通过监控保障,队列深度、消费速率、失败率三个指标钉在监控大盘上,整个异步体系才算真正落地。这条经验也适用于所有异步系统设计,不只是秒杀。给准备落地队列的同学三个具体建议:第一,消费 Worker 的数量要跟着积压动态扩缩,写成一个固定值迟早会在某次活动翻车;第二,消费失败的消息一定要有单独的死信通道和人工处理入口,不要只靠无限重试,重试到底只会把整条队列堵死;第三,批量入库的事务粒度别贪大,我们试过一次提交两百条,结果某条数据脏了导致整批回滚,后来改成五十条一批,吞吐没降多少,稳定性好得多。这三条都是踩过坑之后的学费,提前写进设计文档里,能省下不少大促当晚的加班。最后提醒一个监控细节:队列积压曲线一定要设置环比告警,绝对值阈值会随活动规模失效,与上一小时相比的突增往往才是故障最早的信号,这条规则后来被写进了团队的监控规范,所有异步系统一律照此执行。

延伸阅读