【MySQL 慢查询排查实战】列表接口逐渐变慢时,怎样从请求链路定位原因

🔥个人主页:爱和冰阔乐

📚专栏传送门:《数据结构与算法》C++

🐶学习方向:C++方向学习爱好者

⭐人生格言:得知坦然 ,失之淡然


🏠博主简介

文章目录


前言

订单列表在数据量增加后逐渐变慢,是一个容易误判的演示场景。直接加索引有时有效,却无法回答等待发生在浏览器、应用序列化、连接池还是数据库执行阶段。

下面使用一张精简的 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;

**带副作用的语句不能随便这样跑,大表上的重查询也要谨慎。**可以在隔离的测试库准备代表性数据,再比较执行计划。

如果计划显示数据库只能从单列索引中选择一个,筛完租户后仍需过滤状态并额外排序,再根据访问路径评估联合索引。


四、第一处问题:索引顺序没有贴合查询路径

列表的稳定条件是:

  1. 先定位租户;
  2. 再按状态缩小范围;
  3. 最后按创建时间和主键倒序取前 20 条。

因此可以评估一个与列表访问路径一致的联合索引:

sql 复制代码
CREATE INDEX idx_order_list
ON orders (tenant_id, status, created_at, id);

这四列在当前查询中分别承担筛选、排序和稳定分页的职责。

联合索引的列顺序不是固定口诀。这里把 tenant_idstatus 放在前面,是因为它们在当前查询中都使用等值条件;后面的 created_atid 用于保持分页排序稳定。

如果系统还存在只按时间、不带状态查询的另一类接口,不能想当然地认为这一个索引能包办所有场景。应该分别取真实高频 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_nametotal_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 COMMITTEDREPEATABLE 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 UPDATESELECT ... 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 也可能等待与其不兼容的锁。此时可以检查锁等待信息,并根据业务语义决定是否使用 NOWAITSKIP 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 SELECTREAD COMMITTED / REPEATABLE READ 下通常使用 MVCC 一致性非锁定读;只有锁定读、特定 SERIALIZABLE 条件、元数据锁等场景才进入相应的等待排查。

索引、返回字段和分页方式都应由查询形状及执行计划决定。把慢查询与锁等待分开记录,后续优化才不会用一个原因解释所有长耗时。


参考资料

相关推荐
JacksonMx1 小时前
企业级 JPA 与数据库架构落地指南
数据库·数据库架构
Dxy12393102161 小时前
MySQL有哪些锁?InnoDB锁体系完整梳理博客
数据库·mysql
wear工程师2 小时前
MySQL 索引条件下推是什么?Using index condition 并不是覆盖索引
sql·mysql
Wang's Blog2 小时前
PostgreSQL笔记38:慢查询定位与优化的系统方法论
数据库·笔记·postgresql
幻风_huanfeng3 小时前
软考:高级软件架构师学习笔记----数据库
数据库·软考·系统架构师
衿心.3 小时前
Django TemplateDoesNotExist
数据库·django·sqlite
方便面不加香菜3 小时前
MySQL 复合查询
数据库·mysql
花花鱼3 小时前
MySQL Illegal mix of collations 全解|字符集与排序规则冲突原理、分层排错、通用解决方案(适配5.7/8.0迁移)
数据库·mysql