在 16G 内存的笔记本上压测优惠券系统:4 万次抢券、百万行分发,我压出了 4 个生产级 Bug
一次”照着简历指标跑个分”的压测,最后变成了一次完整的排错实战。 所有数字均为本机实测,所有结论都有对账依据。
一、背景
最近在学习和完善一个优惠券营销平台项目(模板管理、用户抢券、批量分发、结算核销),简历上写了三个核心指标:
- 券列表查询 4000+ QPS
- 秒杀抢券 1500+ TPS
- 百万级名单分发分钟级完成
这些数字最初来自教程作者的宣称值(他的环境是 M2 Max + 独立部署)。我不想在面试时讲一个自己没跑过的数字,于是决定在本机完整压测一轮——目标不只是拿数字,还要做数据对账。事实证明这一步是对的:压测过程中压出了 4 个隐藏 Bug,其中一个直接导致百万分发任务丢失 5000 条数据。
二、压测环境
全部组件挤在一台笔记本上,这个前提很重要——以下所有数字都是”极限环境”下的保守值。
2.1 硬件与操作系统
| 项 | 配置 |
|---|---|
| CPU | AMD Ryzen 7 6800H(8 核 16 线程) |
| 内存 | 16 GB |
| 操作系统 | Windows 11 专业版(10.0.26200) |
2.2 软件版本
| 组件 | 版本 | 说明 |
|---|---|---|
| JDK | Amazon Corretto 17.0.13 LTS | 服务与 RocketMQ 共用 |
| Spring Boot | 3.x | 微服务:merchant-admin / engine / settlement / distribution |
| MySQL | 8.0.40 | 双库 one_coupon_0 / one_coupon_1,t_user_coupon 按 user_id 分 32 表 × 2 库 |
| Redis | 5.0.14(Windows 版) | 这个版本后面会引发一个大坑 |
| RocketMQ | 5.3.4 | NameServer + Broker |
| ShardingSphere | 5.x | 分库分表中间件 |
| JMeter | 5.6.3 | 非 GUI 模式压测,与被测服务同机 |
2.3 JVM 参数(关键)
16G 内存要同时跑 4 个 Java 服务 + NameServer + Broker + JMeter + MySQL + Redis,堆必须精打细算:
| 进程 | 堆参数 | 来源 |
|---|---|---|
| 4 个业务服务 | -Xms256m -Xmx768m | java -jar 显式指定 |
| RocketMQ NameServer | -Xms256m -Xmx512m | Windows 版 runserver.cmd 默认值 |
| RocketMQ Broker | -Xms256m -Xmx512m | Windows 版 runbroker.cmd 默认值 |
| JMeter | 默认 1G | 非 GUI 模式 |
全部进程稳定运行时约占用 3 GB 出头,16G 内存刚好能覆盖,但已没有余量——这也是吞吐数字偏保守的原因之一。
2.4 部署拓扑
┌────────────────────────── 笔记本(R7 6800H / 16G / Win11)──────────────────────────┐│ ││ JMeter(压测机) ──HTTP──> merchant-admin:10010 ─┐ ││ engine:10020 ├─> MySQL 8.0(64 张分表) ││ settlement:10030 ├─> Redis 5.0.14 ││ distribution:10040 └─> RocketMQ 5.3.4(NS + Broker) ││ ││ 服务间协作:MySQL 共享表 + RocketMQ 消息(Nacos 已禁用,无 RPC 调用) │└─────────────────────────────────────────────────────────────────────────────────────┘说明:Nacos 在各服务配置中均为 enabled: false——该项目服务间没有远程调用(协作靠共享表 + MQ),服务发现形同虚设,直连端口调用即可。
三、压测方法
- JMeter 非 GUI 模式:
jmeter -n -t x.jmx -l result.jtl -e -o report,避免 GUI 渲染影响压测端性能 - 线程数通过逐步加压找吞吐拐点确定:列表查询 80 线程、抢券 100 线程为本机最优
- 指标口径:列表查询报 QPS(无状态读),抢券报 TPS(一次请求对应一次库存扣减 + 异步落库事务)
- 每一轮压测后做数据对账:Redis 库存扣减量 vs MySQL 落库行数 vs MQ 消费日志条数,三方一致才算通过
四、阶段 1:券列表查询——并行化反而更慢
4.1 场景
结算页要遍历用户全部可用券计算最优组合。项目里有两个版本:
- 同步版
/coupon-query-sync:串行遍历 - 并行版
/coupon-query:CompletableFuture 并行金额试算 + Redis Pipeline 批量读模板
4.2 结果(80 线程 × 1000 循环 = 8 万请求/组)
| 版本 | 吞吐 | 平均 RT | 中位数 | P99 | 错误率 |
|---|---|---|---|---|---|
| 同步版 | 4687.4 /s | 16.4ms | 12ms | 26ms | 0% |
| 并行版 | 4384.5 /s | 17.6ms | 14ms | 29ms | 0% |
4.3 反直觉结论:并行版比同步版慢 6.5%
这是本次压测第一个值得写的发现。原因分析:
- 金额试算是 CPU 轻量任务(几次比较和加减),单次计算微秒级,并行化的收益封顶就这么点
- CompletableFuture 线程上下文切换的开销 > 计算本身
- 并行收集结果用了
synchronizedList,高并发下锁竞争进一步吃掉吞吐
真正有效的优化是 Redis Pipeline:把逐个 hGetAll 模板的 N 次网络往返合并成 1 次批量请求——这解释了为什么同步版 + Pipeline 反而是最快的组合。
结论:并行化适合 IO 密集型任务,CPU 轻量任务并行只会更慢。“用了 CompletableFuture”不等于”更快”,压测数据说话。
五、阶段 2:秒杀抢券——压出中间件版本兼容 Bug
5.1 链路
布隆过滤器(防穿透) → Redis Hash 模板校验 → Lua 原子扣减库存+记领取次数→ RocketMQ 异步落库(消费幂等 + 64 分表批量插入 + 延时消息到期关闭)前置准备:创建 stock = limitPerPerson = 2000 万的立减券模板;Lua 脚本的位运算返回值字段从 14 bit 改 16 bit(支持 6 万+ 的领取计数)。
5.2 结果(100 线程 × 400 循环 = 4 万请求)
| 指标 | 数值 |
|---|---|
| 抢券 TPS | 1687.8 /s |
| 平均 RT | 56.4ms |
| 中位数 / P90 / P95 / P99 | 37 / 63 / 69 / 81 ms |
| 错误率 | 0% |
| 库存对账 | 20000000 → 19959999,精确等于 40001(1 次单测 + 4 万压测) |
| MQ 消费落库对账 | 消费条数 = 落库条数,零丢失 |
| 消费端吞吐 | 单消费者约 100+/s(DB 事务 + Redis ZSet 写 + 延时消息) |
5.3 压测插曲:4 万条消息在消费端全军覆没
第一轮压测后对账发现:接口层 4 万次全部成功,但消费端一条都没落库,消息在无限重试。
排查路径:消费日志 → 每条消息都报 @user_script:4: ERR syntax error → 定位到幂等防重切面里的这段 Lua:
-- 修复前(Redis 7.0+ 语法)return redis.call('SET', key, value, 'NX', 'GET', 'PX', expire_time_ms)SET key val NX GET PX 这个组合语法是 Redis 7.0 才引入的。本机 Redis 5.0.14 直接报语法错误,导致幂等组件失效 → 全部消息消费失败 → 退避重试。
修复(等价改写,Lua 内天然原子):
-- 修复后(Redis 2.6+ 兼容)local old = redis.call('GET', key)if old then return oldendredis.call('SET', key, value, 'PX', expire_time_ms)return false教训两点:
- 写中间件相关代码时要确认目标版本,“新语法”在旧版本上不是降级而是直接报错
- 压测必须做全链路对账——如果只看接口成功率,这个 Bug 完全发现不了
5.4 其他本阶段踩坑
- RocketMQ 5.x 定时消息(timer wheel)最大延迟 3 天,模板有效期设到 3 天外会创建失败
- 消费失败重试是退避式的(10s/30s/1m/2m/…/2h),修完代码不会立刻追平,要有耐心等收敛
六、阶段 3:百万分发——丢的那 5000 条去哪了
本阶段是整个压测最有价值的部分。
6.1 场景
批量发券:EasyExcel 流式读取 100 万行用户名单 Excel(29.4MB),逐行 Lua 预扣库存,攒批 5000 条发 MQ,消费端批量落库(两级 MQ 拆解)。
6.2 结果
| 指标 | 数值 |
|---|---|
| 总耗时 | 907 秒(15.1 分钟),吞吐约 1103 行/秒 |
| Excel 读取进度 | 1000000 / 1000000 |
| t_user_coupon 落库 | 995000(差整整一批 5000 条) |
| 失败表 t_coupon_task_fail | 0 条 |
| 模板库存 | 20000000 → 18999999(扣了 1000001 次) |
| MQ 消息量 | 9349 条(理论 201 条);重试队列 1 条已消化,无死信 |
任务状态正常完成(status=3),但少了 5000 条,失败表却一条记录都没有——数据静默丢失。
6.3 排查过程
第一步:MySQL 复查。 多次计数确认 995000 不变,排除”还在重试补齐”的可能。
第二步:mqadmin 查消费组状态。 Topic 全部 ACK(diff=0),重试队列只有 1 条且已消费,无死信队列——说明末批消息被”成功”消费了,但数据没进库。
第三步:拉取 broker 末尾消息做尸检。 用 queryMsgByOffset 拉出 4 个队列的末尾消息,真相浮出水面——连续两条数据消息的 batchUserSetSize 分别是 5002、5003(连续两行的 Set 快照,差 1),最后一条 EndFlag 消息紧随其后。
6.4 根因:三个设计缺陷叠加
缺陷 1:消息风暴。 发送侧的条件写的是 SCARD >= 5000 时发送(而非 == 5000)。消费组一旦滞后、Redis Set 积压超 5000,Excel 每读一行就发一条 MQ 消息。100 万行的任务实际发出 9349 条消息,是理论值(200 批 + 1 结束标识)的 4.7 倍。
缺陷 2:快照错位多扣库存。 消费端用消息里的 batchUserSetSize 快照决定扣库存数量和 SPOP 数量。消息乱序消费时,快照与 Set 实际内容错位——某条消息按快照扣了 5000 库存,SPOP 实际弹出 0 条(早被别的消息弹走了),事务却正常提交。总扣减 1000001 = 995000 落库 + 5000 空扣 + 1 次单测验证,账目完全闭合。
缺陷 3(核心):Redis SPOP 无法随 MySQL 事务回滚。 那条进入重试队列的消息(中途一次瞬时失败触发重试),首次消费时 SPOP 弹出 5000 条 → 后续 MySQL 事务回滚 → 但 Redis 的弹出不会跟着回滚。消息重试时 Set 内容已变,弹到的是下一批数据,首次弹出的 5000 条永久丢失。落库 995000 + 失败表 0 + Set 清空 + 无死信,所有账目都能对上——这是”非事务资源先于事务提交被消费”的经典分布式一致性缺口。
缺陷 4(附赠): 通知组消费者对 notifyType="" 做 StrUtil.split("") 得到 [""],parseInt("") 抛 NumberFormatException,导致通知组每批消息退避重试 16 次(不影响落库,但浪费消费资源)。
6.5 修复方向
- 发送侧:改为”SCARD 恰好等于 5000 才发消息”,消除消息风暴
- 消费侧:扣库存/SPOP 数量改用实际弹出条数,消除快照错位
- 数据不丢的思路:SPOP 改为”先弹到临时 key 暂存、落库成功后再确认删除”;或改用游标模式(按批次号定位数据,先落库后清理);或引入本地消息表做对账兜底
6.6 关于”5000 条 v1 → v2 提升 60 倍”的说法
教程宣称同步版 5000 条要 1 分钟、优化后 1 秒内(60 倍)。我没有复刻 v1 实测,但可以给出有锚点的推算:
- v2 实测吞吐 1103 行/秒 → 5000 条约 4.5 秒
- v1 同步逐条:每行 3 次 DB 往返(查重 + 扣库存 + 插入)≈ 200 行/秒 → 5000 条约 25~30 秒
- 端到端约 6 倍;单看落库环节(逐条 200 行/秒 → 5000 条/批约 2500 行/秒)是一个数量级
为什么不是 60 倍?因为本机 v2 的瓶颈根本不在落库,而在 Excel 逐行处理时每行 3 次 Redis 往返(GET 进度 + Lua 扣减 + SET 进度)——它稀释了批量落库的收益。这也提示了下一步优化方向:进度记录攒批写,或用 Pipeline 合并往返。
七、总览
| 指标 | 教程作者(M2 Max / 独立部署) | 本机实测(R7 6800H / 16G 全家桶同机) |
|---|---|---|
| 同步券列表 QPS | 7033 | 4687 |
| 并行券列表 QPS | 5707 | 4385(比同步版还低,见 4.3) |
| 秒杀抢券 TPS | 2588 | 1687.8 |
| 百万分发耗时 | 210 秒 | 907 秒(约 15 分钟) |
本机数字普遍是作者的 60%~70%——考虑到被测服务、压测机、MySQL、Redis、RocketMQ 全部挤在一台笔记本上且堆只有 768m,这个比例是合理的。报数字先报环境,两个数字放一起对比没有意义。
八、踩坑清单(按出现频率排序)
- Redis 版本兼容:
SET ... NX GET PX是 7.0+ 语法,5.x 直接 syntax error(幂等组件全灭) - RocketMQ 5.x 定时消息上限 3 天,模板有效期超限创建失败
- Windows 的 java -jar 会锁定 fat jar:服务不重启,jar 删不掉也重建不了
- Maven repackage 的坑:pom 未声明 spring-boot 插件只出 thin jar;且时间戳未变时会跳过构建,需删旧 jar 强制重建
- SQL 细节:GROUP_CONCAT 默认 1024 长度截断,拼 64 张分表的 UNION ALL 前要
SET group_concat_max_len = 65535 - PowerShell 的 JSON 转义:curl 传 JSON 建议写文件后
--data-binary @file - 大文件日志分析:数百 MB 日志不能一次性读入内存,用流式逐行 + 滑动窗口
九、写在最后
这次压测给我最大的收获不是那几个数字,而是三件事:
- 对账思维:接口成功 ≠ 数据正确。库存扣减量、落库行数、消费日志条数三方对账,才能发现”静默丢失”
- 压测是最好的 Code Review:消息风暴、快照错位、SPOP 事务缺口这三个问题,读代码时都”看起来没问题”,只有真实流量跑起来才会暴露
- 报数字先报环境:脱离环境谈 QPS/TPS 都是耍流氓
简历上的指标最后都换成了自己的实测值:1600+ TPS、4600+ QPS、百万分发约 15 分钟——每一个数字背后都有对账记录和 HTML 报告兜底,面试可以随便深挖。
相关文件:压测 JMX 脚本、JMeter HTML 报告、一键启停脚本均随项目仓库存放。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时