AI 写的 SQL 跑得通却算错数:join、空值、时区、大表扫描怎么查

2026-07-29

数据截至 2026-07。各数据库对空值、时区函数、执行计划输出的具体行为存在方言差异,落到你手上那套时以官方最新说明为准。

**多数人把这类问题归给「模型不懂 SQL」,其实错因是模型不懂你的表。**语法它写得比你熟,窗口函数、CTE、递归查询都能一气呵成;但它不知道你那张订单表里 refund_time 有七成是空的,不知道 user_id 在 A 库是 bigint、在 B 库是 varchar,不知道你的 created_at 存的是 UTC 而看板按东八区口径出数。这些信息不在语句里,在你脑子里和线上数据里。语句跑通、结果有数、图表也画出来了,错误就这样安静地流到周报上。

所以排查 AI 写的 SQL,不能从「语句写得对不对」入手,要从「它假设了什么、这些假设成不成立」入手。下面按可执行的顺序来。

一、先分清:跑不通、跑得慢、还是跑得通但算错

这三类问题的排查路径完全不同,混在一起查会绕远路。

跑不通最省事。报错指向明确的列名、函数名或语法位置,把原始报错整段贴回去让模型修,一两轮就好。唯一要留神的是它会「自信地」把一个不存在的列名换成另一个不存在的列名——改之前先自己确认表结构,别让它猜。跑得慢是资源问题,成因集中在执行计划上,第四节讲。

跑得通但算错,是最贵的一类。它没有报错、没有超时、结果看着合理,只有和别的口径对账时才露馅。这类问题里,join 方向、空值语义、时区口径三样占了绝大多数。我把「大表扫描」也放进来,是因为它常常和前三样纠缠:模型为了绕开空值写了个 IS NOT NULL 包在函数里,顺手把索引也废了。

判断顺序建议是:先跑一遍行数对账(结果行数 vs 主表行数),再抽三五条已知答案的样本手工核对,最后才看性能。把行数对账放最前面是因为它成本最低:一条 SELECT COUNT(*) 就能跑,而 join 方向错和空值被过滤掉这两类失误,最直接的外在表现恰好就是行数偏离。样本核对能补上行数对不出来的那部分——行数刚好对上但每行的值算错了,只有拿已知答案比才看得见。

二、四类失误的判别表

现象大概率成因怎么验证处置动作
结果行数比主表少,少掉的都是「没有下单/没有退款」的用户LEFT JOIN 被 WHERE 里的右表条件降级成了 INNER JOIN把右表条件从 WHERE 挪到 ON 里再跑一次,行数变多即命中过滤右表的条件写进 ON;只对主表生效的条件才留在 WHERE
结果行数比主表多,某些主键重复出现join 键在从表不唯一,一对多被当成一对一对从表按 join 键 GROUP BY ... HAVING COUNT(*) > 1 看有没有重复先聚合从表再 join,或明确用窗口函数取一行,别指望 DISTINCT 兜底
用 NOT IN 过滤后一条都不剩子查询结果里含 NULL,谓词结果非假即未知,没有一行能判为真单独跑子查询,看结果集里有没有 NULL换成 NOT EXISTS,或在子查询里加 IS NOT NULL
分母对、分子小,比率明显偏低COUNT(某列) 跳过了该列为 NULL 的行同一查询里同时输出 COUNT(*)COUNT(col) 比大小想统计行数一律 COUNT(*);想统计非空要在注释里写清楚
求和结果是空白而不是 0空集上的 SUM 返回 NULL 而非 0把 SUM 换成 COUNT(*) 看是不是 0 行外面套 COALESCE(SUM(x), 0)
按天出的数总差一天,或凌晨那几个小时归错日期存储时区与计算时区不一致,或直接对时间戳截断取日期挑一条跨零点的记录,把原始值和转换后的日期并排打出来存 UTC、算的时候显式转成业务时区再截断,转换函数里写死目标时区
月末、月初数据对不上,跨月订单被算了两次或漏掉时间范围用了闭区间且两端都含边界检查边界写法是不是 >= 起< 止统一半开区间,全团队一个写法
单条查询几秒变几分钟,还越跑越慢索引列被函数或隐式类型转换包住,走了全表扫描看执行计划里该表是不是全表扫描、预估行数是否接近总行数把函数从列上挪到常量侧;类型对齐后再比较
加了分页以后总数对但明细有重有漏排序键不唯一,同值行之间的先后顺序未定义对排序键跑 GROUP BY 排序键 HAVING COUNT(*) > 1,有重复值就命中;再把相邻两页拉全去查交集和缺号排序键后面追加主键做兜底排序

