4.3 RocketMQ 削峰:秒杀场景实战

场景:五万 QPS 砸在一个库上

秒杀是我们业务里最刺激的形态:晚上八点,十万个用户同时抢一千件商品,瞬时 QPS 从日常的两千直接飙到五万以上。拆库之前这种流量靠主库硬抗,结果就是连接数打满、慢查询堆积,最后整站抖动三分钟才缓过来。拆完库、上了本地消息表之后,我们发现还是差一口气——请求还没进订单服务,网关层就被打穿了。

有意思的是,秒杀活动把问题暴露得最彻底,但真正让我们下定预算做削峰的,是每周一次的常规抢购。常态化的小规模峰值虽然只有一两万 QPS,但一周抖三四次,客服和运维都受不了。峰值流量治理这件事,不能只针对大促一天做,要看常态化的流量形状。

问题的本质是流量形状:秒杀的峰值又高又窄,而数据库的处理能力是平坦的。解法就两个字:削峰。把瞬时进来的请求先堆积在消息队列里,消费者按数据库能接受的速度平稳消费,把一座尖山削成平原。这就像超市结账排队,收银员不用同时应付一千个顾客,按处理速度一个个来。

当时选型对比过 Kafka 和 RocketMQ:

维度 Kafka RocketMQ
延迟队列 需自行实现 原生支持
事务消息 无 原生支持
消费重试与死信 需自行实现 内置机制完整
运维复杂度 中 成熟控制台
云上集成 一般 一键开通,云监控打通

最后选 RocketMQ 的决定性因素是重试和死信队列开箱即用,这对秒杀场景的容错至关重要,五人团队不想自己造这套轮子。腾讯云上的实例选了标准版三副本,控制台直接开通,省去自建集群的运维人力。

方案:秒杀链路三道关

整个秒杀链路我们设计了三道关,层层削减流量:

第一道关在API 网关上:秒杀接口独立限流,按用户 ID 做令牌桶,同一秒内同一用户只放一个请求进来。

第二道关在应用层:请求进来先做库存预扣减检查,直接用 Redis 的原子减操作,库存不够立即返回失败,根本不碰消息队列。

第三道关才是消息队列:通过前两关的请求被组装成订单消息投递到 RocketMQ,前端立即返回“排队中”。消费者按固定并发数平稳消费,写库速度控制在数据库的舒适区内。

环节 作用 拦截或缓冲的流量占比
网关限流 挡住刷单与超量请求 约 40%
Redis 预扣减 挡住无库存请求 约 45%
消息队列削峰 平滑写库峰值 剩余全部
数据库 稳定写入 约 15% 源流量

配置与代码

生产者部分,订单消息发送的核心逻辑:

func (p *Producer) SendOrderMsg(ctx context.Context, o *Order) error {
    msg := &primitive.Message{
        Topic: "seckill_order",
        Body:  o.ToJSON(),
    }
    msg.WithTag(fmt.Sprintf("sku_%d", o.SkuID))
    msg.WithKeys([]string{o.OrderNo})
    res, err := p.producer.SendSync(ctx, msg)
    if err != nil {
        log.WithContext(ctx).Errorf("send failed, key=%s, err=%v", o.OrderNo, err)
        return err
    }
    log.WithContext(ctx).Infof("send ok, msgId=%s", res.MsgID)
    return nil
}

消费者端控制速度是关键,并发数不是越大越好,而是压测出来的:

c, _ := rocketmq.NewPushConsumer(
    consumer.WithGroupName("seckill_consumer_group"),
    consumer.WithNameServer(addrs),
    consumer.WithConsumeMessageBatchMaxSize(16),
)
c.Subscribe("seckill_order", consumer.MessageSelector{},
    func(ctx context.Context, msgs ...*primitive.MessageExt) (consumer.ConsumeResult, error) {
        for _, m := range msgs {
            if err := processOrder(ctx, m); err != nil {
                return consumer.SuspendCurrentQueueAMoment, nil
            }
        }
        return consumer.ConsumeSuccess, nil
    })

processOrder 里做了两件防护的事:基于订单号的幂等表插入,重复消息靠唯一索引拦下;以及超时消息丢弃,消息里带创建时间戳,超过十分钟未处理的直接拒绝并告警,防止活动结束后积压消息慢慢消费再把库存加回去。

幂等表的建表语句也简单,关键是订单号字段加唯一索引:

CREATE TABLE order_consume_log (
    order_no VARCHAR(64) NOT NULL COMMENT '订单号',
    consumed_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '消费时间',
    status TINYINT DEFAULT 1 COMMENT '1 成功,2 失败已跳过',
    PRIMARY KEY (order_no)
);

消费入口的幂等检查就一行插入,冲突即重复:

err := tx.Create(&log).Error
if isDuplicateKey(err) {
    return nil // 已处理过,直接跳过
}

消费堆积是必须盯的指标。我们在云监控上配了两个告警:堆积量超过五千条、消费 TPS 连续五分钟低于生产 TPS,任何一个触发都会砸醒值班的同学。腾讯云 RocketMQ 控制台自带这些指标,配告警就行,不用自己搭采集。

踩坑:削峰不是万能药

第一个坑是消费者把数据库打满。我们最初把并发调到 64,心想吞吐越高越好,结果省掉了消费侧的写库压力——秒杀是削了,但消费速度超过了数据库的承受力,高峰期数据库又开始抖。后来压测确定了并发 16 这个甜点,写库 TPS 稳定在三百左右,数据库负载在 60% 以下。

第二个坑是重复消费。消息队列的 at-least-once 语义加上重试机制,同一条消息处理三四次很正常。我们有一回新链路忘记做幂等,同一个订单扣了三次库存,对账时账面亏空吓出一身冷汗。之后所有消费入口都强制幂等:业务唯一键入库唯一索引,重复插入被拦下,消费日志兜底记录处理结果。

第三个坑是死信队列的监控。消费失败若干次的消息进入死信队列,一开始谁也没看,两周后一查里面躺了两千多条,全是异常 SKU 导致的扣减失败,用户那边显示排队中,实际库存根本没扣。复盘后定了规矩:死信队列每天巡检一次,超过十条自动告警,并且要求当周清零。

第四个坑是压测方法论。并发数到底该设多少,前期我们全靠猜,压测之后才有底气。压测脚本模拟真实流量形状:三秒内涌入五万请求,观察消费 TPS、堆积量、数据库负载三条曲线。带业务逻辑的压测很重要,空接口压出来的数字毫无参考价值。

附上我们压测得到的参数关系,供参考:

消费并发 消费 TPS 数据库负载 结论
8 180 百分之四十 余量大,浪费
16 300 百分之五十八 甜点,稳定
32 420 百分之八十五 危险边缘
64 抖动剧烈 超过百分之九十五 不可用

小结

削峰的本质是用空间换时间、用最终一致换系统稳定。秒杀链路三道关的思路可以迁移到任何高并发写入场景:入口限流、前置过滤、队列异步化。三个数字记住就有八成把握:网关挡四成、缓存挡四成、队列平滑剩下的峰值。

消息队列实例与配套的消费节点 CVM 是一次性规划好的规格,趁新用户活动采购成本友好不少,有采购计划的同学可以参考这个腾讯云 CVM 限时活动页。

延伸阅读