接口从 200ms 慢到 8 秒:我用 MySQL 慢日志揪出一条没走索引的 SQL

举报
phantomxjc 发表于 2026/09/30 09:42:46 2026/09/30
【摘要】 上周三早上刚到单位,业务科室的电话就来了:共享监控系统那个共享数据列表页,点进去要转半天圈,有时候干脆超时。这套系统是Flask+PyMySQL写的,跑在政务云的一台服务器上,用了一年多一直挺稳,突然

上周三早上刚到单位,业务科室的电话就来了:共享监控系统那个共享数据列表页,点进去要转半天圈,有时候干脆超时。这套系统是 Flask + PyMySQL 写的,跑在政务云的一台服务器上,用了一年多一直挺稳,突然慢成这样,第一反应是「数据库是不是扛不住了」。

后来查下来,根因特别老套:一条 SQL 没走索引。但整个过程里踩的几个坑,我觉得值得记一笔。

先别急着调数据库,确认慢在哪一段

我最早犯过的错就是一听说「慢」就直接去看数据库,结果折腾半天发现是应用自己的问题。现在我会先做一次粗分。

如果前面挂了 nginx,在日志格式里加两个变量最省事:

log_format main '$remote_addr [$time_local] "$request" '
                '$status $request_time $upstream_response_time';

$request_time 是整条请求耗时,$upstream_response_time 是后端 Flask 真正处理的耗时。两个数值接近,说明慢在应用里;$request_time 明显大很多,那可能是客户端网络或者请求体上传拖的。

我这次没走 nginx,直接在 Flask 里加了个计时装饰器,哪一步慢一眼就看见了:

import time, logging
from functools import wraps

log = logging.getLogger("slow")

def cost(name):
    def deco(fn):
        @wraps(fn)
        def wrapper(*a, **kw):
            t = time.perf_counter()
            try:
                return fn(*a, **kw)
            finally:
                ms = (time.perf_counter() - t) * 1000
                if ms > 200:
                    log.warning("%s cost=%.1fms", name, ms)
        return wrapper
    return deco

跑了几分钟看日志,list_shared_data 这个函数基本都在 7000ms 往上,其他函数都是几十毫秒。范围一下就锁定了:就是那条查询。

打开慢日志,不用重启

确认是数据库慢之后,我不想重启 MySQL。慢日志可以在线开:

SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
SET GLOBAL slow_query_log_file = '/var/lib/mysql/slow.log';

这里有个坑我第一次踩过:long_query_time 改完不会自动对已有连接生效。GLOBAL 变量只影响之后新建的连接,而我们那套 Flask 用的是连接池,连接早就建好了,改完等半天日志还是空的。要么重启一下应用让连接重建,要么在业务连接里补一句 SET SESSION long_query_time = 1;。

另外别顺手把 log_queries_not_using_indexes 也开成 ON。听起来很美,实际上没走索引但跑得飞快的小查询会疯狂往里灌,日志几小时就能涨到几个 G,反而把真正的问题埋了。查完记得关掉。

想要重启后还生效,就把配置落进 my.cnf 的 [mysqld] 段:

slow_query_log = 1
long_query_time = 1
slow_query_log_file = /var/lib/mysql/slow.log

把日志读成人话

日志原文很长,直接 cat 没意义。MySQL 自带了一个汇总工具:

mysqldumpslow -s t -t 10 /var/lib/mysql/slow.log

-s t 按总耗时排序,-t 10 只看前 10 条。这次第一条就是它,平均耗时 7.8 秒,出现几百次,SQL 长这样(脱敏过):

SELECT * FROM t_share_log
WHERE biz_no = 43030020260001
ORDER BY create_time DESC
LIMIT 20;

一眼看过去没什么毛病,biz_no 上是有索引的。顺手 EXPLAIN 一下:

EXPLAIN SELECT * FROM t_share_log WHERE biz_no = 43030020260001 ...

结果里 type 是 ALL,rows 一百多万,Extra 里挂着 Using filesort。全表扫。

索引为什么没用上:列是 varchar,我传了数字

对了一下表结构,biz_no 是 varchar(32),而我这句 SQL 里写的是裸数字 43030020260001,没有引号。

这就是典型的隐式类型转换。MySQL 在做比较时,会把字符串列转成数字来跟数字比 —— 相当于在列上套了一层 CAST 函数,索引自然就废了,只能一行行算。

改法极其简单,加引号:

WHERE biz_no = '43030020260001'

再 EXPLAIN,type 变成 ref,rows 从一百多万掉到 42,接口直接回到 100 多毫秒。

顺手把这次遇到的几个同类情况一起列一下,都是我或者同事真实写出来的:

  • 列上套函数:WHERE DATE(create_time) = '2026-09-28' 一定走不了索引,改成范围 create_time >= '2026-09-28' AND create_time < '2026-09-29'。
  • 左模糊:LIKE '%430300%' 用不上 B+ 树索引,实在要模糊匹配就考虑全文索引或者别的方案。
  • 联合索引最左前缀:索引是 (dept_id, biz_no),查询只给 biz_no,一样扫全表。查询条件要从最左列开始连续使用。
  • ORDER BY 排序字段跟索引对不上,就容易出现 Using filesort,数据量一大就很疼。

在 Python 这边还有个容易忽略的点:用 PyMySQL 时,参数一定要走占位符交给驱动处理,别自己拼字符串。

cur.execute("SELECT * FROM t_share_log WHERE biz_no = %s ORDER BY create_time DESC LIMIT 20",
            (biz_no,))

这样驱动会按类型正确加引号,既是防注入,也顺带避免了上面那种类型踩坑。

收尾时顺手做的两件事

一是把日志关了、留个基线。慢日志长期开着没什么必要,排查完我就把 long_query_time 调回原来的值,只保留文件。下次再出问题,有这份日志做对比也方便。

二是看了一眼分页。列表页有深度翻页,原来的写法是 LIMIT 2000, 20,offset 越大 MySQL 要扫描并丢弃的行越多。改成基于上一页最大 id 的游标写法后,翻到后面也不会变慢:

SELECT * FROM t_share_log
WHERE biz_no = %s AND id < %s
ORDER BY id DESC LIMIT 20;

最后

这次从头到尾也就二十分钟,工具全是 MySQL 自带的。真正的教训其实是「慢」这个现象本身太笼统,先花两分钟把耗时定位到具体函数、具体 SQL,后面就不用瞎猜了。我们平时写 SQL 习惯了让数据库自己推断,遇到 varchar 列传数字这种写法,开发和测试环境数据量小根本看不出来,上了生产、表涨到百万行才会集中爆发。

现在我给自己定了条规矩:新上的查询但凡涉及大表,上线前先 EXPLAIN 一遍,看一眼 type 和 rows,比事后救火省事太多。

作者:phantomxjc

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。