这张表覆盖不了所有情况,但它能把「结果不对」这个模糊描述压缩成一个可验证的假设。有了假设再去问模型,比让它凭空重写有效得多。

三、动手:从 ON 和 WHERE 的分界开始

join 方向是四类里最容易复现也最容易修的。核心机制只有一句:ON 决定哪些行能配上,WHERE 决定配完之后留下谁。LEFT JOIN 的语义是「主表全留、从表配不上就补 NULL」,可一旦你在 WHERE 里写了个从表字段的条件,那些补出来的 NULL 行会被这个条件直接筛掉,外连接就退化成了内连接。

-- 会悄悄退化成 INNER JOIN
SELECT u.id, o.amount
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.status = 'paid';

-- 保住外连接语义
SELECT u.id, o.amount
FROM users u
LEFT JOIN orders o
  ON o.user_id = u.id
 AND o.status = 'paid';

第一种写法在公开代码里非常常见,模型输出的是最常见的形态,未必是你这次要的语义。你要做的是在提需求时把「没有订单的用户也要保留」这句说出来——它缺的是这个约束,不是语法能力。顺带一提,这个坑对 RIGHT JOIN 一样成立,方向反过来而已;FULL OUTER JOIN 上更明显,WHERE 里随便加一个任意一侧的普通条件,两边补出来的 NULL 行都会被扫掉。

验收动作固定三步:单独查主表行数、查最终结果行数、查结果里从表字段为空的行数。判读这三个数有个前提要先说清楚——只有当从表在 join 键上唯一时,「结果行数 = 主表行数」才是正确状态;从表一对多的时候,结果行数本来就该比主表大,这时候不能拿相等去卡。所以顺序是:先确认从表唯不唯一,再用对应的期望值去比。唯一的情况下结果行数应等于主表行数,且从表字段为空的行数正好等于没配上的主表行数;不唯一的情况下,把结果按主表主键去重,去重后的行数才应该等于主表行数。这一步花不了两分钟,能省掉半天对账。

一对多的重复放大要用另一种方式验。别急着加 DISTINCT,DISTINCT 会把「真实存在的重复业务行」也一起吞掉,问题从多算变成少算,更难发现。正确顺序是先确认从表在 join 键上到底唯不唯一,不唯一就先在子查询里聚合成一行再 join。

四、动手:空值、时区和执行计划

空值的麻烦在于三值逻辑。NULL = NULL 不为真,NULL <> 'a' 也不为真,任何和 NULL 的比较结果都是未知,而未知在 WHERE 里等同于不通过。所以「排除掉状态不是 cancelled 的记录」写成 status <> 'cancelled' 时,status 为 NULL 的行会被一起排除掉,而这往往不是你要的。要么写成 (status IS NULL OR status <> 'cancelled'),要么在建表阶段就把这一列设成非空并给默认值。后者才是根治。

NOT IN 那个坑更隐蔽:子查询里只要出现一个 NULL,整个 NOT IN 就永远返回空集,一行都不剩。这时候查询没报错、执行计划正常、结果就是干干净净的 0 行。习惯性用 NOT EXISTS 替代 NOT IN,能免掉这一整类问题。

