AI 写的 SQL 能直接上线吗?上线前必查的 6 项 + EXPLAIN 速查

0 阅读11分钟

@TOC


AI 写 SQL 有个特点:语法几乎不会错。

JOIN、子查询、窗口函数张口就来,格式还很整齐。问题出在它不知道的东西上:你的表有多大、建了哪些索引、数据库是什么版本、这条语句是在线接口在调,还是半夜跑一次的报表。

所以 AI 写的 SQL,「能跑」和「能上线」之间隔着一层它看不到的信息。这篇记我审 AI 写的 SQL 时固定检查的 6 项,附一份 EXPLAIN 速查和一段审查 Prompt。以 MySQL 为主,PostgreSQL 有差异的地方单独标了。


一、SQL 的「对」有三层

层次含义AI 的表现
语法对能执行,不报错基本不出问题
结果对返回的数据符合预期看情况,NULL 和 JOIN 常出错
代价对在真实数据量下不拖垮数据库它不知道数据量,只能猜

测试库里几百行数据,三层都能「通过」。真正的问题都在后两层,而且要到线上数据量才暴露。


二、先把这 4 样东西喂给它

1. 表结构,含索引

-- MySQL
SHOW CREATE TABLE orders;

-- PostgreSQL(psql 里执行)
\d+ orders

2. 数据量级

不用精确,知道量级就行:

-- MySQL:估算值,比 COUNT(*) 快得多
SELECT table_name, table_rows
FROM information_schema.tables
WHERE table_schema = 'your_db';

-- PostgreSQL
SELECT relname, reltuples::bigint FROM pg_class WHERE relname = 'orders';

3. 数据库和版本

MySQL 5.7 和 8.0 差别很大:窗口函数和 CTE 要 8.0 才有,改表结构的能力也不一样。

4. 使用场景

在线接口、后台报表、一次性修数,三者对性能和安全的要求完全不同。

不给表结构,它会按「常见命名」编字段:user_name 还是 username,create_time 还是 created_at,全靠猜。字段名猜错会直接报错,还算好的;更麻烦的是索引猜错了——写出来的语句能跑,但走不上索引。

我在 wescode 里会把建表的迁移文件和对应的 model 文件一起 @ 进对话,让它先对一遍字段和索引,再开始写。

⟦截图:wescode 对话里 @ 迁移文件和 model 文件后让它核对字段,说明文字「先对字段和索引,再写 SQL」⟧


三、上线前必查的 6 项

1. UPDATE / DELETE 的 WHERE 范围

修数脚本最怕范围不对。AI 写的条件看着合理,但边界可能和你想的不一样:< 还是 <=,状态值有没有漏,时区对不对。

固定动作:先把 UPDATE / DELETE 改写成 SELECT COUNT(*),用同样的 WHERE 跑一遍,看行数是否符合预期。

-- 先确认影响行数
SELECT COUNT(*) FROM orders
WHERE status = 'pending' AND created_at < '2026-09-01';

-- 数量对了再执行。大表分批,重复执行直到影响行数为 0
-- (UPDATE ... LIMIT 是 MySQL 语法)
UPDATE orders SET status = 'expired'
WHERE status = 'pending' AND created_at < '2026-09-01'
LIMIT 1000;

MySQL 还可以在会话里开安全模式。WHERE 没用到索引列、又没带 LIMIT 的 UPDATE / DELETE 会被直接拒绝:

SET SESSION sql_safe_updates = 1;

2. NULL 的语义

AI 最常踩的是 NOT IN:

-- 查「没下过单的用户」
SELECT * FROM users
WHERE id NOT IN (SELECT user_id FROM orders);

只要 orders.user_id 里有一个 NULL,这条语句一行都不返回。不报错,只是结果为空——很容易被当成「确实没有」。

改用 NOT EXISTS:

SELECT * FROM users u
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);

同类的还有:status != 'paid' 不会返回 status 为 NULL 的行;COUNT(col) 不计 NULL,COUNT(*) 计。哪些列允许 NULL 写在表结构里,这也是要把建表语句给它的原因。

3. JOIN 之后的重复计算

一对多 JOIN 之后再聚合,金额会被放大:

-- 想算每个用户的订单总额和商品件数
SELECT o.user_id, SUM(o.amount) AS total, COUNT(i.id) AS items
FROM orders o
JOIN order_items i ON i.order_id = o.id
GROUP BY o.user_id;

一个订单有 3 件商品,这个订单的 amount 就被加了 3 次。语法完全正确,结果是错的。

改法是先按订单把明细聚合好,再 JOIN:

