前言
订单列表在数据量增加后逐渐变慢,是一个容易误判的演示场景。直接加索引有时有效,却无法回答等待发生在浏览器、应用序列化、连接池还是数据库执行阶段。
下面使用一张精简的 orders 表和一条列表 sql,展示如何收集证据。文中不提供未经执行的耗时数字,也不会把普通查询和锁等待混为一谈。
一、先确认到底慢在什么位置
接口地址很普通:
get /api/orders?status=2&page=1&page_size=20
页面表现是表格骨架出现后,要等一两秒才有数据。浏览器里看到的总耗时只能说明“请求慢”,不能直接说明是数据库慢。
先在后端把一次请求拆成三段:参数处理、数据库调用、结果序列化。
from time import perf_counter
def list_orders():
begin = perf_counter()
filters = parse_filters()
parsed = perf_counter()
rows = order_repository.query(filters)
queried = perf_counter()
payload = serialize_orders(rows)
finished = perf_counter()
app.logger.info(
"order_list parsed=%.1fms query=%.1fms serialize=%.1fms total=%.1fms",
(parsed - begin) * 1000,
(queried - parsed) * 1000,
(finished - queried) * 1000,
(finished - begin) * 1000,
)
return payload这段日志能把请求时间拆成几个可比较的区间。

只有日志显示数据库调用占主要部分时,才继续检查 mysql。如果序列化占比更高,或接口还串行调用其他服务,修改索引无法解决等待。
这里要先避开两个常见误区:
- 前端等待久,不等于数据库一定慢;
- 数据库连接方法返回得慢,也不一定全是 sql 执行慢,还可能在等连接或等锁。
二、把页面真正执行的 sql 找出来
项目里最初的查询大致如下:
select * from orders where tenant_id = '17' and status = 2 order by created_at desc, id desc limit 20 offset 0;
表结构做了精简,保留这次分析需要的字段:
create table orders (
id bigint unsigned not null auto_increment,
tenant_id bigint unsigned not null,
order_no varchar(32) not null,
buyer_name varchar(64) not null,
status tinyint not null,
total_amount decimal(12, 2) not null,
remark text null,
created_at datetime not null,
updated_at datetime not null,
primary key (id),
unique key uk_order_no (order_no),
key idx_tenant_id (tenant_id),
key idx_status (status),
key idx_created_at (created_at)
) engine=innodb;
这张表并不是“完全没有索引”。问题在于现有三个单列索引,和页面的筛选、排序方式没有形成一条连续路径。
在继续改之前,应把 orm 最终生成的 sql 和绑定参数一起记录下来。代码中看似相同的查询,实际发送到数据库后可能多了一层函数、隐式转换或未注意到的排序条件。
如果项目不方便改日志,可以在测试环境临时使用慢查询日志定位。不要在不了解写入量的情况下长期打开全量 sql 日志,尤其不要把包含敏感参数的语句直接带到公开环境。
三、先读执行计划,不凭感觉猜索引
先执行普通的 explain:
explain select id, order_no, buyer_name, total_amount, created_at from orders where tenant_id = 17 and status = 2 order by created_at desc, id desc limit 20;
排查时重点看下面几列:
| 字段 | 这次主要看什么 |
|---|---|
type | 访问方式是不是退化到了全表扫描 |
possible_keys | 优化器认为哪些索引可能可用 |
key | 最终实际选择了哪个索引 |
rows | 预计要检查多少行 |
filtered | 经过条件过滤后大约能留下多少 |
extra | 是否出现额外排序、临时表等提示 |
第一次看执行计划时,可以先关注访问方式、候选索引、实际索引、估算行数和额外操作。

这里有一个容易踩的坑:rows 是估算值,不是接口真实耗时,也不是实际扫描行数。mysql 8.0.18 及以后可以在安全的测试条件下使用 explain analyze,它会真正执行语句并给出实际循环和耗时信息。
explain analyze select id, order_no, buyer_name, total_amount, created_at from orders where tenant_id = 17 and status = 2 order by created_at desc, id desc limit 20;
带副作用的语句不能随便这样跑,大表上的重查询也要谨慎。可以在隔离的测试库准备代表性数据,再比较执行计划。
如果计划显示数据库只能从单列索引中选择一个,筛完租户后仍需过滤状态并额外排序,再根据访问路径评估联合索引。
四、第一处问题:索引顺序没有贴合查询路径
列表的稳定条件是:
- 先定位租户;
- 再按状态缩小范围;
- 最后按创建时间和主键倒序取前 20 条。
因此可以评估一个与列表访问路径一致的联合索引:
create index idx_order_list on orders (tenant_id, status, created_at, id);
这四列在当前查询中分别承担筛选、排序和稳定分页的职责。