聚合函数对空值的处理也要记牢:COUNT(*) 数行,COUNT(col) 数非空值,AVG 的分母是非空个数不是行数,空集上的 SUM 返回 NULL。这几条组合起来,足以让一个「转化率」指标偏低而看起来仍然合理。

时区的排查思路是把链路拆成三段分别确认:写入时存的是什么(UTC 还是本地时间,带不带时区信息)、数据库会话按什么时区解释、展示层又转了没转。三段里任意两段不一致就会差几个小时,跨零点的记录归到隔壁一天。模型不可能知道你这三段怎么配的,它会按最通用的假设写,通常是直接对时间戳截断取日期。

稳妥做法是存储层一律 UTC,查询里显式写出目标时区做转换,不依赖服务器或会话的默认设置——默认设置会随部署环境变,同一条 SQL 在本地和线上出不同的数。时区这条链路上还有很多和 SQL 无关的坑,比如应用层拿到时间戳后二次转换、前端按浏览器时区再转一次,那部分我在时区与时间戳错乱的排查里单独写过;本篇只管 SQL 这一段,写完的语句进了应用层还怎么错,看那篇。同理,如果你的问题出在翻页时明细有重有漏,分页重复与漏掉的成因讲的是分页机制本身,本篇只在判别表里给一个快速定位的入口。

执行计划这一步,规则很简单:看你的过滤条件有没有把索引列包在函数里。WHERE DATE(created_at) = '2026-07-01' 走不了 created_at 上的索引,因为索引存的是原始值不是函数结果;改成 WHERE created_at >= '2026-07-01' AND created_at < '2026-07-02' 就能走。隐式类型转换是同一个机制的另一副面孔:字符串列拿数字去比,宽松类型的数据库不会报错,而是先把这一列整体转成数字再比较,等于在列上套了一层看不见的转换函数,索引一样废掉;严格类型的数据库则会直接抛类型错误,反倒更容易发现。具体是哪种行为取决于你用的数据库和版本,别照搬别人的结论,自己拿执行计划看一眼。开头提到的「user_id 在 A 库是 bigint、在 B 库是 varchar」就会撞上这一条——模型看不见类型定义,它只按名字对齐。

-- 索引失效
WHERE DATE(created_at) = '2026-07-01'

-- 索引可用(半开区间,边界不会重不会漏)
WHERE created_at >= '2026-07-01'
  AND created_at <  '2026-07-02'

各数据库的执行计划命令和输出格式不同,具体语法以你所用数据库的官方说明为准。要看的东西是一致的:这张表是全表扫描还是走了索引,预估扫描行数和表总行数差多少个量级。

五、哪一步必须人来定

AI 在这件事上能帮到的边界,比很多人预期的靠前。

它能替你做的是:把自然语言的取数需求翻译成一个结构正确的语句骨架;把一段写得很绕的 SQL 改写成 CTE 分层的可读版本;把某个方言的写法翻成另一个方言;在你给出表结构和几行样例数据之后,指出明显的语义漏洞。这几件事它做得又快又稳,值得放手用。

必须人来定的有四样:业务口径(「活跃用户」到底怎么算,这是定义问题不是技术问题)、空值的业务含义(refund_time 为空是「没退款」还是「还没同步」,两种含义下 SQL 写法完全不同)、时区口径(按用户所在时区还是按公司总部时区出数)、以及结果的验收标准(和哪个已有报表对账、允许多大差异)。

这四样的共同点是:答案不在数据库里,在业务约定里。模型没法从表结构推出来,你不给它就只能编一个最常见的。判断一个 AI 生成的取数任务能不能放手,看的就是这四样你有没有先想清楚——这和AI 写的代码能不能上生产是同一个判断逻辑。

六、什么情况下别再折腾

来回让模型改 SQL 是有隐性成本的,超过下面几条就该换路。

**改到第三轮还没对,停。**前两轮改不对,说明你给的上下文缺了关键信息,继续改只是在同一个信息盲区里换排列组合,越改越长越难看懂。正确动作是回退到第一版能跑通的语句,把表结构(列名、类型、是否可空)和三五行脱敏样例数据补齐,重新开一轮。

