【金仓数据库征文】AI Agent SQL 执行超时与资源保护——在线问数不能把数据库当成“无限工具”
文章目录
- 每日一句正能量
- 1. 背景与问题:SQL 是只读的,也可能把生产库拖慢
- 2. 环境与数据:先定义服务预算,再讨论参数
- 2.1 为什么不能直接照抄一个 30 秒超时
- 3. 复现过程:为什么只设 statement_timeout 仍然会出问题
- 3.1 第一版:完全没有超时
- 3.2 第二版:只设置 statement_timeout
- 锁等待不是执行计算
- SQL 完成不代表事务结束
- 3.3 第三版:把连接池大小当成 SQL 并发限制
- 4. 方案实施:KFS MCP Server 的资源治理闭环
- 4.1 第一层:端到端 Request Budget
- 4.2 第二层:在获取数据库连接之前做并发限制
- 4.3 第三层:并发限制要分全局、租户、用户
- 4.4 第四层:使用短队列,禁止无限等待
- 4.5 第五层:数据库原生四类超时
- 4.6 第六层:结果行数、内存和临时磁盘同样要限制
- 4.7 完整 query_readonly 工具调用
- 4.8 MCP 2026-07-28 的无状态核心与扩容
- 5. 结果对比:不要把“所有请求都跑完”当成性能目标
- 5.1 端到端 P95
- 5.2 峰值数据库并发
- 5.3 Queue Wait
- 5.4 Resource Rejection Rate
- 5.5 Blast Radius
- 稳态测试
- 峰值测试
- 慢 SQL 混合测试
- 6. 风险与复盘:超时是止损,不是 SQL 优化
- 6.1 三秒超时不会把三十秒 SQL 变成三秒 SQL
- 6.2 超时后必须回滚并正确释放连接
- 6.3 客户端超时不等于数据库 SQL 被取消
- 6.4 无限自动重试会制造更大压力
- 6.5 单实例 Semaphore 不等于数据库总并发
- 6.6 并行查询会放大资源消耗
- 6.7 本文参数不能直接照抄到生产
- 最终复盘
- 第一层:限制“有多少请求能进入数据库”
- 第二层:限制“请求能等待多久”
- 第三层:限制“SQL 能执行多久、消耗多少”
- 第四层:限制“异常会影响多少其他用户”
- 附录 A:最小并发保护代码
- 附录 B:数据库执行预算
- 附录 C:推荐错误码
每日一句正能量
读书可以经历一千种人生,不读书只能活一次。
物理上,我们被局限在单一的时间线里;但精神上,书籍是穿越时空的虫洞。透过书本,我们可以与古人对话,体验异域文化,感受角色的悲欢离合。不读书的人,只能在自己的小世界里打转;读书的人,却能在无数个灵魂中穿梭。
1. 背景与问题:SQL 是只读的,也可能把生产库拖慢
在企业问数场景中,把 Agent 限制成只读 SQL 只是第一步。真正上线后,最先暴露的问题往往不是 DELETE,而是资源争用。
例如用户问:
最近一年每个用户、每个商品、每天的购买趋势是什么?
模型可能生成:
SELECTuser_id,sku_id,DATE(created_at)ASdt,SUM(amount)AStotal_amountFROMordersWHEREcreated_at>=CURRENT_DATE-INTERVAL'1 year'GROUPBYuser_id,sku_id,DATE(created_at)ORDERBYtotal_amountDESC;这条 SQL 没有写操作,也可能完全符合权限规则,但如果orders有数亿甚至数十亿行,它依旧可能产生大范围扫描、Hash 聚合、排序落盘、临时文件膨胀、CPU 与 I/O 抢占,最终拖慢正常在线请求。
在线问数与离线分析最大的区别之一,是在线链路有明确的交互预算。用户通常不会接受“SQL 最终能跑完,但要等 40 秒”。所以 KFS MCP Server 不应该只是一个 SQL 转发器,而应该承担资源治理职责:
自然语言问题 → Agent 生成 SQL → KFS 请求预算检查 → 并发闸门 → 短队列 → 获取数据库连接 → 数据库原生超时 → 结果行数与资源限制 → 取消 / 回滚 → 释放连接 → 指标与审计如果不做这一层,一次流量尖峰很容易形成故障链:
大量 Agent 请求进入 → 每个请求都拿数据库连接 → 慢 SQL 占满连接池 → 新请求等待 → 上游 HTTP 超时 → Agent 自动重试 → 数据库压力继续升高因此本篇的核心问题不是“怎么让一条 SQL 在 3 秒后报错”,而是:
如何让不可承受的 SQL 尽早被限制,让正常查询在峰值期间仍有稳定资源。
2. 环境与数据:先定义服务预算,再讨论参数
本文以 PostgreSQL 18 为例,示例业务库包含:
orders(order_id,user_id,sku_id,channel_id,amount,status,created_at)假设演示规模:
orders:5 亿行 日增数据:约 300 万行 平时在线问数并发:3~8 活动高峰并发:30~50 数据库:业务只读副本 KFS MCP Server:2 个实例这些数字只是复现口径,不是通用最佳值。
2.1 为什么不能直接照抄一个 30 秒超时
很多系统的第一版会设置:
statement_timeout = 30s这个配置比无限执行好,但对于在线问数仍可能太宽松。假设数据库同时允许 20 条 Agent SQL,那么 20 条 30 秒级查询足以把资源持续占住很久。
更合理的方式是从服务目标反推预算。例如:
用户感知 P95:3 秒左右 端到端 Request Budget:5 秒 单 SQL 执行预算:3 秒 锁等待预算:300 ms 队列等待预算:250 ms 事务最长生命周期:4 秒 最大结果行数:200这里最重要的设计是:
超时不是一个参数,而是一组由外向内逐层收紧的时间预算。
3. 复现过程:为什么只设 statement_timeout 仍然会出问题
3.1 第一版:完全没有超时
最简单的数据库调用:
cur.execute(sql)rows=cur.fetchall()如果 SQL 运行 90 秒,连接就可能被占用 90 秒。更糟的是,上游客户端可能在第 5 秒已经放弃等待,但数据库查询仍然继续执行。
这会形成一种非常浪费的状态:
用户已经失败 数据库仍然在执行3.2 第二版:只设置 statement_timeout
PostgreSQL 18 官方文档说明,statement_timeout会终止执行时间超过阈值的语句,默认值 0 表示关闭限制。针对 Agent 专用会话,可以使用:
SETLOCALstatement_timeout='3000ms';这能解决“SQL 无限运行”,但仍然不能覆盖所有资源问题。
锁等待不是执行计算
查询可能不是慢,而是在等待锁。因此还需要:
SETLOCALlock_timeout='300ms';PostgreSQL 将lock_timeout定义为获取表、索引、行等数据库对象锁时的等待上限,它只计算锁等待,不等同于整个 SQL 执行时间。
SQL 完成不代表事务结束
代码异常时可能出现:
BEGIN → SELECT 完成 → 客户端异常 → 事务保持打开PostgreSQL 18 还提供:
transaction_timeout idle_in_transaction_session_timeout前者限制事务总生命周期;后者专门处理“事务打开,但会话长时间没有继续发送命令”的场景。
长期 idle transaction 可能阻碍旧版本清理、增加表膨胀风险,也可能长期持有某些锁。
推荐关系通常是:
lock_timeout < statement_timeout < transaction_timeout < request budget例如:
300ms < 3000ms < 4000ms < 5000ms3.3 第三版:把连接池大小当成 SQL 并发限制
假设连接池最大连接数是:
50这不代表数据库适合同时运行 50 条复杂聚合 SQL。
连接池解决的是连接建立与复用问题,而 Agent 查询并发应该有独立上限。例如:
数据库连接池:30 Agent SQL 最大执行并发:12这样才能给元数据查询、健康检查和其他后台操作保留容量。
4. 方案实施:KFS MCP Server 的资源治理闭环
4.1 第一层:端到端 Request Budget
假设:
REQUEST_BUDGET = 5000ms它应该覆盖:
Agent 推理后的工具调用 → MCP 网络传输 → 排队 → SQL 执行 → 结果序列化 → 返回如果请求到达 KFS MCP Server 时已经消耗 4.8 秒,就没有必要再启动一条理论上最多可运行 3 秒的 SQL。
可以将调用开始时间或 deadline 下传:
remaining_ms=deadline_ms-now_msifremaining_ms<500:return{"ok":False,"code":"REQUEST_BUDGET_EXHAUSTED"}这样可以避免上游已经超时、数据库仍继续运行的资源浪费。
4.2 第二层:在获取数据库连接之前做并发限制
最小实现:
global_sem=asyncio.Semaphore(12)awaitasyncio.wait_for(global_sem.acquire(),timeout=0.25)如果 250ms 内拿不到槽位:
{"ok":false,"code":"RESOURCE_BUSY","retryable":true}这里有一个非常重要的顺序:
先获取 Query Slot 再获取 Database Connection而不是:
先拿数据库连接 再等待执行槽位后一种方式会让排队请求提前占满连接池,连接池本身变成昂贵的等待队列。
4.3 第三层:并发限制要分全局、租户、用户
生产环境建议至少有:
concurrency:global:12per_tenant:6per_user:2这样同时解决三个问题:
global:保护数据库总容量;per_tenant:避免一个业务部门吃掉全部资源;per_user:防止单个用户多窗口并发占满系统。
安全控制与资源控制的共同特点是:
不能只判断 SQL 本身,还要判断“谁在什么上下文里执行它”。
4.4 第四层:使用短队列,禁止无限等待
并发满后不能无限排队。
例如:
queue_wait = 250ms250ms 内没有槽位就返回:
RESOURCE_BUSY对于在线系统,快速而明确的失败通常比“排队 8 秒之后再超时”更好。
Agent 可以根据结构化错误向用户解释:
当前在线查询容量已满,请稍后重试。但应限制自动重试次数,否则可能形成 retry storm。
4.5 第五层:数据库原生四类超时
工具获取连接后设置:
SETTRANSACTIONREADONLY;SETLOCALlock_timeout='300ms';SETLOCALstatement_timeout='3000ms';SETLOCALtransaction_timeout='4000ms';SETLOCALidle_in_transaction_session_timeout='2000ms';PostgreSQL 18 官方文档对这几种超时做了明确区分:
statement_timeout:限制单条 SQL 执行时长;lock_timeout:限制每次获取锁的等待时间;transaction_timeout:限制事务持续时间;idle_in_transaction_session_timeout:清理长时间停在事务中的空闲会话。
对在线问数来说,这种分层比单一 30 秒超时更有价值。
4.6 第六层:结果行数、内存和临时磁盘同样要限制
一条 SQL 可能在 1 秒内返回 100 万行。
如果代码直接:
rows=cur.fetchall()压力会从数据库转移到:
KFS 内存 JSON 序列化 网络传输 LLM Token因此建议:
MAX_ROWS=200rows=cur.fetchmany(MAX_ROWS)同时关注 PostgreSQL 的:
work_mem temp_file_limit官方资源文档特别提醒,work_mem并不是“一条 SQL 最多使用这么多内存”。复杂查询可能存在多个排序、Hash 等操作,每个操作都可能获得相应的内存额度;并发会话和并行 worker 还会进一步放大总内存使用。
因此 Agent 会话不应随意配置超大的work_mem。
例如可以从保守值开始:
SETLOCALwork_mem='8MB';SETLOCALtemp_file_limit='256MB';其中temp_file_limit可以限制一个 PostgreSQL 进程用于排序、Hash 等临时文件的磁盘空间,超过限制时取消事务。
4.7 完整 query_readonly 工具调用
Agent 调用:
{"name":"query_readonly","arguments":{"sql":"SELECT channel_id, SUM(amount) FROM orders WHERE created_at >= CURRENT_DATE - INTERVAL '90 day' GROUP BY channel_id","request_started_ms":1786160000000}}KFS MCP Server 的执行顺序应该是:
1. 检查 request budget 2. 校验 SQL 只读与对象权限 3. 获取 global / tenant / user concurrency token 4. 超过 queue_wait 则 RESOURCE_BUSY 5. 获取数据库连接 6. 开启只读事务 7. 设置数据库 timeout/resource 参数 8. 执行 SQL 9. 最多 fetch 指定行数 10. rollback / reset 11. 释放数据库连接 12. 释放 concurrency token 13. 记录耗时、行数、错误码和 SQL 指纹如果 SQL 超时:
{"ok":false,"code":"QUERY_TIMEOUT","retryable":false}为什么QUERY_TIMEOUT通常不建议原样重试?
因为它已经证明当前 SQL 无法在在线预算内完成。更合理的动作是:
缩小时间范围 减少分组维度 增加过滤条件 使用预聚合表 切换离线任务而不是消耗同样的资源再试一次。
4.8 MCP 2026-07-28 的无状态核心与扩容
MCP 2026-07-28 规范把核心协议转向无状态请求:每个请求都能够携带足够信息落到普通负载均衡后的任意实例;工具方法与工具名还可以通过请求头进行路由和授权。
这非常适合 KFS MCP Server 水平扩展:
Load Balancer ↓ KFS-1 KFS-2 KFS-3网关可以基于工具名、客户端身份做:
限流 授权 路由 指标聚合但要注意:
协议无状态,不代表并发额度天然是集群全局共享的。
如果每个实例都配置:
Semaphore(12)三个实例理论上可能产生 36 个并发数据库查询。
所以必须明确:
12 是 per-instance 还是 per-database / per-cluster如果要做集群级限流,可以放在 API Gateway、Redis 令牌桶、服务网格或数据库代理层。
5. 结果对比:不要把“所有请求都跑完”当成性能目标
资源治理评测建议至少看五类指标。
5.1 端到端 P95
应该从工具请求进入开始计算,而不只是 PostgreSQLEXPLAIN ANALYZE的执行时间。
因为:
排队 2 秒 + SQL 1 秒用户感知仍是 3 秒。
5.2 峰值数据库并发
关注数据库真正同时执行多少条 Agent SQL。
如果接入 KFS 之后:
入口并发 = 50 数据库 active query ≈ 12说明并发保护确实生效。
5.3 Queue Wait
排队时间应单独记录:
queue_wait_ms否则 P95 高了之后无法判断是 SQL 慢还是容量满。
5.4 Resource Rejection Rate
至少区分:
RESOURCE_BUSY REQUEST_BUDGET_EXHAUSTED QUERY_TIMEOUT RESOURCE_LIMIT DB_CONNECTION_ERROR不要全部叫:
QUERY_FAILED错误分类越清楚,Agent 后续动作才越合理。
5.5 Blast Radius
一个用户或租户的异常请求能影响多少其他用户。
这是资源隔离质量的核心指标之一。
本文演示压测结果可以按下面方式展示:
| 指标 | 无治理 | KFS 资源治理 |
|---|---|---|
| P95 请求延迟 | 9.8s | 2.6s |
| 峰值 DB 并发 | 64 | 12 |
| 队列等待 | 无上限 | 250ms |
| SQL 超时 | 无 | 3s |
| 最大结果行数 | 无限制 | 200 |
| 失败分类 | connection timeout | RESOURCE_BUSY / QUERY_TIMEOUT |
这些数字是演示基准,正式投稿应替换为真实压测或线上观测结果。
建议做三组测试。
稳态测试
并发 5 持续 10 分钟目标是验证治理层不会明显影响正常请求。
峰值测试
瞬时并发 50观察:
DB active query connection pool usage RESOURCE_BUSY ratio P95是否仍在预期范围。
慢 SQL 混合测试
构造:
80%:100~500ms 15%:1~2s 5%:故意 >10s重点验证少量异常 SQL 是否会拖慢大部分正常查询。
优秀的资源治理不是:
把 100% SQL 都执行完成。
而是:
让可承受的查询稳定完成,让超出容量与预算的请求尽快、可解释地失败。
6. 风险与复盘:超时是止损,不是 SQL 优化
6.1 三秒超时不会把三十秒 SQL 变成三秒 SQL
如果一条查询本来要 30 秒:
statement_timeout = 3s只是让它第 3 秒失败。
真正的优化还需要检查:
- 是否缺索引;
- 是否选错事实表;
- 是否需要分区裁剪;
- 是否使用预聚合表;
- 是否应该限制时间范围;
- 是否应该把问题转成离线分析。
资源治理解决的是“坏 SQL 不无限伤害系统”,不是自动解决性能。
6.2 超时后必须回滚并正确释放连接
PostgreSQL 语句发生错误后,事务可能进入失败状态。
所以不能:
catch timeoutreturnerror然后直接把连接放回池里。
必须确认:
cancel rollback reset release否则下一个请求可能拿到异常状态的连接。
6.3 客户端超时不等于数据库 SQL 被取消
HTTP 客户端 5 秒超时只表示:
客户端不再等了并不自动证明数据库查询已经停止。
是否真正取消,取决于驱动、连接生命周期、取消协议以及服务端 timeout。
因此数据库原生statement_timeout仍然非常重要。
6.4 无限自动重试会制造更大压力
错误要分类处理:
RESOURCE_BUSY → 可以有限重试,但必须退避 QUERY_TIMEOUT → 不应原样重试,应该改写查询 UNAUTHORIZED → 禁止重试 INVALID_SQL → 可以修正 SQL 后重试如果 Agent 对所有错误统一:
retry immediately资源高峰会被放大。
6.5 单实例 Semaphore 不等于数据库总并发
当 KFS 从 1 个实例扩成 3 个实例时:
12 × 3 = 36可能瞬间突破原本数据库容量规划。
所以所有并发配置都应该明确范围:
per_user per_tenant per_instance per_cluster per_database最终真正需要保护的是数据库。
6.6 并行查询会放大资源消耗
PostgreSQL 18 官方资源文档明确提醒,并行查询 worker 也是独立执行进程,会消耗额外 CPU、内存和 I/O;work_mem等资源在并行场景下也可能被多个 worker 分别使用。
因此:
一个连接并不等价于:
一个 CPU 执行单元对于在线问数专用角色,可以基于真实压测决定是否进一步限制并行查询能力。
6.7 本文参数不能直接照抄到生产
本文示例:
global concurrency = 12 statement_timeout = 3s queue_wait = 250ms max_rows = 200只是用于说明治理逻辑。
真实参数应该综合:
数据库 CPU 磁盘 I/O 缓存命中率 慢 SQL 比例 平均扫描量 只读副本容量 连接池大小 在线 SLA 峰值用户数通过压测逐步确定。
最终复盘
AI Agent SQL 资源治理可以归纳成四层。
第一层:限制“有多少请求能进入数据库”
global / tenant / user concurrency第二层:限制“请求能等待多久”
queue_wait request_budget第三层:限制“SQL 能执行多久、消耗多少”
statement_timeout lock_timeout transaction_timeout work_mem temp_file_limit max_rows第四层:限制“异常会影响多少其他用户”
tenant isolation read replica circuit breaker classified errorsKFS MCP Server 的价值不是简单地把大模型与数据库连接起来,而是在它们之间建立一个:
可限流、可超时、可拒绝、可审计、可度量的资源边界。
如果继续向生产推进,建议按顺序实施:
- 给 Agent 建立独立只读数据库账号和连接池;
- 明确在线问数 P95 与端到端 request budget;
- 设置
statement_timeout与lock_timeout; - 增加事务生命周期保护;
- 在获取数据库连接之前设置并发闸门;
- 使用短队列而不是无限排队;
- 限制 max rows、work_mem、临时文件;
- 区分 RESOURCE_BUSY 与 QUERY_TIMEOUT;
- 建立稳态、峰值、慢 SQL 混合三类压测;
- 根据真实容量持续校准参数。
做在线问数时,真正危险的场景不一定是一条 SQL 报错。
更危险的是:
每一条 SQL 都合法,每一条 SQL 都还在跑,最后整个数据库一起慢下来。
因此企业 AI 数据查询除了 SQL 安全,还必须有 SQL资源治理。
附录 A:最小并发保护代码
GLOBAL_LIMIT=12QUEUE_WAIT_SEC=0.25global_sem=asyncio.Semaphore(GLOBAL_LIMIT)asyncdefacquire_slot():try:awaitasyncio.wait_for(global_sem.acquire(),timeout=QUEUE_WAIT_SEC)exceptasyncio.TimeoutError:raiseResourceBusy("RESOURCE_BUSY")必须注意顺序:
先 acquire_slot() 再 acquire database connection附录 B:数据库执行预算
withpsycopg.connect(DSN,autocommit=False)asconn:withconn.cursor()ascur:cur.execute("SET TRANSACTION READ ONLY")cur.execute("SET LOCAL lock_timeout = '300ms'")cur.execute("SET LOCAL statement_timeout = '3000ms'")cur.execute("SET LOCAL transaction_timeout = '4000ms'")cur.execute("SET LOCAL work_mem = '8MB'")cur.execute("SET LOCAL temp_file_limit = '256MB'")cur.execute(sql)rows=cur.fetchmany(200)conn.rollback()附录 C:推荐错误码
{"RESOURCE_BUSY":{"retryable":true,"meaning":"当前并发容量已满"},"REQUEST_BUDGET_EXHAUSTED":{"retryable":false,"meaning":"当前请求剩余时间不足"},"QUERY_TIMEOUT":{"retryable":false,"meaning":"SQL 超过在线执行预算,应缩小查询"},"RESOURCE_LIMIT":{"retryable":false,"meaning":"查询超过内存、临时文件或结果集限制"}}转载自:https://blog.csdn.net/u014727709/article/details/163644272
欢迎 👍点赞✍评论⭐收藏,欢迎指正