# SQL 采集的总时限与客户端取消

LLMS 索引： [llms.txt](/zh/llms.txt)

---

> **状态：** 已随 v1.6.0 发布。<br>
> **决策／最后核验：** 2026-11-09。<br>
> **范围：** main 上的 v1.6.0，源码 `6bee809`，解决 [#116](https://github.com/pgsty/pg_exporter/issues/116)，未随 v1.5.0 发布。<br>
> **交付：** v1.6.0 标签指向 `6bee809`；GitHub Release、带版本号的压缩包、RPM／DEB 和校验和已发布。源码 CI、制品验收与 amd64／arm64 容器 TLS 验证通过。软件发布不等于生产部署或生产负载验证。

## 决策 {#decision}

此前查询从 `context.Background()` 派生 context，指标客户端断开后工作仍可能继续，后续 HTTP 请求也可能无限等待 exporter 锁。本次限制这些工作，同时保留失败隔离与缓存复用。

最多接纳两个指标请求，涵盖采集、等待及响应写入。一次只执行一轮采集，第二个抓取者可以在剩余请求预算内等待同一把采集锁，更多请求返回 HTTP 503。等待可以取消，不启动额外 goroutine，也不执行 SQL。这样支持双 Prometheus 抓取，且不引入无界队列或共享任务的取消归属问题。

请求预算沿用现有 30 秒 HTTP 写入时限，合法、有限且为正数的 `X-Prometheus-Scrape-Timeout-Seconds` 请求头可以缩短它；排队也消耗同一预算。预检继承请求 context；采集在每个数据库和采集器开始前检查 context，过期后不再启动新的采集器。已开始执行且设有 timeout 的采集器，其谓词与主查询共用同一时限，即使请求结束也会执行完毕，结果照常缓存供后续抓取使用。负数 timeout 取消单查询期限，因此只有这类查询会随请求一起取消。

沿用原有失败缓存策略：刷新失败（包括被取消的无上限查询）时，清空该采集器结果，并按正常 TTL 记录此次尝试，下个请求不会立即重跑。客户端离开不会把健康的有界刷新变成失败：fatal 采集器不会向其他抓取者报告 `pg_up 0`，TTL 为 60 或 300 秒的容量与膨胀采集器也不会在整个 TTL 内缺失。例外是 timeout 为负数的 fatal 采集器：取消它会在 TTL 内缓存 fatal 失败，因此 fatal 采集器应保留 timeout。因客户端离开或请求预算耗尽而中断的采集轮次的汇总日志为 INFO，不计入 `pg_exporter_scrape_error_count` 与 `pg_exporter_server_scrape_error_count`，这两个计数继续只统计连接错误与 fatal 采集器错误。其他采集器的缓存保持有效。不发布中断采集器的部分结果，也不把过期值悄悄当作新数据返回。TTL 为零或 `--disable-cache` 仍表示每次抓取都刷新。因此较长 TTL 也会延迟失败后的恢复，这是已有权衡，不增加重试调度器。

采集形成响应快照后，在编码和网络写入前释放采集锁。请求直到响应结束仍占用接纳名额，从而同时限制等待和慢响应。保留 Prometheus 编码、压缩、Go／进程指标及 HTTP 自监控；标准采集器接口继续可用，不在 exporter 上保存可变请求 context。

## 备选方案与边界 {#alternatives-and-boundaries}

一律立即拒绝重叠请求会给双抓取者制造不必要的缺口；取消后清零重试时间会反复运行昂贵查询并重建连接，评审后放弃这两个方案。同样放弃随请求取消有界查询：一个客户端断开会变成所有抓取者共享的失败缓存，而且 lib/pq 每次取消都会关闭连接。让正在执行的采集器执行完毕，代价通常是预算过期后最多再占用采集锁该采集器自身的 timeout（默认配置最长 2 秒）。也放弃在同一采集器的谓词与主查询之间中止：那需要单独的“未执行”状态和回滚，省下的工作仍受同一时限约束。无限返回旧结果会掩盖数据陈旧，需要单独的新鲜度契约。本次不增加后台刷新、共享 gather、并行调度、新参数，也不迁移驱动。

预算过期会停止采集并取消无上限 SQL。正在执行的有界采集器可能让响应超出预算，通常最多为其自身 timeout。网络阻塞时驱动可能占用更久，v1.5.0 亦然，因此预算与查询 timeout 都不是硬性的墙钟上限，也不预留固定编码余量；HTTP 写入期限仍是传输层限制。尤其是 lib/pq 建连握手需要合适的 DSN `connect_timeout`，仅有查询 context 无法约束每个网络阶段。独立健康探测见[目标状态记录](/zh/design/target-state-contracts/)。

## 错误计数边界 {#error-accounting}

中断轮次不增加 `pg_exporter_scrape_error_count` 和 `pg_exporter_server_scrape_error_count`，轮次汇总日志为 INFO `interrupted`；查询错误计数与 fatal 查询的 ERROR 日志仍然保留。因此，恰好与请求取消重叠的真实失败可能不计入这两个轮次计数。请求没有取消时，连接／预检失败和 fatal 查询失败（包括自身超时）仍按错误计数；非 fatal 失败仍体现在查询级计数中。

## 验收 {#acceptance}

验证连续取消不会绕过 TTL，健康采集器及有效缓存仍可用，并在 TTL 过期后恢复尝试。覆盖双抓取重叠、取消等待者、接纳名额已满、取消无上限 SQL、有界 SQL 执行中客户端离开后结果仍须缓存、中断的采集轮次不得增加错误计数，以及慢响应不能占用采集锁。真实 PostgreSQL 须确认取消、SQL 尝试次数受限及后续抓取成功，另行验证 PgBouncer。本地验收不等于发布或生产负载保证。

## 本地验证 {#local-verification}

完整 race 测试与重点并发用例连续十轮通过。在 PostgreSQL 18.6 上，被取消的刷新在 TTL 内只执行一次；后续六次请求返回健康指标，没有重复失败 SQL，TTL 过期后可以成功重试。五轮双抓取均返回 HTTP 200，每轮只刷新一次 SQL。测试还覆盖取消等待者、接纳名额耗尽、部分行、谓词／预检取消，以及慢响应结束前释放采集锁。本次未增加后台刷新或旧值回退。

上述取消实测早于一项评审调整，该调整只影响 timeout 为正数的查询。调整后在 PostgreSQL 18.6 上，0.8 秒的有界查询执行期间客户端离开或 0.3 秒预算到期，查询都会执行完毕；预算到期的请求在 0.8 秒后返回 503，下一次抓取直接复用结果，不再执行。3 秒的无上限查询在客户端于 1.5 秒离开时被 PostgreSQL 取消（SQLSTATE 57014），没有残留活跃后端，并在 TTL 内保持缺失。调整前，同一有界查询会被取消，并在整个 TTL 内缺失。回归测试确认，fatal 采集器执行中客户端离开，下一个抓取者不再看到 `pg_up 0`。

第二轮评审发现，取消后正在执行的采集器还会继续执行其余谓词与主查询，并且中断的采集轮次会使两个错误计数增加、记录 error 日志。上文契约已改为按采集器描述这一行为，中断轮次也不再计数。在 PostgreSQL 18.6 上，0.8 秒的有界 fatal 查询执行中客户端离开，此前两个计数均为 1 并有 error 日志；修正后均保持 0，只记 info 日志，下一次抓取返回 `pg_up 1` 与缓存指标。回归测试改用显式同步而非 sleep，并确认真实连接失败仍会计数；完整测试在 race 检测下连续 20 轮通过。