SELECT o.user_id, SUM(o.amount) AS total, SUM(i.cnt) AS items
FROM orders o
JOIN (
  SELECT order_id, COUNT(*) AS cnt
  FROM order_items
  GROUP BY order_id
) i ON i.order_id = o.id
GROUP BY o.user_id;

检查方法:JOIN 前后各跑一次 COUNT(*)。行数变多了,就要确认被聚合的列会不会被重复计算。

4. 索引失效的写法

就算给了表结构,AI 也经常写出用不上索引的条件。最常见的四种:

写法问题改法
WHERE DATE(created_at) = '2026-09-01'对索引列用了函数created_at >= '2026-09-01' AND created_at < '2026-09-02'
WHERE phone = 13800000000(phone 是 varchar)隐式类型转换WHERE phone = '13800000000'
WHERE name LIKE '%伟'前导通配符调整需求,或改用全文索引
联合索引 (a, b),条件只有 b = ?不满足最左前缀,通常用不上调整索引或查询条件

隐式转换那条最隐蔽:字符串列和数字比较,MySQL 会把列的值逐行转成数字再比,索引就用不上了。反过来(数字列和字符串比较)不受影响。

另一头是改索引:加索引或删索引之前,得先弄清楚项目里有哪些查询在用这张表。我会在 wescode 里问「项目里哪些地方查询了 orders 表,WHERE 和 ORDER BY 分别用了哪些列」,把这些地方列出来逐条对照:新索引能不能用上,删掉旧索引会影响谁。用命令行的话,git grep -n orders 加上 ORM 的查询方法名一起搜;表名是动态拼出来的地方要额外留意。

5. 分页和排序

  • LIMIT 不带 ORDER BY:返回顺序不确定,翻页时可能重复或者漏数据。
  • 深分页:LIMIT 100000, 20 要先扫过 100020 行、再丢掉前 100000 行,越往后翻越慢。改成按上一页最后一条的 id 往后取:
SELECT * FROM orders
WHERE id > ?   -- 上一页最后一条的 id
ORDER BY id
LIMIT 20;

AI 写分页基本都是 OFFSET 写法,数据量小的时候看不出问题。

6. DDL:锁和回滚

改表结构的迁移脚本,是 AI 最「不知道自己不知道」的地方。语句本身通常没错,风险在执行的那一刻。

MySQL

显式写上 ALGORITHM 和 LOCK。当前版本不支持这种方式时会直接报错,而不是悄悄退化成锁表拷贝:

ALTER TABLE orders ADD COLUMN remark VARCHAR(255) NULL, ALGORITHM=INSTANT;
ALTER TABLE orders ADD INDEX idx_user_created (user_id, created_at), ALGORITHM=INPLACE, LOCK=NONE;

执行前把锁等待超时调短,等不到锁就失败,别把后面的请求堵住(下面翻车 1 就是这个):

SET SESSION lock_wait_timeout = 5;

大表改结构,考虑 gh-ost、pt-online-schema-change 这类在线变更工具。

PostgreSQL

  • 建索引用 CREATE INDEX CONCURRENTLY,否则建索引期间写入会被阻塞。它不能在事务块里执行,而很多迁移工具默认给每个迁移包一层事务,要单独处理。
  • 给有数据的表加 NOT NULL 列又不给默认值,会直接失败。

通用

  • 改列名、删列不要一次做完。滚动发布期间旧代码还在跑,列没了就报错。拆成几步:加新列 → 双写 → 回填 → 切读 → 下个版本再删旧列。
  • 写清楚回滚方案。DROP COLUMN 删掉的数据,回滚脚本是找不回来的。

四、EXPLAIN 速查(MySQL)

EXPLAIN SELECT ...;
字段看什么危险信号
type访问方式ALL(全表扫描)、index(扫全部索引)
key实际用上的索引NULL(没用上索引)
rows预估扫描行数远大于实际返回的行数
Extra附加信息Using filesort、Using temporary

type 从好到差大致是:const > eq_ref > ref > range > index > ALL。在线接口的查询出现 ALL,基本就要处理。

EXPLAIN 的输出我会直接贴回 wescode 的对话里,让它逐行解释 type、rows、Extra 分别说明了什么,再对照上面这张表自己核对一遍。

⟦截图:把 EXPLAIN 结果贴进 wescode 对话后的逐行解读,说明文字「逐行解读执行计划」⟧

两个注意:

EXPLAIN 只是预估。 想看真实执行情况用 EXPLAIN ANALYZE(MySQL 8.0.18 起支持,PostgreSQL 也有),但它会真的执行这条语句。在 PostgreSQL 里分析 UPDATE / DELETE,要包在事务里再回滚:

