@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 的问题不在语法,在于它缺三样信息:表结构、数据量、执行那一刻的数据库状态。前两样可以喂给它,第三样只能你自己在执行前确认。
固定动作:
- 先给表结构和数据量级,再让它写
- 修数先 COUNT,大表分批
- 逐项查 NULL、JOIN 放大、索引失效、分页
- DDL 显式写 ALGORITHM / LOCK,执行前先查长事务
- 执行计划以 EXPLAIN 为准,不以 AI 的判断为准
上面的检查项我是在 wescode 里写 SQL 时用的,官网是 weisyn.com。你们上线 SQL 有没有强制的审核流程,或者踩过什么 AI 写 SQL 的坑,欢迎评论区聊聊。