# 服务级可观测性

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

---

一次请求跨过 HTTP、应用 pool、PgBouncer、PostgreSQL transaction 与 outbox。每层都有自己的 identity：

```text
request/trace ID       one logical request or business flow
idempotency key        one replayable business command
application_name       one low-cardinality workload class
query identity         one normalized SQL shape
transaction/backend    one execution attempt
outbox event key       one publishable fact
release identity       one application/database artifact
```

把它们全塞进一个字符串会失去可聚合性；一个都不关联又无法从用户症状走到数据库证据。

## 12.5.1 请求、事务、查询指纹与 `application_name` {#item-12-5-1}

### Identity 分层

本章的关联关系：

```text
request:
  X-Request-ID=trace-order-001

database session class:
  application_name=pg36-ch12-api

durable facts:
  sales_order.trace_id=trace-order-001
  outbox.trace_id=trace-order-001
  outbox.event_key=order:order-001:placed

business replay:
  request_key=order-001
  fingerprint=sha256(canonical command fields)
```

`trace_id` 告诉你一次调用从哪里来；`request_key` 告诉数据库它是否与先前命令是同一个业务意图。两者不能互换：

- retry 可以有新 trace，但保留同 idempotency key；
- 同一 trace 可能跨多个内部调用；
- trace 通常有保留期，idempotency ledger 是业务状态；
- trace 不应承担 uniqueness/authorization。

### `application_name` 保持低基数

本章所有业务 backend：

```text
application_name=pg36-ch12-api
```

不要每请求改成：

```text
pg36-api/tenant-123/user-456/trace-abcdef...
```

否则：

- metrics cardinality 爆炸；
- `pg_stat_activity` 分组失去意义；
- PgBouncer tracking/reset 行为更复杂；
- 日志与 dashboard 成本上升；
- 可能泄露 tenant/user。

合理粒度：

```text
pg36-api-rw
pg36-worker-outbox
pg36-migration
pg36-read-report
```

必要时附固定 version/channel，但先评估 cardinality。per-request identity 放结构化 log/trace，不放 session label。

### Query identity 不等于 raw SQL + values

观测 query 应优先关联：

- PostgreSQL `query_id` / `pg_stat_statements.queryid`；
- normalized query text；
- driver operation name；
- route/query bundle mapping；
- application version。

不要把 password、token、PII、完整 JSON 或 payment data 作为 metric label。参数对诊断重要时：

- 保存经过批准的 bounded class，例如 `sku_class=known/missing`；
- 对值 hash/pseudonymize；
- 只在短期受控 evidence 中保留；
- 遵循日志保留与访问政策。

第 8 章已说明 query text 与 parameter identity 缺一不可；本章增加了 API route 与 trace，但没有取消隐私边界。

### Transaction attempt 与 logical request

`trace-retry-001` 的服务请求只返回一次，数据库执行了两个 transaction attempt：

```text
attempt 1 → 40001 rollback
attempt 2 → commit order 1200002
```

metrics 同时需要：

```text
request total=1
transaction retry total=1
SQLSTATE 40001 total=1
committed order total=1
```

如果只看 HTTP 201，会漏掉 serialization pressure；如果把每次 attempt 都算一个请求，会夸大业务流量。

### 结构化日志

正常请求：

```json
{
  "msg": "request",
  "route": "orders.create",
  "status": 201,
  "duration_ms": 3.004,
  "trace_id": "trace-order-001"
}
```

数据库 timeout：

```json
{
  "msg": "request_error",
  "error_code": "database_timeout",
  "status": 504,
  "retryable": true,
  "trace_id": "trace-timeout-001",
  "sqlstate": "57014",
  "constraint": ""
}
```

日志不包含：

```text
PG36_DATABASE_URL
password
raw request body
full SQL arguments
stack trace in client response
```

不是所有错误都要打 stack；预期 domain conflict 应按可聚合 code 记录，真正未知 defect 才需要更深内部 cause。

## 12.5.2 延迟、错误、连接等待和数据库等待 {#item-12-5-2}

### 一个慢请求至少有四段

