游乐游手机版
首页/AI教程/文章详情

SQL安全执行并返回查询结果方法详解

时间:2026-08-06 14:09
安全执行SQL的流程包括检查验证标记、开启只读事务、通过SQLGlot添加内部LIMIT、序列化Decimal和日期等特殊类型、处理重复列名、截断超出行数和单元格,最终返回含columns、rows、row_count、truncated、elapsed_ms等字段的JSON并通过SSE推送。配置了max_rows、timeout_seconds、max_c

15 | 安全执行 SQL 并返回查询结果

这是一篇系列文章,请务必按顺序阅读,以确保理解完整流程。

15

本文目标

前几个节点已经完成了以下关键步骤:

用户问题→ 召回字段、指标和维度值→ 过滤候选→ 组装 SQL 上下文→ 生成 SQL→ 验证并有限修正 SQL

现在仅剩最后一步:执行已经通过验证的 SQL,并将数据库查询结果转换为前端可通过 SSE 接收的 JSON 数据。

但执行节点绝不能仅仅写成:

await session.execute(sql)

即使 SQL 已经通过 AST 和 EXPLAIN 校验,仍然可能遭遇多种隐患:

  • 返回几十万行数据,导致内存和 SSE 响应被撑爆。
  • 扫描大量数据,长时间占用数据库连接资源。
  • 返回 Decimal、日期或二进制数据,导致 json.dumps() 直接报错。
  • 出现同名输出列,转换成字典时互相覆盖丢失数据。
  • 因数据库状态变化,在真正执行时失败。
  • 因未来图结构调整,错误地绕过验证节点。

本文实现的受控执行流程如下:

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 中读取以下数据:

{"sql": """SELECT SUM(fo.order_amount) AS sales_amountFROM fact_order AS foJOIN dim_region AS drON fo.region_id = dr.region_idWHERE dr.province = '北京市'""","sql_validated": True,"validation_errors": [],"error": None,}

决定能否进入执行节点的关键条件为:

sql_validated is True and error is None

不能仅判断 error is None,因为工作流初始 State 也可能暂时没有错误,但此时 SQL 尚未经过验证。

2. 查询成功时的 State 输出

