秒杀系统高并发优化实战(C++ / Drogon):收官篇 · 6-8 章规划与预计实现(MQ 削峰 / Lua 预扣 / 防刷限流)
系列写到第五章「读性能优化」时,项目在 v0.2.0 收官了:代码定格在「直打数据库 + Redis 读缓存」这一档,第六至八章(MQ 削峰 / Lua 原子预扣 / 防刷限流)不再落地实现。
但设计不该烂尾。这一篇把剩下三章一次性讲完——不贴落地代码,只讲"如果继续做,会怎么做、为什么这么做、预计能拿到什么、会踩哪些坑"。
本篇性质说明:本篇没有配套落地代码(仓库止于
v0.2.0,见docs/PLAN.mdADR-10)。文中所有架构、接口、Key 均沿用前五章既有约定,属预计方案;出现的 QPS 数字均为预期目标,不是实测值。判断依据只有一条:它们能不能把已经证明过的方法论继续用下去。
一、先交代:为什么停在第五章
| 理由 | 说明 |
|---|---|
| 验收指标的性质变了 | 项目的核心命题是"QPS 跃迁来自架构决策,而不是换框架或堆中间件"。前五章已经把这件事证明完整:440 QPS → 万级,全部来自读缓存这一层架构改动 |
| 取舍密度会被稀释 | 前五章每章都有真实两难(超卖怎么防、Redis 同步客户端为什么不能用、缓存失效删 item 还是 all、四项增强为什么默认关)。到了 MQ / Lua 阶段,多数决策会退化成"按中间件文档来" |
| 收尾质量 > 功能数量 | 这个仓库要作为"可复习标本 + 拿得出手的开源库"交付。把已完成部分打磨到每篇都能对照真实代码与实测数字,价值高于多两个从没压测过的半成品阶段 |
代价也说清楚:架构演进止步"单体 + 缓存","单体 → 微服务"这条线不完整。所以才有这篇——留档设计,而不是留档代码。
二、第六章预计实现:MQ 削峰填谷
2.1 它要解决的真问题
第五篇加了读缓存,读路径压下去了;但写路径没有任何变化:每个下单请求依旧占一条 DB 连接、持 seckill_sku 行锁直到事务提交。100 并发下临界区约 22ms,行锁把写吞吐锁死在几百到几千 QPS。
瓶颈不在"数据库慢",而在请求的瞬时速率直接等于数据库的落库速率。削峰填谷要做的,就是把这两件事解耦。
2.2 预计架构
flowchart LR
R["10 万并发<br/>下单请求"] --> A["下单接口<br/>只投消息,不落库"]
A --> Q["RabbitMQ 队列<br/>堆积 10 万条<br/>持久化 + prefetch"]
Q --> C["消费者<br/>按 DB 速率落库"]
A -.->|"解耦"| G["接口不再碰 DB<br/>QPS 可达数万"]
Q -.->|"削峰填谷"| H["峰值 10 万条/秒 → DB 只看到恒定流速<br/>空闲期加速消费,把积压吃回去"]
C -.->|"可靠性底线"| I["生产者 confirm<br/>消费者手动 ACK<br/>死信队列兜底"]
2.3 预计实现要点
- 接口形态:
POST /api/seckill校验参数后只投递消息,立即返回{"code":0,"msg":"QUEUED","data":{"requestId":"..."}}。响应从"下单结果"变成"受理回执"——这是异步化的必要代价。 - 消息体与幂等:
{requestId, userId, skuId, ts}。requestId用于消费端去重;最终兜底仍是seckill_order的uk_user_sku唯一键(前五章那套幂等照旧有效)。 - 削峰原理:队列把"瞬时并发"变成"待办清单"。数据库面对的始终是消费者按自己节奏发出的请求,这就是"削峰";空闲时消费者可以多并发把积压吃回去,这就是"填谷"。
- 可靠性三件套:生产者
confirm确保消息真进了 broker;消费者手动 ACK(落库成功才 ACK,失败进死信);死信队列 + 定时补偿处理反复失败的消息。 - 结果查询:新增
GET /api/seckill/result/{requestId}返回PENDING / SUCCESS / SOLD_OUT / DUPLICATE。前五章的 SSE / WebSocket 能力(Drogon 原生支持)可用于主动推送。
2.4 预计会踩的坑
| 坑 | 现象 | 预计解法 |
|---|---|---|
| 消息丢失 | 接口返回"已受理"但订单永不落库 | 生产端 confirm + 本地消息表/重投;消费端先落库后 ACK |
| 重复消费 | 同一 requestId 落两次库 |
消费端幂等(requestId 去重 + uk_user_sku 兜底) |
| 消费者串行 | 队列很长但落库速率上不去 | 多消费者线程 / 协程 + prefetch 调参 |
| 接口"假成功" | 用户看到受理但实际失败 | 结果查询要能如实返回失败原因,而不是永远 PENDING |
预计验收:下单接口 QPS 数万级;DB 落库速率稳定可控;0 超卖、0 重复下单(硬指标不放松)。
三、第七章预计实现:Redis Lua 原子预扣
3.1 为什么 MQ 之后还有瓶颈
MQ 削峰解决的是"请求速率 ≠ 落库速率",但没解决行锁本身:消费者再多,只要它们抢的是同一行 seckill_sku,落库仍然是串行的。要再上一个量级,必须让"抢"这件事离开数据库——DB 退化成"最终落账",不再承担竞争裁决。
flowchart LR
R["请求<br/>限流后"] --> L["Redis Lua(原子)<br/>判库存 + 扣减<br/>+ 一人一单"]
L --> M["投递 MQ<br/>扣成功才投"]
M --> D["DB 落账<br/>不再裁决竞争"]
L -.->|"扣减失败<br/>无库存 / 已买过"| X["直接拒绝<br/>连 MQ 都不投"]
M -.->|"投递失败"| Y["补偿回补库存<br/>回补必须幂等"]
L -.->|"库存预热"| Z["活动前写入<br/>seckill:sku:v1:stock:{id}"]
3.2 预计实现要点
- 库存预热:活动开始前把
seckill_sku.stock写入 Redisseckill:sku:v1:stock:{id}(这个 key 在前五章的CacheKeys.h里已经预留,当时标注"阶段四 7.2 预留,当前不读写")。 - Lua 一次裁决三件事:读库存 →
> 0则扣减 → 同时判"这个 user 是否已买过"。三件事必须在同一个 Lua 脚本里原子完成,否则并发下会各自读到旧值。 - 一人一单下沉到 Redis:用
SET记录user:sku已购,Lua 里原子SADD判重;DB 的uk_user_sku仍是最终一致性的兜底,但不再是第一道防线。 - 售罄标记:库存扣到 0 时写一个标记 key,后续请求在入口快速失败,不必再进 Lua 全流程——把"抢不到"的请求成本压到一次 GET。
- 回补的幂等:MQ 投递失败要把 Lua 已扣的库存加回去。回补必须可重入(记录补偿凭证),否则重试会把库存加多,直接导致超卖。
3.3 预计会踩的坑
| 坑 | 后果 |
|---|---|
| Redis 与 MySQL 最终不一致 | 预扣成功但落库失败 → 少卖;需对账与补偿 |
| 回补不幂等 | 库存越补越多 → 超卖(最危险) |
| Lua 脚本过长 | Redis 单线程被阻塞,整个缓存层抖动 |
| 预热与开抢竞态 | 活动已开始但库存还没写完 → 误判售罄 |
预计验收:QPS 从万级跃到数万~十万级;DB 压力从"每单一次写"降为"批量落账";0 超卖、0 重复。
四、第八章预计实现:防刷、限流与秒杀资格校验
前两章解决"系统扛不住",这一章解决"流量该不该进来"。三层防线,全部挡在预扣链路之前:
flowchart LR
T["全部流量<br/>含脚本 / 黄牛"] --> L1["① 接口限流<br/>用户 / IP 双维度"]
L1 --> L2["② 令牌桶 / 漏桶<br/>平滑放行"]
L2 --> L3["③ 秒杀令牌大闸<br/>按库存发令牌"]
L3 --> P["三层都通过<br/>才进入预扣与落库链路"]
L3 -.->|"验收项"| N["挡掉的是「无效流量」,不是「正常用户」<br/>误伤率本身就是验收项"]
4.1 预计实现要点
- 接口限流(固定窗口 / 滑动窗口):Redis Lua 原子计数,按用户 + IP 双维度限频。固定窗口实现最简单但有临界突刺(窗口边界可放行两倍流量);滑动窗口用
ZSET记时间戳,精度更高但内存更贵——这是本章第一个真实取舍。 - 令牌桶 / 漏桶:把"平均速率"与"突发容量"分开控制。自实现后做成路由层中间件,与业务解耦。令牌桶允许突发(桶里存了多少就能一次性放多少),漏桶强制匀速——秒杀场景通常选令牌桶。
- 秒杀令牌大闸:活动开始前按库存量发放略多于库存的令牌(多发的余量用于对冲"抢到不付款"),下单必须携带有效令牌。
- 一次性令牌:令牌校验通过即删除(
DEL原子),同一 token 无法重复提交——这比"在业务里判重复"更早、更便宜。
4.2 预计会踩的坑
| 坑 | 后果 |
|---|---|
| 令牌超发 | 请求数超过库存 → 少卖或(若无兜底)超卖 |
| 令牌覆盖 | 并发下发时后写覆盖前值 → 有的人拿到无效令牌 |
| 限流误伤 | 阈值太紧,正常用户被挡;太松则形同虚设 |
| 固定窗口突刺 | 窗口交界处瞬时放行 ≈ 2× 阈值 |
| 并发扣大闸 | 大闸余额扣成负数 → 超发令牌 |
预计验收:无效/重复流量在进入预扣前被挡下;正常用户可用;大闸发放量与库存关系可对账。
五、三章的共性:为什么"取舍密度"确实更低
这不是给偷懒找理由,是实打实的差别。对比一下每章的决策类型:
| 章 | 典型决策 | 决策类型 |
|---|---|---|
| 二(表设计) | 幂等靠唯一键还是应用层判重 | 自主设计,有真实代价取舍 |
| 三(登录) | 同步 Redis 客户端在 IO 线程里为什么不能用 | 踩坑驱动的架构约束 |
| 四(下单) | 乐观锁 / 悲观锁 / SQL 条件判断 | 多方案权衡,各有适用面 |
| 五(缓存) | 失效删 item 还是 all;四项增强为什么默认关 |
新鲜度 ↔ 命中率 的定量交换 |
| 六(MQ) | 用 RabbitMQ 还是 Kafka | 多为范式选择(都成熟,按生态选) |
| 七(Lua 预扣) | 脚本里先判库存还是先判一人一单 | 实现细节,正确答案基本唯一 |
| 八(限流) | 固定窗口还是滑动窗口 | 算法既定,按精度/内存选 |
前四章的决策没有标准答案,答案取决于你的业务约束;后三章的决策有标准答案,更多是"按成熟范式正确实现"。这正是收官的核心理由:这个项目想练的是前者。
六、如果续做:路线与验收标准
| 阶段 | 手段 | 预期 QPS | 验收硬指标 | 压测方法 |
|---|---|---|---|---|
| 三(六章) | MQ 削峰填谷 | 数万 | QPS↑一个量级 · 0 超卖 · 0 重复 | 下单接口压测(吞吐)+ 结果查询对账 |
| 四上(七章) | Lua 原子预扣 | 数万~十万 | 同上 + Redis 与 DB 对账一致 | 压测后比对 Redis 余量 / DB 余量 / 订单数 |
| 四下(八章) | 限流 + 令牌大闸 | 同上 | 同上 + 大闸发放量与库存可对账 | 混合流量(正常 + 脚本)验证误伤率 |
硬指标全程不变:QPS 提升一个量级、不超卖、不重复下单。这三条是前五章一路守下来的底线,续做时一条都不能放松。
七、对比表:已实测 vs 预计
| 维度 | 前五章(已交付) | 后三章(本篇预计) |
|---|---|---|
| 状态 | ✅ 代码落地 + 实测 | ⏸ 规划与设计(无代码) |
| 核心手段 | 原子 SQL / 应用层闸门 / 读缓存 | MQ 削峰 / Lua 预扣 / 限流令牌 |
| 瓶颈认知 | 行锁串行化(读侧已解,写侧未解) | 逐层把裁决从 DB 移出 |
| 数据来源 | 官方 JMeter 实测(440 / 万级) | 预期目标,未实测 |
| 决策密度 | 高(无标准答案的取舍) | 低(按成熟范式实现) |
功能抉择(本篇核心权衡)
① 为什么"不写"也是一种技术决策?
技术项目最容易犯的错是"因为路线图上画了,所以必须做完"。但路线图是手段,不是目的。这个项目的目的是练"有真实代价的架构取舍";后三章的取舍密度明显更低,继续做只是把"引入中间件 + 写胶水"重复三遍。承认范围边界,比假装什么都能做完更专业。
② 如果只能续做一章,从哪开始?
第六章(MQ)。理由:它针对的是前五章唯一没被解决的真瓶颈——写路径的行锁串行化。读缓存的收益已经吃干净了,不解决写路径,QPS 就卡在万级。第七章的 Lua 预扣本质是"把裁决从 DB 挪到 Redis",属于在第六章基础上的进一步优化;第八章是流量治理,优先级在两者之后。
③ 为什么这篇要如实标注"未实测"?
因为技术写作最贵的东西是可信度。前五章的每个数字都有压测脚本和 HTML 报告可复核;后三章没有代码,就绝不能把"预期"写成"实测"。预期目标与实测数字必须用不同措辞隔离——这本身就是本系列从第一章延续到收官的态度。
小结
- 系列交付范围止于第五章(阶段二,
v0.2.0收官);第六至八章以本篇「规划与预计实现」收口,无落地代码。 - 第六章(MQ 削峰):下单接口只投消息不落库,消费者按 DB 承受速率落库;预计接口 QPS 数万,可靠性靠 confirm + 手动 ACK + 死信 + 幂等。
- 第七章(Lua 预扣):库存预热到 Redis,Lua 原子完成"判库存 + 扣减 + 一人一单";DB 退化为最终落账;预计 QPS 再上一层,回补幂等是防超卖的关键。
- 第八章(防刷限流):接口限流(用户 + IP)+ 令牌桶 + 秒杀令牌大闸三层前置;挡无效流量而非正常用户。
- 收官判据:前五章证明了"QPS 跃迁来自架构决策";后三章的决策有标准答案、取舍密度更低——把已完成的部分做扎实,比堆两个没压测过的半成品阶段更有价值。🐾
配套源码
本文对应代码已开源:Hespethorn/seckill-cpp
C++ / Drogon 高并发秒杀系统实战,含完整的压测基线、技术决策记录(ADR)与可复现脚本:
- 阶段一(直打数据库):QPS≈440,0 超卖
- 阶段二(Redis 缓存):读接口 list 10557 QPS / detail 8024 QPS
如果对你有帮助,欢迎 star ⭐