\[
T_{request}
= T_{app}
+ T_{pool}
+ T_{db}
+ T_{response}
\]

`T_db` 还可拆：

```text
parse/plan
execution CPU/IO
lock wait
client read/write wait
commit/WAL
```

仅看总延迟无法决定加 index、加 connection 还是减并发。

### 应用 pool wait

pgxpool 暴露：

```text
AcquireCount
AcquireDuration
EmptyAcquireCount
EmptyAcquireWaitTime
CanceledAcquireCount
AcquiredConns
IdleConns
TotalConns
MaxConns
```

本章转换成：

```text
pg36_pool_acquire_total
pg36_pool_acquire_seconds_total
pg36_pool_empty_acquire_total
pg36_pool_canceled_acquire_total
pg36_pool_connections{state=...}
```

`CanceledAcquireCount` 上升表示请求在拿到 DB connection 前就耗尽 context。此时 PostgreSQL `pg_stat_activity` 看不到对应 query；从数据库侧“没有慢 SQL”并不能证明数据库路径无关，连接预算或 pool queue 可能已经挡在前面。

### PgBouncer wait

通过 Pigsty primary service 时还有 PgBouncer 队列。需要结合：

- PgBouncer client active/waiting；
- server active/idle；
- pool size/reserve；
- average/max wait；
- connection errors；
- user/database pool identity；
- HAProxy/service health。

app pool 不等待、PostgreSQL backend 也不多，仍可能是 PgBouncer client 在等 server slot。第 22 章会用 `SHOW POOLS` / `SHOW STATS` 和 Pigsty dashboard 深化。

### PostgreSQL wait

取得 backend 后，观察：

```sql
SELECT
    pid,
    application_name,
    state,
    wait_event_type,
    wait_event,
    query_id,
    xact_start,
    query_start
FROM pg_catalog.pg_stat_activity
WHERE application_name = 'pg36-ch12-api';
```

典型解释：

| state/wait | 方向 |
|---|---|
| active + Lock | blocking graph |
| active + IO | plan/buffers/storage |
| active + Client* | application consume/send |
| idle in transaction | leaked transaction/locks/vacuum impact |
| no backend + app pool wait | app-side admission |
| PgBouncer waiting + few DB slots | pooler/server budget |

不要把 `state=active` 等同于“在用 CPU”；wait event 才说明当前等待类型。

### Error 指标保留 SQLSTATE 与领域 code

本章同时记录：

```text
HTTP route/status class
domain error code in logs
SQLSTATE counter
transaction retries
idempotent replays
```

一次运行：

```text
pg36_db_errors_total{sqlstate="40001"}=1
pg36_db_errors_total{sqlstate="57014"}=1
pg36_transaction_retries_total=1
pg36_idempotent_replays_total=2
```

SQLSTATE cardinality 是 bounded vocabulary；constraint name 通常也相对 bounded，但 table/tenant/raw error message 不适合无审查直接做 label。

### Rate、error、duration 与 saturation 同看

最小服务面板：

```text
traffic:
  requests/s by route

errors:
  status class + domain code + SQLSTATE

duration:
  p50/p95/p99 request
  DB/query duration
  pool acquire duration

saturation:
  app pool acquired/max/wait/cancel
  PgBouncer wait/server slots
  PostgreSQL active/wait/CPU/IO
```

平均值会隐藏 tail。pool wait 若只占 1% 请求，平均可能很小，p99 已经超时。

## 12.5.3 从一次请求追到数据库证据 {#item-12-5-3}

### 一条可执行调查路径

用户报告下单超时，先固定：

```text
UTC window
route=orders.create
trace_id
idempotency key if authorized
application version
service endpoint/pool mode
```

然后沿层次走：

```text
1. application log
   status/domain code/duration/trace

2. pool metrics
   acquired/max/acquire wait/canceled

3. PgBouncer/Pigsty
   client wait/server slots/service target

4. PostgreSQL
   application_name/query_id/wait/blocker/SQLSTATE

5. durable business evidence
   idempotency ledger/order/outbox

6. client outcome
   response received, timed out, or unknown
```

