一个秒杀就把 MySQL 打挂了?我用 Redis + 异步削峰扛住了 100 倍流量

📅 2026/7/23 17:08:05 👁️ 阅读次数 📝 编程学习
一个秒杀就把 MySQL 打挂了?我用 Redis + 异步削峰扛住了 100 倍流量

一个秒杀就把 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 集群。看起来没什么问题对吧?

MySQL集群TomcatNginx用户MySQL集群TomcatNginx用户点击"立即秒杀"转发请求检查库存库存充足校验一人一单未购买过UPDATE stock SET count=count-1INSERT INTO order ...订单创建成功返回"抢购成功"

这套流程在低并发时表现完美。但在秒杀场景下,暴露了三个致命问题:

问题 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 CPU100%
平均响应时间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 的操作放到队列里异步执行。

MySQL集群异步线程阻塞队列RedisNginx用户MySQL集群异步线程阻塞队列RedisNginx用户异步解耦点击"立即秒杀"LUA脚本检查库存 + 一人一单返回"抢购成功" + 订单ID保存订单信息到阻塞队列独立线程读取队列减库存(无锁竞争)创建订单落库成功

变更后重新压测:

指标优化前优化后
数据库连接池满(120/120)稳定 12 个连接
MySQL CPU100%15%
平均响应时间8.7 s12 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 没有告诉你的)

  1. Redis DECR 可能变成负数——LUA 脚本里必须先 GET 判断库存 > 0 再 DECR,不要直接 DECR 后判断。
  2. 阻塞队列不能无限大——上限设为 10000,超过直接拒绝。否则内存被打满,OOM 了连日志都写不出去。
  3. 异步线程要单独处理失败重试——如果数据库写入失败,不能简单重试。设计一张状态表记录异步订单的处理状态,用定时任务补偿。

🧠 原理分析

为什么 Redis 比 MySQL 快这么多?

当我说"Redis 能在几微秒内完成资格校验",你不是应该只记住这个结论,而是要理解为什么

对比维度MySQLRedis
数据存储磁盘 + 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万请求

Redis资格校验

1%通过 99%快速拒绝

阻塞队列 削峰填谷

单线程异步 顺序写入MySQL

优化前

3万请求

直接写MySQL

行锁/死锁/雪崩

优化前,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 秒未处理的做补偿处理。这叫最终一致——秒杀场景下,你不需要强一致性,但必须保证数据不丢。

📝 总结

  1. 不要把 MySQL 当秒杀引擎。MySQL 的行锁和磁盘 IO 决定了它不适合做"热点写",让它做最终落库就够了。
  2. Redis + LUA 原子脚本做资格校验,把 3 万 QPS 降维到几百 TPS 的 DB 写入,延迟从 8 秒降到 12 毫秒。
  3. **异步削峰(阻塞队列 + 单线程消费者)**让数据库写入速率变得可控,完全避免死锁和雪崩。
  4. 最终一致性 > 强一致性。秒杀不是银行转账,短暂的缓存和数据库不一致可以接受,但数据绝对不能丢。

延伸思考

如果你的秒杀规模更大(比如双十一百亿级),上面这套方案还需要补充什么?

  • Redis 单机不够 →Redis Cluster + 本地标记缓存
  • 阻塞队列内存不够 →Kafka/RocketMQ 替代内存队列
  • 数据库还不够 →分库分表 + 读写分离

技术选型的核心就一句话:让合适的组件干合适的活。

📚 参考资料

  • Redis Lua 脚本官方文档
  • Spring Boot 异步任务配置
  • MySQL 行锁与死锁分析
  • 秒杀系统设计 · 美团技术博客
  • nginx ngx_http_limit_req_module

本文为原创内容,转载请注明出处。

如果这篇文章对你有帮助,欢迎点赞 👍收藏 ⭐关注 ➕,你的支持是我持续输出的动力!