Skip to content

模型写的 SQL 能跑通,离放它去查库还差几道检查 ​

给 Agent 一个「执行 SQL」的工具,让它用自然语言回答数据问题。模型写出的语句语法正确、能执行、返回了一个数字:880。正确答案是 480。在这之前,它还可能写出一条估算成本 10 亿的自连接,或者在提示里带上一句让会话超时失效的注释。

让模型生成 SQL 并执行,风险分三类:改了不该改的(写入、删表、导出)、用了不该用的资源(全表自连接、一次返回几十万行)、算出不对的数(语句合法,口径是错的)。一次「能跑通」对这三类都说明不了什么。

本文不评测任何模型。实验用一组预置语句代表「模型可能写出来的 SQL」,在 MySQL 8.4.11 和 Connector/J 9.7.0 上逐层验证:每道检查能挡住什么,挡不住什么。实验见文末。

如果 SQL 的结构由程序定义,用户只提供条件值,可以先看参数绑定、排序与搜索的边界。模型直接生成整条语句时,结构也需要验收,不能仅靠绑定条件值处理。

检查能挡住漏过去的最小权限账号写入、未授权的表、系统表、导出文件、加锁读SLEEP、大扫描:权限不限制资源只读事务 / 只读连接账号权限配错时的写入同上单语句SELECT 1; DELETE …单条的慢查询超时(应用侧)SLEEP、失控的连接语句里的提示可以覆盖会话变量成本估算 + 输出上限估算成本极高的连接、返回几十万行估算不准的查询;成本低但结果错黄金数据能执行、结果与已知答案不符测试集没覆盖到的口径
图 1 · 每一道检查都有明确的盲区,所以要叠起来用。前三道由数据库强制执行,生成什么样的语句都绕不过去;超时要放在应用一侧,会话变量会被语句里的提示覆盖;只有最后一道用已知答案的小数据集去比对,才能发现「语法、权限、成本都没问题,但数字是错的」

一、最小权限账号:由数据库强制执行 ​

给生成的 SQL 单独建一个账号,只授予需要的表和视图的 SELECT。不复用应用自己的读写账号,也不靠在提示词里写「只生成 SELECT」来约束。

实测这个账号执行各种语句的结果:

语句结果
DELETE FROM ordersERROR 1142,DELETE command denied
读没有授权的表(含手机号列)ERROR 1142
SELECT user FROM mysql.userERROR 1142
SELECT … INTO OUTFILEERROR 1227,需要 FILE 权限
SELECT … FOR UPDATEERROR 1142,SELECT with locking clause command denied
EXPLAIN DELETE FROM ordersERROR 1142

敏感列用视图隔开:建一个不含手机号的视图,只把视图授权给查询账号。这里有一个细节,视图的 SQL SECURITY 决定了它以谁的权限访问底表:

视图查询账号 SELECT说明
SQL SECURITY DEFINER(MySQL 默认)正常返回以定义者的权限读底表,查询账号不需要底表权限
SQL SECURITY INVOKERERROR 1356以调用者的权限读底表,查询账号没有底表权限

要把视图当作「只能看到这些列」的入口,用 DEFINER,并且不给查询账号底表权限。视图本身不等于只读:只授予 SELECT 时 UPDATE 视图被拒绝(ERROR 1142),但如果图省事授予了视图的全部权限,可更新视图是可以写穿到底表的。

权限管不了资源。 同一个账号执行 SELECT SLEEP(2),正常睡满 2 秒。只有 SELECT 权限的账号照样可以写出把数据库拖慢的查询。

二、只读事务与单语句:第二道保险 ​

账号权限可能配错,或者暂时只能复用现有账号。两个与账号无关的限制:

  • 只读事务。读写账号在 START TRANSACTION READ ONLY 里执行 DELETE,得到 ERROR 1792(Cannot execute statement in a READ ONLY transaction),数据不变。JDBC 一侧,connection.setReadOnly(true) 之后执行 DELETE,驱动直接抛出 SQLException: Connection is read-only。连接池可以把整个池配置成只读。
  • 单语句。SELECT 1; DELETE FROM orders 这样的输入,用 PREPARE 准备时是 ERROR 1064:预处理语句只接受一条。Connector/J 默认(allowMultiQueries=false)也一样,实测抛 SQLSyntaxErrorException。不要为了方便打开这个参数。

这两条和账号权限叠加,写入要同时突破三层才会发生。

应用层常见的检查是「语句以 SELECT 开头」。它既拦错了也漏了:以 WITH 开头的 CTE 是合法查询;SELECT … INTO OUTFILE、SELECT … FOR UPDATE、SELECT SLEEP(100) 都以 SELECT 开头。要在应用层做语句白名单,应该用 SQL 解析器取得语句类型和涉及的表,而不是匹配字符串;即使这样,它也只是数据库权限之外的补充。

三、超时:放在应用一侧 ​

MySQL 的 MAX_EXECUTION_TIME 可以限制只读 SELECT 的执行时间。在会话上设置 500ms:

语句结果
SELECT SLEEP(2)1 秒内返回,SLEEP 被打断
10 万行的表按低基数列自连接ERROR 3024,超过最大执行时间
SELECT /*+ MAX_EXECUTION_TIME(20000) */ SLEEP(2)睡满 2 秒

