mobile wallpaper 1
mobile wallpaper 2
mobile wallpaper 3
mobile wallpaper 4
3464 字
10 分钟
在 16G 内存的笔记本上压测优惠券系统:4 万次抢券、百万行分发,我压出了 4 个生产级 Bug

在 16G 内存的笔记本上压测优惠券系统:4 万次抢券、百万行分发,我压出了 4 个生产级 Bug#

一次”照着简历指标跑个分”的压测,最后变成了一次完整的排错实战。 所有数字均为本机实测,所有结论都有对账依据。

一、背景#

最近在学习和完善一个优惠券营销平台项目(模板管理、用户抢券、批量分发、结算核销),简历上写了三个核心指标:

  • 券列表查询 4000+ QPS
  • 秒杀抢券 1500+ TPS
  • 百万级名单分发分钟级完成

这些数字最初来自教程作者的宣称值(他的环境是 M2 Max + 独立部署)。我不想在面试时讲一个自己没跑过的数字,于是决定在本机完整压测一轮——目标不只是拿数字,还要做数据对账。事实证明这一步是对的:压测过程中压出了 4 个隐藏 Bug,其中一个直接导致百万分发任务丢失 5000 条数据。

二、压测环境#

全部组件挤在一台笔记本上,这个前提很重要——以下所有数字都是”极限环境”下的保守值。

2.1 硬件与操作系统#

项配置
CPUAMD Ryzen 7 6800H(8 核 16 线程)
内存16 GB
操作系统Windows 11 专业版(10.0.26200)

2.2 软件版本#

组件版本说明
JDKAmazon Corretto 17.0.13 LTS服务与 RocketMQ 共用
Spring Boot3.x微服务:merchant-admin / engine / settlement / distribution
MySQL8.0.40双库 one_coupon_0 / one_coupon_1,t_user_coupon 按 user_id 分 32 表 × 2 库
Redis5.0.14(Windows 版)这个版本后面会引发一个大坑
RocketMQ5.3.4NameServer + Broker
ShardingSphere5.x分库分表中间件
JMeter5.6.3非 GUI 模式压测,与被测服务同机

2.3 JVM 参数(关键)#

16G 内存要同时跑 4 个 Java 服务 + NameServer + Broker + JMeter + MySQL + Redis,堆必须精打细算:

进程堆参数来源
4 个业务服务-Xms256m -Xmx768mjava -jar 显式指定
RocketMQ NameServer-Xms256m -Xmx512mWindows 版 runserver.cmd 默认值
RocketMQ Broker-Xms256m -Xmx512mWindows 版 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 /s16.4ms12ms26ms0%
并行版4384.5 /s17.6ms14ms29ms0%

4.3 反直觉结论:并行版比同步版慢 6.5%#

这是本次压测第一个值得写的发现。原因分析:

  1. 金额试算是 CPU 轻量任务(几次比较和加减),单次计算微秒级,并行化的收益封顶就这么点
  2. CompletableFuture 线程上下文切换的开销 > 计算本身
  3. 并行收集结果用了 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 万请求)#

指标数值
抢券 TPS1687.8 /s
平均 RT56.4ms
中位数 / P90 / P95 / P9937 / 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 old
end
redis.call('SET', key, value, 'PX', expire_time_ms)
return false

教训两点:

  1. 写中间件相关代码时要确认目标版本,“新语法”在旧版本上不是降级而是直接报错
  2. 压测必须做全链路对账——如果只看接口成功率,这个 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_fail0 条
模板库存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 修复方向#

  1. 发送侧:改为”SCARD 恰好等于 5000 才发消息”,消除消息风暴
  2. 消费侧:扣库存/SPOP 数量改用实际弹出条数,消除快照错位
  3. 数据不丢的思路: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 全家桶同机)
同步券列表 QPS70334687
并行券列表 QPS57074385(比同步版还低,见 4.3)
秒杀抢券 TPS25881687.8
百万分发耗时210 秒907 秒(约 15 分钟)

