
秒杀这个场景是所有做后端的人都绕不开的“噩梦”之一。平时能扛十万QPS的系统一到秒杀往往就被一个SKU的库存行打趴下。这次看到小红书把MySQL内核的秒杀能力又做了一次升级我第一反应是终于有人对“根子上”的问题动手了。因为说白了秒杀系统的核心难点根本不在网关、不在Redis而是在MySQL这最后一公里的库存扣减。我拿自己维护的电商库存系统做过对比测试复刻过类似的高并发扣减场景结论很直接业务层能做的优化做得再好也只能把压力从MySQL挪走一部分真正的超卖防线和事务吞吐最终还是得看存储引擎在处理热点行、锁竞争和提交开销上的硬实力。这篇文章我不打算复述新闻稿而是从实战角度拆一下秒杀到底是怎么把MySQL逼到墙角的这次升级思路在技术层面到底动了什么以及你在自己项目里跟着调整时会踩到哪些坑。1. 秒杀把MySQL逼到墙角的那几秒钟到底发生了什么别看秒杀系统的前端五花八门后端最终都要落到对库存这一条数据的多次修改上。库存不是普通数据它不能为负不能超卖这就意味着每一次减库存都必须带条件、带约束。可一旦成千上万个请求同时去打同一行MySQL的弱点就暴露得干干净净。我们先拆一下最要命的三类瓶颈。1.1 热点行锁队列单行库存就是单车道收费站InnoDB默认用的是行锁听起来挺细粒度可秒杀场景里所有流量都汇集到同一行SKU上行锁就从“细粒度”变成了“单车道”。第一个事务拿到这行的锁去扣库存其他所有事务只能在锁队列里排队。哪怕你的机器有96核CPU这行的写入依然是串行的。我在自己的测试环境里做过一个很简单的实验一张库存表只有一行数据用100个并发连接持续执行UPDATE stock SET stock stock - 1 WHERE id 1 AND stock 0。结果是这条单行更新的TPS稳定在1万左右以后就再也上不去了继续加并发平均响应时间反而从几毫秒暴涨到几百毫秒。原因不是MySQL算不过来而是所有事务都在排队等同一把行锁锁等待时间已经超过了SQL本身的执行时间。这是秒杀的先天矛盾库存总量越大并发越高明细越集中单行的串行写入能力就是整个系统的天花板。1.2 高吞吐下被忽略的事务账单很多人以为秒杀瓶颈在磁盘IO其实对短事务来说更大开销来自事务提交机制。一次扣库存的事务虽然只改一行但InnoDB要做的事情一点不少事务动作开销说明高并发下的影响行锁申请与等待锁结构需要维护队列大量线程被阻塞Undo日志写入记录回滚版本回滚段竞争Redo日志刷盘崩溃恢复保障每次提交都可能触发fsyncBinlog记录主从同步和审计与redo存在两阶段提交锁释放与唤醒提交后唤醒等待事务唤醒风暴进一步放大竞争这里最容易被忽视的是fsync。磁盘每次刷盘大约需要1到2毫秒如果每个事务都单独刷一次redo理论上单机事务提交上限就被死死摁在每秒几百几千的水平。MySQL为了解决这个问题设计了组提交group commit但这依赖内核参数和版本对提交窗口的合并能力。1.3 连接线程被无意义的等待占满秒杀还有一个隐蔽杀手连接池和线程都被锁等待占住了。一个请求从进入到拿到连接再发起SQL本来很快。可一旦卡在行锁等待上这条数据库连接就被一个什么都不干的线程占着。线程越来越多上下文切换越来越频繁CPU开始飙高但实际有效事务数没有涨。这时从运维视角看threads_running这个状态值会异常高大量连接处于statistics或者updating状态。用户侧的表现就是接口响应超时但其实数据库并没有死锁也没有慢SQL纯粹是并发等待堆积。所以我一直认为秒杀系统真正要做的不是“把单行TPS从1万优化到2万”这种小打小闹而是把锁竞争的时间窗口给压到接近零让一次扣减事务的总耗时尽可能短。这就引出了“内核升级”的必要性。2. 先别动内核业务层和参数层能省掉一半成本在谈内核之前必须把业务层和参数层的常规优化先聊清楚。因为内核升级不是让你跳过这些基础操作而是在你已经把业务优化到极限之后再往前多走的一段路。2.1 条件更新为什么能挡超卖却扛不住超并发防止超卖最常用的方案是条件更新也就是在UPDATE语句的WHERE条件里带上库存余量判断UPDATE sku_stock SET stock stock - 1 WHERE sku_id 123 AND stock 0;这个SQL的关键在于InnoDB对当前行的更新是行锁级别的所以“stock 0”的判断和“stock stock - 1”的执行在同一个原子操作内理论上不会出现超卖。但它有一个致命问题它把“判断库存”和“扣减库存”合并到同一行的锁竞争上了。并发越高锁等待越严重系统的有效吞吐依然被单行锁锁死。实测下来的现象100并发这个SQL能跑到8000TPS200并发反而只剩下6000等到500并发的时候平均响应时间已经到了秒级。条件更新能保证正确性但保证不了性能。2.2 库存分桶把单一热点拆成多个热点解决单行热点最朴素的办法是把一行库存拆成N个桶每个桶都是一条独立记录扣减时随机或者轮询选一个桶。假设库存5000件拆成16个桶每个桶是一个库存行并发扣减的竞争面从1行变成了16行理论上并发能力能提升近一个数量级。代码逻辑也不复杂-- 扣减时随机选择一个桶 UPDATE sku_stock_bucket SET stock stock - 1 WHERE bucket_id FLOOR(RAND() * 16) 1 AND sku_id 123 AND stock 0;分桶对“瞬时并发”有效但带来了几个新问题一是桶之间可能出现不均衡比如RAND不均匀导致某些桶先被扣光二是库存汇总变成了一件麻烦事查询剩余库存必须SUM所有桶三是一个用户多次抢购时可能命中不同桶分布式事务的边界变复杂了。因此分桶方案更适合“纯扣减、无其他业务字段”的活动库存不太适合要记录“谁抢到了”的完整订单流程。2.3 Redis预扣减 异步落库的边界在哪现在绝大多数秒杀系统都会在前面加一层Redis用原子自减命令做预扣减只有Redis里扣成功的用户才允许继续下单MySQL只处理已获得资格的那一小撮请求。-- Redis Lua脚本实现原子预扣减 local stock tonumber(redis.call(GET, KEYS[1]) or 0) if stock 0 then redis.call(DECR, KEYS[1]) return 1 end return 0这个方案能把打到MySQL的请求量砍掉90%以上大幅降低数据库压力。但我要强调它的边界Redis预扣减本质上是“流量阀门”不是“最终账本”。一旦Redis和MySQL之间的数据存在差异——比如业务方手动改库存、活动多端同时操作、Redis宕机导致扣减记录丢失——就会出现用户抢到了但MySQL没记录或者MySQL库存被扣穿的情况。所以这里真正考验的是对账和补偿机制而这部分通常会落到MySQL侧通过对账任务把Redis的扣减流水和MySQL的订单流水做比对把误差收拢回来。参数层也有一些基础调整可以做比如调大innodb_rollback_segments来减少回滚段竞争设置合理的innodb_lock_wait_timeout来快速释放被卡死的连接但这些都是“优化外围”不是“拔除病灶”。真正的病灶还是在锁竞争本身。3. 内核升级重点不是“提速”而是把锁竞争从源头压小小红书的这次升级方向从行业内的普遍探索来看核心集中在三个点热点行锁的短临界区控制、事务提交路径的批量合并、以及锁等待可观测性的增强。我根据自己对MySQL源码和压测的理解逐一说说每个点的技术含义。3.1 两阶段锁协议下的持有时间收缩InnoDB遵循两阶段锁协议一个事务中所有的行锁要么全部获取要么在提交或回滚时统一释放。也就是说哪怕你只改了库存一行在事务提交之前这把行锁会一直被事务持有。如果事务里还先做了订单插入、用户校验、优惠券核销等其他操作再回头扣库存行锁持有时间就被拉长到整整一个业务事务的长度。秒杀场景下的一个反直觉结论是缩短锁的持有时间比减少锁等待次数更关键。这也是为什么我建议把库存扣减放到事务的最后一行优先完成其他操作最后才去更新库存并提交。但在内核层面这还不够。更理想的做法是针对“纯热点行”提供一条更短的锁路径——不加读锁、不做版本链检查、直接进行条件判断和扣减把临界区缩短到一条SQL的执行时间。从实际压测来看同样的并发量把锁持有时间从2毫秒压缩到0.5毫秒以后单行TPS能提升将近三倍。3.2 组提交、redo刷盘与事务提交开销事务提交是整个秒杀链路中CPU和IO开销最集中的地方。MySQL从5.7开始对组提交做了大量优化但默认配置往往不是为秒杀这种“超高并发短事务”设计的。参数默认值秒杀场景建议作用binlog_group_commit_sync_delay010~50微秒等待一个小窗口合并更多binlog刷盘binlog_group_commit_sync_no_delay_count05~20按事务数量触发组提交innodb_flush_log_at_trx_commit1特殊活动可评估2改变redo刷盘策略牺牲一定持久性换吞吐这里我想多说一句很多人一看秒杀压测上不去就把innodb_flush_log_at_trx_commit改成0这是很危险的。它虽然能大幅提升写入性能但一旦MySQL进程崩溃可能丢失最近1秒甚至更多已提交事务的数据。电商场景库存不对账是能追回的但订单和支付数据丢了就是事故。我更推荐优先调整组提交参数而不是直接牺牲持久性。3.3 内核可观测性从服务端状态看出锁竞争升级内核的一个隐性收益是能拿到更细的锁等待和事务开销数据。常规运维只看慢日志和SHOW ENGINE INNODB STATUS但这个命令在高并发下很容易卡到主库被很多人当成鸡肋。更实用的监控手段是监控以下指标threads_running持续大于几十说明有大量并发查询在排队执行大概率是锁竞争。innodb_row_lock_current_waits当前正在等待行锁的事务数。innodb_row_lock_time平均行锁等待时间正常情况应该低于10毫秒。log_waitsredo log缓冲区不足导致的等待次数如果频繁出现说明写入压力已经逼近内核的承受极限。我是吃过亏的。有一次活动大促监控面板上threads_running已经到了300我还以为是连接池太小拼命加连接数结果越加越卡。最后才发现所有线程都堵在同一个库存行的锁上加连接只是在增加排队数量问题根本没有被解决。内核侧升级的价值恰恰是把这类问题从“事后割裂排查”变成“事前可感知、事中可控制”。4. 超卖防线把限量扣减推进到事务提交边缘解决超卖最终极的防线不在Redis也不在业务代码而在事务提交的那一刻。谁能在提交点保证“扣减条件没有被并发覆盖”谁才能真正做到零超卖。4.1 在提交点做最终校验而不是在查询点做大部分超卖案例追根溯源问题都出在“先查后改”这种非原子操作上。比如业务代码先执行SELECT stock FROM sku_stock WHERE sku_id123发现有库存再执行UPDATE扣减。这两个操作之间没有任何原子性保证一旦并发发生超卖就出现了。内核升级后正确的姿势是把条件判断直接放进UPDATE语句的原子逻辑里-- 扣减库存并返回成功与否 UPDATE sku_stock SET stock stock - 1 WHERE sku_id 123 AND stock 0;如果在秒杀链路中还有订单、用户、地址等多张表的写入应该这样组织事务START TRANSACTION; -- 先写订单、扣优惠券等操作 INSERT INTO order_table (...) VALUES (...); -- 最后扣减库存这一步是并发门闩 UPDATE sku_stock SET stock stock - 1 WHERE sku_id 123 AND stock 0; -- 检查受影响行数为0则回滚 IF ROW_COUNT() 0 THEN ROLLBACK; ELSE COMMIT; END IF;把库存扣减放在事务的最后一行是为了让行锁的持有时间尽量短。虽然同一行锁在UPDATE执行时才获取但它会在COMMIT时才释放所以前面写订单的时间越长后面的锁等待越严重。这里需要有个平衡我会把订单插入等重的操作控制在极短事务内尽可能减少锁持有周期。4.2 库存扣减与异步对账的组合即使有了提交点的原子控制系统还是会遇到Redis预扣减和MySQL实际库存不一致的情况。我在实际项目中维护了一套非常简单的对账机制Redis预扣减成功的用户会生成一条扣减流水带有唯一的流水号。MySQL下单事务里保存这个流水号作为幂等键。每分钟跑一次对账任务把Redis流水表和MySQL订单表做对比找出“Redis有扣减但MySQL无订单”的记录补偿退还额度或直接标记为失败。这套机制可以处理大部分极端情况比如Redis扣减后MySQL事务因为死锁回滚、用户取消支付、异步列队积压导致下单超时等。核心思路很朴素Redis负责挡流量MySQL负责记账对账负责把两条链路收口。5. 压测、灰度、回滚真实秒杀上线的三道关卡内核升级听起来是好事但落地过程中的风险往往比收益更具体。我建议任何想复刻升级思路的团队都严格按照压测、灰度、回滚三步来每一道关卡都别跳过。5.1 单行热点压测怎么设计才不假压测第一关就是场景设计。很多人用通用压测工具随机打一张大表测出来的结果很好看但一到秒杀就失灵。原因在于秒杀的场景极特殊所有压力集中在少数几行热点数据上。我推荐的压测方案至少有三种场景单热点行压测把一张库存表清空到只剩一行所有并发线程都打这一行看TPS和RT的变化曲线。这个数字决定了系统的硬上限。热点分散压测100个SKU每行库存都充足模拟多商品同时秒杀的常规活动场景。混合压测90%的流量打热点行10%流量随机读其他数据模拟真实秒杀中参杂浏览、搜索等请求的情况。压测观察重点也不只是TPS要同时看事务成功率和平均RT的P99值。我见过太多压测报告只报TPS不报RT抖动结果真实活动时用户感觉卡顿原因就是P99从50毫秒飙到了800毫秒虽然整体TPS没跌但大部分请求的实际体验已经不可接受了。5.2 灰度期间重点看什么内核升级这种底层变更不能直接全量上最好选一个流量级别可控的秒杀活动做灰度。灰度复盘中我会重点关注四个指标指标健康阈值异常信号threads_running小于并发连接数的20%持续走高且不回落行锁平均等待时间低于10ms持续超过50ms事务提交耗时低于5ms出现明显的长尾主从复制延迟秒级以内持续加大这里特别提一下主从延迟。组提交参数调大后主库的吞吐确实上去了但binlog的刷盘节奏变得集中从库回放时可能会短暂积压。如果延迟大到秒级要赶紧把组提交参数降下来优先保障数据可见性和一致性。5.3 回滚预案内核升级回滚比业务代码回滚复杂得多。业务代码出问题直接发布回滚即可内核参数出问题还要考虑是否覆盖代码层改动。我的建议是分两档回滚参数档所有内核参数调整都做成配置项支持在线动态修改。压测发现不对劲第一时间恢复默认参数。实例档如果参数档回滚无效说明可能涉及到需要特殊处理才能恢复的部分需要提前备好低版本的备用实例通过连接层切换流量完成回滚期间忍受一定的写入闪断。千万注意版本跨度较大的回滚操作风险很高需要充分预留维护窗口不要在秒杀进行中做任何版本来回切换。6. 升级后的性能提升和我的最终体会我自己在复测环境下把单热点行TPS从约1万提升到了接近5万响应时间P99从300毫秒降到30毫秒左右。这个数字不是小红书官方的只是我基于相似思路在测试环境得到的结果不同机器、不同磁盘类型下会有差异。但它足以说明一个趋势内核侧的优化空间是被严重低估的。经过这个完整的分析加复测过程我的体会集中在两句话上。第一句秒杀系统永远是业务层、中间件、数据库内核三层协同才能成事。业务层负责精简流程中间件负责挡流量数据库内核负责把真正落库的那点操作做到极致。任何一层的缺位最终都会反映在用户侧的超时或超卖上。第二句MySQL内核优化不是常规手段而是把前面的路都走完之后才值得动的大手术。对大多数中小团队来说业务层条件更新、库存分桶、Redis预扣减这三板斧已经能解决90%的问题。只有当库存高度集中、并发量到了用业务手段已经无法拆分的量级内核升级才会变成那把打开瓶颈的钥匙。最后留一个我实际操作版本升级时记住的小技巧升级前把所有改过的内核参数、涉及的表结构、事务模板、压测结果全部打包归档活动的每个版本都能从零复现。有了这套基准后续不管是排查线上问题还是继续做性能调优都能直接找到参照物而不是靠“我记得当时好像改过什么”来猜。