联合索引的列顺序不是固定口诀。这里把 tenant_id 和 status 放在前面,是因为它们在当前查询中都使用等值条件;后面的 created_at、id 用于保持分页排序稳定。
如果系统还存在只按时间、不带状态查询的另一类接口,不能想当然地认为这一个索引能包办所有场景。应该分别取真实高频 sql 查看计划,再判断是否需要另一条索引。
索引建好后需要完成三项核对:
- 再跑一次执行计划,确认实际选择的是新索引;
- 检查返回顺序,尤其是相同
created_at的多条记录; - 对比冷启动和多次执行,避免只拿缓存后的最好数字。
只执行 create index 然后宣布优化完成,这一步还远远不够。
五、第二处问题:列表只显示六列,sql 却取了整行
原查询使用了 select *,而订单表里还有备注、地址快照等较大的字段。页面实际只显示订单号、买家、金额、状态和时间。
查询可以改成明确列名:
select id,
order_no,
buyer_name,
total_amount,
status,
created_at
from orders
where tenant_id = 17
and status = 2
order by created_at desc, id desc
limit 20;
这样做不一定让查询直接变成“覆盖索引查询”,因为 buyer_name、total_amount 等字段仍不在联合索引中。但它至少减少了行读取后的数据传输和应用层序列化,也避免将无用的大字段带出数据库。
是否要为了覆盖查询把更多列塞进索引,需要单独衡量。索引越宽,占用空间越大,写入和维护成本也越高。一个写入频繁的订单表,不适合为了二十行列表就把所有展示字段都拼进索引。
在该查询结构下,可以优先考虑“窄联合索引 + 回表取少量行”。查询先通过索引确定前 20 个主键,再回表读取展示字段,无需仅为追求执行计划中的 using index 盲目扩大索引。
六、第三处问题:越往后翻,offset 丢掉的数据越多
第一页与深页的访问成本不同。下面用第 5000 页对应的 sql 演示大 offset:
select id, order_no, buyer_name, total_amount, status, created_at from orders where tenant_id = 17 and status = 2 order by created_at desc, id desc limit 20 offset 99980;
offset 99980 并不是让数据库直接跳到第 99981 条。数据库仍要按条件找到并越过前面的记录,再返回最后 20 条。数据越深,前面被扫描后丢掉的内容越多。
同样返回 20 条记录,offset 和游标抵达这些记录的路径并不相同。

