深度复盘|数据库迁移实战(上):腾讯云助手解析慢查询日志,定位索引缺失与语法不兼容
分类:数据库 / MySQL
标签:MySQL、慢查询、SQL优化、索引、数据库迁移、腾讯云、DBA
摘要:迁移前手上只有一份 mysqldumpslow 的原始慢查询日志和 3000+ 条 SQL。本文记录如何用腾讯云助手把非结构化日志解析成结构化问题清单,自动识别索引缺失、隐式类型转换、函数包裹索引列等 6 类问题,输出可执行的优化方案。
一、起点:一份 1.2GB 的慢查询日志
迁移项目启动时,DBA 给了我一份 slow.log,1.2GB,覆盖 7 天。先做基础统计:
bash
# long_query_time 与 time 范围
grep -c '^# Time:' slow.log
# 48213
head -50 slow.log
text
# Time: 2026-08-01T09:12:33.114512Z
# User@Host: app_rw[app_rw] @ [10.10.20.55] Id: 812733
# Query_time: 12.483291 Lock_time: 0.000213 Rows_sent: 20 Rows_examined: 8743215
SET timestamp=1754039553;
SELECT o.id, o.order_no, o.amount, o.status, u.nickname
FROM biz_order o
LEFT JOIN sys_user u ON o.user_id = u.id
WHERE o.status = 2
AND DATE(o.created_at) >= '2026-07-01'
ORDER BY o.created_at DESC
LIMIT 20;
48213 条慢查询,日均 6887 条 。按 Rows_examined 排序,前 10 条就占据了大头。
传统做法是 pt-query-digest 出个报告,然后人工一条条看。但 3000+ 个不同的 SQL 指纹,人工看不完。
二、思路:让 AI 做"日志 → 问题清单"的翻译
我把这件事拆成三步,其中第 1、3 步交给腾讯云助手:
text
步骤 1(AI):非结构化日志 → 结构化问题清单
步骤 2(脚本):清单 → 可执行的验证/优化语句
步骤 3(AI):优化语句 → 兼容性预检(针对目标版本)
2.1 第一步提示词
text
我有一份 MySQL 5.7 慢查询日志(已按 Query_time 降序排列,共约 500 条 Top SQL),
以及每条的 Query_time / Lock_time / Rows_sent / Rows_examined。
请帮我:
1. 先写一个 Python 脚本,把原始日志解析成 CSV(字段:timestamp, user, host,
query_time, lock_time, rows_sent, rows_examined, sql_digest, full_sql)
2. 基于解析结果,按"问题类型"对 SQL 分类。已知的问题类型包括但不限于:
- 索引缺失(全表扫描 / 扫描行数远大于返回行数)
- 隐式类型转换(字段类型与入参类型不一致)
- 函数包裹索引列导致索引失效
- 索引区分度不足(选择性差的列建索引)
- 深分页(大 OFFSET)
- 排序 + 过滤组合导致 filesort
- 子查询可改 JOIN
3. 每类问题给出:判定依据(从日志字段如何判断)、涉及 SQL 数量、典型样例、
修复方向(DDL 或 SQL 改写)
4. 输出一个 Markdown 格式的问题清单表,我要直接拿去做迁移评审
约束:目标库是 MySQL 8.0(腾讯云 MySQL),请在每类问题里额外标注
"该类问题在 8.0 上是否会更严重/是否属于强制报错项"。
关键点:把"问题类型"清单先给出来。AI 的分类能力很强,但如果你不给分类框架,它会给你一堆零散观察;给了框架,它就能系统性地逐类比对。
三、产出物一:日志解析脚本
助手写的解析脚本(我做了少量调整,加了 --min-time 过滤):
python
#!/usr/bin/env python3
"""
slowlog_parse.py ------ MySQL 慢查询日志解析为 CSV
用法: python3 slowlog_parse.py slow.log > slow.csv
"""
import re
import sys
import csv
import hashlib
from datetime import datetime
HEADER_TIME = re.compile(r'^# Time: (\S+)')
HEADER_USER = re.compile(r'^# User@Host: (\S+)\[.*?\]\s+@\s+(\S+)\s+Id:\s+(\d+)')
HEADER_STAT = re.compile(
r'^# Query_time: ([\d.]+)\s+Lock_time: ([\d.]+)\s+'
r'Rows_sent: (\d+)\s+Rows_examined: (\d+)'
)
SET_TS = re.compile(r'^SET timestamp=(\d+);')
def normalize(sql: str) -> str:
"""归一化 SQL,生成指纹:字面量替换为占位符"""
s = re.sub(r"'(?:[^'\\]|\\.)*'", '?', sql)
s = re.sub(r'\b\d+\b', '?', s)
s = re.sub(r'\s+', ' ', s).strip().lower()
return s
def fingerprint(sql: str) -> str:
return hashlib.md5(normalize(sql).encode()).hexdigest()[:16]
def parse(path, min_query_time=1.0):
rows = []
ctx = {}
sql_lines = []
in_sql = False
with open(path, 'r', encoding='utf-8', errors='replace') as f:
for line in f:
line = line.rstrip('\n')
m = HEADER_TIME.match(line)
if m:
if in_sql and sql_lines:
_emit(rows, ctx, sql_lines, min_query_time)
ctx = {'time': m.group(1)}
sql_lines, in_sql = [], False
continue
m = HEADER_USER.match(line)
if m:
ctx['user'], ctx['host'], ctx['id'] = m.group(1), m.group(2), m.group(3)
continue
m = HEADER_STAT.match(line)
if m:
ctx['query_time'] = float(m.group(1))
ctx['lock_time'] = float(m.group(2))
ctx['rows_sent'] = int(m.group(3))
ctx['rows_examined'] = int(m.group(4))
in_sql = True
continue
m = SET_TS.match(line)
if m:
ctx['unix_ts'] = int(m.group(1))
continue
if in_sql and line and not line.startswith('#'):
sql_lines.append(line.strip())
if in_sql and sql_lines:
_emit(rows, ctx, sql_lines, min_query_time)
return rows
def _emit(rows, ctx, sql_lines, min_query_time):
sql = ' '.join(sql_lines)
if ctx.get('query_time', 0) < min_query_time:
return
rows.append({
'timestamp': ctx.get('time', ''),
'user': ctx.get('user', ''),
'host': ctx.get('host', ''),
'query_time': ctx.get('query_time', 0),
'lock_time': ctx.get('lock_time', 0),
'rows_sent': ctx.get('rows_sent', 0),
'rows_examined': ctx.get('rows_examined', 0),
'scan_ratio': round(
ctx.get('rows_examined', 0) / max(ctx.get('rows_sent', 1), 1), 1),
'digest': fingerprint(sql),
'sql': sql,
})
def main():
if len(sys.argv) < 2:
print(__doc__, file=sys.stderr)
sys.exit(1)
rows = parse(sys.argv[1], min_query_time=float(
sys.argv[2]) if len(sys.argv) > 2 else 1.0)
writer = csv.DictWriter(sys.stdout, fieldnames=list(rows[0].keys()))
writer.writeheader()
writer.writerows(rows)
print(f"parsed {len(rows)} slow queries", file=sys.stderr)
if __name__ == '__main__':
main()
执行:
bash
python3 slowlog_parse.py slow.log 2.0 > slow.csv
# parsed 1847 slow queries (Query_time >= 2s)
# 按扫描比排序,先看最浪费的
sort -t, -k8 -rn slow.csv | head -20
四、产出物二:问题分类清单(核心产出)
以下是我实际拿去做迁移评审的清单,每一类都附了从日志里如何判定。
类别 1:索引缺失 / 全表扫描
判定依据 :Rows_examined / Rows_sent > 1000 且 Query_time > 2s,SQL 带 WHERE 但 EXPLAIN 显示 type=ALL。
数量:742 条(占 40.2%),是最大头。
典型样例:
sql
SELECT o.id, o.order_no, o.amount, o.status, u.nickname
FROM biz_order o
LEFT JOIN sys_user u ON o.user_id = u.id
WHERE o.status = 2
AND DATE(o.created_at) >= '2026-07-01'
ORDER BY o.created_at DESC
LIMIT 20;
-- Query_time: 12.48 Rows_sent: 20 Rows_examined: 8743215
扫描 874 万行只返回 20 行。这里有两个独立问题,第二个更容易被忽略:
status上没有索引 → 全表扫描DATE(o.created_at)函数包裹了索引列 → 即使created_at有索引也用不上
修复方向:
sql
-- 1) 组合索引:等值列在前,范围列在后,排序列最后
ALTER TABLE biz_order
ADD INDEX idx_status_created (status, created_at);
-- 2) SQL 改写:范围条件不裹函数
SELECT o.id, o.order_no, o.amount, o.status, u.nickname
FROM biz_order o
LEFT JOIN sys_user u ON o.user_id = u.id
WHERE o.status = 2
AND o.created_at >= '2026-07-01' -- 去掉 DATE()
AND o.created_at < '2026-08-01'
ORDER BY o.created_at DESC
LIMIT 20;
MySQL 8.0 补充 :8.0 支持函数索引,可以作为兜底:
sql
ALTER TABLE biz_order
ADD INDEX idx_status_date ((CAST(DATE(created_at) AS DATE)), status);
-- 但强烈建议优先改 SQL,函数索引的维护成本与优化器识别成本更高
类别 2:隐式类型转换
判定依据 :EXPLAIN 中 key 为空但表上其实有索引;SHOW WARNINGS 出现 Cannot use ref access。
数量:213 条。
典型样例:
sql
-- user_id 是 varchar(32),传入数字 → 全表扫描
SELECT * FROM sys_user WHERE user_id = 10086;
-- SHOW WARNINGS: Cannot use ref access on index 'idx_user_id' due to type or collation conversion
修复方向:
sql
-- SQL 侧:加引号
SELECT * FROM sys_user WHERE user_id = '10086';
-- 表结构侧(根治):如果业务上确实是数字 ID,改类型
ALTER TABLE sys_user MODIFY COLUMN user_id BIGINT UNSIGNED NOT NULL;
-- 注意:改类型属于不兼容变更,需评估应用层与 DTS 迁移影响
为什么这类问题必须先改 SQL 而不是只改索引 :隐式转换是双向的------如果表字段是数字而参数是字符串,MySQL 会把列转成字符串比较,索引完全失效。这类问题在迁移后不会自动变好。
类别 3:排序 + 过滤组合导致 filesort
判定依据 :EXPLAIN 的 Extra 含 Using filesort 且 Rows_examined 大。
数量:318 条。
典型样例:
sql
SELECT id, title FROM cms_article
WHERE category_id = 12 AND deleted = 0
ORDER BY publish_time DESC LIMIT 50;
修复方向:让索引顺序与"等值过滤 → 排序"一致:
sql
ALTER TABLE cms_article
ADD INDEX idx_cat_deleted_time (category_id, deleted, publish_time DESC);
MySQL 8.0 优势 :8.0 正式支持降序索引 (5.7 中 DESC 被静默忽略)。如果你的 SQL 是 ORDER BY col DESC,8.0 上可以用真正的降序索引消除 filesort,这是迁移到 8.0 的一个实际收益点。
类别 4:深分页
数量:96 条。
sql
-- 反例:OFFSET 900000,扫描 90 万行只为丢弃
SELECT * FROM biz_order ORDER BY id LIMIT 900000, 20;
修复方向:改游标分页(keyset pagination):
sql
-- 正例:带上游标,让优化器走索引区间扫描
SELECT id, order_no, amount FROM biz_order
WHERE id > 899980 -- 上一页最后一条 id
ORDER BY id
LIMIT 20;
这类改造需要应用层配合(前端要能传游标),所以必须在迁移窗口前完成接口改造,不能留到迁移当天。
类别 5:子查询可改 JOIN
数量:58 条。
sql
-- 反例:关联子查询,外层每行执行一次
SELECT id, order_no FROM biz_order o
WHERE EXISTS (SELECT 1 FROM biz_order_item i WHERE i.order_id = o.id);
修复方向 :8.0 会对部分 EXISTS 做半连接优化,但显式改写更稳定:
sql
SELECT DISTINCT o.id, o.order_no
FROM biz_order o
JOIN biz_order_item i ON i.order_id = o.id;
类别 6:索引区分度不足
数量:41 条。
判定依据 :索引列 COUNT(DISTINCT col) / COUNT(*) 极低的独立索引。
sql
-- deleted 只有 0/1 两个值,单独建索引毫无意义
SHOW INDEX FROM biz_order;
-- KEY idx_deleted (deleted) ← 选择性 0.0001
修复方向:删除或合入组合索引(作为组合索引的后缀列):
sql
ALTER TABLE biz_order DROP INDEX idx_deleted;
-- 已包含在 idx_status_created 中,无需重复
五、汇总清单表(可直接用于迁移评审)
| # | 问题类型 | 数量 | 占比 | 修复方式 | 8.0 风险变化 |
|---|---|---|---|---|---|
| 1 | 索引缺失 / 全表扫描 | 742 | 40.2% | 新增组合索引 + 去函数包裹 | 可用函数索引兜底 |
| 2 | 隐式类型转换 | 213 | 11.5% | 参数加引号;根治需改列类型 | 不自动改善 |
| 3 | filesort | 318 | 17.2% | 索引顺序对齐过滤+排序 | 可用降序索引改善 |
| 4 | 深分页 | 96 | 5.2% | 游标分页(需改接口) | 无变化 |
| 5 | 子查询 | 58 | 3.1% | 改 JOIN | 半连接优化部分改善 |
| 6 | 索引区分度不足 | 41 | 2.2% | 删索引 / 并入组合索引 | 无变化 |
| --- | 其他(未知) | 379 | 20.5% | 逐条人工复核 | --- |
注意最后一行的 20.5% :AI 分类不是 100% 准确的,有 379 条它标记为"需人工复核"。这是正确行为------让工具承认不确定,比强行分类更有价值。这 379 条我们人工过了一遍,其中 12 条是真问题。
六、这次复盘的几点收获
1. 先分类,再优化,不要先优化
我最初的冲动是"看到慢 SQL 就加索引"。但 48213 条慢查询里,真正需要动 DDL 的只有 6 类模式。先做分类,才能批量处理,而不是打地鼠。
2. "扫描行数 / 返回行数"是最有效的单一指标
它同时能抓到索引缺失、函数包裹、隐式转换三类问题,且完全来自日志、不需要连库。迁移前就能用,这是它最大的价值。
3. 指数列被函数包裹是最容易被忽略的一类
因为"表上有索引",很多人看到 SHOW INDEX 有 created_at 就以为没问题。日志里的 Rows_examined 才是真相。建议在评审时专门做一轮 grep:
bash
# 找出所有索引列被函数包裹的 SQL
grep -iE "(DATE|MONTH|YEAR|SUBSTR|LEFT|RIGHT|IFNULL|COALESCE|LOWER|UPPER)\s*\(\s*[a-z_]+\s*\)" slow.sql
4. 让 AI 输出的不是"答案",而是"待验证清单"
最有用的产出不是"帮我把这条 SQL 改好",而是"这 742 条属于同一类问题,判定依据是 X,修复方向是 Y"。批量问题的模式化处理,才是 AI 在 DBA 场景里真正的杠杆。
七、下一步:SQL 兼容改造
上面的工作只解决"性能"。真正的迁移难点是语法兼容------MySQL 5.7 → 8.0 有一批 SQL 会直接报错,这才是会阻塞迁移的部分。
下一篇会展开:
ONLY_FULL_GROUP_BY、NO_ZERO_DATE等 SQL_MODE 变更导致的报错- 保留字冲突(
rank、groups、system) utf8mb3弃用与字符集校验- 迁移前后 SQL 改写对照表
- 双跑对账的灰度校验方案
相关阅读:《深度复盘|数据库迁移实战(下):从自建 MySQL 到腾讯云 MySQL 的 SQL 兼容改造》