遥测的 Postgres 池吃 asyncpg 默认的 min_size=10,共享实例上建池即失败并永久降级 #15

Closed
opened 2026-08-24 12:04:50 +08:00 by iomgaa · 1 comment
Owner

现象

2026-08-23 一次满量运行的中途,某一路模型调用的遥测静默停止写入,一直持续到进程重启。日志里唯一的线索是这一行:

WARNING | polygateway.telemetry.postgres:_open_pool:151
  - Postgres 遥测建池失败,后续记录降级为 no-op: sorry, too many clients already

后果:那一路此后跑了 19 次调用、一行遥测都没落库,按用途汇总的成本因此少记约 $5。业务侧全程正常,库里看不出任何异常——是拿日志里的完成里程碑条数和 llm_calls 的行数对了一次账才发现的。

机制

读的是环境里装着的 1.1.2(polygateway-1.1.2.dist-info)。四步:

  1. _build_telemetryclient.py:377-384)在每次 from_settings 里各 new 一个 PostgresRecorder,所以每个 GatewayClient 一个池
  2. _open_poolpostgres.py:144)调 asyncpg.create_pool(self._dsn, timeout=10)不传 min_size / max_size,吃 asyncpg 的默认值 10 / 10。
  3. asyncpg 建池时立刻把 min_size 条连接连满asyncpg/pool.py_initialize:先 await first_ch.connect(),再并发补齐剩下的 min_size - 1 条)。
  4. 建池失败后 self._failed = Truepostgres.py:150),此后 _ensure_ready 返回 None、每次写入直接 return;而 aclose()postgres.py:236-241)只清 _schema_ready不清 _failed,所以只有进程重启才恢复。

为什么它比业务连接更容易倒

业务侧的连接是一条一条按需要的——实例余量剩几条时,业务代码照样跑得动,它只要一条。

而这个池要么一次拿到 10 条、要么建池失败。所以余量紧张时,先倒下的必然是它。它是整条链路上最脆的一环,承担的却是最不该悄悄失败的职责之一。

规模

我们这边一个 worker 进程建 4 个 GatewayClient(4 个用途),就是 40 条常驻连接专用于写遥测;机器上有两条这样的外呼队列,两套环境并排跑,理论天花板 160 条。

而遥测的实际写入量是每次外呼一行,需要的并发是个位数。

那台 PostgreSQL 是多个项目共用的,max_connections=100

可能的方向(不主张任何一种,判断哪种合适是维护者的事)

  • 让池尺寸可配,或者直接调小默认值——按写入量看 1-2 条就够
  • 让指向同一个 DSN 的多个 recorder 共享一个池
  • 建池失败后允许退避重试,而不是永久降级

第三条要谨慎:postgres.py:147-149 的注释写明永久降级是刻意的,理由是遥测在业务路径上 await、每次重试都要内联吞掉一次 connect 超时。如果这条设计不变,那前两条的价值更大——把池子调小之后,「一次要 10 条」这个脆点本身就消失了,即使总连接数一条没少。

版本

/PolyGateway 的 1.2.4 checkout 我也对照读了一遍,上面这四点逐字未变(postgres.py:100 / 106 / 246-251client.py:405-420),min_size / max_size 在两个版本的整个包里一次都没出现过。

