一次 SQL 评审平均花七分钟。七分钟里,评审者通常在看"这段 SQL 语法对不对、字段名有没有拼错",而真正导致线上事故的五类问题,几乎从不在评审里被提到。
SQL 的评审价值不在正确性——语法错误跑一次就暴露了。它的价值在那些能通过测试、只在上量后才出问题的地方:索引是否可用、扫描行数会不会失控、事务边界是否过长、锁的范围有多大。
1. 五种该拦下的问题
| 问题 | 表现 | 上量后的后果 |
|---|---|---|
| 索引失效形态 | 函数包裹列、隐式类型转换、前导通配 | 全表扫描 |
| 分页深翻 | LIMIT 100000, 20 |
越翻越慢 |
| 范围过大 | 无时间边界或边界过宽 | 扫描量不可控 |
| 事务内过多操作 | 事务里含远程调用或多表大更新 | 锁持有时间长 |
| 隐式锁升级 | 批量更新未分批 | 锁等待与死锁 |
第一项与第四项是"上量后才出现"的典型:小数据量时函数包裹的列照样走全表扫描,但全表只有几千行,一次几十毫秒,没人会注意;数据到千万行后,同一条 SQL 变成几秒。
第二条是中文互联网上被讨论最多、也最常被重复踩的一条:偏移分页的成本与偏移量成正比,翻到第一万页时要扫一万零二十行。评审时看到 LIMIT 后面那个数字很大,就该拦。
2. 索引失效的四种具体形态
| 形态 | 例子 | 修法 |
|---|---|---|
| 列被函数包裹 | WHERE DATE(created_at) = '2026-11-10' |
改为范围条件 |
| 隐式类型转换 | 字符串列与数字比较 | 统一类型 |
| 前导通配 | LIKE '%abc' |
反向索引或搜索引擎 |
| 复合索引断链 | 跳过最左列 | 调整条件或索引 |
第三项(前导通配)有个替代思路:如果检索确实是"包含匹配",那么关系库不是合适的工具,该用倒排索引(搜索引擎)而不是在库里硬撑。评审时看到 LIKE '%x%',先问"为什么需要中间匹配",而不是问"能不能加索引"。
第一项最容易被忽视,因为它"看起来完全正常",且在小数据量下执行很快。它的问题在于:DATE(created_at) 让索引无法用于过滤,只能作为函数计算后的结果参与。
-- 失效:函数包裹列,索引不可用
SELECT * FROM orders WHERE DATE(created_at) = '2026-11-10';
-- 有效:改成范围,索引可用
SELECT * FROM orders
WHERE created_at >= '2026-11-10' AND created_at < '2026-11-11';
3. 深分页的三种替代
| 方式 | 做法 | 适用 |
|---|---|---|
| 游标分页 | 用上一页最后一条的排序列值作为起点 | 列表类、可按序 |
| 延迟关联 | 先用索引取 ID,再回表取整行 | 需要总数与跳页 |
| 限制最大页数 | 只允许翻到第 N 页 | 后台管理类 |
游标分页有个前提容易被忽略:排序列必须唯一或近似唯一。用 created_at 排序时若有同秒记录,翻页会出现漏行或重复。可行做法是复合游标(时间 + ID)。
-- 复合游标:避免同值排序导致的漏行
SELECT id, amount FROM orders
WHERE (created_at, id) < ('2026-11-01 10:00:00', 88912)
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- 需要 (created_at, id) 复合索引支撑,否则退化为范围扫描
第一种是标准解法,代价是不能随机跳页。第二种保留了跳页能力,仍要扫偏移量,只是减少了回表成本。
-- 深分页:先用覆盖索引取 ID,再回表
SELECT o.* FROM orders o
JOIN (SELECT id FROM orders
WHERE status = 'PAID'
ORDER BY created_at DESC
LIMIT 100000, 20) t ON o.id = t.id;
-- 内层只扫索引,不取整行;外层按 20 个 ID 回表
4. 事务边界与锁
| 问题 | 机制 | 修法 |
|---|---|---|
| 事务含远程调用 | 网络等待期间持有锁 | 把调用移出事务 |
| 事务内批量更新多行 | 行锁数量随行数增长 | 分批提交 |
| 事务里做查询再更新 | 间隙锁与范围锁 | 缩短范围或改隔离级别 |
| 长事务 | undo 无法回收 | 拆分或异步化 |
第二项(批量更新多行)的判断有个简单口径:一次 UPDATE 影响的行数上界是多少。答不出上界,就不该合并成一条语句;答案超过几千,就该分批。
第一项是最常见的结构性错误:事务里调用第三方接口(发短信、调支付),网络一慢,锁就持有几十秒,其它请求全部排队。
5. 评审时该问的五个问题
// SQL 评审五问,按顺序,前三个必须回答
1. 这条查询走哪个索引?给 EXPLAIN 的 type 与 rows。
2. 最坏情况扫多少行?上界是多少?
3. 有没有范围没有上界(如只有 >= 没有 <)?
4. 这段在事务里吗?事务里还有别的操作吗?
5. 更新/删除影响多少行?会不会一次几万行?
第一问要求给出 EXPLAIN,而不是"应该走索引"。"应该"是评审里最没信息量的词,它既不能验证也不承担责任。
-- 评审材料:把执行计划贴进 PR,而不是口头保证
EXPLAIN FORMAT=JSON
SELECT id, amount FROM orders
WHERE user_id = 42 AND status = 'PAID' AND created_at >= '2026-01-01'
ORDER BY created_at DESC LIMIT 20;
-- 关注:type(ref/range 而非 ALL)、rows 估算、是否 Using filesort
评审材料的最小集合可以固化成 PR 模板,五格填完才允许合并:
// SQL 变更 PR 模板
索引:走 (user_id, status, created_at),EXPLAIN type=range, rows≈180
上界:单用户单状态最近一年,估算 ≤ 5000 行
边界:created_at 有上下界;status 为枚举,无范围
事务:不在事务内,单条只读
影响行数:不涉及写
6. 排错:五种"评审通过后慢查询"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 上线后变慢 | 数据量增长后索引选择性下降 | 看 rows 估算与统计信息 |
| 偶发慢 | 执行计划被数据分布影响 | 固定计划或强制索引 |
| 高峰期锁等待 | 事务过长或更新范围大 | 拆分事务、分批更新 |
| 分页越翻越慢 | 深偏移 | 改游标分页 |
| 单条 SQL 拖垮库 | 无边界扫描 | 加时间与状态边界 |
排错时还有一个高效手段:拿生产的数据分布做一次抽样执行。在只读副本上跑同一条 SQL 并看 EXPLAIN ANALYZE 的真实行数,比在测试库上跑一万遍都有用——因为差异往往来自数据分布而不是 SQL 写法。
第二项在评审时最难预见:同一段 SQL 在不同数据分布下可能选不同计划。评审能做的只有两件事——确认最坏情况的扫描上界,以及要求有慢查询告警覆盖。
7. 边界:什么情况可以简化评审
| 场景 | 为什么 | 做法 |
|---|---|---|
| 小表(万行内) | 扫描成本可忽略 | 只看正确性 |
| 按主键点查 | 计划稳定 | 快速通过 |
| 只读报表库 | 不影响在线 | 只关注资源占用 |
| 一次性数据修复 | 有明确时间窗 | 关注分批与回滚 |
还有一条与流程有关的边界:评审不能替代慢查询监控。评审只能拦住"明显没有上界"的写法,拦不住"数据分布变化导致计划改变"。所以评审与监控是两道防线,缺一道都会漏。
判断口径一句话:这条 SQL 的最坏扫描量有没有上界。有上界且可接受,评审可以简化;没有上界,它就是一颗定时炸弹,无论当前数据量多小。
8. 自测
- SQL 评审的价值为什么不在"正确性"上?语法错误靠什么暴露?
- 五种该拦的问题里,哪一类最常被"看起来正常"掩盖?它的具体形态是什么?
- 深分页成本与偏移量成正比,三种替代方式各自的代价是什么?
- 事务里调用第三方接口为什么会造成锁等待?正确做法是什么?
- 评审五问里,为什么要求给出
EXPLAIN而不是"应该走索引"? - 判断"能否简化 SQL 评审"的那个问题是什么?为什么它与当前数据量无关?