SQL 审查
把"这条 SQL 能不能上线"变成一份可执行的问题清单:先静态检查语法层面的风险,再结合执行计划判断真实瓶颈,最后给出改写后的语句。
适用于 MySQL、OceanBase、seekdb 等 MySQL 兼容数据库。核心脚本只依赖 Python 标准库,不连接数据库、不联网。
什么时候用
- 用户贴出 SQL,问"有问题吗 / 能上线吗 / 为什么慢"
- 需要 review 迁移脚本、上线脚本、ORM 生成的语句
- 拿到
EXPLAIN输出,需要判断瓶颈在哪一步 - 建表、加索引前,想确认设计是否合理
- Code Review 中发现可疑查询,需要给出具体的改写
什么时候不用
- 必须依赖真实数据分布才能判断的问题:先让用户提供
EXPLAIN与表结构,再下结论 - 性能问题来自锁等待、连接池、网络或缓存,而不是 SQL 本身
- 非 MySQL 方言(PostgreSQL / Oracle / ClickHouse):部分规则仍适用,但优化器行为不同,结论需人工复核
- 需要实际执行才能验证的语义问题(如存储过程逻辑)
快速开始
# 1. 静态审查,输出 Markdown 报告
python scripts/sql_review.py path/to/query.sql
# 2. 同时分析执行计划(MySQL 表格 / 垂直格式、OceanBase 算子树都支持)
python scripts/sql_review.py query.sql --explain explain.txt
# 3. 输出 JSON,接入流水线;发现 major 及以上返回非零
python scripts/sql_review.py query.sql --format json --fail-on major
工作流
第 1 步:收集三样东西
- SQL 原文:优先用慢日志或应用日志里的最终语句,而不是拼接前的模板。
- 表结构与索引:
SHOW CREATE TABLE,重点看索引列顺序。 - 数据量级:几千行和上千万行的判断完全不同。
缺少第 2、3 项时可以做静态初筛,但结论要标注"待确认"。
第 2 步:机器初筛
跑 sql_review.py,把 blocker 与 major 全部过一遍;minor、nit 按场景取舍。脚本输出是线索,不是判决。
第 3 步:计划验证
有 EXPLAIN 就用 --explain 一起分析;没有就建议用户对目标库执行 EXPLAIN。静态规则与执行计划冲突时,以执行计划为准。
第 4 步:人工判定
逐条确认三件事:是不是误报、是否在本次变更范围内、修复成本与收益是否匹配。误报的常见原因见 references/rules.md 的"已知边界"。
第 5 步:输出
按"阻断 → 高危 → 建议"排序。每条都要给出:位置、影响、改写后的 SQL。只给结论不给语句,等于没审。
严重级别怎么用
| 级别 | 含义 | 处理 | | --- | --- | --- | | 阻断 | 可能造成数据事故或不可逆影响 | 拒绝上线,先改 | | 高危 | 大概率导致慢查询、锁等待或索引失效 | 本次变更内修复 | | 建议 | 有明确代价,但特定场景可接受 | 排期处理或明确接受 | | 提示 | 风格与可维护性 | 可忽略 |
高频问题的改写
1. 整表更新或删除
-- 改前
UPDATE users SET status = 0;
-- 改后:有条件、可分批
UPDATE users SET status = 0 WHERE id = 42;
确需全表更新时按主键分批,每批 1000~5000 行,批间留出间隔。
2. LIKE 前导通配符
-- 改前:索引失效
SELECT id FROM articles WHERE title LIKE '%数据库%';
-- 改后:前缀匹配可用索引
SELECT id FROM articles WHERE title LIKE '数据库%';
中缀搜索改用全文索引或搜索引擎,不要指望 B+Tree。
3. 列上套函数
-- 改前
SELECT id FROM orders WHERE DATE(created_at) = '2026-01-01';
-- 改后:范围条件
SELECT id FROM orders
WHERE created_at >= '2026-01-01' AND created_at < '2026-01-02';
4. OR 连接不同列
-- 改前:通常只能用一个索引
SELECT id FROM users WHERE status = 1 OR city = '杭州';
-- 改后:两条索引各自生效
SELECT id FROM users WHERE status = 1
UNION ALL
SELECT id FROM users WHERE city = '杭州';
结果需要去重时用 UNION,但要知道多一次去重排序的代价。
5. 深分页
-- 改前:扫描并丢弃 20 万行
SELECT id, title FROM articles ORDER BY id LIMIT 200000, 20;
-- 改后:游标分页
SELECT id, title FROM articles WHERE id > 200000 ORDER BY id LIMIT 20;
不能改游标时用延迟关联:先用覆盖索引取主键,再回表取详情。
6. NOT IN 子查询
-- 改前:子查询含 NULL 时结果为空
SELECT id FROM users WHERE id NOT IN (SELECT user_id FROM blacklist);
-- 改后
SELECT id FROM users u
WHERE NOT EXISTS (SELECT 1 FROM blacklist b WHERE b.user_id = u.id);
7. SELECT *
-- 改前
SELECT * FROM orders WHERE user_id = 42;
-- 改后:只取必要列,配合覆盖索引
SELECT id, amount, created_at FROM orders WHERE user_id = 42;
8. 隐式类型转换
-- 改前:user_id 是数值列,却与字符串比较
SELECT id FROM orders WHERE user_id = '123';
-- 改后
SELECT id FROM orders WHERE user_id = 123;
参数化查询时确认绑定类型与列类型一致,否则同样会触发转换。
输出模板
审查 <文件>,共 N 条语句,发现 X 个阻断、Y 个高危、Z 个建议。
### 阻断
1. <文件>:<行号> UPDATE 缺少 WHERE
- 影响:整表更新,长事务并放大 binlog
- 改写:UPDATE ... WHERE id = ?
### 高危
...
### 建议
...
### 需要你确认
- orders 表实际行数与 created_at 的区分度
- 是否已有 (user_id, created_at) 联合索引
结论先行,逐条给证据与改写;没有把握的项放进"需要你确认",不要硬下结论。
脚本参数
| 参数 | 说明 |
| --- | --- |
| files... | SQL 文件路径,- 表示标准输入 |
| --explain FILE | 分析执行计划,可重复传入 |
| --format md\|json | 输出格式,默认 Markdown |
| --min-severity | 只显示不低于该级别的问题 |
| --fail-on | 达到该级别返回非零状态码,便于接入 CI |
| --list-rules | 打印全部规则 |
| --version | 版本号 |
参考文件
| 文件 | 内容 |
| --- | --- |
| references/rules.md | 全部规则:触发条件、风险、改写方向、已知边界 |
| references/explain.md | 怎么读 MySQL 与 OceanBase 的执行计划 |
| references/index-design.md | 联合索引、覆盖索引、选择性、分页与 JOIN |
| references/safety.md | 大事务、锁、DDL、批量变更的上线清单 |
边界
- 只做静态文本分析,不连接数据库、不读取统计信息,因此所有结论都需要结合真实数据复核。
- 不理解业务语义:小表上的全表扫描、只查三列的
SELECT *可能完全合理。 - 不覆盖存储过程体与动态拼接后的最终语句。
- 规则可以扩展:在
scripts/sql_review.py的RULES注册规则、在analyze_statement实现判定,并同步references/rules.md与测试。
自测
python -m unittest discover -s tests -v
测试覆盖字符串/注释遮蔽、全部语句规则、误报样例、执行计划三种格式与 CLI 行为。
<!-- OB-S:DnDptzBf_rL72T8pV5Veag -->
Scan to join WeChat group