15 | 安全执行 SQL 并返回查询结果
项目地址:github.com/frontzhm/n2...
每一步对应的完整代码都在仓库里,跟着文档卡住了就去翻源码。
这是一篇系列文,请按顺序阅读。
本文目标
前面的节点已经完成:
text
用户问题
→ 召回字段、指标和维度值
→ 过滤候选
→ 组装 SQL 上下文
→ 生成 SQL
→ 验证并有限修正 SQL
最后还差一步:执行已经通过验证的 SQL,并把数据库结果转换成前端可以通过 SSE 接收的 JSON 数据。
但是执行节点不能只是:
python
await session.execute(sql)
即使 SQL 已经通过 AST 和 EXPLAIN 校验,仍然可能:
- 返回几十万行,把内存和 SSE 响应撑大。
- 扫描大量数据,长时间占用数据库连接。
- 返回
Decimal、日期或二进制数据,导致json.dumps()失败。 - 出现同名输出列,转换成字典时互相覆盖。
- 因数据库状态变化,在真正执行时失败。
- 因未来图结构调整,错误地绕过验证节点。
本文实现的受控执行流程:
text
validate_sql
│
│ sql_validated = true
↓
run_sql 再次检查验证标记
↓
SQLGlot 添加 max_rows + 1 的内部 LIMIT
↓
SET TRANSACTION READ ONLY
↓
在应用超时内执行查询
↓
最多读取 max_rows + 1 行
↓
回滚只读事务
↓
序列化 Decimal / 日期 / bytes / 长文本
↓
通过 SSE 返回结果、截断信息和耗时
先看输入和输出
1. run_sql 的输入
执行节点从 LangGraph State 读取:
python
{
"sql": """
SELECT SUM(fo.order_amount) AS sales_amount
FROM fact_order AS fo
JOIN dim_region AS dr
ON fo.region_id = dr.region_id
WHERE dr.province = '北京市'
""",
"sql_validated": True,
"validation_errors": [],
"error": None,
}
其中真正决定是否可以进入执行节点的是:
python
sql_validated is True and error is None
不能只判断 error is None,因为工作流初始 State 也可能暂时没有错误,但此时 SQL 还没有经过验证。
2. 查询成功时的 State 输出
python
{
"execution_result": {
"columns": ["sales_amount"],
"rows": [
{"sales_amount": "279159.50"}
],
"row_count": 1,
"truncated": False,
"truncated_cell_count": 0,
"elapsed_ms": 36,
},
"error": None,
}
字段含义:
columns:按数据库返回顺序排列的列名。rows:转换成 JSON 兼容类型的结果行。row_count:实际返回给前端的行数,不是数据库结果总行数。truncated:是否因系统行数上限丢弃了更多结果行。truncated_cell_count:有多少个超长单元格被截断。elapsed_ms:从开始执行到完成序列化前的近似毫秒数。
金额使用字符串返回是有意设计:
json
{"sales_amount": "279159.50"}
如果把 MySQL DECIMAL 强制转成 JavaScript 浮点数,可能损失金额精度。
3. 查询成功时的 SSE 输出
json
{
"type": "result",
"columns": ["sales_amount"],
"data": [
{"sales_amount": "279159.50"}
],
"row_count": 1,
"truncated": false,
"truncated_cell_count": 0,
"elapsed_ms": 36
}
继续保留 data 作为结果行,是为了让现有前端消费方式不需要改变;更完整的 元数据放在同一个事件的其他字段中。
4. 查询失败时的输出
真正执行时如果 MySQL 连接断开、表结构刚好发生变化或查询超时,会发送:
json
{
"type": "execution_error",
"message": "MySQL 执行失败:..."
}
State 中记录:
python
{
"execution_result": {},
"error": "MySQL 执行失败:...",
}
执行错误不会再次自动调用 LLM。因为 SQL 在 EXPLAIN 后又失败,可能是连接、 权限、并发变更或运行期资源问题,并不一定能靠改写 SQL 解决。
5. 没有验证标记时的输出
即使未来有人直接调用 run_sql 节点,只要缺少:
python
{"sql_validated": True}
节点就会返回:
text
SQL 尚未通过验证,拒绝执行
这是图路由之外的第二次防御性检查。
第一部分:项目实现
1. 设计执行配置
在 conf/app_config.yaml 中增加:
yaml
sql_execution:
max_rows: 1000
timeout_seconds: 30
max_cell_chars: 10000
分别限制三个不同维度:
text
max_rows 最多向前端返回多少行
timeout_seconds 应用最多等待查询多少秒
max_cell_chars 单个文本或二进制值最多返回多少字符
对应的 Pydantic Settings:
python
class SQLExecutionSettings(StrictSettings):
max_rows: int = Field(default=1000, gt=0, le=10000)
timeout_seconds: float = Field(default=30, gt=0, le=300)
max_cell_chars: int = Field(default=10000, gt=0, le=1000000)
配置值自身也有边界,避免误把 max_rows 设置成几百万,或者把超时设置成 无限大。
2. 为什么 Repository 和 Service 要分开
执行功能分为:
text
SQLExecutionRepository
→ 只负责 MySQL 连接、只读事务、内部 LIMIT、读取原始行、回滚
SQLExecutionService
→ 只负责应用超时、结果类型转换、列名去重、单元格截断、耗时
这样单元测试不需要真实连接 MySQL,就能独立验证超时和各种 Python/MySQL 类型的序列化。
3. 定义 Repository 原始结果
python
@dataclass(frozen=True)
class RawSQLExecutionResult:
columns: list[str]
rows: list[tuple[Any, ...]]
truncated: bool
Repository 暂时保留 Decimal、datetime、bytes 等数据库原始值,不在数据 访问层决定 API 表现形式。
4. 使用 AST 添加内部 LIMIT
只在 Python 中调用:
python
result.fetchmany(max_rows + 1)
并不能保证数据库少返回数据。一些驱动会先把整个结果集读取到客户端,再由 Python 截取前几行。
所以 Repository 在发送 SQL 前,使用 SQLGlot 给根查询添加 LIMIT:
python
return expression.limit(fetch_limit, copy=True).sql(dialect="mysql")
默认 max_rows = 1000,实际查询上限是:
text
1000 + 1 = 1001
多取的一行不返回给前端,只用来判断:
python
truncated = len(fetched_rows) > max_rows
5. 不扩大用户原本的 LIMIT
如果模型已经生成:
sql
SELECT region_id
FROM dim_region
LIMIT 10;
系统不能为了判断截断而把它改成 1001,否则会改变用户要求。Repository 会 检查原 LIMIT:
python
if original_limit <= fetch_limit:
保留原 LIMIT
else:
改成 fetch_limit
示例:
text
无 LIMIT → LIMIT 1001
LIMIT 10 → LIMIT 10
LIMIT 100000 → LIMIT 1001
CTE 和 UNION 的 LIMIT 也加在整个根查询上,而不是错误地加到内部某张表。
6. 执行前再次解析 SQL
build_bounded_sql() 会再次确认:
- 只有一条语句。
- 根节点仍然是查询。
- 可以按 MySQL 方言解析。
这不是替代上一篇的完整验证,而是执行层的防御性编程。执行 Repository 不应 假设所有调用者永远只会从当前 LangGraph 路径进入。
7. 开启 MySQL 只读事务
Repository 获取共享 Engine 中的一条连接:
python
async with self.database.engine.connect() as connection:
await connection.exec_driver_sql("SET TRANSACTION READ ONLY")
result = await connection.exec_driver_sql(execution_sql)
只读保护形成多层结构:
text
第 1 层:SQLValidationService 只允许 Query AST
第 2 层:run_sql 要求 sql_validated = true
第 3 层:MySQL SET TRANSACTION READ ONLY
第 4 层:生产数据库账号只授予 SELECT 权限
其中第 4 层仍然最重要。如果应用代码将来出现漏洞,数据库账号本身没有写权限 才能形成真正的权限边界。
8. 查询成功也回滚
查询完成后执行:
python
if connection.in_transaction():
await connection.rollback()
虽然普通 SELECT 没有需要提交的数据,但回滚可以明确结束只读事务、释放一致性 读快照和相关资源。不能因为"只有查询"就把事务一直留在连接池中。
回滚放在 finally 中,因此查询异常时也会执行。
9. 使用 exec_driver_sql
和 EXPLAIN Repository 一样,执行层使用:
python
await connection.exec_driver_sql(execution_sql)
当前 SQL 是系统生成且已经完整验证的 SQL,不需要 SQLAlchemy 再解释命名绑定 参数。这样还可以避免时间字符串中的冒号被 text() 识别为 :parameter。
这不代表普通业务代码应该拼接用户输入。只有经过本项目整条生成和验证链的 SQL 才能走这个接口。
10. 设置应用等待超时
Service 使用 Python 3.12 的异步超时上下文:
python
async with asyncio.timeout(self.timeout_seconds):
raw_result = await self.repository.execute(sql, self.max_rows)
超时后转换为可读错误:
text
SQL 执行超过 30 秒,已取消等待
应用超时解决的是"当前请求不再无限等待"。它不能保证 MySQL 服务端在同一 毫秒立即停止所有工作。生产环境还应该配置 MySQL 的语句执行时间限制、查询 代理限制或资源组,并确认驱动取消时会关闭或废弃对应连接。
11. 转换 Decimal
MySQL 的 DECIMAL(18,2) 通常被 asyncmy/SQLAlchemy 转成 Python Decimal。
错误做法:
python
float(Decimal("9999999999999999.99"))
浮点数无法精确表示所有十进制金额。因此项目返回:
python
format(value, "f")
最终 JSON 中是字符串:
json
{"amount": "9999999999999999.99"}
前端显示金额时可以保留精度;需要计算时应使用十进制定点库,而不是直接使用 JavaScript Number。
12. 转换日期和时间
以下 Python 类型调用 isoformat():
text
datetime → 2026-07-31T10:30:00
date → 2026-07-31
time → 10:30:00
ISO 格式比本地化字符串稳定,前后端也更容易解析。数据库中没有时区信息的 DATETIME 不会凭空增加时区,业务时区仍应由元数据和应用约定明确。
13. 转换二进制值
bytes、bytearray 和 memoryview 不能直接交给 JSON 编码器,因此先转换为:
text
base64:YWJj...
base64: 前缀告诉消费方这不是普通文本。超过单元格长度上限时会截断并以 省略号结尾,此时它只是预览,不再是可完整解码的 Base64 数据。
14. 限制单个单元格长度
即使只有一行结果,一个超大的文本或 BLOB 字段也可能占用大量响应内存。
python
if len(value) > max_cell_chars:
return value[:max_cell_chars] + "...", True
所有被截断的单元格数量记录到:
text
truncated_cell_count
行截断和单元格截断是两个不同概念:
text
truncated = true 还有更多结果行没有返回
truncated_cell_count > 0 已返回行中有值只返回了预览
15. 处理重复列名
SQL 可能返回:
sql
SELECT fo.region_id, dr.region_id
...
数据库结果列名都是 region_id。直接转换为字典会让后一个值覆盖前一个值。
Service 会生成稳定列名:
text
region_id
region_id_2
region_id_3
更推荐生成 SQL 时主动使用清晰别名,但执行层仍要防御重复输出列。
16. 定义最终执行结果
python
@dataclass(frozen=True)
class SQLExecutionResult:
columns: list[str]
rows: list[dict[str, Any]]
row_count: int
truncated: bool
truncated_cell_count: int
elapsed_ms: int
to_dict() 让节点可以同时把相同结构写进 State 和 SSE。
17. 实现 run_sql 节点
节点首先检查明确验证标记:
python
if state.get("sql_validated") is not True:
return execution_error
随后调用 Service:
python
result = await service.execute(state.get("sql", ""))
成功时推送 result,失败时推送 execution_error。节点只捕获预期的 SQLExecutionError;编程错误不会被静默伪装成普通数据库错误。
18. 扩展 State 和 RuntimeContext
State 增加:
python
sql_validated: bool
execution_result: dict[str, Any]
RuntimeContext 增加:
python
sql_execution: SQLExecutionDependencies
执行结果属于单次工作流状态;数据库 Engine、Repository、Service 和安全配置 属于应用级依赖。
19. 在 lifespan 中注入
python
sql_execution_dependencies = SQLExecutionDependencies(
service=SQLExecutionService(
repository=SQLExecutionRepository(dw_database),
max_rows=app_config.sql_execution.max_rows,
timeout_seconds=app_config.sql_execution.timeout_seconds,
max_cell_chars=app_config.sql_execution.max_cell_chars,
)
)
它复用已经创建的 dw_database Engine 和连接池。应用退出时仍由 dw_database.close() 统一释放,不会为每次查询新建 Engine。
20. 删除全部占位逻辑
之前 nodes.py 中还有 _placeholder_node() 和 asyncio.sleep(1),用于让前端 流程图在节点尚未实现时显示进度。
现在从召回到执行的全部节点都已有真实逻辑,因此删除该占位函数和无用的 asyncio 导入。执行节点不会再返回演示用的男女销售额数据。
21. 单元测试
运行:
shell
UV_CACHE_DIR=/tmp/n2sql-uv-cache uv run python -m unittest discover -s tests/unit -v
当前结果:
text
Ran 49 tests
OK
执行层新增测试覆盖:
- SQL 会先进入只读事务。
- 查询会添加
max_rows + 1的服务端 LIMIT。 - 更小的原始 LIMIT 会被保留。
- 更大的原始 LIMIT 会被限制。
- CTE 根查询可以正确添加 LIMIT。
- 多语句和非查询语句会被执行层再次拒绝。
- 多取的一行只用于判断截断。
- Decimal、日期、时间、二进制和无限浮点数可以序列化。
- 重复列名不会覆盖。
- 超长单元格会记录截断数量。
- 慢 Repository 会触发应用超时。
- 没有明确验证标记的 State 不会路由到执行。
run_sql会拒绝缺少验证标记的直接调用。
22. 真实 MySQL 验证
使用本地 dw MySQL,在只读事务中执行:
sql
SELECT COUNT(*) AS region_count
FROM dim_region;
真实返回:
json
{
"columns": ["region_count"],
"rows": [{"region_count": 6}],
"row_count": 1,
"truncated": false,
"truncated_cell_count": 0,
"elapsed_ms": 160
}
这次测试只读取聚合数量,没有读取维度明细;确认了当前 MySQL 与 asyncmy 环境 支持 SET TRANSACTION READ ONLY、受限查询、结果读取和回滚流程。
elapsed_ms 会随机器、连接池和数据库状态变化,不能把示例中的 160 当作固定 性能指标。
第二部分:相关科普
科普数据库连接、Session 和事务
这三个概念经常混在一起:
text
Engine / 连接池
→ 管理多条数据库连接,通常是进程级长生命周期对象
Connection
→ 连接池借出的一条实际连接
Session
→ SQLAlchemy 面向 ORM 和业务操作的工作单元
Transaction
→ 一组具有提交或回滚边界的数据库操作
本项目执行的是模型生成的原生查询 SQL,不需要 ORM 对象跟踪,所以直接使用 异步 Connection 更清晰。
科普只读事务和只读账号
只读事务是连接当前事务的运行模式;只读账号是数据库授权系统中的长期权限。
text
只读事务:这一次事务不应该写
只读账号:这个身份从权限上就不能写
应用代码可以被修改或出现漏洞,因此不能只依赖 SET TRANSACTION READ ONLY。 生产环境应该给 NL2SQL 使用独立账号,只授予必要 Schema 的 SELECT 权限。
科普为什么成功查询也要回滚
事务不只是为了写入。长时间不结束的只读事务可能持有一致性读快照,影响清理 旧版本和连接复用。
查询结束后显式回滚表达的是:
text
这次工作单元已经结束,不保留任何事务状态。
对于只读查询,回滚不会撤销已经发送给客户端的结果。
科普 LIMIT 和 fetchmany 的区别
LIMIT 在数据库服务端参与查询计划和结果生成:
sql
SELECT ... LIMIT 1001;
fetchmany(1001) 是 Python 驱动从结果游标获取多少行。只使用后者时,驱动或 数据库可能已经生成、传输甚至缓存了更多行。
因此本项目同时使用服务端 LIMIT 和客户端 fetchmany。
科普为什么多取一行
如果上限是 1000,只取 1000 行时无法区分:
text
数据库刚好只有 1000 行
数据库其实有 50000 行,只返回了前 1000 行
取 1001 行即可判断:
text
读取 ≤ 1000 行 → 没有证据表明被系统截断
读取 1001 行 → 返回前 1000 行,truncated = true
这是一种常见的分页和结果上限判断方法。
科普 LIMIT 不能解决所有性能问题
下面的聚合即使最终只返回一行,也可能扫描整个大表:
sql
SELECT SUM(order_amount)
FROM fact_order;
外层 LIMIT 1001 无法减少聚合前的扫描量。类似地,大 OFFSET、排序、JOIN 和 窗口函数仍可能消耗大量资源。
所以还需要:
- 合理索引和分区。
- 查询超时。
- 数据库资源组或查询代理。
- 只读副本。
- 慢查询审计。
科普应用超时和数据库超时
应用超时表示客户端不再等待;数据库超时由数据库服务器控制实际语句运行。
text
应用超时:保护 API 请求和连接占用时间
数据库超时:限制数据库服务端工作时间
二者应该同时配置。仅取消 Python 协程,不应被理解为服务端已经百分之百立即 停止执行。
科普 JSON 序列化
JSON 原生类型只有:
text
null、boolean、number、string、array、object
Python 的 Decimal、datetime 和 bytes 不属于 JSON 原生类型,需要先约定 表示方式。序列化不仅是"让代码不报错",还涉及精度、时区和二进制协议。
科普浮点数和 Decimal
浮点数采用二进制近似表示,适合科学计算,但某些十进制金额不能精确表示。 Decimal 按十进制规则保存精度,更适合财务数据。
通过 JSON 字符串返回 Decimal,可以避免中间层自动变成低精度浮点数。API 契约需要明确告诉前端哪些字段是十进制字符串。
科普 Base64
Base64 使用可打印 ASCII 字符表示二进制数据,体积通常比原始 bytes 大约多 三分之一。它适合在 JSON 中传输小型二进制内容,不适合通过 SSE 返回大型文件。
大型 BLOB 应返回对象存储地址或使用专门下载接口。
科普结果截断和分页
本文的 truncated 只告诉用户"还有更多行",不提供下一页游标。对于自然语言 分析,大多数结果应该是小型聚合或 TopN,1000 行通常已经过多。
如果产品确实需要浏览明细,应单独设计:
- 基于稳定排序字段的游标分页。
- 页面大小上限。
- 下一页 Token。
- 数据权限和脱敏。
不要让 LLM 自己随意修改 OFFSET 来实现产品级分页。
科普 SSE 和大结果集
SSE 适合单向推送进度、模型文本片段和中小型结果。它不是大型数据导出协议。
大结果集会带来:
- JSON 编码内存。
- 网络响应时间。
- 浏览器解析和渲染压力。
- 客户端断开后的资源清理问题。
大规模导出应该转成异步任务,生成 CSV/Parquet 文件后提供下载地址。
科普纵深防御
完整 NL2SQL 安全链现在是:
text
最小元数据召回
→ Prompt 只读约束
→ SQL AST 白名单
→ 表字段和 JOIN 校验
→ MySQL EXPLAIN
→ 显式 sql_validated 标记
→ 只读事务
→ 只读数据库账号
→ 行数、单元格、超时限制
→ 审计与监控
其中任何单独一层都不是绝对安全保证。多层限制让某一层出现遗漏时,后面的层 仍有机会阻止写入、越权读取或资源耗尽。
本文小结
本文完成了最后一个 run_sql 节点:
- 新增
SQLExecutionRepository,在 MySQL 只读事务中执行查询。 - 使用 SQLGlot 添加服务端结果上限,并保留更小的用户 LIMIT。
- 多取一行判断结果是否截断。
- 查询结束始终回滚事务。
- 新增应用级异步超时。
- 将 Decimal、日期、时间和二进制转换为 JSON 兼容值。
- 限制超长单元格并处理重复输出列名。
- 通过 SSE 返回结果、行数、截断状态和耗时。
- 使用
sql_validated防止绕过验证节点。 - 删除最后的演示占位逻辑。
- 通过 49 个单元测试和真实 MySQL 只读查询验证。
至此,当前版本的 NL2SQL 主流程已经完整连通:
text
自然语言问题
→ 关键词与召回
→ 候选合并和过滤
→ SQL 上下文
→ 生成 SQL
→ 验证和有限修正
→ 受控执行
→ SSE 查询结果
后续进入生产化阶段时,优先补充数据库独立只读账号、服务端查询超时、用户级 数据权限、敏感字段脱敏、审计日志和可观测性。