**语句长度超过你一眼能读完的范围,停。**AI 很擅长把逻辑往里塞,一个嵌套五层的子查询它写得出来你却验不了。拆成多个 CTE,每一层单独跑一遍确认行数和样例,逐层往上搭。验不了的语句不要上线,这是硬线。

**结果对不上但你说不清「对」应该是多少,停。**没有对账基准的调优是无底洞。先花时间把基准定下来——哪怕是一个手工算出来的单日数字——再回来改 SQL。

**性能问题查到需要改表结构,停手交给 DBA。**加索引、改列类型、调整分区,这些动作影响面超出你这一个查询,属于需要评估和排期的变更,不是在对话框里能拍板的事。

**回滚点在哪:**任何要写库的 SQL(UPDATE、DELETE、迁移脚本),执行前必须先用同样的 WHERE 条件跑一遍 SELECT,确认影响行数符合预期。行数不符就是回滚信号,别执行。生产库上没有备份和事务保护就不动手,这条没有例外。

七、避坑清单

**坑一:直接把线上表名贴给模型,却没给列的可空信息。**会踩是因为你觉得表名和列名就够了,模型也确实能凭名字猜出语义。但空值行为不在名字里,猜不出来。怎么避:把建表语句整段给它,NOT NULL、默认值、类型长度都在里面,一次粘贴省三轮返工。

**坑二:拿 DISTINCT 修行数变多。**会踩是因为 DISTINCT 确实能让行数降回去,看起来问题解决了。但它同时吞掉了真实的重复业务行,多算变成少算,而少算更难被发现。怎么避:行数变多先查 join 键唯一性,确认是一对多就先聚合再 join,把 DISTINCT 留给真正需要去重的场景。

**坑三:在测试库上验证完就上线。**会踩是因为测试库数据量小、分布均匀,全表扫描也就几十毫秒,感觉不到问题;空值和脏数据也少,语义漏洞暴露不出来。怎么避:性能必须在接近生产的数据量上验,语义必须用生产的空值分布验——至少查一下关键列的空值占比。相关的判断可以看模拟数据混进生产的后果

**坑四:相信模型给出的「这个查询会用到 idx_xxx 索引」。**会踩是因为这句话说得很具体、很像真的。但它没连你的库,索引名和执行计划都是根据常见命名推的。怎么避:索引是否命中只认执行计划输出,模型的任何断言在这件事上都不作数。这属于典型的模型幻觉场景——越是具体的、可核对的事实,越要自己核。

**坑五:把跑通当成验收通过。**会踩是因为「返回结果了」这个信号太强,容易让人跳过对账。但前面讲的四类失误全都不报错。怎么避:把「和已有口径对账」写进流程,不是可选项。跑通只是起点。

**坑六:一次给模型十几个需求,让它写一个大查询。**会踩是因为一次说完看起来效率高。但需求越多,它越容易在某个次要条件上做隐式假设,而你根本不知道它假设了什么。怎么避:一次一个明确的问题,验收通过再叠下一个条件,每一步都留着可回退的中间版本。

收束

AI 写 SQL 这件事,产能提升主要发生在「把想法变成一个能跑的语句」这一段,而正确性的把关成本一点没少,只是从写的时候挪到了验的时候。谁不做这份验的功夫,谁就在用更快的速度产出更多错数。

上线前的自检清单,五条:

  1. 结果行数和主表行数对得上吗?对不上,先查 join 方向和 join 键唯一性。
  2. 参与过滤和聚合的列,哪些可空?空值的业务含义你确认过了吗?
  3. 时间字段存的是什么时区,查询里显式转了吗,边界用的是半开区间吗?
  4. 执行计划里有没有意料之外的全表扫描?过滤条件有没有把索引列包在函数里?
  5. 结果和哪个已有口径对过账?差异在允许范围内吗?

五条全过再上线。有一条答不上来,那就是你下一步要查的地方。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。