一条 UPDATE 锁住半张表:MySQL 没走索引的真实事故
线上接口突然全部变慢。数据库 CPU 不高连接数却一路往上涨。最后排查下来根因不是并发太高而是一条UPDATE没走索引顺手把一堆记录都锁住了。这件事我印象很深因为第一反应是不可能——明明只改一条订单怎么会把整条链路拖死。下面把现场、复现和原理拆开讲。一、事故现场线上有个订单取消接口。用户取消订单时系统把订单状态从WAIT_PAY改成CANCELED代码很普通Transactional(rollbackForException.class)publicvoidcancelOrder(StringorderNo){orderMapper.cancel(orderNo);}SQL 看着也没问题UPDATEorder_infoSETstatusCANCELED,cancel_timeNOW()WHEREorder_noO202607070001ANDstatusWAIT_PAY;谁也没把它当危险品。直到某天高峰期订单相关接口开始成片超时数据库里堆满了锁等待Lock wait timeout exceeded; try restarting transaction业务侧的表现很迷惑取消订单慢、支付回调慢、订单详情偶尔卡住、后台改状态也卡。一开始大家怀疑是数据库压力顶不住了但看监控就懵了——CPU 不算高磁盘 IO 也没满慢 SQL 里全是UPDATE order_info锁等待数量持续往上爬。根因最后就一句话order_no没有索引。这条只更新一条订单的 SQL实际扫描了很多行。二、为什么没索引的 UPDATE 会把锁放大很多人下意识觉得order_no唯一对应一条订单那 UPDATE 肯定只锁这一条。这个直觉是错的因为数据库找数据不是靠你的业务认知是靠执行计划。UPDATE在 InnoDB 里走的是当前读current read不是普通SELECT那种快照读。它一边扫一边给扫过的行加锁。一旦 WHERE 条件没有可用索引优化器只能全表扫描——沿着主键聚簇索引一行一行往下读。关键就在这里在默认的 REPEATABLE READ 隔离级别下如果UPDATE没有合适索引InnoDB 需要沿着聚簇索引扫描大量记录。扫描过程中很多不满足 WHERE 条件的记录也可能被加锁并且锁会持有到事务结束锁范围就被放大了。MySQL 官方文档说得很直白If you have no index suitable for your statement and MySQL must scan the entire table to process the statement, every row of the table becomes locked, which in turn blocks all inserts to the table.所以事务 B 更新的是 O1003、跟事务 A 的 O1002 八竿子打不着照样被卡——因为它俩锁的是同一批行。被锁的不是你要改的那条数据而是扫描路上碰到的所有行。顺带提一句这个坑在 READ COMMITTED 下会轻很多。RC 级别对UPDATE用的是 semi-consistent read不满足条件的不匹配行不会一直持锁全扫描锁全表的情况基本不会出现。但我不建议靠调低隔离级别来兜底索引该加还是得加业务上也很少能随便把 RR 改成 RC。三、最小复现下面复现建议在 MySQL 8.0 InnoDB 默认 REPEATABLE READ 隔离级别下执行并确保两个窗口都手动开启事务不要自动提交。建一张订单表故意不给order_no加索引CREATETABLEorder_info(idBIGINTPRIMARYKEYAUTO_INCREMENT,order_noVARCHAR(64)NOTNULL,user_idBIGINTNOTNULL,statusVARCHAR(32)NOTNULL,amountDECIMAL(10,2)NOTNULL,create_timeDATETIMENOTNULL)ENGINEInnoDB;INSERTINTOorder_info(order_no,user_id,status,amount,create_time)VALUES(O1001,1,WAIT_PAY,99.00,NOW()),(O1002,2,WAIT_PAY,88.00,NOW()),(O1003,3,WAIT_PAY,77.00,NOW());开两个事务。事务 A 先动先不提交BEGIN;UPDATEorder_infoSETstatusCANCELEDWHEREorder_noO1002ANDstatusWAIT_PAY;-- 先不要 COMMIT事务 B 去改另一条订单BEGIN;UPDATEorder_infoSETamount100.00WHEREorder_noO1003;你会发现事务 B 卡住了。两个事务更新的是不同订单却互相阻塞——原因就是order_no没索引UPDATE 扫描范围被放大锁冲突直接被放大。数据量更大、事务更长、并发更高时这种偶发卡顿就会变成线上事故。四、用 EXPLAIN 看执行计划出问题时第一件事不是猜是看执行计划EXPLAINUPDATEorder_infoSETstatusCANCELEDWHEREorder_noO202607070001ANDstatusWAIT_PAY;如果看到这样的结果就要警惕了type: ALL key: NULL rows: 500000type ALL全表扫描key NULL没用上任何索引rows 500000预计扫很多行SELECT慢顶多是慢UPDATE/DELETE这类会加锁的语句一旦type ALL危险程度完全不是一个量级——它拖出来的是一串锁等待。五、为什么 SELECT 没事UPDATE 就炸有人会说这个条件平时查询也用过没觉得慢啊。因为SELECT和UPDATE的代价不一样。普通SELECT没走索引顶多扫描慢。但UPDATE没走索引除了慢还会牵出这些问题行锁和锁等待事务持锁时间变长回滚成本变高事务提交变慢主从复制延迟风险变高尤其是 UPDATE 还包在事务里的时候Transactional(rollbackForException.class)publicvoidcancelOrder(StringorderNo){orderMapper.cancel(orderNo);// 这一句扫得慢stockMapper.release(orderNo);flowMapper.insert(orderNo,CANCEL);}第一条 UPDATE 扫得慢后面两段逻辑全被拖住事务提交变慢、锁释放变慢别的请求就开始排队。所以线上看到的往往不是一条 SQL 慢而是整个订单链路都慢、连接池被占满、接口超时。六、正确修复给条件列建索引order_no是订单唯一编号加唯一索引最稳ALTERTABLEorder_infoADDUNIQUEKEYuk_order_no(order_no);如果查询条件总是WHERE order_no ? AND status ?那order_no唯一索引就够了它已经能精准定位到一条记录。碰到不是唯一字段的情况比如按用户和状态批量更新UPDATEorder_infoSETstatusEXPIREDWHEREuser_id1001ANDstatusWAIT_PAY;可以考虑联合索引ALTERTABLEorder_infoADDKEYidx_user_status(user_id,status);索引当然不能瞎加但凡是UPDATE/DELETE的过滤条件尤其是高频接口必须重点检查有没有索引。七、另一个坑类型不一致导致索引失效有时候索引明明建了SQL 还是没走。比如表结构CREATETABLEorder_info(idBIGINTPRIMARYKEYAUTO_INCREMENT,order_noVARCHAR(64)NOTNULL,statusVARCHAR(32)NOTNULL,KEYidx_order_no(order_no))ENGINEInnoDB;结果 SQL 把order_no写成数字UPDATEorder_infoSETstatusCANCELEDWHEREorder_no202607070001;order_no是字符串传参却是数字。MySQL 会做隐式类型转换——它把列里的值转成数字来比而不是把常量转成字符串于是索引用不上。正确写法是加引号UPDATEorder_infoSETstatusCANCELEDWHEREorder_no202607070001;Java 这边也一样别数据库是varchar代码里传个Long进去intcancel(Param(orderNo)StringorderNo);这种问题特别隐蔽SQL 看着有条件表上也有索引执行计划就是不对。八、再一个坑函数操作导致索引失效订单表建了create_time索引KEYidx_create_time(create_time)SQL 却对索引列套了函数UPDATEorder_infoSETstatusEXPIREDWHEREDATE(create_time)2026-07-07ANDstatusWAIT_PAY;对索引列做函数计算索引基本就废了。更稳的写法是改成范围查询UPDATEorder_infoSETstatusEXPIREDWHEREcreate_time2026-07-07 00:00:00ANDcreate_time2026-07-08 00:00:00ANDstatusWAIT_PAY;如果是高频批量更新还可以考虑联合索引ALTERTABLEorder_infoADDKEYidx_status_create_time(status,create_time);不过批量更新也不能一次扫太多最好分批处理。九、批量 UPDATE 更要小心比如定时任务清理过期订单UPDATEorder_infoSETstatusEXPIREDWHEREstatusWAIT_PAYANDcreate_timeDATE_SUB(NOW(),INTERVAL30MINUTE);要是status、create_time没合适索引可能一口气扫掉大量数据。更稳的做法是先按主键捞出一批SELECTidFROMorder_infoWHEREstatusWAIT_PAYANDcreate_timeDATE_SUB(NOW(),INTERVAL30MINUTE)ORDERBYidLIMIT500;再按主键更新循环分批UPDATEorder_infoSETstatusEXPIREDWHEREidIN(...);这样做有几个实在的好处每次锁的行数可控、事务时间短、失败回滚成本低、对线上请求影响小。那种一条 SQL 更新几十万行的写法平时没事一到高峰期非常容易出事。十、线上怎么排查怀疑是锁等待按这个顺序查。1. 看慢 SQL。重点找UPDATE、DELETE、SELECT ... FOR UPDATE尤其是执行时间长、扫描行数大的。2. 看执行计划。关注type是不是ALL、key是不是NULL、rows是不是很大、Extra有没有Using where。不是所有Using where都有问题但配上全表扫描和巨大的 rows 就得小心。3. 看当前事务和锁等待。MySQL 8.0 可以直接查SELECT*FROMperformance_schema.data_locks;SELECT*FROMperformance_schema.data_lock_waits;还在用 MySQL 5.7 的话这两张表是information_schema下的INNODB_LOCKS和INNODB_LOCK_WAITS8.0 已经移除别照着 8.0 的写法去 5.7 上跑。另外SHOW ENGINE INNODB STATUS;也能看锁信息重点看谁在等锁、谁持有锁、等了多久、SQL 是什么、事务有没有长时间不提交。4. 看应用事务。数据库锁等待经常不是数据库自己的问题。还得看应用里有没有事务里调远程接口、事务里做大循环、事务里写大量数据、异常后没及时结束事务、连接池被占满。锁问题基本都是 SQL 和事务边界一起造成的。十一、上线前 checklist看到UPDATE、DELETE上线前过一遍这张表检查项风险建议WHERE 条件是否有索引全表扫描扩大锁影响给高频条件建索引是否按唯一键更新锁范围更小优先用主键/唯一键字段类型是否一致隐式转换导致索引失效参数类型和字段一致是否对索引列用函数索引可能失效改成范围查询是否一次更新大量行锁太多回滚成本高分批更新是否在事务里执行慢 UPDATE锁持有时间长缩短事务是否和远程调用放一起连接和锁被拖住远程调用移出事务是否压测过高峰数据量本地小数据看不出用接近线上数据验证十二、总结MySQL 锁问题最容易误判的地方以为自己只更新一条实际拖垮了一堆请求。。答案在执行计划里数据库不按你的业务直觉找数据它按索引和执行计划找。UPDATE 没走索引就会从更新一条记录变成扫描大量记录并放大锁冲突。落到行动上就三条高频UPDATE必须确认走了索引别等上线靠EXPLAIN撞运气。批量UPDATE必须分批按主键切别一把梭。慢事务里的UPDATE会把锁问题放大远程调用和重逻辑尽量挪出事务。下一篇准备写MySQL 间隙锁又来了为什么查不到的数据也会被锁住如果你也在排查 Java 后端线上问题可以关注公众号。这个系列会持续更新 Spring 事务、线程池、JVM、MySQL、Redis、MQ 的真实踩坑复现。如果有什么疑问或故障随时私信我免费帮你解决