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 限时活动页。
