1. 一条 UPDATE 语句的“全景路线图”——先建立整体认知
1.1 为什么值得把一个 UPDATE 的执行过程单独拉出来聊
先说个我踩过的坑。早年维护一个订单系统,某天线上突然出现大量Lock wait timeout exceeded,一查全是同一条 UPDATE 语句。当时的第一反应是“是不是索引没建”,但 explain 看下来走了索引,于是开始怀疑参数、怀疑连接池,折腾了大半天,最后才发现问题出在“这条 UPDATE 自己写的子查询里有一个全表扫描”,把整张表的行都锁住了。从那以后我就意识到,如果你仅仅把 UPDATE 当成“改一行数据的语法”,遇到线上问题就会非常被动。
MySQL 8.0 是目前生产环境使用最广的版本之一,很多细节和 5.7 相比有调整,比如默认字符集变了、WITH语法更成熟、优化器成本模型更细腻、undo 和 redo 的机制也有重构。但 UPDATE 的骨架逻辑是稳定且经典的:先定位要改的行,再加锁,然后修改,最后提交或回滚。这四个阶段听上去简单,实际执行过程中牵扯到 SQL 解析、权限校验、优化器选路、存储引擎加锁、binlog 与 redo log 配合、主从同步等一长串环节。任何一个环节出问题,表象都可能只是“Update 很慢”或者“Update 报错”,但根因可能千差万别。
这篇文章不打算堆砌抽象的架构名词,而是带着一条具体的 UPDATE 语句,从客户端发起到最终落盘,走一遍 MySQL 8.0 的完整旅程。每一步都会讲清楚:MySQL 在这个阶段做了什么,为什么要这么做,以及生产环境中常见的坑在哪里。
1.2 先给整条执行链路画个轮廓
如果只说“执行一条 UPDATE”,很多人脑子里只有一句话:“UPDATE t SET name='xx' WHERE id=1,然后行数据变了。”真实情况当然没这么简单。我在排查问题时习惯把整个过程切分成几个阶段:
- 连接与通信阶段:客户端把 SQL 文本发给 MySQL Server,MySQL 分配线程、初始化上下文。
- 解析与预处理阶段:把 SQL 字符串变成 MySQL 认识的内部结构,检查表、列是否存在,权限是否足够。
- 优化阶段:决定用哪个索引、按什么顺序扫描、如何做连接。
- 执行阶段:调用存储引擎接口,定位记录,加锁,读取旧值,写入新值,生成 undo 和 redo 日志。
- 提交阶段:完成 binlog 和 redo log 的两阶段提交,释放锁,返回客户端影响行数。
后面的内容就按这个顺序展开。这样即便以后遇到 UPDATE 相关问题,也能先在脑子里定位“问题可能出在第几步”,再针对性去查。
2. 执行前的第一道坎:SQL 解析与预处理
2.1 词法分析、语法分析到底在做什么
当客户端把“UPDATE t SET name='xx' WHERE id=1”这段文本发给 MySQL 时,服务端首先不是急着找数据,而是先“读题”。MySQL 的解析器会把字符串拆成一个个 Token,比如 UPDATE、t、SET、name、=、xx、WHERE、id、=、1。这一步叫词法分析。然后进入语法分析,MySQL 会根据预定义的语法规则,把这些 Token 组装成语法树。
我在教学时经常用一句话概括:解析器只关心“这句话符不符合 SQL 语法”,完全不关心表里有没有数据。
比如你把条件写成WHERE id=1x并且这一列是整数类型,解析阶段不会报错,真正执行时才会报类型转换或数据转换的问题。再比如你写成UPDATE t SET name='xx' WHERE id=1 AND,这里语法都不完整,解析阶段就会被直接拦住,报You have an error in your SQL syntax。这类错误定位最简单,看错误信息里提示的“near”关键字就能找到问题位置。
MySQL 8.0 在解析阶段一个值得提的改动是对WITH子句(公共表表达式)的支持更完善了。以前 5.7 及更早版本里,UPDATE配合子查询写法受限较多;8.0 里WITH ... UPDATE是合法写法,这让复杂的关联更新语句表达能力更强。但有一点要注意:WITH子句可以被优化器物化,也可以被合并到主查询中,这取决于成本估算。如果物化后的临时表非常大,反而可能导致 UPDATE 变慢。后面优化器部分会细说。
2.2 预处理:检查表、列和权限
语法树生成后,MySQL 会进入预处理阶段(resolve 阶段)。这个阶段的工作包括:
- 解析表名和列名,确认这些对象在数据库中真实存在。
- 对星号
*进行展开。虽然 UPDATE 一般不直接写SELECT *,但如果 SET 或子查询里出现*,这里会展开成具体列。 - 校验权限。用户是否有这张表的 UPDATE 权限,是否对 SET 涉及的列有更新权限,是否对 WHERE 条件里涉及的列有 SELECT 权限。
很多人对权限校验不敏感,觉得“反正我是 root,不会碰到”。但在生产环境里,业务账号通常是最小权限。我见过一个真实案例:某个报表账号能查数据,也能执行 UPDATE,但 UPDATE 语句里带了一个子查询,而子查询引用了另一张业务表,该账号对这张表没有 SELECT 权限,结果报错SELECT command denied to user。从报错信息看,明明是在执行 UPDATE,却被拒绝在 SELECT 权限上,很多人会懵。理解了预处理阶段在解析时就会校验子查询涉及的所有对象权限,这个问题就很容易解释了。
预处理阶段还有一个容易忽略的细节:列的可见性。MySQL 8.0 里如果表上建了不可见列(INVISIBLE),普通的 SELECT 不会显示该列,但 UPDATE 如果显式指定列名去更新它是可以的。如果你用的是UPDATE t SET col = ...这种写法,MySQL 在预处理阶段就会对列名做精确解析,列不存在会直接报Unknown column。这一类错误通常不会拖到执行阶段才暴露。
2.3 预处理阶段容易踩的隐式类型转换坑
预处理阶段除了检查对象和权限,还会做一部分类型推导和隐式转换的准备。举个例子,执行UPDATE t SET name='xx' WHERE id='1',如果id是整数类型,字符串'1'会转换为数字 1。这本身没问题,但如果你写的是WHERE id='1abc',MySQL 在比较时会把'1abc'转换成 1,行为可能和你预期的完全不一样。
我碰到过一个典型事故:某张表的 user_id 是 varchar 类型,但存的内容是纯数字,比如'1001'、'1002'。有人 UPDATE 时条件写成WHERE user_id=1001,MySQL 会把字段值转成数字做比较,由于字符串转数字时会忽略后面的非数字字符,看似能匹配到,但一旦表中存在类似'1001abc'这样的脏数据,也会被误匹配,导致更新行数超出预期。这类问题在预处理阶段不会暴露,但在执行阶段会造成“影响行数异常”,排查起来比语法错误痛苦得多。
所以在写 UPDATE 时,条件列的类型一定要和字段类型完全对齐,能用字符串就用字符串,能用数字就用数字,尽量不要依赖隐式转换。优化器在做隐式转换时通常也会放弃索引,这一点放在优化器部分再展开。
3. 优化器:你的 UPDATE 为什么慢,在这里就决定了
3.1 从 SQL 文本变成执行计划
如果说解析阶段是“读题”,优化器阶段就是“决定用哪种方式做题”。MySQL 的优化器是一个基于成本的优化器(CBO,Cost-Based Optimizer),它的核心思路是:根据表的统计信息估算各种执行路径的成本,选择成本最低的路径。
对 UPDATE 语句来说,优化器要考虑的事情比 SELECT 多一些,因为 UPDATE 最终需要定位到具体记录并修改,如果走全表扫描,就是逐行判断 WHERE 条件;如果走索引,就是先根据索引找到目标记录,再回表读取完整行。优化器的核心决策点包括:
- 选择哪个索引。WHERE 条件里有多个字段,是走单列索引还是走联合索引,还是干脆全表扫描。
- 连接顺序。UPDATE 如果带有子查询或关联表(比如
UPDATE t1 JOIN t2 ON ... SET t1.a=t2.b WHERE ...),优化器要决定先驱动哪张表。 - 子查询的处理方式。是物化成临时表,还是改写成 semi-join,还是直接嵌套执行。
我平时排查 UPDATE 性能问题,第一步永远是EXPLAIN UPDATE ...,看 type 列和 key 列。如果在 type 列看到ALL,而表数据量又很大,基本可以断定这条 UPDATE 会扫描全表,不仅慢,而且会锁住大量行。
注意:MySQL 8.0 中EXPLAIN UPDATE是支持的,但EXPLAIN ANALYZE只支持 SELECT,这一点别搞混了。如果你想分析 UPDATE 的真实执行耗时和行数,一般做法是先把 WHERE 条件拿出来,改成SELECT COUNT(*)去看扫描行数,或者用 performance_schema 里的事件统计。
3.2 优化器选择索引时的一个隐性成本:回表
举个简单例子。表结构如下:
CREATE TABLE `orders` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL, `user_id` bigint NOT NULL, `status` tinyint NOT NULL DEFAULT 0, `amount` decimal(10,2) NOT NULL DEFAULT 0, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`), KEY `idx_order_no` (`order_no`) ) ENGINE=InnoDB;执行:UPDATE orders SET status=1 WHERE user_id=10086 AND status=0。
优化器面前有两条路:走idx_user_id索引,找到所有user_id=10086的记录,回表读完整行,再判断status=0,匹配成功则更新。或者直接全表扫描,逐行判断。如果user_id=10086的订单只有 3 条,而全表有 1000 万行,走索引显然划算,成本模型会给索引路径一个低得多的成本值,最终选择索引。
但这里有个细节:如果user_id=10086的订单有 50 万行,而全表 1000 万行,且status=0的比例很低(比如只有 1%),优化器估算时如果把二级索引回表的成本算得很高,可能会选择全表扫描。全表扫描意味着 InnoDB 要扫 1000 万行,每行都判断条件,虽然只更新 5000 行,但加锁范围几乎是全表,这是生产环境最怕看到的场景。
优化器走idx_user_id时,还会做一个“回表数量”的估算。这个估算依赖两个统计信息:索引的区分度和表的行数。如果统计信息不准确,优化器就可能做出错误选择。MySQL 8.0 中可以通过ANALYZE TABLE更新统计信息,也可以调整innodb_stats_persistent和innodb_stats_auto_recalc参数来控制自动更新策略。遇到“明明有索引,却走了全表”的情况,先别急着骂优化器,跑一次ANALYZE TABLE再看执行计划,大概率能解决。
3.3 关联更新语句的执行计划,比单表更新更容易翻车
生产环境里真正麻烦的 UPDATE,往往是多表关联更新,比如:
UPDATE orders o JOIN users u ON o.user_id = u.id SET o.status = 1, o.receiver_name = u.name WHERE u.level = 3;优化器要决定先用users表过滤出 level=3 的用户,再关联orders,还是反过来。这个决策直接影响性能。通常的经验是:先用小表作为驱动表,再去大表里查匹配行。但优化器是否真的这么做,取决于统计信息。
我在实际排障中遇到过一种情况:users表只有 5000 行,orders表有 3000 万行,按常理应该先扫 users,再走 orders 的 user_id 索引。但因为 users 表某次批量导入后没有更新统计信息,MySQL 以为 users 表有 500 万行,优化器一算成本,决定反过来先扫 orders 表,结果一条 UPDATE 跑了十几分钟,锁了一堆行。当时就是用ANALYZE TABLE users解决了问题。
从这个案例可以得出一个结论:多表关联 UPDATE 的执行计划不稳定,因为它依赖多个表的统计信息。避免这种不确定性的最佳方式,是尽量改成“先 SELECT 出主键列表,再逐批 UPDATE”的写法,或者在业务层分步执行。虽然代码会多一点,但执行路径完全可控,锁粒度也更小。
3.4 优化器对“影响行数”的估算与真实数据的偏差
还有一个影响优化器判断的因素是“影响行数”。如果优化器认为某条 UPDATE 会影响 90% 的行,它可能选择全表扫描而不是索引,因为全表扫描在这种情况下反而更高效。但优化器估算的前提是列的数据分布均匀。如果表里有一条 SQL 的 WHERE 条件WHERE status='a',而status字段 99% 的行都是'a',另有 1% 是'b',但统计信息很久没更新,直方图信息没有,优化器可能不知道'a'占大头,就会误判。
MySQL 8.0 从 8.0.2 开始支持直方图(Histogram),这是一个重要的能力。通过直方图,优化器可以更准确地估算不同值的选择性,尤其是在没有索引的列上。如果更新条件经常落在某些非索引列上,可以给这些列建立直方图:
ANALYZE TABLE orders UPDATE HISTOGRAM ON status WITH 16 BUCKETS;直方图不是索引,不参与索引选择,但可以帮助优化器在估算扫描行数时更准确,避免因统计偏差导致执行计划劣化。
4. 执行器与存储引擎:UPDATE 真正“动手”的阶段
4.1 执行器如何与 InnoDB 协作
优化器生成执行计划后,就把控制权交给执行器。执行器负责调用存储引擎的接口,逐条读取记录,判断条件,发出修改指令。InnoDB 在收到指令后,真正承担了存储层面的工作:读页、定位记录、加锁、写 undo、写 redo。
这里要先说一个容易误解的点:UPDATE 并不是先执行 DELETE 再执行 INSERT,而是原地更新记录。InnoDB 在更新时,会先找到目标记录的聚簇索引记录,尝试在原有位置上进行更新。如果更新导致记录大小变化超过页内可用空间,InnoDB 可能会把记录迁移到新位置,这时会留下旧记录的“删除标记”并插入新记录。从宏观表现看,类似 delete+insert,但内部机制不同。
当 WHERE 条件命中的是二级索引时,执行器会先通过二级索引找到主键值,再回到聚簇索引上读取完整记录。这就是“回表”。回表流程在 UPDATE 里比 SELECT 更敏感,因为回表过程不仅要读,还要对目标记录加锁。如果条件命中的二级索引区分度很低,比如status=0命中了 50 万行,InnoDB 会逐行回表并逐行加锁,锁的范围展开非常大,并发环境下很容易造成锁等待。
4.2 InnoDB 的锁机制:这条 UPDATE 会锁住哪些行
锁是 UPDATE 执行中最关键的机制,也是 DBA 排障时最难受的部分。InnoDB 支持多种锁,我通常把它们分成几个维度来记:
- 按粒度:行锁(Record Lock)、间隙锁(Gap Lock)、临键锁(Next-Key Lock)、表锁(意向锁等)。
- 按模式:共享锁(S)、排他锁(X)、意向共享锁(IS)、意向排他锁(IX)。
对 UPDATE 来说,InnoDB 会在匹配到的记录上加排他锁。但问题来了:InnoDB 在 RR(可重复读)隔离级别下,为了防止幻读,会在扫描到的范围上额外加间隙锁或临键锁。
举个例子,表里id有 1、5、10 三条记录,执行:
UPDATE t SET name='xx' WHERE id=6;在 RR 隔离级别下,InnoDB 会锁住(5, 10)这个区间,也就是所谓的“间隙锁”。哪怕没有任何id=6的记录,其他事务想插入id=7的记录,也会被阻塞。很多人不理解:明明 UPDATE 没更新任何行,为什么还会锁等待?答案就是间隙锁在起作用。
再比如最常见的条件:
UPDATE t SET name='xx' WHERE id=5;此时 InnoDB 会加 Next-Key Lock,锁住的范围是(1, 5]这个左开右闭区间。也就是说,其他事务想插入id=2的记录会被阻塞,因为id=2落在(1,5]区间内。具体表现和索引上有哪些记录有关系,但原理就是如此。
在 RC(读已提交)隔离级别下,InnoDB 只加 Record Lock,不加 Gap Lock,所以锁粒度小很多,这也是为什么很多高并发系统会主动把隔离级别设为 RC。代价是 binlog 必须使用 ROW 格式,并且无法依赖数据库层面的间隙锁来防止幻读。生产环境里,如果业务场景允许,将隔离级别从 RR 调整为 RC,是缓解 UPDATE 锁竞争的一个常见手段。
4.3 加锁与 SQL 执行顺序的细节
还有一点很值得注意:InnoDB 加锁的顺序和更新数据的顺序并不完全一致。InnoDB 在执行 UPDATE 时,先根据二级索引找到主键,再回聚簇索引读取记录,加锁是在聚簇索引记录上完成的。这意味着如果一条 UPDATE 走了二级索引,它可能先对二级索引记录加锁(准确说是对索引读路径加锁),再回表对聚簇索引记录加锁。如果二级索引的键值本身也要被更新(比如UPDATE t SET status=1 WHERE status=0,status是二级索引列),InnoDB 会采用“先插入新记录、再删除旧记录”的方式来维护索引,这个过程中新插入的索引记录会加锁,旧记录的删除标记也会持有锁逻辑。
这带来一个实际经验:如果你 UPDATE 的列恰好是二级索引列,锁竞争往往比更新非索引列更严重,因为索引维护涉及更多锁操作。高并发更新场景下,尽量把 WHERE 条件设计成主键或唯一索引来定位记录,避免通过二级索引大范围扫描后更新同样的二级索引列。
网上关于SELECT ... FOR UPDATE、FOR UPDATE SKIP LOCKED的讨论很多,核心就是锁粒度的问题。比如LIMIT 1 FOR UPDATE SKIP LOCKED这个组合,语义是“跳过已经被其他事务锁住的行,取一条可以锁定的记录”。它到底锁住一条还是整个 WHERE 条件范围?答案是:InnoDB 会扫描满足 WHERE 条件的记录,逐个跳过已被锁的行,直到找到第一条可用的记录并加锁,然后因为LIMIT 1,停止继续扫描。所以最终只锁住一条记录,但扫描过程中可能读取并跳过大量被锁的行,扫描路径上的某些锁判断会产生额外成本。
4.4 redo log、undo log 和两阶段提交
UPDATE 修改数据后,数据页并不会立即刷到磁盘。为了崩溃恢复和事务回滚,InnoDB 会同时生成两类日志。
- undo log:记录“如何撤销这个修改”,用于事务回滚和 MVCC。比如把
name从'a'改成'b',undo log 会记录“原来的值是'a'”。如果事务回滚,InnoDB 根据 undo log 恢复旧值。 - redo log:记录“这个修改做了哪些物理变更”,用于崩溃恢复。比如“把某个数据页的某个偏移量处的字节从某个值改成某个值”。数据库异常宕机后,重启时通过 redo log 重放未落盘的修改。
MySQL 8.0 中,redo log 的实现相比 5.7 有改动,比如innodb_log_writer_threads等参数,但两阶段提交的框架仍然稳定。具体流程是:
- 事务中执行 UPDATE,InnoDB 将修改写入 redo log buffer,此时状态是 Prepare。
- 事务提交时,MySQL Server 将事务产生的 binlog 事件写入 binlog 文件,并调用
fsync(取决于sync_binlog参数)。 - 两阶段提交的第二种:InnoDB 将 redo log 从 Prepare 状态变为 Commit 状态,再次
fsync(取决于innodb_flush_log_at_trx_commit)。
这套“先写 redo(Prepare),再写 binlog,再提交 redo(Commit)”的逻辑,是为了保证 binlog 和 redo log 的一致性。如果崩溃发生在 binlog 写入前,事务回滚;如果崩溃发生在 binlog 写入后、redo 提交前,MySQL 重启时会根据 binlog 和 redo 的状态做判断,保证主从一致。
这里经常被忽略的是:sync_binlog=1和innodb_flush_log_at_trx_commit=1都是安全配置,但每条提交都有两次fsync,小事务密集写入的场景下性能会明显受限。如果业务允许丢失少量最近事务,可以适当调整参数换取性能,但这是安全性和性能的权衡,不要在不理解后果的情况下盲目调参。
4.5 影响行数与返回结果
UPDATE 执行完成后,MySQL 会给客户端返回“影响行数”。默认情况下,如果新旧值完全一样,InnoDB 也会报告影响行数为 0(即使匹配到了行)。这个行为和 MySQL 的CLIENT_FOUND_ROWS标志位有关:如果连接设置了CLIENT_FOUND_ROWS,返回的是“匹配到的行数”;默认则是“实际修改的行数”。
线上遇到过排查问题的人问:“为什么 UPDATE 说影响 0 行,binlog 里却能看到这条 UPDATE?”这其实是正常的,因为 binlog 默认记录的是整条 UPDATE 语句及其匹配范围,不一定代表实际修改了数据。如果在 binlog 里看到大量影响行数为 0 的 UPDATE,反而值得关注:是不是业务代码在重复执行无意义的更新?这类“空更新”也会走完整的加锁、日志流程,白白消耗数据库资源。
5. 实操:一次 UPDATE 执行过程中的故障排查实录
5.1 场景一:更新不走索引导致锁等待飙升
现象:某天监控告警,information_schema.INNODB_TRX里大量事务处于LOCK WAIT状态,等待时间持续上涨。查看sys.schema_table_lock_waits,发现多条 UPDATE 语句都在等待同一张表的行锁。
排查过程:
首先抓出阻塞源头:
SELECT * FROM performance_schema.data_lock_waits\G;然后根据BLOCKING_ENGINE_TRANSACTION_ID找到持有锁的事务。再把持有锁的事务完整 SQL 拿出来和等待中的 SQL 对比,发现持有锁的事务执行的是:
UPDATE payment_orders SET status=2 WHERE merchant_id=333 AND status=1;这条语句语义没问题,但merchant_id列上没有索引,而业务表已经 2000 万行。执行EXPLAIN UPDATE后 type 是ALL,rows 估算接近全表。InnoDB 在扫描过程中会把所有已扫描的行都加上锁,所以这条 UPDATE 等于把整张表的写能力都“冻结”了。
解决方案分两步:先通知业务暂停该批量更新,然后给merchant_id建索引:
ALTER TABLE payment_orders ADD INDEX idx_merchant_id (merchant_id);索引建好后,同样的 UPDATE 只命中几百条记录,锁范围大幅缩小。这个案例给我的教训是:批量 UPDATE 上线前,必须做 EXPLAIN 验证,尤其是 WHERE 条件里的列是否有合适索引。不要假设“数据量小就没事”,生产环境的数据量和你本地测试完全不是一个量级。
5.2 场景二:并发更新相同行导致死锁
现象:应用日志频繁报Deadlock found when trying to get lock; try restarting transaction,且发生在同一个订单号的更新上。
排查过程:死锁日志查看方式有两种,SHOW ENGINE INNODB STATUS\G里看LATEST DETECTED DEADLOCK部分,或者打开innodb_print_all_deadlocks=1把所有死锁打印到错误日志。日志里通常包含两个事务的 SQL,以及每个事务持有的锁和等待的锁。
典型场景是:事务 A 先更新订单 1001,再更新订单 1002;事务 B 先更新订单 1002,再更新订单 1001。两个事务并发时,各自持有一半的锁,又互相等待对方释放锁,就形成死锁。
InnoDB 检测到死锁后,会选择回滚其中一个事务,让另一个继续。业务侧如果没有完整的事务重试机制,就会看到报错。
这个问题的根治手段并不是去调数据库参数,而是统一应用层获取锁的顺序。比如所有涉及多行更新的操作,都先按主键排序再执行:
UPDATE orders SET status=1 WHERE id IN (1001, 1002) ORDER BY id;或者业务代码里在事务开始前,先对要操作的订单号集合做排序,保证所有事务以相同顺序加锁,就能有效规避死锁。更多时候,死锁发生的根因是应用层逻辑问题,而不是数据库本身的 bug。数据库只是把问题暴露了出来。
5.3 场景三:主从延迟的罪魁祸首是一条超大 UPDATE
现象:从库延迟持续增大,SHOW REPLICA STATUS里Seconds_Behind_Source不断上升,从库 CPU 使用率也偏高。在主库执行SHOW PROCESSLIST,发现当前有一条 UPDATE 正在执行,已经跑了很久。
排查过程:这条 UPDATE 本身在主库也耗时较长,但主库因为并行能力、硬件资源充足,业务还能忍受;到了从库,SQL 线程是单线程回放大事务,延迟就会迅速累积。
查看该 UPDATE 的条件和涉及行数,发现是对一张大表的全量更新,比如UPDATE t SET flag=1 WHERE flag=0,涉及 3000 万行,单事务执行,整个 redo log 和 binlog 都非常大。
这类问题的解决思路:
- 业务上进行分批更新,比如按主键范围每 5 万行提交一次,避免单一大事务。
- 如果无法改业务,可以使用
pt-osc等工具做在线表结构变更,但实际上这种全表 UPDATE 不太适合用工具自动处理,还是得改逻辑。 - 从库并行复制参数要合理设置,比如
replica_parallel_workers。MySQL 8.0 的 MTS(多线程复制)能力比 5.7 更好,但大事务在从库仍然无法拆分成并行回放,因为属于同一个事务的事件必须按顺序执行。
这个案例的核心启示是:大批量 UPDATE 看起来只是改数据,但它产生的日志量、锁持有时间、从库回放压力,都可能成为更大范围事故的导火索。对生产环境来说,控制单条 UPDATE 的影响行数,比追求“一条 SQL 搞定一切”要重要得多。
5.4 常见问题速查表
| 现象 | 可能原因 | 快速排查手段 | 解决方向 |
|---|---|---|---|
| UPDATE 执行极慢 | WHERE 条件无索引、统计信息不准、锁等待 | EXPLAIN 看 type/key/rows;查 INNODB_TRX | 建索引、ANALYZE TABLE、拆分事务 |
| 报 Lock wait timeout exceeded | 其他事务持锁未释放 | 查 performance_schema.data_lock_waits | 优化持锁事务、减小事务范围、缩短事务时间 |
| 报 Deadlock found | 多事务加锁顺序不一致 | SHOW ENGINE INNODB STATUS | 统一加锁顺序、增加重试机制 |
| UPDATE 报权限错误 | 子查询涉及其他表无 SELECT 权限 | 查看错误信息中表名 | 给账号授权或改写 SQL |
| 影响行数为 0 | 新旧值相同 | 无需处理;如需匹配行数设置 CLIENT_FOUND_ROWS | 检查业务逻辑是否存在无意义更新 |
| 从库延迟迅速增大 | 大事务、大 UPDATE | SHOW REPLICA STATUS 查看耗时 | 分批更新、优化单事务大小 |
| 修改后数据不对 | 隐式类型转换 | 检查列类型与条件值类型 | 显式类型匹配,避免依赖转换 |
6. 关于参数调优与 UPDATE 性能的几个补充经验
6.1 先看业务设计,再谈参数调优
很多人在优化 UPDATE 性能时,第一反应就是调innodb_buffer_pool_size或者innodb_flush_log_at_trx_commit。这些参数当然重要,但优先级一定要放在业务设计之后。
我在实际项目中总结出的顺序是:
- 先确认 WHERE 条件是否走索引,这是性价比最高的优化手段。一个合适的索引,能让 UPDATE 从全表扫描变成点查,性能提升可能是几个数量级。
- 再审视事务大小。一次 UPDATE 更新的行数越少,锁持有时间越短,冲突概率越低。如果批量更新无法避免,就拆分多批次,每批加
LIMIT或按主键范围限定。 - 然后检查并发冲突。如果多个事务频繁竞争同一批行,即使每条 UPDATE 都很快,也会因为等待导致整体吞吐量上不去。
- 最后才轮到参数调优。盲目调参可能带来副作用,比如调大 buffer pool 会占用更多内存,调低刷新频率会提高崩溃丢失数据的风险。
6.2 与 UPDATE 强相关的几个关键参数
innodb_lock_wait_timeout:默认 50 秒,控制事务等待行锁的超时时间。调小可以让问题更早暴露,但业务会更容易报错;调大则可能让等待堆积到不可控的程度。不建议随意调大。binlog_format:MySQL 8.0 默认是 ROW。ROW 格式下,binlog 记录的是每一行变更前后的完整镜像,虽然日志量比 STATEMENT 大,但主从数据一致性更好。UPDATE 大批量修改时,ROW 格式的 binlog 膨胀会非常明显,需要提前规划磁盘空间和主从带宽。innodb_flush_log_at_trx_commit:默认 1,每次提交都刷 redo log。这个参数的调优空间一直存在,但要想清楚安全性和性能的取舍。tx_isolation(8.0 里是transaction_isolation):默认 REPEATABLE-READ。如果业务可以接受 RC 隔离级别,UPDATE 的间隙锁问题会大大减少。
多提一句,innodb_buffer_pool_size虽然不直接控制 UPDATE 执行速度,但 UPDATE 需要读取目标数据页到 buffer pool 中才能修改。如果页已经在内存里,速度会快很多;如果 buffer pool 太小,每次都要从磁盘读取,性能自然上不去。通常建议把 buffer pool 设置为物理内存的 60%~75%,但也要考虑机器上还有操作系统和其他进程。
6.3 一条 UPDATE 语句的“最小化锁范围”实践模板
如果你需要更新一批订单状态,建议用下面这种可控的批处理方式,而不是一条 SQL 扫全表:
-- 假设每次更新 1000 条,按主键顺序取 UPDATE orders SET status = 2 WHERE status = 1 AND id >= :last_max_id ORDER BY id LIMIT 1000;每次执行后记录:last_max_id为本次更新的最大主键值,循环执行直到影响行数为 0。这样做的好处是:单事务锁定的行数有限,不会长时间占用大量锁;每批事务完成后立即提交,释放锁;即使中途出错,也不会因为回滚超大事务导致长时间不可用。这种写法在批量清理、批量标记、历史数据归档等场景中非常实用。
6.4 别忽略连接层面的小问题
有些 UPDATE 性能问题其实不是 MySQL 本身造成的,而是连接层。比如长事务一直持有事务未提交,连接池里的连接把事务边界搞错了,导致一条 UPDATE 在执行时,事务还持有之前其他操作留下的锁。这类问题从 SQL 本身看不出毛病,必须检查应用层的事务管理。
我遇到过的最典型情况是:Spring 事务切面配置错误,导致一个本不该开启事务的查询操作和后面的 UPDATE 被放在同一个事务里,前面的查询虽然已结束,但事务一直没提交,持有的一批锁也一直没释放,后面的 UPDATE 自然就卡住了。这种问题在代码 review 时很难发现,但一旦出现,会让人怀疑人生。
所以排查 UPDATE 性能问题时,除了看数据库侧的执行计划、锁等待、日志,也别忘了检查应用的事务边界是否正确。数据库和代码是配合的关系,任何一端出了问题,另一端都会表现异常。
最后分享一个小技巧。如果你经常需要分析 UPDATE 的加锁行为,可以在测试环境开启innodb_status_output_locks=1和performance_schema=ON,然后通过SELECT * FROM performance_schema.data_locks\G查看具体锁信息,这比猜要高效得多。数据量越大、并发越高,越要养成“用数据说话、用日志定位”的习惯。SQL 优化没有银弹,但只要把执行旅程的每一步都想清楚,再奇怪的问题也会变得有迹可循。