尧图网站建设 尧图网络
  • 首页
  • 关于我们
  • 服务项目
  • 案例展示
  • 建站流程
  • 资讯中心
  • 联系我们
首页/资讯中心/详情

一条 UPDATE 锁住半张表:MySQL 没走索引的真实事故

一条 UPDATE 锁住半张表:MySQL 没走索引的真实事故
📅 发布时间:2026/7/8 14:26:42

线上接口突然全部变慢。数据库 CPU 不高,连接数却一路往上涨。最后排查下来,根因不是并发太高,而是一条UPDATE没走索引,顺手把一堆记录都锁住了。

这件事我印象很深,因为第一反应是"不可能"——明明只改一条订单,怎么会把整条链路拖死。下面把现场、复现和原理拆开讲。


一、事故现场

线上有个订单取消接口。用户取消订单时,系统把订单状态从WAIT_PAY改成CANCELED,代码很普通:

@Transactional(rollbackFor=Exception.class)publicvoidcancelOrder(StringorderNo){orderMapper.cancel(orderNo);}

SQL 看着也没问题:

UPDATEorder_infoSETstatus='CANCELED',cancel_time=NOW()WHEREorder_no='O202607070001'ANDstatus='WAIT_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)ENGINE=InnoDB;
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_infoSETstatus='CANCELED'WHEREorder_no='O1002'ANDstatus='WAIT_PAY';-- 先不要 COMMIT

事务 B 去改另一条订单:

BEGIN;UPDATEorder_infoSETamount=100.00WHEREorder_no='O1003';

你会发现事务 B 卡住了。两个事务更新的是不同订单,却互相阻塞——原因就是order_no没索引,UPDATE 扫描范围被放大,锁冲突直接被放大。数据量更大、事务更长、并发更高时,这种偶发卡顿就会变成线上事故。


四、用 EXPLAIN 看执行计划

出问题时第一件事不是猜,是看执行计划:

EXPLAINUPDATEorder_infoSETstatus='CANCELED'WHEREorder_no='O202607070001'ANDstatus='WAIT_PAY';

如果看到这样的结果就要警惕了:

type: ALL key: NULL rows: 500000
  • type = ALL:全表扫描
  • key = NULL:没用上任何索引
  • rows = 500000:预计扫很多行

SELECT慢顶多是慢,UPDATE/DELETE这类会加锁的语句一旦type = ALL,危险程度完全不是一个量级——它拖出来的是一串锁等待。


五、为什么 SELECT 没事,UPDATE 就炸

有人会说:这个条件平时查询也用过,没觉得慢啊。因为SELECT和UPDATE的代价不一样。

普通SELECT没走索引,顶多扫描慢。但UPDATE没走索引,除了慢,还会牵出这些问题:

  • 行锁和锁等待
  • 事务持锁时间变长
  • 回滚成本变高
  • 事务提交变慢,主从复制延迟风险变高

尤其是 UPDATE 还包在事务里的时候:

