接口从 200ms 慢到 8 秒:我用 MySQL 慢日志揪出一条没走索引的 SQL
上周三早上刚到单位,业务科室的电话就来了:共享监控系统那个共享数据列表页,点进去要转半天圈,有时候干脆超时。这套系统是 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
- 点赞
- 收藏
- 关注作者
评论(0)