ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MySQL热点行更新问题排查与根治:从行锁原理到拆分实战

MySQL热点行更新问题排查与根治:从行锁原理到拆分实战 先说一个我亲历过的场景。某次电商大促压测凌晨两点监控突然炸了数据库活跃连接数从平时的几十飙到五百多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_detectON它通过等待图来检测死锁发现后立即回滚其中一个事务。死锁检测本身需要消耗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 balancebalance-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落到MySQLUPDATE 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/sP99延迟: 850msP99延迟: 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、优化前后的压测数据整理成文档。因为这类问题的排查路径高度相似有了历史记录下一次遇到类似问题能在一小时内定位完成。如果你团队里还没有整理这类文档的习惯强烈建议从这次开始。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进