本机数字普遍是作者的 60%~70%——考虑到被测服务、压测机、MySQL、Redis、RocketMQ 全部挤在一台笔记本上且堆只有 768m,这个比例是合理的。报数字先报环境,两个数字放一起对比没有意义。

八、踩坑清单(按出现频率排序)#

  1. Redis 版本兼容:SET ... NX GET PX 是 7.0+ 语法,5.x 直接 syntax error(幂等组件全灭)
  2. RocketMQ 5.x 定时消息上限 3 天,模板有效期超限创建失败
  3. Windows 的 java -jar 会锁定 fat jar:服务不重启,jar 删不掉也重建不了
  4. Maven repackage 的坑:pom 未声明 spring-boot 插件只出 thin jar;且时间戳未变时会跳过构建,需删旧 jar 强制重建
  5. SQL 细节:GROUP_CONCAT 默认 1024 长度截断,拼 64 张分表的 UNION ALL 前要 SET group_concat_max_len = 65535
  6. PowerShell 的 JSON 转义:curl 传 JSON 建议写文件后 --data-binary @file
  7. 大文件日志分析:数百 MB 日志不能一次性读入内存,用流式逐行 + 滑动窗口

九、写在最后#

这次压测给我最大的收获不是那几个数字,而是三件事:

  1. 对账思维:接口成功 ≠ 数据正确。库存扣减量、落库行数、消费日志条数三方对账,才能发现”静默丢失”
  2. 压测是最好的 Code Review:消息风暴、快照错位、SPOP 事务缺口这三个问题,读代码时都”看起来没问题”,只有真实流量跑起来才会暴露
  3. 报数字先报环境:脱离环境谈 QPS/TPS 都是耍流氓

简历上的指标最后都换成了自己的实测值:1600+ TPS、4600+ QPS、百万分发约 15 分钟——每一个数字背后都有对账记录和 HTML 报告兜底,面试可以随便深挖。


相关文件:压测 JMX 脚本、JMeter HTML 报告、一键启停脚本均随项目仓库存放。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

在 16G 内存的笔记本上压测优惠券系统:4 万次抢券、百万行分发,我压出了 4 个生产级 Bug
https://xinrose.top/posts/project/coupon-stress-test/
作者
xinRose
发布于
2026-09-16
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

相关文章 智能推荐
1
分布式优惠券系统:主链路全景架构与实战深度解析
项目 全面深度解析分布式优惠券系统核心主链路:分库分表拓扑、查券缓存三件套与双重判定防击穿、领券 Lua 原子防超发、编程式事务与 MQ 异步削峰、自研幂等切面三状态机、批量分发、结算查询试算与用券状态机。
2
AI PPT 智能生成平台:架构原理、核心源码与面试通关手册
项目 基于 LangChain4j + LangGraph4j + DeepSeek 的 AI PPT 智能生成平台全景剖析:结构化输出 JSON Schema、自纠工作流状态图、Redis 阻塞队列与 SSE 流式推送、Tool Calling 局部编辑、Apache POI 原生 PPTX 两阶段质检门禁,以及 PGVector RAG 检索与生成期溢出自动收缩。
3
实习经历背诵底稿(云玑版):真实代码与面试口径对照及高频题库
项目 高校教学信息反馈平台 Java 开发实习经历完整背诵底稿:实习三问话术、慢 SQL 索引优化、EasyExcel 流式防 OOM、Semaphore 导出限流、真实代码与面试口径对照表及网申开放题回答。
4
一场小厂技术面复盘:简历钩子、八股现形与破局补漏
面试 深圳某小公司 Java 开发岗技术面复盘:项目撑场、八股暴露,总结简历钩子诱饵策略、三大短板深度补漏方案及 HR 面谈薪避坑指南。
5
Java 常见业务与高频技术场景面试题汇总
面试 全面整理 Java 开发高频技术场景与实战面试题:单点登录(SSO/JWT)、RBAC 权限认证、数据与文件上传安全、ELK 日志采集与排查、Linux 运维命令、Arthas 生产问题排查与系统性能瓶颈调优。

目录