BEGIN;
EXPLAIN ANALYZE UPDATE orders SET status = 'expired' WHERE ...;
ROLLBACK;

测试库上的执行计划不作数。 数据量和分布跟线上不一样,优化器可能选完全不同的执行计划。至少要在数据量接近的环境里看。


五、可复制审查 Prompt

你是 DBA 视角的 SQL 审查员。数据库:MySQL 8.0(InnoDB)。

表结构(含索引):
<粘贴 SHOW CREATE TABLE 的输出>

数据量级:orders 约 3000 万行,order_items 约 1 亿行,users 约 500 万行
使用场景:<在线接口,峰值 QPS 约 200 / 后台报表,每天一次 / 一次性修数>

待审查 SQL:
<粘贴>

请检查:
1. 结果正确性:NULL 语义、JOIN 是否会放大行数导致重复计算、边界条件
2. 执行代价:预计使用哪个索引;可能出现全表扫描、filesort、临时表的地方;索引失效的写法
3. 如果是 UPDATE / DELETE:WHERE 范围是否可能过大,是否需要分批
4. 如果是 DDL:是否锁表、能否用 INSTANT / INPLACE、回滚方案

输出格式:问题 → 依据 → 改法。
执行计划相关的判断一律标注「需要 EXPLAIN 验证」,不要把推测写成结论。

最后一句是关键。不加这句,它会很笃定地告诉你「这条会走 idx_user_id 索引」——那只是它的推测,以 EXPLAIN 的结果为准。

这段 Prompt 我在 wescode 里存成了一个自定义技能(就是一份 SKILL.md),审 SQL 时直接按这个技能来,不用每次重新粘贴;数据量级和使用场景那两行,每次按实际情况补上。


六、两次翻车

翻车 1:加个字段,接口全超时

给一张大表加字段。AI 给的语句没毛病,MySQL 8.0 下还是 INSTANT,理论上秒级完成。

执行之后卡住了。紧接着,读这张表的接口开始大面积超时。

SHOW PROCESSLIST 一看:ALTER 的状态是 Waiting for table metadata lock,后面排了一长串普通查询,状态一模一样。

原因是有个长事务一直没提交(后来查到是有人在客户端里开了事务、查完忘了关),拿着这张表的元数据锁。ALTER 要拿排他锁,只能等;而 ALTER 一旦开始排队,后面新来的查询也得排在它后面。一个没提交的事务,加一条本该秒级完成的 DDL,把整张表堵死了。

AI 写的语句没问题,问题是它不知道执行那一刻数据库里正在发生什么。从那以后,执行 DDL 前我固定做两件事:

-- 先看有没有长事务
SELECT trx_id, trx_started, trx_mysql_thread_id
FROM information_schema.innodb_trx
ORDER BY trx_started;

-- 锁等待超时调短,等不到就失败
SET SESSION lock_wait_timeout = 5;

翻车 2:测试库上飞快的报表

一条报表 SQL,测试库几百行数据,毫秒级返回。上线后第一次跑,把从库 CPU 打满了。

EXPLAIN 一看,type 是 ALL。WHERE 里写的是 DATE(created_at) BETWEEN ...,created_at 上的索引完全没用上。测试库数据太少,全表扫描也就一眨眼,根本看不出来。

改成范围条件之后走上了索引。教训有两条:审 SQL 时把线上数据量告诉 AI;EXPLAIN 要在数据量接近的环境里看,测试库上的「很快」说明不了任何问题。


七、什么时候不用这么较真

一次性的只读查询。 在从库或本地跑,查完就扔,结果对就行。

小表。 几千行的配置表、字典表,全表扫描也无所谓。

已经有 SQL 审核平台和慢查询告警的团队。 机器能拦的交给机器,人重点看结果正确性和 DDL 的执行时机。


小结

AI 写 SQL 的问题不在语法,在于它缺三样信息:表结构、数据量、执行那一刻的数据库状态。前两样可以喂给它,第三样只能你自己在执行前确认。

固定动作:

  1. 先给表结构和数据量级,再让它写
  2. 修数先 COUNT,大表分批
  3. 逐项查 NULL、JOIN 放大、索引失效、分页
  4. DDL 显式写 ALGORITHM / LOCK,执行前先查长事务
  5. 执行计划以 EXPLAIN 为准,不以 AI 的判断为准

上面的检查项我是在 wescode 里写 SQL 时用的,官网是 weisyn.com。你们上线 SQL 有没有强制的审核流程,或者踩过什么 AI 写 SQL 的坑,欢迎评论区聊聊。