第三行:语句里的优化器提示优先于会话变量。模型见过大量带提示的 SQL,生成一条带 MAX_EXECUTION_TIME 提示的语句并不奇怪,会话级的限制就这样被改掉了。

所以超时要由应用控制。实测同一条带提示的自连接,statement.setQueryTimeout(1) 在 3 秒内抛出 MySQLTimeoutException:驱动到时间后用另一个连接向服务器发取消命令,不依赖语句内容。会话变量可以保留,作为应用超时失效时的兜底。查询超时与取消对执行线程的实际影响,见超时了、取消了,任务其实还在跑。

四、执行之前看成本,执行之后限行数 ​

成本估算。 EXPLAIN FORMAT=JSON 不执行查询,返回优化器估算的 query_cost 和每一步的扫描行数:

查询query_cost
按状态汇总 5 行订单0.75
10 万行事件表按低基数列自连接约 10 亿

设一个阈值,超过的直接拒绝并把原因返回给模型(「估算扫描行数过大,请加上时间范围」),模型可以据此改写。成本是估算值,统计信息不准时会偏差,所以它用来挡住明显失控的查询,不能代替超时。执行计划的读法见读懂 Explain 执行计划。

输出上限。 返回的行最终要进模型的上下文,几十万行既慢又贵。

做法结果
会话 sql_select_limit=3,语句没写 LIMIT3 行
会话 sql_select_limit=3,语句自带 LIMIT 1010 行
外层包一层 SELECT * FROM (…) t LIMIT 44 行
JDBC statement.setMaxRows(100)100 行

sql_select_limit 和超时提示一样,会被语句自己的 LIMIT 覆盖。外层包装或 setMaxRows 不受语句内容影响。上限比展示需要的多取一行,就能知道结果是否被截断,并把「结果已截断」告诉模型,避免它把不完整的数据当成全部。

超时和行数上限都有同一个规律:会话级的设置会被语句本身改掉,应用侧的设置不会。

会话级:语句自己可以改掉会话超时 500msmax_execution_time语句带优化器提示MAX_EXECUTION_TIME(20000)SLEEP(2)睡满 2 秒会话行数上限 3 行sql_select_limit语句自带 LIMIT 10返回 10 行上限失效应用侧:与语句内容无关应用超时 1 秒setQueryTimeout(1)同一条带提示的自连接3 秒内被取消驱动另开连接发取消外层包装 / 驱动上限LIMIT 4 · setMaxRows(100)任意语句按上限截断多取一行判断是否截断会话变量保留作兜底;真正的上限放在应用一侧
图 2 · 实测(MySQL 8.4.11、Connector/J 9.7.0):会话变量设定的超时和行数上限,会被语句里的优化器提示和 LIMIT 覆盖;由驱动或外层包装施加的限制与语句内容无关,才是可靠的上限

五、能执行,不等于数字对 ​

回到开头。「订单金额合计是多少」,一个连接了明细表的查询:

sql
SELECT SUM(o.amount) FROM orders o JOIN order_items i ON i.order_id = o.id;   -- 880.00

它通过了前面所有检查:只读、单语句、成本很低、只返回一行。正确答案是 480:订单金额被明细行数放大了,原因见 JOIN 之后,合计从 480 变成了 880。模型在这里犯的错和人一样。

能发现这类错误的只有对照已知答案:

  • 准备一份小的合成数据集,结构与生产一致,数据少到每个指标都可以手算;
  • 为常见问题写下期望的结果,形成测试集;
  • 模型、提示词、表结构描述每次变更,都在这个数据集上跑一遍,比较结果而不是比较 SQL 文本(同一个问题有很多种正确写法)。

更直接的做法是缩小模型的自由度。把「订单金额合计」「支付订单数」这类指标的口径定义成视图或者参数化的查询模板,让模型选择指标和筛选条件,而不是从明细表开始自己连接。口径只定义一次,由人评审。表结构只能告诉模型有哪些列,不能告诉它「一行代表什么」「人均的分母是谁」,这些要写进给模型的描述里。

测试集覆盖不到的问题仍然可能算错。面向用户的回答应该附上实际执行的 SQL 和数据截止时间,让结果可以被复核。

六、检查清单 ​

  1. 专用的查询账号,只授予需要的表和视图的 SELECT;敏感列用 DEFINER 视图隔开。
  2. 连接只读;驱动不开多语句。
  3. 超时由应用设置(setQueryTimeout),会话变量作兜底。
  4. 执行前 EXPLAIN,成本或扫描行数超过阈值就拒绝,并把原因返回给模型。
  5. 输出行数由外层包装或驱动限制,告知是否截断。
  6. 查询走只读副本或分析库,不与在线交易共用实例;限制并发数。
  7. 用手算答案的合成数据集做回归;高频指标用视图或模板固定口径。
  8. 记录每次生成的 SQL、执行结果摘要和耗时,便于回放。

这些检查是 Agent 工具设计的一部分,工具描述、写操作确认与评测的总体做法见给 Java 服务接一个可控的 Agent。


配套实验

  • codesphere-labs/ai/generated-sql-guardrails:MySQL 8.4.11 上最小权限账号、两种视图、只读事务、PREPARE 多语句、会话超时与提示覆盖、输出上限、成本估算、与手算答案对比;Connector/J 9.7.0 默认参数下的多语句、只读连接、查询超时与最大行数(验证记录)

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权