当前位置: 代码网 > it编程>数据库>Mysql > MySQL中慢查询排查和索引优化的实战教学

MySQL中慢查询排查和索引优化的实战教学

2026年09月10日 Mysql 我要评论
前言订单列表在数据量增加后逐渐变慢,是一个容易误判的演示场景。直接加索引有时有效,却无法回答等待发生在浏览器、应用序列化、连接池还是数据库执行阶段。下面使用一张精简的 orders 表和一条列表 sq

前言

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

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

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

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

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

列表的稳定条件是:

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

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

create index idx_order_list
on orders (tenant_id, status, created_at, id);

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

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

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

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

以上就是mysql中慢查询排查和索引优化的实战教学的详细内容,更多关于mysql慢查询排查的资料请关注代码网其它相关文章!

(0)

相关文章:

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论

验证码:
Copyright © 2017-2026  代码网 保留所有权利. 粤ICP备2024248653号
站长QQ:2386932994 | 联系邮箱:2386932994@qq.com