接手一个已经跑了两年的项目,数据量从几十万条涨到几千万条的时候,从前“嗖嗖快”的接口开始一个接一个超时。最典型的一条列表页查询,刚上线时 30 毫秒,三个月后 3000 毫秒,等业务方来反馈时已经要 10 秒了。优化这条 SQL 的过程其实不复杂——加了一个组合索引、改掉一处在索引列上做运算的写法,查询时间直接掉回 50 毫秒以内。但真正值钱的不是那一次修改,而是整套“从定位到分析再到改造”的排查方法。
这篇内容不打算讲那些“记得加索引”的入门科普,而是想聊一聊 SQL 优化完整的实战链路:慢查询日志怎么配置、EXPLAIN 执行计划怎么读、索引失效有哪些常见写法、深分页和排序的优化手段、多表 JOIN 的驱动表选择,以及并发更新场景下锁等待造成的伪慢 SQL。适合已经写过一定量 SQL、遇到过程序慢但说不清慢在哪、想系统建立排查能力的开发者。按这条链路走一遍,绝大多数线上性能问题都能找到方向和答案。
1. 慢查询日志:先让 MySQL 自己告诉你哪条 SQL 有问题
很多人接到 SQL 性能问题,第一反应是打开代码翻业务逻辑,或者凭感觉猜哪条查询慢。其实最靠谱的起点是慢查询日志——它记录了所有超过阈值的 SQL,让 MySQL 自己把嫌疑对象列出来。这一步不用纠结,先把开关打开。
1.1 日志开关与阈值设置
MySQL 的慢查询日志默认是关闭的,需要手动开启。有两个层面:一是运行时用 SET GLOBAL 动态开启,适合临时排查,重启后失效;二是写进配置文件,适合长期生效。
sql复制SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
SET GLOBAL log_queries_not_using_indexes = 'ON';
long_query_time 的单位是秒,默认值 10 秒,这意味着一条查询要跑 10 秒才会被记录,日常很难捞到问题。生产环境我习惯先设成 1 秒,也就是超过 1 秒的查询全部记录下来。如果系统本身性能压力不大,可以进一步调到 0.5 甚至 0.1,但日志量会明显增加,注意磁盘空间。
log_queries_not_using_indexes 这个开关要谨慎:它会把所有没走索引的查询都记录下来,包括那些数据量很小、全表扫描也无所谓的查询。这种日志膨胀速度非常快,更适合在测试环境开,或者线上短时间开一下做全量诊断,事后关掉。
持久化配置的话,在 my.cnf 的 [mysqld] 段下加上:
ini复制slow_query_log = 1
slow_query_log_file = /var/log/mysql/mysql-slow.log
long_query_time = 1
log_queries_not_using_indexes = 0
配置完成后重启 MySQL 生效。执行 SHOW VARIABLES LIKE 'slow_query_log%' 可以确认状态。
1.2 mysqldumpslow 与 sys 库配合使用
日志捞出来只是第一步,几百条慢 SQL 堆在一起,怎么快速找到最值得优化的那几条?MySQL 自带的 mysqldumpslow 工具就是干这个的。
bash复制mysqldumpslow -s t -t 10 /var/log/mysql/mysql-slow.log
-s t 表示按总耗时排序,-t 10 表示只取前 10 条。mysqldumpslow 会自动把 SQL 里的数字参数替换成 N,把结构相同但参数不同的 SQL 聚合到一组,输出中能看到 Count(执行次数)、Time(总耗时/平均耗时)、Rows(扫描行数)。
这个工具输出的可读性一般,但胜在零依赖。如果觉得不够直观,可以直接等 MySQL 跑一段时间,然后查 sys 库里的 statement_analysis 视图:
sql复制SELECT * FROM sys.statement_analysis
ORDER BY total_latency DESC
LIMIT 10;
statement_analysis 把每条 SQL 的总耗时、平均耗时、扫描行数、返回行数、未走索引的次数都列出来了,一眼就能看出“哪些 SQL 执行次数多但平均耗时不低”“哪些 SQL 扫描行数和返回行数差距极大”。这两类往往就是性能瓶颈所在。
1.3 profiling 定位单条 SQL 的耗时分布
锁定了某一条具体的慢 SQL 之后,还要回答一个问题:它到底慢在哪个阶段?是执行本身阻塞,还是排序花了太多时间,还是发送数据太慢?MySQL 的 profiling 可以精确回答。
sql复制SET profiling = 1;
-- 这里执行那条慢 SQL
SELECT * FROM orders WHERE user_id = 123 ORDER BY create_time DESC;
SHOW PROFILES;
SHOW PROFILE FOR QUERY 1;
SHOW PROFILE 会显示这条 SQL 在 parsing、preparing、executing、sending data、sorting result 等阶段的耗时。如果 Sending data 占比极高,通常说明扫描的数据量太大,需要去查执行计划、看索引;如果 Sorting result 很高,说明 filesort 是主要瓶颈,重点看排序字段有没有覆盖到索引。
要注意 profiling 是 session 级别的,SET profiling = 1 只对当前连接有效,用完记得关掉,避免额外的性能开销。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 读懂执行计划:EXPLAIN 里藏着 MySQL 的路线图
慢查询日志和 profiling 告诉你“哪个 SQL 慢、慢在哪个阶段”,但没告诉你“为什么慢”。回答为什么,必须看执行计划。EXPLAIN 是 MySQL 给出的执行路线图,任何一个字段都不能漏读。
sql复制EXPLAIN SELECT user_id, order_amount
FROM orders
WHERE user_id = 123 AND status = 1
ORDER BY create_time DESC
LIMIT 10;
2.1 type 字段:从 system 到 ALL 的等级链
type 字段是执行计划里最直观的指标,它描述的是 MySQL 访问表的方式。完整的等级链大致是:
| type | 含义 | 典型场景 |
|---|---|---|
| system | 表只有一行(系统表) | 极少出现 |
| const | 主键或唯一索引等值匹配 | WHERE id = 1 |
| eq_ref | 被驱动表通过主键或唯一索引关联 | JOIN 关联查询 |
| ref | 普通二级索引等值匹配 | WHERE user_id = 123 |
| range | 索引范围扫描 | BETWEEN、>、<、IN |
| index | 索引全扫描 | 扫描整个索引树 |
| ALL | 全表扫描 | 无索引可用 |
一般来说,达到 ref 或 range 就算比较理想,index 需要警惕,ALL 则基本意味着必须优化。看到 type = ALL 时,第一选择不是去改 SQL 写法,而是看 WHERE 条件中的列有没有合适的索引。大多数情况下,索引加对了,type 立刻就会变好。
2.2 key_len:用字节数反推联合索引用到第几列
很多人看执行计划只看 type 和 key,忽略 key_len。这个字段其实非常有价值,它告诉你在当前查询中,联合索引实际用了多少字节,进而可以反推用到了联合索引的哪几列。
举个例子。假设有一张用户表,建了联合索引 idx_phone_status(phone, status),phone 是 varchar(20) 且 NOT NULL,status 是 tinyint 且 NOT NULL。在 utf8mb4 字符集下,phone 一个字符占 4 个字节,varchar 还需要额外 2 字节记录长度,所以 phone 这一列占 20 * 4 + 2 = 82 字节;status 占 1 字节。整个联合索引理论长度是 83 字节。
如果 EXPLAIN 结果显示 key_len = 82,说明 MySQL 只用到了联合索引中的 phone 这一列;如果 key_len = 83,说明两列都用上了。这个信息在排查“明明建了多个联合索引,为什么有的查询还是慢”时非常关键——很可能索引没有被完整使用。
注意,如果列允许为 NULL,每列还要额外增加 1 字节。计算时要把字段的可空性考虑进去。
2.3 Extra 里出现的三个“红灯”
Extra 字段包含很多附加信息,其中有几个是常见的警告信号。
Using filesort 表示排序没有走索引,MySQL 需要额外的排序操作。排序字段如果能包含在索引中,这个警告通常会消失。
Using temporary 表示查询使用了临时表,常见于 GROUP BY、DISTINCT、UNION 等场景。临时表意味着数据要落地到内存或磁盘,存在额外开销。看到它时,优先考虑能否通过索引避免分组或去重的临时表操作。
Using index 是绿灯,表示“覆盖索引”,查询所需的列全部在索引中,不需要回表。比如查询只包含 user_id 和 status,而 idx_phone_status 恰好包含这两列,就会出现 Using index。覆盖索引是优化查询的重要手段,尤其是对高频查询。
还有一个容易混淆的是 Using where。它并不代表查询有问题,只是表示存储引擎返回记录后,Server 层又做了一次过滤。关键要结合 rows 字段看过滤比例——如果扫描 10 万行、返回 10 行,过滤性就很差,需要找更精确的索引。
3. 索引失效的七种写法:看着走了索引,实际在扫全表
索引加得再多,如果 SQL 写法触发了索引失效,执行计划还是会回到全表扫描。这一节把最常见的失效场景列出来,每一个都是我在实际代码里见过的真实写法。
3.1 隐式类型转换:字符串列用数字查询的第一个坑
之前排查过一个问题:用户表有 500 万条数据,通过手机号查询用户信息,查询语句类似:
sql复制SELECT * FROM users WHERE phone = 13800001111;
phone 列类型是 varchar,但查询条件直接用了整数 13800001111。MySQL 在比较时会尝试把手机号这一列转成数字,而不是把整数转成字符串,结果导致 phone 列上的索引完全无法使用,全表扫描,耗时超过 2 秒。
修复方式很简单,给查询条件加上引号:
sql复制SELECT * FROM users WHERE phone = '13800001111';
这里有一个容易混淆的反向场景:如果列本身是 int,查询条件写字符串没有影响,比如 WHERE user_id = '123',MySQL 会把 '123' 转成数字,索引照常使用。关键是看“列的类型是什么”,索引列上发生了类型转换就是灾难。
3.2 列上做运算:id + 5 不等于 10
这是面试中的经典题目,也是实际代码里常出现的写法:
sql复制SELECT * FROM orders WHERE id + 5 = 10;
索引是基于列原始值构建的,当 WHERE 条件变成 id + 5,MySQL 无法直接定位索引中的哪个元组等于 10,只能遍历所有记录,对每条记录算 id + 5 再判断。也就是说,MySQL 不会帮你把 id + 5 = 10 代数化简成 id = 5。正确的写法是:
sql复制SELECT * FROM orders WHERE id = 5;
类似的还有:
sql复制-- 错误:age * 2 = 30
SELECT * FROM users WHERE age * 2 = 30;
-- 正确:写成列本身
SELECT * FROM users WHERE age = 15;
这条规则适用于任何“列上面套了一个表达式”的写法。优化的核心原则是:尽量让索引列独立出现在比较符的一侧,不做任何运算。
3.3 函数套列:DATE(create_time) 是一个典型
非常常见的业务场景是“查某一天创建的所有订单”:
sql复制SELECT * FROM orders WHERE DATE(create_time) = '2024-01-01';
create_time 上有索引也没有用,因为 MySQL 无法用索引去匹配一个经过 DATE() 函数计算后的结果。正确写法应该是范围查询:
sql复制SELECT * FROM orders
WHERE create_time >= '2024-01-01 00:00:00'
AND create_time < '2024-01-02 00:00:00';
改写后,create_time 列本身参与了比较,索引就能用上,而且语义完全一致。要注意别漏掉边界条件,>= 加 < 下一天是稳妥的写法,不要用 BETWEEN '2024-01-01' AND '2024-01-02',那会多包含 1 月 2 日 0 点整的这条数据(如果存在的话)。
3.4 最左前缀与 OR、LIKE 的边界
联合索引遵循最左前缀原则。以联合索引 idx(a, b, c) 为例:
- WHERE a = 1 AND b = 2 AND c = 3:完整用到三列
- WHERE a = 1 AND c = 3:只能用到 a 列,c 列的过滤无法通过索引完成
- WHERE b = 2 AND c = 3:一列都用不上,因为跳过了 a
最左前缀原则决定了联合索引中列的顺序非常关键。高频查询条件应该放在最左侧,区分度高的列适合放在前面,但这和查询条件出现的频率之间需要权衡。
OR 的坑在于:OR 两边都必须是索引可用的条件,否则整条查询可能退化。比如:
sql复制WHERE user_id = 123 OR status = 1
如果 user_id 有索引而 status 没有,MySQL 很可能选择全表扫描,而不是先查 user_id 再合并结果。解决思路有两个:给 status 也加上索引,或者把 OR 改写为 UNION ALL:
sql复制SELECT * FROM orders WHERE user_id = 123
UNION ALL
SELECT * FROM orders WHERE status = 1;
LIKE 的边界就简单多了:前缀匹配能走索引(LIKE 'abc%'),后缀匹配不能(LIKE '%abc'),中间匹配大概率也不能(LIKE '%abc%')。如果业务确实需要全文搜索,别硬靠 LIKE,考虑引入专门的全文检索方案。
3.5 函数索引的补救思路
前面说了函数套列会导致索引失效,但 MySQL 8.0.13 之后有了函数索引,可以把函数先算好存进索引。比如 DATE(create_time) 这种高频查询,可以建一个函数索引:
sql复制CREATE INDEX idx_create_date ON orders ((DATE(create_time)));
这样 WHERE DATE(create_time) = '2024-01-01' 就能正常走索引,不需要改写 SQL。函数索引的代价是写入时会额外计算,适用于查询远多于写入的场景。
8.0 还提供了一个很实用的运维功能——隐藏索引。想验证某个索引是否真的有用,又不敢直接在线上删除,可以先把它隐藏:
sql复制ALTER TABLE orders ALTER INDEX idx_user_id INVISIBLE;
ALTER TABLE orders ALTER INDEX idx_user_id VISIBLE;
隐藏后 MySQL 优化器不会使用这个索引,观察几天执行计划和慢日志,如果完全没有影响,再真正删除;如果有影响,恢复可见即可。这是个非常稳妥的实验手段。
4. 深分页和排序:为什么 limit 100000, 20 会把你拖垮
列表页分页查询是业务系统的标配,但数据量一大,深分页就成了最常见的性能杀手。一条看似简单看似加了索引的查询,也可能因为分页太深而变得极慢。
4.1 filesort 什么时候触发?
先看一段代码:
sql复制SELECT order_id, user_id, amount
FROM orders
WHERE status = 1
ORDER BY create_time DESC
LIMIT 20;
如果 status 上有索引,但 create_time 不在这个索引里,MySQL 取回所有满足 status = 1 的记录后,还需要额外排序,执行计划里就会出现 Using filesort。filesort 意味着额外的内存或磁盘操作,数据量大时代价很高。
最好的解决思路,是让 ORDER BY 的字段直接包含在查询使用的索引中。比如建一个联合索引 idx_status_time(status, create_time),查询时通过 status 定位,索引内部本身按 create_time 有序,排序步骤直接省略。这里要注意,如果联合索引的排序方向与查询的 ASC/DESC 不一致,可能还是无法完全利用索引的有序性,需要实际验证。
如果排序字段无法加入索引,优化 filesort 的另一个思路是控制返回列。filesort 面临单路排序和双路排序的选择:如果查询列太长,会把大量数据放进 sort buffer;反之尽可能只放排序字段和主键。从工程上讲,避免 SELECT *,只查需要的列,对排序性能有明显帮助。
4.2 深分页为什么慢,以及延迟关联的用法
sql复制SELECT * FROM orders
ORDER BY create_time DESC
LIMIT 100000, 20;
这句 SQL 的慢,不是因为这 20 条数据难查,而是 MySQL 必须先从表里读出前 100020 条记录,排好序,再把前 100000 条丢弃,只留下最后 20 条。如果 create_time 上没有覆盖索引,前面 10 万条记录都要回表,IO 开销可想而知。
延迟关联是解决深分页问题的经典手法。思路是:先用覆盖索引快速定位到需要返回的主键,再用主键去关联回原表取完整记录。
sql复制SELECT o.*
FROM orders o
INNER JOIN (
SELECT id
FROM orders
ORDER BY create_time DESC
LIMIT 100000, 20
) tmp ON o.id = tmp.id;
子查询只查 id 和排序字段,可以完全走索引,避免回表前 10 万行,同时子查询的结果只有 20 个主键,外层回表的成本非常低。我在千万级表上实测,这条路能把 2 秒以上的深分页降到 0.2 秒左右。
不过延迟关联只是优化,不是终极方案。分页越深,子查询自身的扫描量也会越来越大。更彻底的做法是改成游标分页,用上一页最后一条记录的 create_time 作为下一页的起始条件:
sql复制SELECT * FROM orders
WHERE create_time < '2024-01-01 10:00:00'
ORDER BY create_time DESC
LIMIT 20;
这是“无偏移量分页”,每一页的查询成本都差不多,不会随着页码变深而增加。当然它要求业务场景适合这种交互方式,通常用在移动端的下拉加载场景,而不是传统页码分页。
4.3 order by 与索引顺序的匹配
ORDER BY 要利用索引,除了字段匹配,顺序也要匹配。MySQL 8.0 之前,索引默认都是 ASC。如果查询是 ORDER BY a ASC, b DESC,a 升序可以走索引,但 b 需要降序,索引的有序性就无法完全利用,MySQL 只能额外排序。
MySQL 8.0 引入了降序索引,可以这样建:
sql复制CREATE INDEX idx_a_asc_b_desc ON orders (a ASC, b DESC);
建好之后,查询 ORDER BY a ASC, b DESC 就可以完全利用索引的有序性,省掉 filesort。如果项目还在 5.7,遇到混合排序的场景,只能考虑把其中一个排序方向统一,或者在应用层做排序。
5. 多表 JOIN 和子查询:驱动表选错,优化全废
查询慢不一定是一条 SQL 本身的问题,多表 JOIN 时,驱动表的选择和连接字段的索引情况,直接决定查询是毫秒还是秒级。
5.1 小表驱动大表:连接字段必须有索引
JOIN 的本质是嵌套循环。驱动表被逐行扫描,每一行都要去被驱动表里查找匹配记录。如果驱动表有 1000 行、被驱动表有 100 万行,被驱动表上的连接字段有索引,那么总共需要执行 1000 次索引查找;反过来用 100 万行驱动、1000 行被驱动,就要执行 100 万次索引查找,差距是 1000 倍。
优化器虽然会自动选择小表做驱动表,但基于的是统计信息估算,统计信息不准确、或者 SQL 里有复杂表达式导致估算偏差时,仍然可能选错。所以被驱动表的连接字段必须有索引,这是底线。实践中最常见的问题是两个表关联字段字符集不一致,比如一个表用 utf8,另一个用 utf8mb4,MySQL 需要做转换,索引就失效了。排查这类问题时直接用 SHOW CREATE TABLE 对比两个关联字段的字符集和排序规则。
5.2 子查询的改写与 semi-join
在 MySQL 5.6 之前,IN 子查询的效率很不稳定,很多教程建议一律改成 JOIN。到了 5.6 之后,优化器引入了 semi-join 重写,很多 IN 子查询会被自动转换成半连接,执行效率和 JOIN 已经非常接近。所以现在的原则应该是:不盲目改写,直接用 EXPLAIN 看执行计划。
如果 EXPLAIN 显示子查询被“物化”(materialized)成临时表,且临时表没有索引,就要考虑改写。我遇到过一个典型场景,关联的 IN 子查询包含 GROUP BY 和 AVG 聚合,优化器无法自动优化:
sql复制SELECT o.order_id
FROM orders o
WHERE o.amount > (
SELECT AVG(t.amount) FROM orders t WHERE t.user_id = o.user_id
);
这是典型的逐行相关子查询,每一行都要执行一次子查询,性能极差。改写方案是把聚合结果先算出来,再关联:
sql复制SELECT o.order_id
FROM orders o
INNER JOIN (
SELECT user_id, AVG(amount) AS avg_amount
FROM orders
GROUP BY user_id
) tmp ON o.user_id = tmp.user_id
WHERE o.amount > tmp.avg_amount;
这里有个取舍:派生表会先物化,如果 GROUP BY 的结果集很大,物化成本也很高。但在大多数场景下,改成派生表 JOIN 仍然比逐行相关子查询快得多。实际项目中,我还会在派生表的 user_id 上显式建临时索引来做连接加速,进一步降低物化后的查询成本。
5.3 straight_join 强制指定驱动表的场景
正常情况下不要干预优化器的选择,但如果已经通过 EXPLAIN 确认驱动表选错了,可以手动指定。STRAIGHT_JOIN 会强制左边表作为驱动表,不管大小。
sql复制SELECT * FROM big_table STRAIGHT_JOIN small_table
ON big_table.id = small_table.big_id;
这个语法要谨慎使用。统计信息更新之后,优化器的判断可能又会变化,强制的写法反而会成为新的瓶颈。我的实践是:只在排查阶段用 STRAIGHT_JOIN 验证“如果改用另一张表做驱动表,性能是否能提升”,确认有效后,再去修正统计信息、调整索引或改写 SQL,最终让优化器自己做出正确决策,而不是长期依赖强制语法。
5.4 连接字段字符集与排序规则不一致
这个问题特别隐蔽,值得单独拎出来说。两个表关联字段看起来都是 varchar(50),但一张表是 utf8mb4_general_ci,另一张表是 utf8mb4_unicode_ci 或者一个是 utf8 一个是 utf8mb4,MySQL 进行 JOIN 时就会发生隐式转换,导致被驱动表无法使用索引。
排查方法很简单:执行计划里被驱动表 type 是 ALL 而不是 ref 或 eq_ref,同时关联字段两边都能确认各自有索引,那就大概率是字符集/排序规则不一致。统一两张表的字符集和排序规则后,问题立刻消失。这个坑我在老项目里遇到过不止一次,很多历史表建库时用了不同模板,数据接进来一 JOIN 就漏出问题。
6. 等锁造成的“慢 SQL”:一条 UPDATE 卡住的完整排查
有些 SQL 看起来慢,其实根本不是查询本身的问题,而是卡在锁等待上。这类问题最容易让人摸不着头脑,因为 EXPLAIN 和慢日志都看不出异常,耗时却一动不动。
6.1 先排除锁等待:查看 processlist 和锁等待视图
有一次线上反馈订单状态更新超时,应用日志里报的是数据库慢查询。我跑 SHOW PROCESSLIST 看了一眼,状态是 Waiting for lock,而不是正常的 Updating,这就说明不是执行慢,是在等锁。
排查锁等待的第一步是查 sys.innodb_lock_waits 视图:
sql复制SELECT * FROM sys.innodb_lock_waits\G
这个视图会直接列出等待中的事务、被阻塞的 PID、执行中的 SQL、以及阻塞它的 PID 和 SQL。看到结果后,就能够定位到“哪个事务持有锁不释放”。通常持有锁的是另一个连接里正在执行的大事务,或者是一条没有提交的 UPDATE/DELETE。
如果视图查不到,或者想要更底层的信息,直接查 information_schema.innodb_trx:
sql复制SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query
FROM information_schema.innodb_trx;
trx_started 特别关键。如果一个事务启动了很久还在 RUNNING,那它大概率持有大量锁,阻塞了后续的更新操作。这种场景下,要么联系业务方让事务尽快提交或回滚,要么在确认影响可控的情况下,用 KILL 杀掉对应的线程。
6.2 间隙锁与唯一索引的冲突
有一类锁等待的根因是间隙锁(Gap Lock),在可重复读(RR)隔离级别下,InnoDB 不仅锁定记录本身,还会锁定索引记录之间的间隙,防止其他事务向这个间隙插入数据。
典型的死锁场景:事务 A 执行 UPDATE 一条不存在的记录,比如 id = 100(表中没有这一行),会对 id=100 附近的间隙加锁;事务 B 同时执行 UPDATE id = 101(也不存在),也会对附近的间隙加锁。两个事务互相覆盖了对方的间隙范围,就可能出现死锁。SHOW ENGINE INNODB STATUS 的 LATEST DETECTED DEADLOCK 段会打印完整的死锁信息,包括两条 SQL 以及锁类型。
这类问题的治理思路有几个层面。业务上,尽量避免对不存在的记录执行 UPDATE,或者先确保记录存在再更新;隔离级别上,如果业务逻辑允许,把事务隔离级别从 RR 调整为读已提交(RC),InnoDB 在 RC 下对普通记录不加间隙锁,可以显著降低死锁概率;还有一个非常实用的经验是,UPDATE 的 WHERE 条件尽量用主键或唯一索引精确定位,避免范围更新带来的间隙锁扩大。
6.3 优化 UPDATE 类 SQL 的几条原则
锁等待问题防大于治,几条原则值得贴在代码评审规范里:
第一,UPDATE 的 WHERE 条件必须走索引。如果 WHERE 条件无法使用索引,MySQL 会在扫描过程中锁定大量行,锁的范围可能是一整张表,这是锁等待最常见的源头。
第二,控制单条 UPDATE 影响的行数。一次更新 10 万行,意味着 10 万条记录都要持有锁直到事务提交。比较好的做法是分批更新,每批 1000 行左右,循环提交,把单次锁持有时间降到最低。虽然代码看起来多几行,但对数据库并发的影响是完全不同的。
第三,事务范围尽量小。事务里不要混入无关的查询,不要在事务中做远程调用或等待外部响应。锁的释放时间是事务提交时间,事务拖得越久,锁占用的时间越长。
第四,不要把 UPDATE 和 SELECT 分开做。很多同学习惯先查出来判断一下,再决定是否 UPDATE,结果引入了额外的读锁和间隙锁。如果业务允许,尽量直接写一条带条件 UPDATE,一次完成判断和更新,减少锁的持有时间和数量。
我自己现在接到任何线上数据库问题,第一步一定是查 sys.innodb_lock_waits 和 PROCESSLIST,先排除锁,再看执行计划和索引。锁等待排查起来往往比慢查询本身更隐蔽,因为 EXPLAIN 是正常的、索引是完整的、SQL 写法也没什么问题,唯一的异常就是“卡住不动”。多养成分层排查的习惯,能省下很多半夜上线的折腾时间。
最后再分享一个小技巧:无论是查询优化还是锁等待治理,不要凭感觉下结论。每条优化方案上线前,先在测试环境对比优化前后的执行计划和耗时;上线后,再观察慢查询日志里对应 SQL 是否消失。数据才是衡量 SQL 优化效果唯一可靠的标准。
