
🔥个人主页:爱和冰阔乐
🐶学习方向:C++方向学习爱好者
⭐人生格言:得知坦然 ,失之淡然

🏠博主简介

文章目录
-
- 前言
- 一、先确认到底慢在什么位置
- [二、把页面真正执行的 SQL 找出来](#二、把页面真正执行的 SQL 找出来)
- 三、先读执行计划,不凭感觉猜索引
- 四、第一处问题:索引顺序没有贴合查询路径
- [五、第二处问题:列表只显示六列,SQL 却取了整行](#五、第二处问题:列表只显示六列,SQL 却取了整行)
- [六、第三处问题:越往后翻,OFFSET 丢掉的数据越多](#六、第三处问题:越往后翻,OFFSET 丢掉的数据越多)
- [七、第四处问题:SQL 看起来一样,绑定参数类型却不一样](#七、第四处问题:SQL 看起来一样,绑定参数类型却不一样)
- 八、慢查询与等待问题要分开判断
-
- [8.1 普通 `SELECT` 通常不会等另一事务的行锁](#8.1 普通
SELECT通常不会等另一事务的行锁) - [8.2 锁定读会等待冲突的行锁](#8.2 锁定读会等待冲突的行锁)
- [8.3 `SERIALIZABLE` 会改变普通查询的锁语义](#8.3
SERIALIZABLE会改变普通查询的锁语义) - [8.4 元数据锁与行锁不是一回事](#8.4 元数据锁与行锁不是一回事)
- [8.1 普通 `SELECT` 通常不会等另一事务的行锁](#8.1 普通
- 九、验收不能只看一次耗时
- 这类问题应怎样收口
- 参考资料
前言
订单列表在数据量增加后逐渐变慢,是一个容易误判的演示场景。直接加索引有时有效,却无法回答等待发生在浏览器、应用序列化、连接池还是数据库执行阶段。
下面使用一张精简的 orders 表和一条列表 SQL,展示如何收集证据。文中不提供未经执行的耗时数字,也不会把普通查询和锁等待混为一谈。
一、先确认到底慢在什么位置
接口地址很普通:
text
GET /api/orders?status=2&page=1&page_size=20
页面表现是表格骨架出现后,要等一两秒才有数据。浏览器里看到的总耗时只能说明"请求慢",不能直接说明是数据库慢。
先在后端把一次请求拆成三段:参数处理、数据库调用、结果序列化。
python
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 找出来
项目里最初的查询大致如下:
sql
SELECT *
FROM orders
WHERE tenant_id = '17'
AND status = 2
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 0;
表结构做了精简,保留这次分析需要的字段:
sql
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:
sql
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,它会真正执行语句并给出实际循环和耗时信息。
sql
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 条。
因此可以评估一个与列表访问路径一致的联合索引:
sql
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 *,而订单表里还有备注、地址快照等较大的字段。页面实际只显示订单号、买家、金额、状态和时间。
查询可以改成明确列名:
sql
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:
sql
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 和游标抵达这些记录的路径并不相同。

后台管理页面如果必须允许用户直接跳到任意页,深分页很难完全消失。但对于"继续加载"和普通前后翻页,可以改为基于上一页末尾位置的游标分页。
上一页最后一条记录为:
json
{
"created_at": "2026-08-09 10:20:31",
"id": 735821
}
下一页查询改为:
sql
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。**只用时间作游标,当多条记录时间相同时,可能出现重复或漏数据。
接口返回值也从单纯的页码改为:
json
{
"items": [],
"next_cursor": {
"created_at": "2026-08-09 10:20:31",
"id": 735821
},
"has_more": true
}
这不是对所有分页场景的统一答案。报表导出、任意页跳转和滚动列表需要的交互不同,应该在产品层先分清楚,而不是强行让一个分页接口同时满足全部需求。
七、第四处问题:SQL 看起来一样,绑定参数类型却不一样
前面的 SQL 中,tenant_id 被写成了字符串:
sql
WHERE tenant_id = '17'
在这个简单条件里,MySQL 可能仍然选择可用索引,但下面两类写法更值得检查:
sql
WHERE CAST(tenant_id AS CHAR) = '17'
也有人为了按日期查询,直接对索引列加函数:
sql
WHERE DATE(created_at) = '2026-08-09'
这种写法会改变数据库利用普通索引的方式。日期范围更适合写成:
sql
WHERE created_at >= '2026-08-09 00:00:00'
AND created_at < '2026-08-10 00:00:00'
参数类型也应该在接口入口统一校验,而不是把未经处理的字符串一路交给 SQL:
python
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 一致性非锁定读。它读取可见版本,不设置所访问记录的行锁,因此不能用"另一事务正在更新这些订单"来直接解释普通列表查询等待行锁。
sql
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 属于锁定读。下面是一个可复现示例:
sql
-- 会话 A
START TRANSACTION;
UPDATE orders
SET status = 3
WHERE id = 735821;
-- 暂不提交
sql
-- 会话 B
START TRANSACTION;
SELECT id, status
FROM orders
WHERE id = 735821
FOR UPDATE;
会话 B 请求同一行的冲突锁,会等待会话 A 提交或回滚。FOR SHARE 也可能等待与其不兼容的锁。此时可以检查锁等待信息,并根据业务语义决定是否使用 NOWAIT 或 SKIP LOCKED;后者会返回不完整视图,不适合一般查询。
8.3 SERIALIZABLE 会改变普通查询的锁语义
MySQL 8.4 中,SERIALIZABLE 在关闭 autocommit 时会把普通 SELECT 隐式转换为 SELECT ... FOR SHARE,因此可能等待其他事务。若 autocommit 开启,单条只读 SELECT 仍可作为一致性非锁定读执行。排查时要同时记录隔离级别、autocommit 和完整 SQL。
sql
SELECT @@transaction_isolation, @@autocommit;
8.4 元数据锁与行锁不是一回事
事务访问表后,会持有相应元数据锁直到事务结束。另一会话执行 ALTER TABLE 等 DDL 时可能等待元数据锁;存在排队的高优先级写锁请求时,后续访问也可能被影响。可先查看:
sql
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 条件、元数据锁等场景才进入相应的等待排查。
索引、返回字段和分页方式都应由查询形状及执行计划决定。把慢查询与锁等待分开记录,后续优化才不会用一个原因解释所有长耗时。