后台管理页面如果必须允许用户直接跳到任意页,深分页很难完全消失。但对于“继续加载”和普通前后翻页,可以改为基于上一页末尾位置的游标分页。
上一页最后一条记录为:
{
"created_at": "2026-08-09 10:20:31",
"id": 735821
}下一页查询改为:
select id, order_no, buyer_name, total_amount, status, created_at
from orders
where tenant_id = 17
and status = 2
and (
created_at < '2026-08-09 10:20:31'
or (created_at = '2026-08-09 10:20:31' and id < 735821)
)
order by created_at desc, id desc
limit 20;
这里一定要同时带上 id。只用时间作游标,当多条记录时间相同时,可能出现重复或漏数据。
接口返回值也从单纯的页码改为:
{
"items": [],
"next_cursor": {
"created_at": "2026-08-09 10:20:31",
"id": 735821
},
"has_more": true
}这不是对所有分页场景的统一答案。报表导出、任意页跳转和滚动列表需要的交互不同,应该在产品层先分清楚,而不是强行让一个分页接口同时满足全部需求。
七、第四处问题:sql 看起来一样,绑定参数类型却不一样
前面的 sql 中,tenant_id 被写成了字符串:
where tenant_id = '17'
在这个简单条件里,mysql 可能仍然选择可用索引,但下面两类写法更值得检查:
where cast(tenant_id as char) = '17'
也有人为了按日期查询,直接对索引列加函数:
where date(created_at) = '2026-08-09'
这种写法会改变数据库利用普通索引的方式。日期范围更适合写成:
where created_at >= '2026-08-09 00:00:00' and created_at < '2026-08-10 00:00:00'
参数类型也应该在接口入口统一校验,而不是把未经处理的字符串一路交给 sql:
tenant_id = int(request.args["tenant_id"]) status = int(request.args["status"])
类型在入口收紧以后,错误请求可以直接失败,查询代码也无需到处兼容空字符串、带空格的数字和非法状态值。
八、慢查询与等待问题要分开判断
数据库调用时间变长,可能来自 sql 执行,也可能来自连接池、锁、磁盘或 cpu 等待。判断锁问题前,先确认 sql 的读类型。
8.1 普通select通常不会等另一事务的行锁
在 innodb 的 read committed 和 repeatable read 隔离级别下,普通 select 默认是 mvcc 一致性非锁定读。它读取可见版本,不设置所访问记录的行锁,因此不能用“另一事务正在更新这些订单”来直接解释普通列表查询等待行锁。
select @@transaction_isolation; select id, order_no, status from orders where tenant_id = 17 order by created_at desc, id desc limit 20;
如果这条普通查询变慢,应继续检查执行计划、连接池、资源压力和元数据锁,不能先假定它在等更新事务释放行锁。
8.2 锁定读会等待冲突的行锁
select ... for update 与 select ... for share 属于锁定读。下面是一个可复现示例:
-- 会话 a start transaction; update orders set status = 3 where id = 735821; -- 暂不提交
-- 会话 b start transaction; select id, status from orders where id = 735821 for update;
会话 b 请求同一行的冲突锁,会等待会话 a 提交或回滚。for share 也可能等待与其不兼容的锁。此时可以检查锁等待信息,并根据业务语义决定是否使用 nowait 或 skip locked;后者会返回不完整视图,不适合一般查询。
8.3serializable会改变普通查询的锁语义
mysql 8.4 中,serializable 在关闭 autocommit 时会把普通 select 隐式转换为 select ... for share,因此可能等待其他事务。若 autocommit 开启,单条只读 select 仍可作为一致性非锁定读执行。排查时要同时记录隔离级别、autocommit 和完整 sql。
select @@transaction_isolation, @@autocommit;
8.4 元数据锁与行锁不是一回事
事务访问表后,会持有相应元数据锁直到事务结束。另一会话执行 alter table 等 ddl 时可能等待元数据锁;存在排队的高优先级写锁请求时,后续访问也可能被影响。可先查看:
show full processlist; select * from performance_schema.metadata_locks where object_schema = database() and object_name = 'orders';
如果状态出现 waiting for table metadata lock,处理方向是找到未结束事务和等待中的 ddl,而不是继续增加行索引。
“sql 执行慢”“锁定读等待行锁”“ddl 等待元数据锁”是三类证据和处理方式都不同的问题。
九、验收不能只看一次耗时
修改前后只比较一次请求没有意义。验收可以覆盖下面几项:
| 检查项 | 验收方式 |
|---|---|
| 第一页查询 | 固定数据集与参数,多轮执行并记录分布 |
| 深分页 | 对比页码分页与游标分页的执行计划和扫描范围 |
| 返回结果 | 核对条数、排序、相同时间记录以及筛选条件 |
| 索引使用 | 保存修改前后的 explain 或测试库 explain analyze |
| 写入影响 | 测试插入、更新,评估新索引的维护成本 |
| 等待分类 | 记录普通读、锁定读、隔离级别与元数据锁状态 |
| 回滚方案 | 明确旧查询开关和删除新索引的步骤 |

没有实际执行结果时,不写“从数百毫秒降到几十毫秒”。可以保存执行计划、固定数据集、测试命令和运行环境,交给读者或项目维护者复现。
这类问题应怎样收口
列表变慢时,先确认请求慢在哪一段,再取得完整 sql 和绑定参数,之后阅读执行计划。普通 innodb select 在 read committed / repeatable read 下通常使用 mvcc 一致性非锁定读;只有锁定读、特定 serializable 条件、元数据锁等场景才进入相应的等待排查。
索引、返回字段和分页方式都应由查询形状及执行计划决定。把慢查询与锁等待分开记录,后续优化才不会用一个原因解释所有长耗时。
以上就是mysql中慢查询排查和索引优化的实战教学的详细内容,更多关于mysql慢查询排查的资料请关注代码网其它相关文章!
发表评论