{"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:从开始执行到完成序列化前的近似毫秒数。

金额用字符串返回是刻意设计的:

{"sales_amount": "279159.50"}

如果将 MySQL DECIMAL 强制转换为 JavaScript 浮点数,金额精度将无法保证。

3. 查询成功时的 SSE 输出

{"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 连接断开、表结构刚好发生变化或查询超时,会发送:

{"type": "execution_error","message": "MySQL 执行失败:..."}

State 中记录:

{"execution_result": {},"error": "MySQL 执行失败:...",}

执行错误不会再次自动调用 LLM。因为 SQL 在 EXPLAIN 后又失败,很可能是连接、权限、并发变更或运行期资源问题,并不一定能通过改写 SQL 解决。

5. 没有验证标记时的输出

就算未来有人直接调用 run_sql 节点,只要缺少:

{"sql_validated": True}

节点就会直接返回:

SQL 尚未通过验证,拒绝执行

这是图路由之外的第二次防御性检查。

第一部分:项目实现

1. 设计执行配置

conf/app_config.yaml 中增加:

sql_execution:max_rows: 1000timeout_seconds: 30max_cell_chars: 10000

分别限制三个不同维度:

max_rows最多向前端返回多少行timeout_seconds 应用最多等待查询多少秒max_cell_chars单个文本或二进制值最多返回多少字符

对应的 Pydantic Settings:

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 要分开

执行功能分为两层:

SQLExecutionRepository→ 只负责 MySQL 连接、只读事务、内部 LIMIT、读取原始行、回滚SQLExecutionService→ 只负责应用超时、结果类型转换、列名去重、单元格截断、耗时

这样单元测试不需要真实连接 MySQL,就能独立验证超时和各种 Python/MySQL 类型的序列化。

3. 定义 Repository 原始结果

@dataclass(frozen=True)class RawSQLExecutionResult:columns: list[str]rows: list[tuple[Any, ...]]truncated: bool

Repository 暂时保留 Decimaldatetimebytes 等数据库原始值,不在数据访问层决定 API 的表现形式。

4. 使用 AST 添加内部 LIMIT

仅在 Python 中调用:

result.fetchmany(max_rows + 1)

并不能保证数据库少返回数据。有些驱动会先把整个结果集读取到客户端,再由 Python 截取前几行。

因此 Repository 在发送 SQL 前,使用 SQLGlot 给根查询添加 LIMIT:

return expression.limit(fetch_limit, copy=True).sql(dialect="mysql")

默认 max_rows = 1000,实际查询上限是:

1000 + 1 = 1001

多取的一行不返回给前端,只用来做判断:

truncated = len(fetched_rows) > max_rows

5. 不扩大用户原本的 LIMIT

如果模型已经生成:

SELECT region_idFROM dim_regionLIMIT 10;

系统不能为了判断截断而把它改成 1001,否则会改变用户要求。Repository 会检查原 LIMIT:

if original_limit <= fetch_limit:保留原 LIMITelse:改成 fetch_limit

示例:

无 LIMIT → LIMIT 1001LIMIT 10 → LIMIT 10LIMIT 100000 → LIMIT 1001

CTE 和 UNION 的 LIMIT 也加在整个根查询上,而不是错误地加到内部某张表。

6. 执行前再次解析 SQL

build_bounded_sql() 会再次确认:

  • 只有一条语句。
  • 根节点仍然是查询。
  • 可以按 MySQL 方言解析。

这不是替代上一篇的完整验证,而是执行层的防御性编程。执行 Repository 不应假设所有调用者永远只会从当前 LangGraph 路径进入。

7. 开启 MySQL 只读事务

Repository 获取共享 Engine 中的一条连接:

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)

只读保护形成多层结构:

第 1 层:SQLValidationService 只允许 Query AST第 2 层:run_sql 要求 sql_validated = true第 3 层:MySQL SET TRANSACTION READ ONLY第 4 层:生产数据库账号只授予 SELECT 权限

其中第 4 层仍然最重要。如果应用代码将来出现漏洞,数据库账号本身没有写权限才能形成真正的权限边界。

8. 查询成功也回滚

查询完成后执行:

if connection.in_transaction():await connection.rollback()

虽然普通 SELECT 没有需要提交的数据,但回滚可以明确结束只读事务、释放一致性读快照和相关资源。不能因为“只有查询”就把事务一直留在连接池中。

回滚放在 finally 中,因此查询异常时也会执行。

9. 使用 exec_driver_sql

和 EXPLAIN Repository 一样,执行层使用:

await connection.exec_driver_sql(execution_sql)

当前 SQL 是系统生成且已经完整验证的 SQL,不需要 SQLAlchemy 再解释命名绑定参数。这样还可以避免时间字符串中的冒号被 text() 识别为 :parameter

这不代表普通业务代码应该拼接用户输入。只有经过本项目整条生成和验证链的 SQL 才能走这个接口。

10. 设置应用等待超时

Service 使用 Python 3.12 的异步超时上下文:

async with asyncio.timeout(self.timeout_seconds):raw_result = await self.repository.execute(sql, self.max_rows)

超时后转换为可读错误:

SQL 执行超过 30 秒,已取消等待

应用超时解决的是“当前请求不再无限等待”。它不能保证 MySQL 服务端在同一毫秒立即停止所有工作。生产环境还应该配置 MySQL 的语句执行时间限制、查询袋里限制或资源组,并确认驱动取消时会关闭或废弃对应连接。

11. 转换 Decimal

MySQL 的 DECIMAL(18,2) 通常被 asyncmy/SQLAlchemy 转成 Python Decimal

错误做法:

float(Decimal("9999999999999999.99"))

浮点数无法精确表示所有十进制金额。因此项目返回:

format(value, "f")

最终 JSON 中是字符串:

{"amount": "9999999999999999.99"}

前端显示金额时可以保留精度;需要计算时应使用十进制定点库,而不是直接使用 JavaScript Number

12. 转换日期和时间

以下 Python 类型调用 isoformat()

datetime → 2026-07-31T10:30:00date → 2026-07-31time → 10:30:00

ISO 格式比本地化字符串稳定,前后端也更容易解析。数据库中没有时区信息的 DATETIME 不会凭空增加时区,业务时区仍应由元数据和应用约定明确。

13. 转换二进制值

bytesbytearraymemoryview 不能直接交给 JSON 编码器,因此先转换为:

base64:YWJj...

base64: 前缀告诉消费方这不是普通文本。超过单元格长度上限时会截断并以省略号结尾,此时它只是预览,不再是可完整解码的 Base64 数据。

14. 限制单个单元格长度

即使只有一行结果,一个超大的文本或 BLOB 字段也可能占用大量响应内存。

if len(value) > max_cell_chars:return value[:max_cell_chars] + "…", True

所有被截断的单元格数量记录到:

truncated_cell_count

行截断和单元格截断是两个不同概念:

truncated = true还有更多结果行没有返回truncated_cell_count > 0已返回行中有值只返回了预览

15. 处理重复列名

SQL 可能返回:

SELECT fo.region_id, dr.region_id...

数据库结果列名都是 region_id。直接转换为字典会让后一个值覆盖前一个值。

Service 会生成稳定列名:

region_idregion_id_2region_id_3

更推荐生成 SQL 时主动使用清晰别名,但执行层仍要防御重复输出列。

16. 定义最终执行结果

@dataclass(frozen=True)class SQLExecutionResult:columns: list[str]rows: list[dict[str, Any]]row_count: inttruncated: booltruncated_cell_count: intelapsed_ms: int

to_dict() 让节点可以同时把相同结构写进 State 和 SSE。

17. 实现 run_sql 节点

节点首先检查明确验证标记:

if state.get("sql_validated") is not True:return execution_error

随后调用 Service:

result = await service.execute(state.get("sql", ""))

成功时推送 result,失败时推送 execution_error。节点只捕获预期的 SQLExecutionError;编程错误不会被静默伪装成普通数据库错误。

18. 扩展 State 和 RuntimeContext

State 增加:

sql_validated: boolexecution_result: dict[str, Any]

RuntimeContext 增加:

sql_execution: SQLExecutionDependencies

执行结果属于单次工作流状态;数据库 Engine、Repository、Service 和安全配置属于应用级依赖。

19. 在 lifespan 中注入

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. 单元测试

运行:

UV_CACHE_DIR=/tmp/n2sql-uv-cache uv run python -m unittest discover -s tests/unit -v

当前结果:

Ran 49 testsOK

执行层新增测试覆盖:

  • SQL 会先进入只读事务。
  • 查询会添加 max_rows + 1 的服务端 LIMIT。
  • 更小的原始 LIMIT 会被保留。
  • 更大的原始 LIMIT 会被限制。
  • CTE 根查询可以正确添加 LIMIT。
  • 多语句和非查询语句会被执行层再次拒绝。
  • 多取的一行只用于判断截断。
  • Decimal、日期、时间、二进制和无限浮点数可以序列化。
  • 重复列名不会覆盖。
  • 超长单元格会记录截断数量。
  • 慢 Repository 会触发应用超时。
  • 没有明确验证标记的 State 不会路由到执行。
  • run_sql 会拒绝缺少验证标记的直接调用。

22. 真实 MySQL 验证

使用本地 dw MySQL,在只读事务中执行:

SELECT COUNT(*) AS region_countFROM dim_region;

真实返回:

{"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 和事务

这三个概念经常混在一起:

Engine / 连接池→ 管理多条数据库连接,通常是进程级长生命周期对象Connection→ 连接池借出的一条实际连接Session→ SQLAlchemy 面向 ORM 和业务操作的工作单元Transaction→ 一组具有提交或回滚边界的数据库操作

本项目执行的是模型生成的原生查询 SQL,不需要 ORM 对象跟踪,所以直接使用异步 Connection 更清晰。

科普只读事务和只读账号

只读事务是连接当前事务的运行模式;只读账号是数据库授权系统中的长期权限。

只读事务:这一次事务不应该写只读账号:这个身份从权限上就不能写

应用代码可以被修改或出现漏洞,因此不能只依赖 SET TRANSACTION READ ONLY。生产环境应该给 NL2SQL 使用独立账号,只授予必要 Schema 的 SELECT 权限。

科普为什么成功查询也要回滚

事务不只是为了写入。长时间不结束的只读事务可能持有一致性读快照,影响清理旧版本和连接复用。

查询结束后显式回滚表达的是:

这次工作单元已经结束,不保留任何事务状态。

对于只读查询,回滚不会撤销已经发送给客户端的结果。

科普 LIMIT 和 fetchmany 的区别

LIMIT 在数据库服务端参与查询计划和结果生成:

SELECT ... LIMIT 1001;

fetchmany(1001) 是 Python 驱动从结果游标获取多少行。只使用后者时,驱动或数据库可能已经生成、传输甚至缓存了更多行。

因此本项目同时使用服务端 LIMIT 和客户端 fetchmany。

科普为什么多取一行

如果上限是 1000,只取 1000 行时无法区分:

数据库刚好只有 1000 行数据库其实有 50000 行,只返回了前 1000 行

取 1001 行即可判断:

读取 ≤ 1000 行 → 没有证据表明被系统截断读取 1001 行 → 返回前 1000 行,truncated = true

这是一种常见的分页和结果上限判断方法。

科普 LIMIT 不能解决所有性能问题

下面的聚合即使最终只返回一行,也可能扫描整个大表:

SELECT SUM(order_amount)FROM fact_order;

外层 LIMIT 1001 无法减少聚合前的扫描量。类似地,大 OFFSET、排序、JOIN 和窗口函数仍可能消耗大量资源。

所以还需要:

  • 合理索引和分区。
  • 查询超时。
  • 数据库资源组或查询袋里。
  • 只读副本。
  • 慢查询审计。

科普应用超时和数据库超时

应用超时表示客户端不再等待;数据库超时由数据库服务器控制实际语句运行。

应用超时:保护 API 请求和连接占用时间数据库超时:限制数据库服务端工作时间

二者应该同时配置。仅取消 Python 协程,不应被理解为服务端已经百分之百立即停止执行。

科普 JSON 序列化

JSON 原生类型只有:

null、boolean、number、string、array、object

Python 的 Decimaldatetimebytes 不属于 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 安全链现在是:

最小元数据召回→ Prompt 只读约束→ SQL AST 白名单→ 表字段和 JOIN 校验→ MySQL EXPLAIN→ 显式 sql_validated 标记→ 只读事务→ 只读数据库账号→ 行数、单元格、超时限制→ 审计与监控

其中任何单独一层都不是绝对安全保证。多层限制让某一层出现遗漏时,后面的层仍有机会阻止写入、越权读取或资源耗尽。

本文小结

本文完成了最后一个 run_sql 节点:

  • 新增 SQLExecutionRepository,在 MySQL 只读事务中执行查询。
  • 使用 SQLGlot 添加服务端结果上限,并保留更小的用户 LIMIT。
  • 多取一行判断结果是否截断。
  • 查询结束始终回滚事务。
  • 新增应用级异步超时。
  • 将 Decimal、日期、时间和二进制转换为 JSON 兼容值。
  • 限制超长单元格并处理重复输出列名。
  • 通过 SSE 返回结果、行数、截断状态和耗时。
  • 使用 sql_validated 防止绕过验证节点。
  • 删除最后的演示占位逻辑。
  • 通过 49 个单元测试和真实 MySQL 只读查询验证。

至此,当前版本的 NL2SQL 主流程已经完整连通:

自然语言问题→ 关键词与召回→ 候选合并和过滤→ SQL 上下文→ 生成 SQL→ 验证和有限修正→ 受控执行→ SSE 查询结果

后续进入生产化阶段时,优先补充数据库独立只读账号、服务端查询超时、用户级数据权限、敏感字段脱敏、审计日志和可观测性。

来源:https://juejin.cn/post/7667895598097350682
上一篇数据库选型新思路:开发者自主授权从社区版到常青藤计划全解析 下一篇MSSQL数据库SSL加密连接最佳实践
本站内容用于信息整理与展示,如有侵权或内容问题请及时联系处理。

相关推荐

补充同频道和同主题内容,方便继续浏览更多相关内容。

同类最新

继续查看同栏目最近更新的文章。

更多
CAD零基础入门教程:坐标输入、图层管理与基础绘图命令
AI教程 · 2026-09-01

CAD零基础入门教程:坐标输入、图层管理与基础绘图命令

本文面向CAD零基础学习者,系统讲解坐标输入、图层管理与基础绘图命令的核心用法。通过分步实操与常见问题排查,帮助新手建立精确绘图习惯,掌握规范出图的基础能力。

CAD从入门到项目交付:绘图、标注、图块与实战工作流
AI教程 · 2026-09-01

CAD从入门到项目交付:绘图、标注、图块与实战工作流

掌握CAD的核心在于建立“画得准、标得清、复用快、交付稳”的工作流。本文提供从环境设置、高频命令组合、标注规范、图块标准化到项目分阶段交付的完整路径,帮助初学者避免常见返工陷阱,独立完成可检查、可复用、可打印的工程图纸。

Claude Code 登录指南:个人、Teams 与企业账号区分与授权步骤
AI教程 · 2026-09-01

Claude Code 登录指南:个人、Teams 与企业账号区分与授权步骤

本文详细解析 Claude Code 登录前的账号类型区分方法,涵盖个人订阅、Teams 席位与企业 Enterprise 席位的授权路径差异。提供终端登录命令、环境变量排查及常见异常处理步骤,帮助用户快速完成正确授权并避免登录路径混淆。

Claude Code 文件修改前的权限模式配置与命令审批指南
AI教程 · 2026-09-01

Claude Code 文件修改前的权限模式配置与命令审批指南

本文详细介绍Claude Code在修改文件前的权限模式配置方法,包括defaultMode可选值、permissions allow与deny规则设置、多层级配置文件管理以及 status验证技巧,帮助开发者安全高效地使用AI编程助手。

Claude Code接入VS Code后先测扩展和终端命令
AI教程 · 2026-09-01

Claude Code接入VS Code后先测扩展和终端命令

在VS Code中接入Claude Code后,建议优先验证扩展面板与集成终端两条入口。本文提供标准检查顺序、关键命令与常见故障排查路径,帮助你快速确认环境就绪,避免后续开发受阻。