跳至内容

22.1 服务端点的语义

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

postgresql://user@host:port/database

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

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

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

22.1.1 主写、只读、同步只读与直连管理端点

从业务动作定义端点

先列动作,再列端口:

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

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

主写服务

主写服务至少承诺:

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

它不承诺:

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 的:

target_session_attrs=read-write

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

对写端点还要定义:

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

只读服务

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

role semantics
  当前 session 不能写

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

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

客户端可以组合:

target_session_attrs=read-only

以及事务级护栏:

BEGIN READ ONLY;
SELECT ...;
COMMIT;

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

只读服务还要明确降级:

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,不能只相信服务名称。

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

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

primary commit waits until chosen standby has durable WAL

这仍不自动表示:

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

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

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

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

离线/分析服务

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

普通 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 明显落后时是否摘除。

直连管理端点

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

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

写一份端点合同

一个最小合同可以写成:

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

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

异步流复制的路径是:

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 可能先到。

形式化地,令:

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

要让该副本具备读取边界,至少需要:

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

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

四种常用策略

策略一:写后粘主

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

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

适合:

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

策略二:携带一致性 token

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

SELECT pg_current_wal_flush_lsn();

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

SELECT pg_last_wal_replay_lsn();

只有 replay 越过 token 才查询,否则:

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,再让读取只去被确认的集合。它增加写延迟, 并把副本可用性纳入提交路径。

必须定义降级时:

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

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

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

有些系统不用裸 LSN,而用:

aggregate version
event sequence
updated_at + unique version
outbox offset

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

延迟指标的几个阶段

副本延迟不是一个数字:

阶段PostgreSQL 观察能推出什么
sentprimary sent_lsnWAL 已发到何处
writeprimary write_lsnstandby OS 收到/写入
flushprimary flush_lsnstandby 持久化
replayprimary replay_lsn / standby replay LSN查询可见边界接近何处
query业务 token 查询目标事实是否可见

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

本章的一个样本

正式实验:

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 后返回了该行。

它不能证明:

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

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

读端点失败也是语义

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

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 复制本身。 执行受控的:

RECONNECT test;

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

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

22.1.3 DNS、VIP、代理与客户端发现

四种入口解决不同问题

固定节点地址

host=10.10.10.11
  • 优点:简单、可诊断。
  • 缺点:节点故障就是入口故障;即使 HAProxy 能路由数据库角色,客户端也到 不了它。

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

DNS

DNS 可以:

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

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

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:

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 或服务网格可以提供入口。 仍要验证:

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

一个完整发现链

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:

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

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

target_session_attrs 的位置

它是客户端接受条件:

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 留给生产准入矩阵,不能由一次成功连接替代。

本节检查表

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

参考资料


返回本章目录 · 下一节:连接的服务端成本 · 查看全书目录 · 查看索引中心

最后更新于