# 服务端点的语义

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

---

数据库连接串经常被当成部署细节：

```text
postgresql://user@host:port/database
```

但每个字段都在选择行为：

```text
host/port                入口和发现机制
database/user            catalog、身份和 pool key
target_session_attrs     可接受的服务端角色
sslmode/certificate      传输与身份验证
connect_timeout          一次建立连接最多占用多久
options/startup params   会话初始语义
```

所以 URI 是应用与数据平台之间的 API。修改它可能改变一致性、会话、性能、
安全与故障行为，不能当作无语义的运维替换。

## 22.1.1 主写、只读、同步只读与直连管理端点 {#item-22-1-1}

### 从业务动作定义端点

先列动作，再列端口：

| 动作 | 必要语义 | 不必要或有害的假设 |
|---|---|---|
| 创建订单 | 当前可写主库、提交结果可判定 | 固定机器 |
| 查看刚下的订单 | read-your-writes | 任意异步副本 |
| 浏览商品目录 | 可接受少量陈旧、只读 | 必须占用主库 |
| 生成日报 | 大查询、资源隔离、允许陈旧 | 普通 OLTP 副本 |
| 执行迁移 | 跟随主库、稳定 backend、完整会话能力 | 事务池 |
| 调查故障 | 保留管理槽、短超时、可审计 | 与应用共享无界池 |

由此可以得到几类服务，而不是几个机器别名。

### 主写服务

主写服务至少承诺：

```text
new connection
  -> currently accepted writable role
  -> transaction_read_only = off
  -> write identity and permissions valid
```

它不承诺：

```text
existing connection survives promotion/demotion
in-flight transaction transparently migrates
connection success means the next commit cannot fail
network error means the last commit did not happen
```

客户端可用 libpq 的：

```ini
target_session_attrs=read-write
```

作为最后一道角色检查。它不能选主，也不能修复代理；它只是在连接建立后拒绝
不满足属性的 session。

对写端点还要定义：

- 事务被断开后是否自动重试；
- 哪些动作有 idempotency key；
- 提交结果未知时如何查询；
- 是否允许 session pooling；
- 应用侧 pool acquire/connect/statement timeout；
- 最大并发和排队位置。

### 只读服务

只读端点有两个不同维度：

```text
role semantics
  当前 session 不能写

freshness semantics
  数据最多允许落后多少
```

`transaction_read_only=on` 只证明第一项，不证明第二项。一个停止 replay
数小时的副本仍然只读。

客户端可以组合：

```ini
target_session_attrs=read-only
```

以及事务级护栏：

```sql
BEGIN READ ONLY;
SELECT ...;
COMMIT;
```

前者防错误落点，后者把意图交给 PostgreSQL。二者都不能自动提供 bounded
staleness。

只读服务还要明确降级：

```text
replica unavailable
  -> fail closed
  -> fall back to primary as read-only workload
  -> return cached/stale result
  -> shed optional traffic
```

Pigsty 默认 replica service 把 primary 作为 backup。因此它表达
“副本优先”，而不是“永远只去副本”。如果业务必须隔离主库，声明与客户端
都要 fail closed，不能只相信服务名称。

### 同步只读不是“副本端口”的同义词

同步复制控制提交确认条件。例如：

```text
primary commit waits until chosen standby has durable WAL
```

这仍不自动表示：

```text
任意只读副本已经 replay 到该提交
客户端下一次连接一定选择那个同步副本
同步副本没有降级或被替换
查询开始前 replay 已越过业务 token
```

真正的“同步只读服务”至少要同时约束：

1. 哪些 standby 进入同步集合；
2. commit 等待到 write、flush 还是 apply；
3. 端点只选择哪个集合；
4. 同步状态丢失时 fail closed 还是降级；
5. 客户端如何携带上次写入边界。

若 `synchronous_commit=remote_apply` 且读取端点只选择参与确认的已 apply
副本，可以收紧窗口，但切换、负载均衡和事务起点仍需单独证明。

### 离线/分析服务

“replica”表示复制角色，“offline”通常表达调度意图：

```text
普通 OLTP 查询不选它
重型 OLAP/ETL 优先选它
资源参数、索引或延迟容忍可能不同
```

