一个秒杀就把 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 负载均衡到 TomcatTomcat 直接操作 MySQL 集群。看起来没什么问题对吧MySQL集群TomcatNginx用户MySQL集群TomcatNginx用户点击立即秒杀转发请求检查库存库存充足校验一人一单未购买过UPDATE stock SET countcount-1INSERT INTO order ...订单创建成功返回抢购成功这套流程在低并发时表现完美。但在秒杀场景下暴露了三个致命问题问题 1MySQL 行锁变表锁噩梦库存表的核心 SQL 长这样UPDATEseckill_stockSETcountcount-1WHEREgoods_id?ANDcount0;-- -- 同一商品所有用户竞争同一行锁秒杀开始瞬间上万请求涌入。MySQL行锁排队事务等待时间飙升到秒级大量请求超时回滚后立刻重试——形成死锁风暴 雪崩。问题 2一人一单校验反复查库// 优化前的校验逻辑OrderorderorderMapper.selectByUserIdAndGoodsId(userId,goodsId);// -- 每次查MySQLif(order!null){thrownewBusinessException(每人限购一件);}假设 3 万 QPS一人一单这一步就产生 3 万次 MySQL 查询。对于秒杀这种写多读更多的场景MySQL 的 IO 根本扛不住。问题 3Tomcat 线程池耗尽Tomcat 默认线程池 200。200 个线程全部阻塞在数据库连接获取上——等待排队、等待锁释放、等待回滚。新请求进不来HTTP 连接超时后用户疯狂 F5 重试把服务器推向更深的雪崩。压测数据说明一切指标优化前3 万并发数据库连接池满120/120MySQL 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. 检查库存localstocktonumber(redis.call(GET,KEYS[1]))ifnotstockorstock0thenreturn-1-- 库存不足end-- 2. 检查一人一单localisBoughtredis.call(SISMEMBER,KEYS[2],ARGV[1])ifisBought1thenreturn-2-- 已购买过end-- 3. 预减库存 记录用户redis.call(DECR,KEYS[1])redis.call(SADD,KEYS[2],ARGV[1])-- 4. 生成唯一订单号localorderIdredis.call(INCR,order:id:gen)returnorderId调用这段 LUA 脚本整个秒杀资格校验在一次网络 IO 几微秒内完成彻底绕过了 MySQL。核心二线程池 阻塞队列异步落库ComponentpublicclassSeckillAsyncProcessor{privatefinalExecutorServiceexecutornewThreadPoolExecutor(1,// corePoolSize1,// maxPoolSize0L,TimeUnit.SECONDS,newLinkedBlockingQueue(10000),// -- 阻塞队列做缓冲区newThreadPoolExecutor.CallerRunsPolicy());PostConstructpublicvoidstartConsumer(){executor.submit(()-{while(true){try{SeckillMessagemsgqueue.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 zoneseckill:10m rate100r/s; location /seckill/ { limit_req zoneseckill burst50 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 能在几微秒内完成资格校验你不是应该只记住这个结论而是要理解为什么。对比维度MySQLRedis数据存储磁盘 Buffer Pool内存数据模型行 表 索引B树Hash / Set / String哈希表事务模型ACID / MVCC / 行锁LUA 原子执行并发瓶颈行锁竞争 → 死锁 → 回滚单线程 事件循环 → 无锁典型延迟1-10 ms含网络0.1-1 ms3 万 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 只判断有资格/没资格返回布尔值。真正的订单号用雪花算法在应用层生成并配套一个任务状态表CREATETABLEseckill_task(idBIGINTPRIMARYKEY,user_idBIGINTNOTNULL,goods_idBIGINTNOTNULL,statusTINYINTDEFAULT0COMMENT0-待处理 1-成功 2-失败,create_timeDATETIMEDEFAULTCURRENT_TIMESTAMP,INDEXidx_status(status));异步线程处理完后更新状态。定时任务每 5 秒扫描 status0 的记录超过 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本文为原创内容转载请注明出处。如果这篇文章对你有帮助欢迎点赞 、收藏 ⭐、关注 ➕你的支持是我持续输出的动力

相关新闻

最新新闻

日新闻

周新闻

月新闻