模型写的 SQL 能跑通,离放它去查库还差几道检查
给 Agent 一个「执行 SQL」的工具,让它用自然语言回答数据问题。模型写出的语句语法正确、能执行、返回了一个数字:880。正确答案是 480。在这之前,它还可能写出一条估算成本 10 亿的自连接,或者在提示里带上一句让会话超时失效的注释。
让模型生成 SQL 并执行,风险分三类:改了不该改的(写入、删表、导出)、用了不该用的资源(全表自连接、一次返回几十万行)、算出不对的数(语句合法,口径是错的)。一次「能跑通」对这三类都说明不了什么。
本文不评测任何模型。实验用一组预置语句代表「模型可能写出来的 SQL」,在 MySQL 8.4.11 和 Connector/J 9.7.0 上逐层验证:每道检查能挡住什么,挡不住什么。实验见文末。
如果 SQL 的结构由程序定义,用户只提供条件值,可以先看参数绑定、排序与搜索的边界。模型直接生成整条语句时,结构也需要验收,不能仅靠绑定条件值处理。
一、最小权限账号:由数据库强制执行
给生成的 SQL 单独建一个账号,只授予需要的表和视图的 SELECT。不复用应用自己的读写账号,也不靠在提示词里写「只生成 SELECT」来约束。
实测这个账号执行各种语句的结果:
| 语句 | 结果 |
|---|---|
DELETE FROM orders | ERROR 1142,DELETE command denied |
| 读没有授权的表(含手机号列) | ERROR 1142 |
SELECT user FROM mysql.user | ERROR 1142 |
SELECT … INTO OUTFILE | ERROR 1227,需要 FILE 权限 |
SELECT … FOR UPDATE | ERROR 1142,SELECT with locking clause command denied |
EXPLAIN DELETE FROM orders | ERROR 1142 |
敏感列用视图隔开:建一个不含手机号的视图,只把视图授权给查询账号。这里有一个细节,视图的 SQL SECURITY 决定了它以谁的权限访问底表:
| 视图 | 查询账号 SELECT | 说明 |
|---|---|---|
SQL SECURITY DEFINER(MySQL 默认) | 正常返回 | 以定义者的权限读底表,查询账号不需要底表权限 |
SQL SECURITY INVOKER | ERROR 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,语句没写 LIMIT | 3 行 |
会话 sql_select_limit=3,语句自带 LIMIT 10 | 10 行 |
外层包一层 SELECT * FROM (…) t LIMIT 4 | 4 行 |
JDBC statement.setMaxRows(100) | 100 行 |
sql_select_limit 和超时提示一样,会被语句自己的 LIMIT 覆盖。外层包装或 setMaxRows 不受语句内容影响。上限比展示需要的多取一行,就能知道结果是否被截断,并把「结果已截断」告诉模型,避免它把不完整的数据当成全部。
超时和行数上限都有同一个规律:会话级的设置会被语句本身改掉,应用侧的设置不会。
LIMIT 覆盖;由驱动或外层包装施加的限制与语句内容无关,才是可靠的上限五、能执行,不等于数字对
回到开头。「订单金额合计是多少」,一个连接了明细表的查询:
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 和数据截止时间,让结果可以被复核。
六、检查清单
- 专用的查询账号,只授予需要的表和视图的
SELECT;敏感列用 DEFINER 视图隔开。 - 连接只读;驱动不开多语句。
- 超时由应用设置(
setQueryTimeout),会话变量作兜底。 - 执行前
EXPLAIN,成本或扫描行数超过阈值就拒绝,并把原因返回给模型。 - 输出行数由外层包装或驱动限制,告知是否截断。
- 查询走只读副本或分析库,不与在线交易共用实例;限制并发数。
- 用手算答案的合成数据集做回归;高频指标用视图或模板固定口径。
- 记录每次生成的 SQL、执行结果摘要和耗时,便于回放。
这些检查是 Agent 工具设计的一部分,工具描述、写操作确认与评测的总体做法见给 Java 服务接一个可控的 Agent。
配套实验
- codesphere-labs/ai/generated-sql-guardrails:MySQL 8.4.11 上最小权限账号、两种视图、只读事务、
PREPARE多语句、会话超时与提示覆盖、输出上限、成本估算、与手算答案对比;Connector/J 9.7.0 默认参数下的多语句、只读连接、查询超时与最大行数(验证记录)
参考资料
- MySQL 8.4 Reference Manual:Privileges Provided by MySQL
- MySQL 8.4 Reference Manual:Stored Object Access Control(
SQL SECURITY DEFINER与INVOKER) - MySQL 8.4 Reference Manual:Optimizer Hints(
MAX_EXECUTION_TIME) - MySQL 8.4 Reference Manual:Server System Variables(
max_execution_time、sql_select_limit) - MySQL 8.4 Reference Manual:START TRANSACTION(
READ ONLY) - MySQL Connector/J Developer Guide:Configuration Properties(
allowMultiQueries)