最后一步不是“SQL 后来成功了吗”，而是：

```text
logical command committed?
what response is persisted?
is replay safe?
is an outbox event pending?
```

### 实测 trace 关系

本章 observer 独立于 app 查询：

```text
order 1200001:
  sales_order.trace_id=trace-order-001

payment 1200001:
  payment.trace_id=trace-payment-001

outbox:
  order:order-001:placed
    → trace-order-001
  order:order-retry:placed
    → trace-retry-001
  payment:pay-001:captured
    → trace-payment-001

pg_stat_activity:
  distinct application_name=[pg36-ch12-api]
```

这让 operator 能从 request 走到 committed fact，又保持 database session label 可聚合。

### 诊断一次 pool exhaustion

实验时间线：

```text
t0:
  holder-1 acquires connection
  holder-2 acquires connection
  database active sleepers=2

t1:
  /health/live → 200

t2:
  /health/ready waits its 150 ms budget
  → 503 pool_unavailable

t3:
  business GET waits 100 ms request budget
  → 503 pool_unavailable

t4:
  holders complete 200
  acquired returns 0
  readiness → 200
```

证据组合：

```text
app pool max=2
canceled acquisition=2
PostgreSQL had exactly two sleepers
no business SQL for rejected GET
liveness unaffected
recovery without process restart
```

这排除了“PostgreSQL query 本身超时”，并证明 admission queue 按预算失败。

### 诊断一次 statement timeout

```text
trace=trace-timeout-001
fault=statement-timeout
SET LOCAL statement_timeout=50ms
pg_sleep(200ms)
SQLSTATE=57014
HTTP=504 database_timeout
state snapshot before == after
```

若只看 504，会与 gateway timeout 混淆；若只看 57014，又会与 client cancel 混淆。timeline + source timeout + state comparison 才是完整证据。

### Unknown commit 的调查

客户端 timeout 后不要先删 key 再重试。先用相同 idempotency key 查询/重放：

```text
ledger has completed response
  → return it; command committed

ledger absent
  → safe to attempt command

ledger exists but incomplete
  → protocol defect/manual repair path
```

本章 schema constraint 允许 transaction 内暂时 `response=NULL`，但完整 transaction 失败会 rollback；正常 committed state 只能是 key+order/payment+response 完整。verify 把 incomplete committed row 当失败。

## 本节检查表

- [ ] trace、idempotency、application、query、event identity 分层；
- [ ] `application_name` 是低基数 workload class；
- [ ] request retry 与 transaction attempt 分开计数；
- [ ] log 使用结构化 code，不泄露 secret/raw payload；
- [ ] request、pool、PgBouncer、DB 延迟可分别观察；
- [ ] pool canceled acquire 有独立 metric；
- [ ] `pg_stat_activity` 同时看 state 与 wait event；
- [ ] SQLSTATE 与 domain code 都保留；
- [ ] tail latency 与 saturation 同窗；
- [ ] trace 能关联到 durable order/payment/outbox；
- [ ] unknown commit 通过 ledger 判断，不凭网络结果猜；
- [ ] 故障结论同时包含反证与 committed state。

## 参考资料

- [PostgreSQL 18：Monitoring Database Activity](https://www.postgresql.org/docs/18/monitoring-stats.html)
- [PostgreSQL 18：pg_stat_activity](https://www.postgresql.org/docs/18/monitoring-stats.html#MONITORING-PG-STAT-ACTIVITY-VIEW)
- [PostgreSQL 18：Error Codes](https://www.postgresql.org/docs/18/errcodes-appendix.html)
- [pgx v5.10.0：pgxpool Stat](https://pkg.go.dev/github.com/jackc/pgx/v5@v5.10.0/pgxpool#Stat)
- [Pigsty：PostgreSQL Dashboards](https://pigsty.io/docs/pgsql/dashboard/)
- [Pigsty：PgBouncer Administration](https://pigsty.io/docs/pgsql/admin/pgbouncer/)

---

[上一节：会话状态与连接池陷阱](../04/) · [返回本章目录](../) · [下一节：部署与接入 `pg36_shop`](../06/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