Pigsty 的 offline service 使用 `/replica` 检查，并通过 inventory selector
优先选 `pg_offline_query` 成员。它仍会 replay WAL，长查询、临时文件与 I/O
可能拖慢 replay；“离线”不表示与主集群完全隔离。

应额外写：

- 最大可接受 replay lag；
- 查询并发、statement timeout、temp file 限额；
- 是否允许 hot standby conflict 取消查询；
- 普通副本能否作为 backup；
- replay 明显落后时是否摘除。

### 直连管理端点

直连管理并不一定是固定主机。更实用的是：

```text
client -> semantic primary proxy -> PostgreSQL 5432
```

它跟随当前主库，但绕过事务池，适合：

- schema migration；
- 需要 session advisory lock 的部署工具；
- `LISTEN/NOTIFY` consumer；
- 大型 `COPY` 或特殊驱动工作流；
- 需要稳定 backend 的诊断；
- CDC/逻辑复制管理，在完成额外评审后。

绕过池不等于无限连接。管理端点通常应有更小、更受控的来源网段、身份、连接
预算与审计。

Pigsty 默认 `default` service 是这种路径：HAProxy 仍用 `/primary`
选择主库，但目标是 PostgreSQL `5432` 而不是 PgBouncer `6432`。

### 写一份端点合同

一个最小合同可以写成：

```yaml
service: pg36_shop_rw
purpose: short OLTP read-write transactions
role: primary
path: haproxy -> pgbouncer(transaction) -> postgres
client_guard: target_session_attrs=read-write
consistency: primary transaction semantics
session_features:
  allowed: [SET LOCAL, protocol_prepare_tested]
  forbidden: [LISTEN, session_advisory_lock, persistent_temp_state]
budget:
  app_instances: 8
  app_pool_per_instance: 12
  pgbouncer_server_pool: 40
  database_reserved_for_platform: 60
timeouts:
  acquire: 200ms
  connect: 2s
  statement: 3s
failure:
  retry: capped exponential backoff with jitter
  write_outcome: reconcile by idempotency token
security:
  tls: verify-full
  role: pg36_shop_app
owner: shop-platform
```

端口只是这个合同的实现字段之一。

## 22.1.2 复制延迟、一致性与 read-your-writes {#item-22-1-2}

### “刚写完却读不到”为什么完全正常

异步流复制的路径是：

```text
primary commit
  -> WAL generated/flushed
      -> sender transmits
          -> standby receives/writes/flushes
              -> startup process replays
                  -> read query starts with a snapshot
```

写请求收到成功，只表示其提交满足当前 `synchronous_commit` 规则。若规则不
等待目标副本 apply，随后的 replica query 可能先到。

形式化地，令：

```text
L_commit = 写事务之后主库的提交边界
L_replay = 读取开始前目标副本的 replay LSN
```

要让该副本具备读取边界，至少需要：

\[
L_{\text{replay}} \ge L_{\text{commit}}
\]

这仍不代表业务一定读到目标行：查询条件、事务 snapshot、权限、分区、
soft delete 和应用 cache 都可能改变结果。

### 四种常用策略

#### 策略一：写后粘主

```text
write success
  -> same request/session reads primary
  -> or tenant/user sticks to primary for bounded time
```

优点是简单；缺点是时间窗口不是因果证明，可能过长浪费主库，也可能过短。

适合：

- 用户刚提交后查看详情；
- 一次 HTTP request 内写后读；
- 没有 LSN token 基础设施的普通应用。

#### 策略二：携带一致性 token

写事务完成后，从主库取得一个 WAL 边界：

```sql
SELECT pg_current_wal_flush_lsn();
```

客户端把它作为 opaque token 传给后续读取。读取端点在副本上观察：

```sql
SELECT pg_last_wal_replay_lsn();
```

只有 replay 越过 token 才查询，否则：

```text
short wait
  -> primary fallback
  -> fail with retryable freshness error
```

注意：

- LSN 必须来自提交之后；事务内提前读取可能落在 commit record 之前；
- timeline 改变后，不能只做字符串大小比较；
- token 协议要绑定 cluster identity；
- 等待必须受 request deadline 限制；
- connection pool 可能在两次语句间换 backend；
- 读取事务 snapshot 必须在 replay 达标之后建立。

