AI 重写 SQL 时 inner join 静默替换 left join 导致行丢失,因结果看似合理而通过快速检查,作者提出差分验证法。
你是否见过这样一幕:生成的 SQL 重构跑得更快了,于是你就默认它一定是对的?但如果速度提升来自一个悄悄丢掉了原有数据的 INNER JOIN,而不是之前的 LEFT JOIN,结论可就完全不同了。结果看起来依然合理——因为每行都显示着客户名称,快速冒烟测试根本发现不了数据已经丢失。我把生成的查询变更当作一个补丁,而不是一个证明,在差分检查比对旧新结果集之前,绝不轻信。
披露:本文是 MonkeyCode 产品推广的一部分。
从一个极小的数据 fixture 入手:其中包含一条订单,对应的客户在 customers 表中根本不存在。这种悬挂引用在遗留系统中相当常见,而这恰恰是 JOIN 转换改变查询语义的地方。
CREATE TABLE customers (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL,
amount_cents INTEGER NOT NULL
);
INSERT INTO customers VALUES
(1, 'Ada'),
(2, 'Lin'),
(3, 'Grace');
INSERT INTO orders VALUES
(10, 1, 2500),
(11, 2, 1800),
(12, 2, 900),
(13, 3, 1200),
(14, 4, 1500);
原始查询保留每一条订单,包括那条孤立的订单。
SELECT o.id, c.name
FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id;
它返回五行:四条匹配的订单,以及一行 name 为 NULL 的记录。
生成的重写只改了 JOIN 类型,因为 INNER JOIN 看起来更优雅,而且在 schema 保证了每个 customer_id 都存在的情况下往往运行更快。但在这个 fixture 中,这种保证并不成立。
SELECT o.id, c.name
FROM orders o
JOIN customers c ON c.id = o.customer_id;
它只返回四行。丢失的那一行才是关键:性能提升如果同时改变了结果,就不叫优化。
不要靠读前几行来比较查询。而是创建一个已知正确的基线,对结果集做归一化,任何不匹配的候选者都要失败。
import sqlite3
SCHEMA = """
CREATE TABLE customers (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL,
amount_cents INTEGER NOT NULL
);
INSERT INTO customers VALUES
(1, 'Ada'),
(2, 'Lin'),
(3, 'Grace');
INSERT INTO orders VALUES
(10, 1, 2500),
(11, 2, 1800),
(12, 2, 900),
(13, 3, 1200),
(14, 4, 1500);
"""
BASELINE = """
SELECT o.id, c.name
FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id;
"""
CANDIDATE = """
SELECT o.id, c.name
FROM orders o
JOIN customers c ON c.id = o.customer_id;
"""
def result_set(sql):
conn = sqlite3.connect(":memory:")
conn.executescript(SCHEMA)
rows = conn.execute(sql).fetchall()
conn.close()
return [tuple(row) for row in rows]
baseline = result_set(BASELINE)
candidate = result_set(CANDIDATE)
print("baseline rows:", len(baseline), "candidate rows:", len(candidate))
print("missing from candidate:", set(baseline) - set(candidate))
if baseline != candidate:
raise SystemExit("candidate changed the result set")
脚本打印出消失的那些行,而不是让开发者在两个表之间眯眼对比。这个差异才是调试信号,而不是查询的表面意图。
如果 fixture 只包含理想路径的行,通过测试的意义就非常有限。加入多个边界情况,这样 golden check 就能捕获不仅限于上面 JOIN 示例的问题。
如果基线和候选查询在这个丑陋的 fixture 上返回相同的归一化集合,你仍然没有证明正确性。你只是排除了某一类静默的结果集变更。
当助手返回一个跑得更快的查询时,最糟糕的下一步就是因为解释听起来很有信心就合并它。更安全的做法是:请模型给出几个重写方案,然后把每个候选都过一遍同样的 golden result 检查。这就是 MonkeyCode 免费模型访问改变游戏规则的地方:生成第二个或第三个候选方案的成本,不再迫使你在第一个看似合理的方案之后就停手。
这不是关于更信任模型,而是让模型输出足够便宜,可以被视为一个需要通过自动化检查才能存活的假设。
以下是我在替换慢查询时使用的工作流。
笔记本适合处理小型 fixture,但有些查询 bug 只在更大的快照上才会暴露,这种快照无法拷贝到每台开发机器上。这种情况下,你可以把 golden result 集放在一个小型的比较端点后面,让每个候选方案 POST 自己的输出以获得通过或失败响应。
当你能保持服务小而数据已脱敏时,免费服务器选项使审查循环变得实用。端点可以做两件事:报告候选行集是否等于基线,以及返回缺失或多余行的 diff。这个 diff 比模型的解释更有用,因为它来自真实数据。
不要把真实客户数据发送给模型或公共服务器。生成一个保留相同 JOIN、NULL 和基数陷阱的 fixture,然后在可控环境内运行完整的隐私敏感比较。
差分测试能捕获语义回归,但无法证明一个查询是正确的。它只能证明候选匹配了选定的基线。
SQLite 是很好的本地 fixture,但 SQL 语义和 planner 行为与 PostgreSQL、MySQL、SQL Server 或 Oracle 不同。
golden 基线本身可能是错的或过时的。
一个查询可能匹配 fixture 但仍然在你没有包含的数据形态上失败。
更快的执行计划仍然可能在生产环境中消耗过多内存、锁定过久,或忽略有用的索引。
这个方法增加了一个步骤,因此对于没有静默丢失风险的只读仪表盘来说,是杀鸡用牛刀。
如果你没有已知正确的结果集,或者无法安全地创建一个有代表性的 fixture,就不要假装本地通过就是数据库的保证。同样的纪律适用于任何生成的代码:在信任解释之前,先验证重要的行为。