@Transactional(rollbackFor=Exception.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_infoSETstatus='EXPIRED'WHEREuser_id=1001ANDstatus='WAIT_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))ENGINE=InnoDB;

结果 SQL 把order_no写成数字:

UPDATEorder_infoSETstatus='CANCELED'WHEREorder_no=202607070001;

order_no是字符串,传参却是数字。MySQL 会做隐式类型转换——它把列里的值转成数字来比,而不是把常量转成字符串,于是索引用不上。正确写法是加引号:

UPDATEorder_infoSETstatus='CANCELED'WHEREorder_no='202607070001';

Java 这边也一样,别数据库是varchar,代码里传个Long进去:

intcancel(@Param("orderNo")StringorderNo);

这种问题特别隐蔽:SQL 看着有条件,表上也有索引,执行计划就是不对。


八、再一个坑:函数操作导致索引失效

订单表建了create_time索引:

KEYidx_create_time(create_time)

SQL 却对索引列套了函数:

UPDATEorder_infoSETstatus='EXPIRED'WHEREDATE(create_time)='2026-07-07'ANDstatus='WAIT_PAY';

对索引列做函数计算,索引基本就废了。更稳的写法是改成范围查询:

UPDATEorder_infoSETstatus='EXPIRED'WHEREcreate_time>='2026-07-07 00:00:00'ANDcreate_time<'2026-07-08 00:00:00'ANDstatus='WAIT_PAY';

如果是高频批量更新,还可以考虑联合索引:

ALTERTABLEorder_infoADDKEYidx_status_create_time(status,create_time);

不过批量更新也不能一次扫太多,最好分批处理。


九、批量 UPDATE 更要小心

比如定时任务清理过期订单:

UPDATEorder_infoSETstatus='EXPIRED'WHEREstatus='WAIT_PAY'ANDcreate_time<DATE_SUB(NOW(),INTERVAL30MINUTE);

要是status、create_time没合适索引,可能一口气扫掉大量数据。更稳的做法是先按主键捞出一批:

SELECTidFROMorder_infoWHEREstatus='WAIT_PAY'ANDcreate_time<DATE_SUB(NOW(),INTERVAL30MINUTE)ORDERBYidLIMIT500;

再按主键更新,循环分批:

UPDATEorder_infoSETstatus='EXPIRED'WHEREidIN(...);

这样做有几个实在的好处:每次锁的行数可控、事务时间短、失败回滚成本低、对线上请求影响小。那种一条 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_WAITS(8.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 的真实踩坑复现。如果有什么疑问或故障,随时私信我免费帮你解决

相关新闻

  • CircuitJS1 Desktop Mod:终极免费离线电路仿真软件,电子爱好者的数字实验室
  • 网约车 Go 后端 + AI 编码工具实战踩坑记录|3 个高频问题完整复盘
  • 桥梁坍塌安全监测与自然灾害预警:改进YOLO算法的目标检测数据集全解析10310期

最新新闻

  • 从0到1使用gala-spider:配置文件详解与最佳实践
  • IPQC 每日检查要点全解析:确保生产质量的关键防线
  • 如何快速上手openEuler/ha-api:从安装到第一个API调用的完整教程
  • 内核日志永不丢失:openeuler/lep Kbox黑匣子分区管理与数据恢复技巧
  • 模型量化 INT8 实战:PyTorch 静态量化 3 步压缩 ResNet-50,精度损失 <1%
  • UBS-atomic部署指南:在生产环境中配置和优化分布式原子服务的完整流程

日新闻

  • PROPKA 3深度解析:蛋白质pKa预测的实战指南与算法原理
  • 微信小程序 globalData 监听:基于 Object.defineProperty 的 3 种实现方案对比
  • MySQL 8.0 数据清洗实战:3类异常值识别与 UPDATE/DELETE 批量处理

周新闻

  • 基于YOLOv12的番茄成熟度智能检测系统开发
  • 终极RimWorld模组管理指南:用RimSort告别模组冲突烦恼
  • AI Agent框架开发:从理论到实践的完整指南

月新闻

  • 2026年6月公司网站搭建最新热门渠道测评:四大低成本/零代码平台对比+避坑
  • 【Linux】Linux arm 编译QT程序,出现expected “}“报错
  • 【MATLAB例程】四基站二维AOA定位与距离辅助增强对比仿真。基于角度观测和测距修正的固定目标平面定位精度分析

关于尧图

  • 公司简介
  • 团队介绍
  • 企业文化
  • 荣誉资质

服务项目

  • 定制开发
  • 电商建站
  • UI 设计
  • 运维服务

快速链接

  • 案例展示
  • 建站流程
  • 常见问题
  • 资讯中心

联系方式

  • 📍北京市朝阳区互联网产业园 A 座 10 层
  • 📞400-888-8888
  • ✉️contact@rkmt.cn
  • 🕐周一至周日 9:00-21:00

© 2024 北京尧图网络科技有限公司 版权所有 | 京 ICP 备 XXXXXXXX 号