事务池场景最好把“等待边界 + 业务查询”放在同一只读事务或服务端函数里，
避免检查和查询落到不同副本。

#### 策略三：同步 apply 与限定路由

可以让提交等待同步副本 replay，再让读取只去被确认的集合。它增加写延迟，
并把副本可用性纳入提交路径。

必须定义降级时：

```text
block writes
reduce synchronous quorum
route reads back to primary
declare consistency downgrade
```

没有明确降级合同的“同步”会在故障时悄悄变成另一个语义。

#### 策略四：业务版本/事件边界

有些系统不用裸 LSN，而用：

```text
aggregate version
event sequence
updated_at + unique version
outbox offset
```

副本读到至少该业务版本才返回。这更贴近业务，但底层仍需可靠映射和超时。

### 延迟指标的几个阶段

副本延迟不是一个数字：

| 阶段 | PostgreSQL 观察 | 能推出什么 |
|---|---|---|
| sent | primary `sent_lsn` | WAL 已发到何处 |
| write | primary `write_lsn` | standby OS 收到/写入 |
| flush | primary `flush_lsn` | standby 持久化 |
| replay | primary `replay_lsn` / standby replay LSN | 查询可见边界接近何处 |
| query | 业务 token 查询 | 目标事实是否可见 |

`replay_lag=0` 是某次采样，不是未来保证。低流量时 timestamp lag 也可能显得
陈旧，LSN gap 与 wall-clock delay 要结合读。

### 本章的一个样本

正式实验：

```text
write path       10.10.10.11:5433 -> primary PgBouncer
read path        10.10.10.11:5434 -> replica PgBouncer
selected replica pg-test-2
token visible    11.092 ms
poll count       1
```

它只证明：

> 在这个时刻、这个 token、这个小型异步沙箱和当前负载下，副本在约
> 11.092 ms 后返回了该行。

它不能证明：

```text
P99 replica lag
切换期间延迟
高 WAL 吞吐下延迟
网络抖动下延迟
所有读取 read-your-writes
```

因此 evidence 把 `EX22-ASYNC-READ-OBSERVATION` 作为必需例外。

### 读端点失败也是语义

本章准备实验时曾观察到：

```text
Patroni topology          correct
direct PostgreSQL state   recovery=true, read_only=true
HAProxy health            UP
target_session_attrs      rejects one pooled path
```

根因位于 PgBouncer server pool 的角色状态，而不是 PostgreSQL 复制本身。
执行受控的：

```sql
RECONNECT test;
```

让空闲/完成的服务端连接重新建立后，端点属性恢复。

这说明一致性证据必须从客户端走完整路径。只在副本上执行
`SELECT pg_is_in_recovery()`，不能证明应用的 5434 连接会得到同样 session。

## 22.1.3 DNS、VIP、代理与客户端发现 {#item-22-1-3}

### 四种入口解决不同问题

#### 固定节点地址

```text
host=10.10.10.11
```

- 优点：简单、可诊断。
- 缺点：节点故障就是入口故障；即使 HAProxy 能路由数据库角色，客户端也到
  不了它。

本章 formal run 只验证这种单入口，因此明确保留例外。

#### DNS

DNS 可以：

- 把名称指向 VIP/代理；
- 返回多个 A/AAAA 记录；
- 在切换时修改地址；
- 为不同服务提供稳定名称。

但 DNS 不迁移既有 TCP 连接。还要考虑：

```text
authoritative TTL
resolver/client cache
JVM/driver cache policy
negative caching
record order and happy-eyeballs
DNS control-plane availability
```

“TTL 5 秒”不等于 5 秒内所有应用都换地址。

#### VIP

VIP 把一个网络地址移动到健康入口。它解决入口地址连续性，不决定数据库主库。

需要证明：

- 谁持有 VIP，使用什么仲裁；
- split brain 时是否可能双持；
- gratuitous ARP/NDP 与交换网络收敛；
- 跨网段/跨 AZ 是否可达；
- VIP manager 与 Patroni 状态如何组合；
- 入口节点的 HAProxy/PgBouncer 是否健康。