## 现象 2026-08-23 一次满量运行的中途,某一路模型调用的遥测**静默停止写入**,一直持续到进程重启。日志里唯一的线索是这一行: ``` WARNING | polygateway.telemetry.postgres:_open_pool:151 - Postgres 遥测建池失败,后续记录降级为 no-op: sorry, too many clients already ``` 后果:那一路此后跑了 19 次调用、一行遥测都没落库,按用途汇总的成本因此少记约 $5。业务侧全程正常,库里看不出任何异常——是拿日志里的完成里程碑条数和 `llm_calls` 的行数对了一次账才发现的。 ## 机制 读的是环境里装着的 1.1.2(`polygateway-1.1.2.dist-info`)。四步: 1. `_build_telemetry`(`client.py:377-384`)在每次 `from_settings` 里各 new 一个 `PostgresRecorder`,所以**每个 `GatewayClient` 一个池**。 2. `_open_pool`(`postgres.py:144`)调 `asyncpg.create_pool(self._dsn, timeout=10)`,**不传 `min_size` / `max_size`**,吃 asyncpg 的默认值 10 / 10。 3. asyncpg 建池时**立刻把 `min_size` 条连接连满**(`asyncpg/pool.py` 的 `_initialize`:先 `await first_ch.connect()`,再并发补齐剩下的 `min_size - 1` 条)。 4. 建池失败后 `self._failed = True`(`postgres.py:150`),此后 `_ensure_ready` 返回 `None`、每次写入直接 `return`;而 `aclose()`(`postgres.py:236-241`)只清 `_schema_ready`、**不清 `_failed`**,所以只有进程重启才恢复。 ## 为什么它比业务连接更容易倒 业务侧的连接是**一条一条按需要**的——实例余量剩几条时,业务代码照样跑得动,它只要一条。 而这个池**要么一次拿到 10 条、要么建池失败**。所以余量紧张时,先倒下的必然是它。它是整条链路上最脆的一环,承担的却是最不该悄悄失败的职责之一。 ## 规模 我们这边一个 worker 进程建 4 个 `GatewayClient`(4 个用途),就是 **40 条常驻连接专用于写遥测**;机器上有两条这样的外呼队列,两套环境并排跑,理论天花板 160 条。 而遥测的实际写入量是**每次外呼一行**,需要的并发是个位数。 那台 PostgreSQL 是多个项目共用的,`max_connections=100`。 ## 可能的方向(不主张任何一种,判断哪种合适是维护者的事) - 让池尺寸可配,或者直接调小默认值——按写入量看 1-2 条就够 - 让指向同一个 DSN 的多个 recorder 共享一个池 - 建池失败后允许退避重试,而不是永久降级 第三条要谨慎:`postgres.py:147-149` 的注释写明永久降级是**刻意的**,理由是遥测在业务路径上 `await`、每次重试都要内联吞掉一次 connect 超时。如果这条设计不变,那前两条的价值更大——把池子调小之后,「一次要 10 条」这个脆点本身就消失了,即使总连接数一条没少。 ## 版本 `/PolyGateway` 的 1.2.4 checkout 我也对照读了一遍,上面这四点逐字未变(`postgres.py:100 / 106 / 246-251`、`client.py:405-420`),`min_size` / `max_size` 在两个版本的整个包里一次都没出现过。
Author
Owner

已随 1.3.0 修复并合并入 main(feat/issue-15-telemetry-pool-lifecycle)。四层缺陷逐条落地: ① min_size=0 + PGW_TELEMETRY_PG_POOL_MAX(缺省 4)+ 每次写入硬预算 PGW_TELEMETRY_PG_WRITE_TIMEOUT_S(缺省 5.0s);② 判死判据从'哪一步失败'改挂'失败是什么性质',永久失能收窄到只剩 DSN 不可解析一类,其余 60s 冷却后自动重试;③ 进入/恢复各一条日志 + 降级期间节流复述 + client.telemetry_status 只读快照(含 dropped_rows);④ 全库统一'谁建的谁关'纪律,共享 recorder 路径打通。真实 PG 实测: 建完 recorder 0 条连接 → 一次写入后 1 条 → 20 行并发后 4 条 → aclose() 后回到 0。详见 CHANGELOG 1.3.0。

已随 1.3.0 修复并合并入 main(feat/issue-15-telemetry-pool-lifecycle)。四层缺陷逐条落地: ① min_size=0 + PGW_TELEMETRY_PG_POOL_MAX(缺省 4)+ 每次写入硬预算 PGW_TELEMETRY_PG_WRITE_TIMEOUT_S(缺省 5.0s);② 判死判据从'哪一步失败'改挂'失败是什么性质',永久失能收窄到只剩 DSN 不可解析一类,其余 60s 冷却后自动重试;③ 进入/恢复各一条日志 + 降级期间节流复述 + client.telemetry_status 只读快照(含 dropped_rows);④ 全库统一'谁建的谁关'纪律,共享 recorder 路径打通。真实 PG 实测: 建完 recorder 0 条连接 → 一次写入后 1 条 → 20 行并发后 4 条 → aclose() 后回到 0。详见 CHANGELOG 1.3.0。
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: iomgaa/PolyGateway#15