先说一个我亲历过的场景。某次电商大促压测,凌晨两点监控突然炸了,数据库活跃连接数从平时的几十飙到五百多,TPS直接掉到两位数,一大堆请求堆积在update语句上。当时的罪魁祸首就是一行库存记录——几万用户同时抢一个SKU,所有人都对这个热点行做update,行锁互相等待,连接池被打满,整个服务全部卡死。这就是典型的MySQL热点行更新问题。
这个问题的本质,是InnoDB在并发更新同一行数据时的行锁互斥导致了排队效应。表面上只影响一行,实际却会拖垮整个数据库集群。很多团队第一次遇到都会从调参、加索引、换机器入手,但折腾一圈发现收效甚微。因为热点行更新表面上是数据库问题,根子上往往是并发模型设计问题。这篇文章我想把完整的排查链路和几种经过线上验证的解法讲透,适合被这类问题困扰过的后端开发、DBA以及架构师参考。
1. 线上事故的常见形态:热点行更新到底长什么样
1.1 从一条慢SQL说起
热点行更新最先暴露出来的,是某条SQL的Rows_examined很小,但执行时间异常长。比如:
UPDATE sku_stock SET stock = stock - 1 WHERE sku_id = 100123;这条语句在MySQL里走主键索引,扫描一行,按理说应该是一毫秒以内的事。但当同一时间有几百个事务都执行这一行更新时,它的执行时间会拖到几百毫秒甚至几秒。如果你在performance_schema或慢日志里看到这类“扫描行数少、执行耗时反而高”的SQL,基本可以锁定是锁等待而不是查询本身慢。
这时候去看SHOW PROCESSLIST,你会发现大量会话状态是Updating或者Statistics,但真正干活的不多,绝大多数都在等待行锁释放。
1.2 三个核心指标判断热点行
排查热点行更新不能只靠猜,分享几个我确认问题的关键指标。
第一个是Threads_running。正常情况下这个值应该是个位数,热点行更新发生时它会剧烈波动,因为大量线程同时进入更新状态但又全部阻塞在锁上。
第二个是Innodb_row_lock_current_waits和Innodb_row_lock_time。这两个状态变量会非常直观地告诉你“当前有多少行锁在等待”以及“累计等待了多少毫秒”。如果持续增长,说明锁竞争一直没有缓解。
第三个是TPS曲线。热点行场景下TPS并不会保持高位,反而会断崖式下跌,因为锁的串行化让并发变成了排队。
如果这三个指标都吻合,那基本不用再怀疑是SQL没优化好或者索引有问题,可以直接往行锁竞争方向查。
1.3 为什么一行数据能把整个库拖垮
很多人不理解:就算那一行更新慢,最多也就是那个请求慢,怎么会把数据库拖垮?关键在于连接池这个放大器。
每个占用连接的会话如果阻塞在行锁上,它不会主动放弃连接。应用层的连接池是有限的,假设最大值是200,那200个会话全被同一行的锁堵住之后,第201个请求就拿到不到连接了。请求在应用层排队,线程池也会被占满,最后整个服务的所有接口都无响应。
更麻烦的是,数据库内部也在恶化。行锁等待会占用事务对象和回滚段资源,锁等待超时后触发大量Lock wait timeout exceeded异常,客户端重试又会产生更多竞争。我见过最极端的情况是主库CPU不高、磁盘IO也不高,但连接数爆满,主从延迟拉大,最后只能重启数据库实例才能恢复。
所以,热点行更新不是“一行”的问题,而是一个线程资源被锁占满的级联故障。
2. 锁机制剖析:行锁、事务与隔离级别如何放大热点问题
2.1 InnoDB行锁的本质:X锁是排他的
要理解热点行更新为什么无解,必须先理解InnoDB锁的本质。在InnoDB中,一行数据上可以加两种锁:共享锁(S锁)和排他锁(X锁)。S锁之间兼容,可以多个事务同时持有;X锁与其他任何锁都互斥。update操作必须获取X锁,这意味着同一时刻只能有一个事务持有这行数据的X锁,其他事务必须等。
这就像一个单人厕所。其他锁模式相当于标识牌,X锁则是进去之后把门反锁了,后面的人只能排队。热点行更新就是所有人都在排队等同一个厕所,队伍越长,等待越久。
而且InnoDB的行锁是在索引记录上实现的。如果更新条件没有走到索引,InnoDB会锁住全表所有记录;即使走到索引,在可重复读隔离级别下,范围条件还会触发间隙锁(Gap Lock)。但热点行连间隙锁都谈不上,纯粹就是同一个索引记录上的X锁互斥。
2.2 锁等待引发的连锁反应
单个行锁等待本身不可怕,可怕的是它的连锁反应。当一个事务持有X锁但迟迟不提交,所有等待这个锁的事务都会挂在waiting状态。这些等待事务占用的连接不能处理新请求,而MySQL的连接数是固定的,于是新的请求又堆积到应用层。
从资源利用率角度看,锁等待期间CPU是空闲的,但这不代表系统是健康的——这属于典型的“资源被占住但没干活”。
另外一个被忽略的点是锁等待超时的重试风暴。MySQL默认的innodb_lock_wait_timeout是50秒,高并发下大量事务在等锁50秒后超时回滚,客户端立刻重试,重试又冲进同一行锁,形成更严重的堆积。我把这种情况叫做“超时重试放大效应”,它比原始的热点更新对系统的伤害更大。
2.3 事务隔离级别和长事务:看不见的帮凶
热点行更新在可重复读(RR)和读已提交(RC)两种隔离级别下都会发生,但RR下还有间隙锁这个额外负担。默认的RR隔离级别虽然符合MySQL历史习惯,但在高并发写场景下,间隙锁会把锁范围从一行扩到一个区间,让本来只是“热点行”的问题变成“热区间”的问题。
长事务才是真正的帮凶。我排查过很多热点行案例,打开information_schema.innodb_trx会发现,持有锁的事务很多不是正在执行更新,而是更新完之后还在事务里做远程调用、写日志、发消息。也就是说,X锁被一个“已经完成任务但还没提交”的事务牢牢攥在手里,其他事务只能干等。
解决这类问题,第一步不是优化SQL,而是把事务里所有非数据库操作全部挪出去。事务只保留必要的更新语句,提交之后再做后续业务动作。这个习惯能在源头上把锁持有时间缩短一到两个数量级。
2.4 死锁检测与锁等待超时的取舍
InnoDB默认开启死锁检测(innodb_deadlock_detect=ON),它通过等待图来检测死锁,发现后立即回滚其中一个事务。死锁检测本身需要消耗CPU资源,每来一个新事务都要检查是否成环。热点行更新场景下,事务到达率高,死锁检测的CPU开销会异常放大,极端情况下系统CPU被打满但不是在执行业务,而是在跑死锁检测算法。
这里有两个方向:如果业务量并发在几千TPS以内,保持死锁检测开启,让死锁自动回滚就好;如果并发量极高且明确不会出现复杂死锁,可以考虑关闭死锁检测然后调小innodb_lock_wait_timeout,让等待方快速失败,避免“先等50秒再死锁”的极端情况。
但我个人不建议一上来就关死锁检测,这是最后手段,关掉之前先确认应用不会产生真正的死锁,否则会变成“锁等死”而不是“锁超时”。
3. 从SQL到事务:先做能立刻见效的优化动作
3.1 索引设计:别让行锁升级成表锁
热点行更新的第一道防线是索引。MySQL的InnoDB行锁锁定的是索引记录,如果更新语句的WHERE条件没有走索引,存储引擎就要扫描所有记录。在扫描过程中,由于不知道哪些记录会被更新,InnoDB会对扫描到的每条记录都加锁。这个效果等同把全表锁住,热点行问题瞬间升级为全表更新阻塞。
所以排查热点行问题时一定要用EXPLAIN确认更新SQL的type是eq_ref或ref,而不是ALL或range。之前遇到过一起事故,业务方给库存表加了一个status字段做条件过滤,但因为区分度太低,MySQL优化器放弃了sku_id索引走了全表扫描,导致所有更新全表串行化。改成强制索引后,锁范围缩小到目标行,问题直接消失。
3.2 用条件更新替代悲观锁
很多团队优化热点行更新会想到在应用层加锁,比如synchronized或Redis分布式锁,把并发更新串行化。这通常是多此一举,因为数据库行锁本身就是最好的悲观锁,应用层加锁还要额外引入一致性和锁超时问题,并没有减少数据库侧的锁竞争。
更有效的方向是使用条件更新。例如扣减库存,不加条件时的写法是:
UPDATE sku_stock SET stock = stock - 1 WHERE sku_id = 100123;并发场景下应改成带库存判断的条件更新:
UPDATE sku_stock SET stock = stock - 1 WHERE sku_id = 100123 AND stock > 0;这样可以将“先查再改”的两步操作合并成一步原子操作,减少事务的锁持有时间,同时用受影响行数判断是否扣减成功。对于余额扣减、优惠券数量、账户积分这类数值型热点,类似的WHERE条件都能锁定语义并压缩事务长度。
3.3 缩小事务边界:把远程调用请出去
这一点我在前面提到过,但因为太重要,单独展开。热点行更新的事务边界必须尽可能短。一个事务从BEGIN到COMMIT,期间获取的所有X锁都不会释放,任何额外的操作都在延长其他事务的等待时间。
我见过一个典型的坏味道:
with db.transaction(): # 事务内 db.execute("UPDATE user SET balance=balance-100 WHERE user_id=?...") call_payment_service(...) # 远程HTTP调用,耗时200ms~1s send_sms(...) # 短信通知 insert_into_log(...) # 日志写入这手操作里,事务持有user记录锁的时间等于所有远程调用的时间总和。一旦支付服务抖动,锁持有时间直接飙升,整张用户表的更新全部卡住。
正确姿势是事务里只放数据库更新和必要的日志插入,事务提交后再做远程调用。如果远程调用失败,再用本地消息表、MQ补偿机制或定时任务做最终一致性。这条准则在任何并发写场景都适用,也是我列出的所有优化方案中最容易忽略但收益最大的一项。
3.4 合理设置锁等待参数
参数调优只能治标,但关键时刻能保命。两个最核心的参数:
innodb_lock_wait_timeout默认50秒太长,热点更新场景建议调到2~5秒。它的意义不是减少等待,而是让那些抢不到锁的事务快速失败,避免连接长时间被占用拖垮整个连接池。调小之后,应用层要有对应的失败重试策略,但注意不要在同一线程里无脑重试,要做退避。
innodb_deadlock_detect的取舍前面说了,这里补充一点:如果你确实要关闭它,一定要同步调小innodb_lock_wait_timeout,不然事务会一直挂到天荒地老。
这两个参数只能缓解,不能根治。当热点行更新达到每秒上千次时,任何SQL级别的优化都救不了,必须采用下文的结构化方案。
4. 根治热点锁竞争:排队、合并、拆分三板斧
4.1 排队化:把无序竞争变成有序消费
热点行更新本质上是一个“并发冲突”问题,最直接的解决思路是让并发变成排队。也就是在应用层用一个队列把所有针对同一热点行的更新请求串行化,再逐个去更新数据库。这样数据库侧的锁竞争完全消失,每个时间点只有一个更新在线上执行。
排队化的实现方式有很多种。轻量级方案是用Redis的分布式锁,把一次性合并写进锁内:
RLock lock = redissonClient.getLock("stock:100123"); if (lock.tryLock(100, TimeUnit.MILLISECONDS)) { try { // 在锁内执行数据库更新,或先更新Redis计数再异步落库 } finally { lock.unlock(); } }注意锁的粒度要细到“业务动作”,比如按sku_id维度加锁,而不是全局一把锁。否则热点行问题会变成“全库更新排队”,吞吐量反而下降。还要考虑锁超时和自动续期的问题,Redisson这类成熟客户端内置了看门狗自动续期,比手写setnx靠谱得多。
更推荐的做法是把更新请求写入MQ(如RocketMQ、Kafka),由消费者以单线程或固定分片的方式消费,同一个sku_id的消息路由到固定队列。这样不但天然排除了竞争,还能做流量削峰,保护数据库不被瞬时大流量打垮。代价是业务上需要接受一定的异步延迟,适合秒杀、抢券这类读多写少且不需要立刻返回结果的场景。
4.2 合并更新:多次高频写合并为一次批量写
另一个思路是合并。针对同一行热点数据的频繁更新请求,不每次都去操作数据库,而是在内存里先聚合,再周期性刷到MySQL。
举个真实例子,某个游戏的登录奖励计数,玩家登录一次就要UPDATE play_count = play_count + 1。高峰期每秒几万次登录,全部打到一个玩家账号记录上,MySQL根本扛不住。优化后我们用Redis做计数器,INCR操作用内存原子递增,每10秒把增量合并成一条UPDATE落到MySQL:
UPDATE player_stat SET login_count = login_count + 10 WHERE player_id = ?;这一步将单位时间内的更新次数减少了几十万倍。同类方案适用于阅读量、点赞数、库存预扣等不要求实时精确、最终一致即可的场景。
合并更新有几个必须注意的坑。一是内存或Redis中的数据不能丢,Redis要开启AOF持久化,重启后要做增量补偿;二是落库时不能覆盖其他维度更新的值,比如登录数和游戏局数如果都在同一行,需要用last_login_count这类中间变量做增量合并而不是直接赋值;三是落库周期不能太长,否则宕机丢数据量太大,10秒到30秒是比较稳妥的窗口。
4.3 热点行拆分:把一把大锁拆成N把锁
合并更新做的是“减少次数”,拆分做的是“分散锁”。这是解决热点行更新的终极方案,尤其适合账户余额、库存这类高频高一致性的数据。
原理很简单:把一行数据从物理上拆成多行。以库存为例,原来一张表只有一行记录,现在分成10个子库存,每个子库存一行:
-- 原结构 sku_id = 100123, stock = 1000 -- 拆分后 sku_id = 100123, bucket = 0, stock = 100 sku_id = 100123, bucket = 1, stock = 100 ... sku_id = 100123, bucket = 9, stock = 100扣减库存时随机或按用户ID取模选择一个桶,执行:
UPDATE sku_stock SET stock = stock - 1 WHERE sku_id = 100123 AND bucket = #{rand} AND stock > 0;原来的10个并发竞争同一行,现在分散到10个不同行,理论上锁冲突概率降低了10倍。桶的数量越多,吞吐量越大,但代价是查询库存总量时需要SUM聚合。我一般建议桶数控制在16~64,多了管理成本高,少了效果不明显。
拆分方案还有一些细节。比如某个桶的库存扣完了,但其他桶还有,业务上要允许跨桶重试;比如批量扣减(一次扣5个),需要在一个事务里跨多个桶更新,这会部分丧失拆分带来的并发优势,需要在事务里最多更新2~3个桶并保证幂等。账户余额拆分也是同样思路,可以把一个资金账号拆成多个子账户,转账时按轮询挑一个子账户做扣减,查询时再汇总。
4.4 只读热点的兜底:缓存与读写分离
需要把“读热点”和“写热点”区分开。如果只有读热点而没有写热点,比如商品详情页被大量请求访问,主从分离加缓存就能解决,根本不需要动数据库锁。但如果写热点和读热点同时存在,比如秒杀页既要读库存又要扣库存,这时候缓存只适合挡读流量,写流量必须用前面三种方案消化。
有一个容易犯的错误:为了扛读热点,把热点数据放进缓存,但更新时先更新缓存再异步更新数据库。这会导致数据库和缓存不一致,而且热点行更新问题并没有消失,只是转移到了数据库。我的建议是读流量用缓存是没问题的,但写路径一定要坚定地走数据库或者走“缓存计数+定时落库”模式,不要做双写,双写的一致性坑真的踩不完。
这里补充一句题外话:主从分离对热点行写没有治疗效果。从库不仅分担不了写锁的压力,反而会因为主库锁等待严重导致主从复制延迟,读库读到旧数据,业务上进一步混乱。别指望加从库能解决写热点,它只能缓解读侧压力。
5. 实战排查链路与验证方法:如何确认修复真的有效
5.1 半小时内定位热点行的诊断SQL
如果你接到一个“数据库卡死”的告警,这是我要说的排查链路。不要先看慢日志,慢日志是滞后的,先看实时状态。
第一步,连上MySQL执行:
SHOW ENGINE INNODB STATUS\G重点看LATEST DETECTED DEADLOCK部分和TRANSACTIONS部分。如果有死锁记录,里面会明确写出是哪条SQL、哪个事务在等哪把锁。如果没有死锁,看TRANSACTIONS段里ACTIVE事务和LOCK WAIT的数量,通常就能定位到热点行。
第二步,查询当前锁等待关系:
SELECT r.trx_id AS waiting_trx_id, r.trx_mysql_thread_id AS waiting_thread, b.trx_id AS blocking_trx_id, b.trx_mysql_thread_id AS blocking_thread, b.trx_query AS blocking_query FROM information_schema.innodb_lock_waits w JOIN information_schema.innodb_trx r ON w.requesting_trx_id = r.trx_id JOIN information_schema.innodb_trx b ON w.blocking_trx_id = b.trx_id;这条SQL能直接告诉你“谁在等谁”。看到阻塞事务的trx_query一直是同一条update语句,而且trx_started时间很长,那热点行就找到了。
第三步,去performance_schema看更底层的数据锁信息:
SELECT * FROM performance_schema.data_lock_waits\G8.0版本下information_schema.innodb_locks已经废弃,但data_lock_waits提供了等价且更细粒度的信息。这套组合拳基本能覆盖90%的定位需求。
5.2 用压测数据验证方案的收益
方案做完了不能凭感觉说“好像好了”,要用数据说话。热点行更新的压测方式跟普通压测不同,要专门模拟同一行的并发更新。
如果你用sysbench,可以写一个简单的Lua脚本,针对同一主键做更新。但更简单的方式是直接用Java或Python写一个并发脚本:比如100个线程同时执行1万次同一条UPDATE sku_stock SET stock = stock - 1 WHERE sku_id = 100123,记录吞吐量和平均延迟。优化前跑一组,优化后再跑一组,对比三组数据:
| 优化前 | 优化后 |
|---|---|
| 吞吐量TP-S: 200/s | 吞吐量TP-S: 4000/s |
| 锁等待次数: 1876/s | 锁等待次数: 12/s |
| P99延迟: 850ms | P99延迟: 5ms |
我建议压测时额外记录SHOW GLOBAL STATUS LIKE 'Innodb_row_lock_current_waits'的变化情况。优化后这个值应趋于稳定或为零,如果还持续波动,说明锁竞争没有根除,需要继续检查事务边界和是否还有长事务。
5.3 线上观察哪些指标避免再次踩雷
复盘时我总结了一套“热点行症状观察表”,只要盯住这几个指标,基本不会再次被突袭。
Threads_running超过innodb_thread_concurrency的70%,需要警惕;Innodb_row_lock_time涨幅超过基线10倍,说明有新的热点行出现;- 活跃事务数持续增加,且大量事务状态为
LOCK WAIT; - 主从延迟
Seconds_Behind_Master持续非零,说明主库写压力异常。
这些指标可以接入Prometheus + Grafana,配置告警规则。健康状态下,热点的行锁等待是零星出现的,不会持续。一旦持续,立刻执行5.1的诊断SQL找到“热点行”,然后从业务侧判断这个热点是突发流量还是长期现象。
5.4 从根上预防:业务架构层面的三道防线
最后聊一下预防。解决热点行问题不能总是“救火”,要从架构层面提前布局。
第一道防线是流量控制。秒杀、开售这类确定性的高并发场景,入口处用令牌桶或消息队列限流降级,避免流量直接打到数据库。很多热点行事故都不是业务常态,而是营销活动突发的,入口限流能把峰值磨平。
第二道防线是事务设计审查。每次设计写操作时都要问:这个事务里有没有远程调用?事务边界能不能再压短?更新走没走索引?这些检查应该进代码评审流程。很多团队代码评审只聊接口逻辑,不聊SQL和事务,这是巨大的盲区。
第三道防线是写路径的预先拆分。核心高并发表在建表时就要想清楚会不会出现热点行。库存、余额、计数类字段从一开始就设计好分桶结构,或者提前预留异步合并方案,不要等到线上出事故再改造。改造的代价远大于一开始设计好。
这三道防线互相配合,入口限流挡掉大流量,事务设计减少锁持有,写路径拆分降低锁竞争,热点行更新问题就不会有冒头的机会。
最后再分享一点经验:每次处理完热点行事故,我都会把当时的告警截图、锁等待SQL、优化前后的压测数据整理成文档。因为这类问题的排查路径高度相似,有了历史记录,下一次遇到类似问题能在一小时内定位完成。如果你团队里还没有整理这类文档的习惯,强烈建议从这次开始。