数据库主库、VIP owner 和 HAProxy backend 是三个状态机，不能假设天然一致。

#### 客户端多 host

libpq 连接串可以列多个 host：

```text
host=proxy-a,proxy-b
port=5433,5433
target_session_attrs=read-write
connect_timeout=2
```

客户端按规则尝试，`target_session_attrs` 拒绝错误角色。这减少单一入口依赖，
但每个应用/驱动的：

- host 顺序；
- DNS 展开；
- 并行或串行尝试；
- overall deadline；
- pool 的 address refresh；
- TLS hostname verification；

都要实测。

#### 专用代理/服务发现

外部 HAProxy、云负载均衡、Kubernetes Service 或服务网格可以提供入口。
仍要验证：

```text
L4 vs L7 protocol handling
idle timeout and TCP keepalive
health check semantics
connection draining
source IP and HBA
TLS termination/passthrough
backend queue
control-plane blast radius
```

### 一个完整发现链

```text
application logical name
  -> resolver / service discovery
      -> one or more reachable proxy addresses
          -> role-aware health check
              -> PgBouncer or PostgreSQL destination
                  -> SQL role assertion
```

每一步都有独立的 stale state：

```text
DNS cache stale
VIP owner stale
HAProxy health stale
PgBouncer backend state stale
application pool holds old TCP connection
```

切换测试必须穿过整个链，而不是只验证最末端的数据库角色。

### `target_session_attrs` 的位置

它是客户端接受条件：

```text
any        任意 session
read-write 必须可写
read-only  必须只读
primary    不是 hot standby
standby    是 hot standby
prefer-standby 优先 standby，必要时接受其他
```

具体取值以当前 libpq 官方文档为准。它不能：

- 限制副本延迟；
- 保证连接经过或绕过 PgBouncer；
- 替代 TLS hostname 检查；
- 让失败事务自动迁移；
- 识别业务上的“正确集群”。

因此连接串还需要正确的 host、database、user、证书和 cluster identity
治理。

### 入口验收矩阵

| 场景 | 要执行的动作 | 通过条件 |
|---|---|---|
| 正常主库 | 从每个入口连接写服务 | 都选同一可写 leader |
| 正常副本 | 连接只读服务 | 只读，成员在允许集合 |
| 一个入口故障 | 停止/隔离入口 | 客户端转向另一入口 |
| DNS 变化 | 修改记录并保留旧连接 | 新连接收敛，旧连接行为已定义 |
| 数据库切换 | planned role change | 入口不变，新连接到新主库 |
| proxy reload | 配置校验后 reload | 现有/新连接行为符合 draining 合同 |
| stale pool | 角色变化后保留 server pool | 能检测并安全 refresh |
| TLS rotation | 轮换 CA/cert | 新旧窗口与 hostname 验证通过 |

本章只执行正常四端点和数据库 planned switch。入口节点故障、DNS/VIP 和 TLS
留给生产准入矩阵，不能由一次成功连接替代。

## 本节检查表

```text
[ ] 每个服务按业务动作命名，而不是按主机命名
[ ] role、freshness、session、path、budget、failure 全部成文
[ ] 写端点有 target role 与 outcome-unknown 策略
[ ] 读端点写清 primary fallback 和 staleness 边界
[ ] 分析端点有资源与 replay 保护
[ ] 管理端点跟随主库但有独立预算/权限
[ ] DNS/VIP/multi-host 的失败域经过测试
[ ] SQL 观察走完整客户端路径
[ ] 角色变化后重新验证池化 session
```

## 参考资料

- [PostgreSQL 18：libpq connection parameters](https://www.postgresql.org/docs/18/libpq-connect.html)
- [PostgreSQL 18：hot standby](https://www.postgresql.org/docs/18/hot-standby.html)
- [PostgreSQL 18：warm standby settings](https://www.postgresql.org/docs/18/runtime-config-replication.html)
- [Pigsty：PostgreSQL Service](https://pigsty.io/docs/pgsql/service/)

---

[返回本章目录](../) · [下一节：连接的服务端成本](../02/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
