一个秒杀就把 MySQL 打挂了?我用 Redis + 异步削峰扛住了 100 倍流量
📌 前言
“服务器竟然挂了?”——下午 14:00,秒杀准时开启,你盯着监控面板,QPS 瞬间飙到 3 万,数据库连接池爆满,慢查询堆积成山,Tomcat 线程全部阻塞。用户端疯狂重试,nginx 日志全是 502,页面白屏转圈一分钟后弹出"网络异常"。
产品经理冲过来问:还能不能恢复?
你盯着数据库连接数的折线图——已经是一条垂直线了。MySQL CPU 100%,磁盘 IO 打满,慢查询队列里排着上万个UPDATE stock正在死锁回滚。
这不是虚构的生产事故。我经历过。而且不止一次。
本文会把那次完整的技术选型、排查过程、优化方案、底层原理全部拆开讲清楚。如果你团队下一个版本也要上秒杀,这可能是你今天读到最值的一篇文章。
📋 环境说明
| 组件 | 版本/说明 |
|---|---|
| 应用服务器 | Spring Boot 2.7 + Tomcat 9 |
| 数据库 | MySQL 8.0 集群(1主2从) |
| 缓存 | Redis 6.x 单机 |
| 操作系统 | CentOS 7, 8C16G × 3 节点 |
| 压测工具 | JMeter 5.5 |
| 预估峰值 | 3 万 QPS(实际扛到 5000 开始雪崩) |
🔍 问题复现
先看优化前的架构。简单到令人放心:用户请求经过 nginx 负载均衡到 Tomcat,Tomcat 直接操作 MySQL 集群。看起来没什么问题对吧?
这套流程在低并发时表现完美。但在秒杀场景下,暴露了三个致命问题:
问题 1:MySQL 行锁变表锁噩梦
库存表的核心 SQL 长这样:
UPDATEseckill_stockSETcount=count-1WHEREgoods_id=?ANDcount>0;-- <-- 同一商品所有用户竞争同一行锁秒杀开始瞬间,上万请求涌入。MySQL行锁排队,事务等待时间飙升到秒级,大量请求超时回滚后立刻重试——形成死锁风暴 + 雪崩。
问题 2:一人一单校验反复查库
// 优化前的校验逻辑Orderorder=orderMapper.selectByUserIdAndGoodsId(userId,goodsId);// <-- 每次查MySQLif(order!=null){thrownewBusinessException("每人限购一件");}假设 3 万 QPS,一人一单这一步就产生 3 万次 MySQL 查询。对于秒杀这种写多读更多的场景,MySQL 的 IO 根本扛不住。
问题 3:Tomcat 线程池耗尽
Tomcat 默认线程池 200。200 个线程全部阻塞在数据库连接获取上——等待排队、等待锁释放、等待回滚。新请求进不来,HTTP 连接超时后,用户疯狂 F5 重试,把服务器推向更深的雪崩。
压测数据说明一切:
| 指标 | 优化前(3 万并发) |
|---|---|
| 数据库连接池 | 满(120/120) |
| MySQL CPU | 100% |
| 平均响应时间 | 8.7 s |
| 下单成功率 | 3.2% |
| Tomcat 线程 | 200/200 阻塞 |
这不是秒杀——这是自毁程序。
🧭 排查过程
尝试 1: 加 MySQL 连接池和索引 ❌
第一反应是"MySQL 太忙了,给它加点资源"。
把连接池从 120 加到 500,给seckill_stock表加了(goods_id, user_id)联合索引,把UPDATE改为只更新乐观锁版本号。
结果:连接多了,行锁竞争更激烈——500 个线程同时争同一行,死锁频次翻倍。TPS 从 200 降到 80。
教训:秒杀的本质矛盾不是 MySQL 连接不够,是同一行记录的写竞争。MySQL 的行锁机制决定了它不适合高并发"热点写"。
尝试 2: 前端限流 + 按钮置灰 ❌
前端倒计时结束后按钮置灰 3 秒,后端 nginxlimit_req限制单 IP 1r/s。
结果:阻止不了专业黄牛(他们发包不经过前端)、阻止不了海量并发(IP 分布极广)。真正用户也被限流挡在外面——误杀率 40%。
教训:前端限流只能做辅助,不能依赖。秒杀的核心矛盾不在入口,在数据库层。
尝试 3: 引入 Redis 预减库存 + 异步下单 ✅
第二次大促前花了两周重构了秒杀链路。核心思路只有一句话:
把秒杀的"资格校验"和"正式下单"解耦。
让 Redis 扛住瞬时流量做资格判断,把真正写入 MySQL 的操作放到队列里异步执行。
变更后重新压测:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 数据库连接池 | 满(120/120) | 稳定 12 个连接 |
| MySQL CPU | 100% | 15% |
| 平均响应时间 | 8.7 s | 12 ms |
| 下单成功率 | 3.2% | 99.7% |
| Tomcat 线程 | 200/200 阻塞 | 20 活跃 |
🛠️ 解决方案(详细实现)
核心一:Redis LUA 脚本做资格校验
为什么用 LUA 脚本?因为 Redis 的 LUA 脚本原子执行,在整个脚本运行期间不会被其他命令打断。
-- check_and_dec.lua-- KEYS[1]: 商品库存 key-- KEYS[2]: 用户已购 set key-- ARGV[1]: 用户 ID-- ARGV[2]: 商品 ID-- 1. 检查库存localstock=tonumber(redis.call('GET',KEYS[1]))ifnotstockorstock<=0thenreturn-1-- 库存不足end-- 2. 检查一人一单localisBought=redis.call('SISMEMBER',KEYS[2],ARGV[1])ifisBought==1thenreturn-2-- 已购买过end-- 3. 预减库存 + 记录用户redis.call('DECR',KEYS[1])redis.call('SADD',KEYS[2],ARGV[1])-- 4. 生成唯一订单号localorderId=redis.call('INCR','order:id:gen')returnorderId调用这段 LUA 脚本,整个秒杀资格校验在一次网络 IO + 几微秒内完成,彻底绕过了 MySQL。
核心二:线程池 + 阻塞队列异步落库
@ComponentpublicclassSeckillAsyncProcessor{privatefinalExecutorServiceexecutor=newThreadPoolExecutor(1,// corePoolSize1,// maxPoolSize0L,TimeUnit.SECONDS,newLinkedBlockingQueue<>(10000),// <-- 阻塞队列做缓冲区newThreadPoolExecutor.CallerRunsPolicy());@PostConstructpublicvoidstartConsumer(){executor.submit(()->{while(true){try{SeckillMessagemsg=queue.take();// <-- 阻塞获取processOrder(msg);}catch(Exceptione){log.error("异步下单失败",e);}}});}publicbooleanaddTask(SeckillMessagemsg){returnqueue.offer(msg,100,TimeUnit.MILLISECONDS);// <-- 超时保护}}关键设计点:
- 单线程消费避免数据库写冲突,天然解决行锁问题
LinkedBlockingQueue作为缓冲区,削峰填谷offer()带超时,队列满时快速失败,不让上游阻塞- 秒杀场景要用快速拒绝策略,然后让用户重试
核心三:nginx 层面做网关限流
limit_req_zone $binary_remote_addr zone=seckill:10m rate=100r/s; location /seckill/ { limit_req zone=seckill burst=50 nodelay; proxy_pass http://backend_servers; limit_req_status 429; error_page 429 /seckill_busy.html; }nginx 限流放在最外层,挡住 90% 的无效流量,让 Redis 只处理真正有资格进来的请求。
踩坑记录(README 没有告诉你的)
- Redis DECR 可能变成负数——LUA 脚本里必须先 GET 判断库存 > 0 再 DECR,不要直接 DECR 后判断。
- 阻塞队列不能无限大——上限设为 10000,超过直接拒绝。否则内存被打满,OOM 了连日志都写不出去。
- 异步线程要单独处理失败重试——如果数据库写入失败,不能简单重试。设计一张状态表记录异步订单的处理状态,用定时任务补偿。
🧠 原理分析
为什么 Redis 比 MySQL 快这么多?
当我说"Redis 能在几微秒内完成资格校验",你不是应该只记住这个结论,而是要理解为什么。
| 对比维度 | MySQL | Redis |
|---|---|---|
| 数据存储 | 磁盘 + Buffer Pool | 内存 |
| 数据模型 | 行 + 表 + 索引(B+树) | Hash / Set / String(哈希表) |
| 事务模型 | ACID / MVCC / 行锁 | LUA 原子执行 |
| 并发瓶颈 | 行锁竞争 → 死锁 → 回滚 | 单线程 + 事件循环 → 无锁 |
| 典型延迟 | 1-10 ms(含网络) | 0.1-1 ms |
| 3 万 QPS 表现 | CPU 100%,连接池爆满 | CPU 20%,连接稳定 |
根本原因:MySQL 为了 ACID,每一行数据写入都要经过Buffer Pool → Redo Log → Binlog → 脏页刷盘,同一行的 UPDATE 还会产生行锁排队。而 Redis 纯粹在内存操作,LUA 脚本保证原子性,单线程模型天然避免了并发写冲突。
秒杀场景下,Redis 做资格校验是降维打击。
为什么要用异步削峰?
优化前,3 万请求同时打到 MySQL,每个请求都需要完整的数据库写操作——这是同心圆式压力放大。
优化后,99% 的请求在 Redis 层就被快速拒绝了(“库存不足"或"已购买”),只有真正抢到的请求进入队列。队列作为缓冲区,让 MySQL 的写入速率变成可控的每秒几百笔——这才是数据库能优雅处理的速度。
LUA 原子性的边界在哪?
一个很多人问的问题:如果 Redis 执行 LUA 脚本的瞬间宕机了怎么办?
场景:Redis 已经执行了 DECR stock 和 SADD user_set,但还没把订单号返回给客户端就宕机了。
解决方案:订单号不要依赖 Redis 返回。让 Redis 只判断"有资格/没资格",返回布尔值。真正的订单号用雪花算法在应用层生成,并配套一个任务状态表:
CREATETABLE`seckill_task`(`id`BIGINTPRIMARYKEY,`user_id`BIGINTNOTNULL,`goods_id`BIGINTNOTNULL,`status`TINYINTDEFAULT0COMMENT'0-待处理 1-成功 2-失败',`create_time`DATETIMEDEFAULTCURRENT_TIMESTAMP,INDEX`idx_status`(`status`));异步线程处理完后更新状态。定时任务每 5 秒扫描 status=0 的记录,超过 30 秒未处理的做补偿处理。这叫最终一致——秒杀场景下,你不需要强一致性,但必须保证数据不丢。
📝 总结
- 不要把 MySQL 当秒杀引擎。MySQL 的行锁和磁盘 IO 决定了它不适合做"热点写",让它做最终落库就够了。
- Redis + LUA 原子脚本做资格校验,把 3 万 QPS 降维到几百 TPS 的 DB 写入,延迟从 8 秒降到 12 毫秒。
- **异步削峰(阻塞队列 + 单线程消费者)**让数据库写入速率变得可控,完全避免死锁和雪崩。
- 最终一致性 > 强一致性。秒杀不是银行转账,短暂的缓存和数据库不一致可以接受,但数据绝对不能丢。
延伸思考
如果你的秒杀规模更大(比如双十一百亿级),上面这套方案还需要补充什么?
- Redis 单机不够 →Redis Cluster + 本地标记缓存
- 阻塞队列内存不够 →Kafka/RocketMQ 替代内存队列
- 数据库还不够 →分库分表 + 读写分离
技术选型的核心就一句话:让合适的组件干合适的活。
📚 参考资料
- Redis Lua 脚本官方文档
- Spring Boot 异步任务配置
- MySQL 行锁与死锁分析
- 秒杀系统设计 · 美团技术博客
- nginx ngx_http_limit_req_module
本文为原创内容,转载请注明出处。
如果这篇文章对你有帮助,欢迎点赞 👍、收藏 ⭐、关注 ➕,你的支持是我持续输出的动力!