深度复盘|数据库迁移实战(上):腾讯云助手解析慢查询日志,定位索引缺失与语法不兼容

深度复盘|数据库迁移实战(上):腾讯云助手解析慢查询日志,定位索引缺失与语法不兼容

分类:数据库 / 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 > 1000Query_time > 2s,SQL 带 WHEREEXPLAIN 显示 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 行。这里有两个独立问题,第二个更容易被忽略:

  1. status 上没有索引 → 全表扫描
  2. 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:隐式类型转换

判定依据EXPLAINkey 为空但表上其实有索引;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

判定依据EXPLAINExtraUsing filesortRows_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 INDEXcreated_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_BYNO_ZERO_DATE 等 SQL_MODE 变更导致的报错
  • 保留字冲突(rankgroupssystem
  • utf8mb3 弃用与字符集校验
  • 迁移前后 SQL 改写对照表
  • 双跑对账的灰度校验方案

相关阅读:《深度复盘|数据库迁移实战(下):从自建 MySQL 到腾讯云 MySQL 的 SQL 兼容改造》

相关推荐
万象新讯1 小时前
申请 AWS Activate 的初创企业要满足什么条件,需准备哪些资料?
大数据·人工智能·aws
SelectDB技术团队1 小时前
从 ClickHouse 迁移到 Doris:SQL 兼容、同步与验证清单
数据库·人工智能·sql·clickhouse·apache doris·selectdb·湖仓架构升级
敲代码的嘎仔1 小时前
从零实现视频续播 + 学习进度统计:前端心跳、条件更新、GROUP BY 统计全链路拆解
java·前端·数据库·学习·面试·职场和发展·音视频
草莓熊Lotso1 小时前
【Redis 初阶】C++ 客户端实战:从 RESP 协议到 redis-plus-plus 工程化用法
linux·开发语言·网络·数据库·c++·redis·缓存
dunge20261 小时前
2026年9月11日|ChatGPT Pro + Codex:GPT‑6 Astra 数据库性能优化
数据库·gpt·chatgpt
NeilYuen2 小时前
【kv存储】结合faiss构建向量内存数据库
数据库·faiss
风哥2号2 小时前
数据库教程FGMT18‑Oracle‑EMCC智能化集中运维管理系统
运维·数据库·oracle
我就是DaLing呀!2 小时前
flutter + ffmpeg_kit_extended_flutter 大视频合并下载
flutter·ffmpeg·音视频
少司府2 小时前
Linux系统篇(一)指令篇·一:初识Linux及其基本指令
linux·运维·服务器·网络·阿里云·linux系统