后端系统工程 15:配置发布与客户端收敛
系列导航:上一篇 14 会话存储与登录撤销 | 本篇 15 | 下一篇 16 服务发现中的旧地址
一次配置发布在数据库里只是一次事务提交,真正难的是提交之后的那段路:成百上千个客户端进程要在有限时间内拿到这份新配置,中途可能错过通知、收到迟到的旧响应、整段时间离线,或者在服务端不可达时启动。发布本身也会出错,一份取值越界的配置一旦被客户端接受,后果和一次错误的代码上线相同。
本篇实现一个最小配置中心:PostgreSQL 保存不可变版本和单调递增的发布记录,一个独立 JVM 提供快照与长轮询接口,客户端是独立的 Python 进程,带本地缓存和灰度命中。实验用真实组件制造五类故障:通知丢失、旧响应、客户端离线、服务器重启、缓存损坏,再加两个负向对照,分别是只靠通知的服务器和无条件接受响应的客户端。代码在 examples/15-config-center/,输出在 evidence/15/output.txt。
需求契约
操作只有四个:发布一份配置(可附灰度规则)、回退到某个旧版本、客户端读取当前快照、客户端等待下一次变化。系统承诺以下不变量:
- 版本内容写入后不再修改;每次发布或回退都追加一条发布记录,
release_seq在 namespace 内单调递增。 - 校验失败的配置不产生版本,也不产生发布记录,因此不可能被任何客户端看到。
- 回退本身是一次新发布:它有自己的序号和时间,旧记录保持原样,审计时能看到“谁在什么时候把配置换回了哪个版本”。
- 客户端的已知序号只增不减;序号不大于已知值的响应一律丢弃。
- 只要数据库和配置服务器可达,任何一次发布都会在“兜底轮询间隔加一次查询”的时间内唤醒所有在线等待者,即使通知丢失也一样。
不承诺:客户端在服务端不可达期间看到新配置;所有客户端在同一时刻切换;离线客户端逐条回放中间版本。最后一条是有意为之,配置的消费者通常只关心当前值,跳过中间版本可以减少无意义的切换。
教学规模假设:一个 namespace,配置只有 timeout_ms(50–10000 的整数)、retry(0–5 的整数)、feature_x(布尔)三个键;服务器兜底轮询间隔 1s,LISTEN 断线后 4s 重连;客户端长轮询超时 20s。这些参数只服务于实验观察,不代表推荐值。
状态归属
图 15-1:状态归属与持久化位置
flowchart LR
subgraph PG["PostgreSQL b2_15(唯一事实来源)"]
V[(cfg.config_version<br/>只插入,触发器拒绝改删)]
R[(cfg.release<br/>namespace + release_seq 主键)]
F["cfg.publish / cfg.rollback<br/>校验 + 追加 + pg_notify"]
end
P[发布者<br/>角色 b2_15_pub] -->|调用函数| F
F --> V
F --> R
S["ConfigServer(独立 JVM)<br/>内存只有唤醒计数器"] -->|LISTEN cfg_release| PG
S -->|每 1s 查 count| R
S -->|cfg.snapshot| R
C1["客户端进程<br/>内存:已知 head_seq"] -->|GET /watch?known=N| S
C1 --> D[("本地缓存 app.json<br/>tmp + fsync + rename")]
配置服务器不持有任何发布状态。它重启时丢掉的只是等待者列表,客户端会重新发起长轮询,服务端在请求开始时查一次库,有新序号就立即返回。这条性质让服务器可以随时 kill -9,后面的实验正是这样做的。
发布事务
cfg.publish 在一个事务里完成全部步骤(setup.sql):
1 | |
咨询锁保证 max + 1 不会被两个并发发布算成同一个值;即便锁漏掉,(namespace, release_seq) 主键也会让第二个事务失败,而不是产生重复序号。pg_notify 放在事务里有一个直接好处:PostgreSQL 规定事务内的通知要等事务提交才投递,事务中止则什么都不发(PostgreSQL 16 NOTIFY)。校验失败导致的回滚因此不会唤醒任何客户端。通知只带 ns:seq,内容由服务器回表读取,这也符合文档的建议:payload 默认要短于 8000 字节,大块数据应放在表里,只传记录的键。
不可变由触发器保证,对表属主同样生效。versions_append_only 用例分别以超级用户执行 UPDATE、DELETE,以发布角色执行 UPDATE:
1 | |
invalid_config_rejected 依次提交截断的 JSON、越界的 timeout_ms=20000、多出一个未知键、把数字写成字符串,以及一条 percent=150 的灰度规则。五次全部被拒,发布记录与版本行数前后都是 2/2,在线客户端在随后 1.5s 内没有产生任何状态事件:
1 | |
校验放在服务端事务里,而不是只放在发布工具里,是因为发布入口可能不止一个:控制台、脚本、批量迁移。只要它们都通过 cfg.publish,规则就只有一份。代价是 schema 变更需要改数据库函数,而且 PL/pgSQL 不适合表达很复杂的跨键约束;规则复杂到一定程度时,常见做法是把校验器做成独立服务,再由发布函数调用它的结果。本篇不涉及这一层。
回退调用 cfg.rollback('app', 1),追加一条 kind=rollback 的全量记录,指向版本 1:
1 | |
序号继续增大到 3,客户端按“序号更大即接受”的规则收敛到旧内容。如果回退被实现成“把 release 表最新一行改回旧版本”,序号不变,已经应用了序号 2 的客户端会把序号 2 的回退响应当作重复而丢弃,回退在这些客户端上就不会生效。
灰度:序号前进与内容生效分开
灰度规则写在发布记录上:{"percent": n} 按客户端标识的 md5 前 32 位取模 100 分桶,桶号小于 n 即命中;{"labels": [...]} 按标签命中,两者取“或”。快照函数返回两个序号:head_seq 是该 namespace 的最新发布序号,release_seq 是对这个客户端命中的最新一条记录。
客户端用 head_seq 判断响应新旧,用 version_id 判断内容是否变化。未命中灰度的客户端收到灰度发布的响应时,已知序号前进,内容不变;如果它只看 release_seq,每次长轮询都会因为“服务端有更新的序号”立即返回,形成忙等。
1 | |
带 canary 标签的客户端应用了灰度版本 3;桶号不小于 30 的 c-000 只记录了 head_advanced,仍停在回退后的版本 1。200 个合成标识中命中 57 个,与按 cfg.bucket 直接计算的期望值相同,重复请求结果不变。57 不等于 60,说明 200 个样本上的哈希分布本来就有偏差;百分比是分桶规则,不是对实际比例的保证。随后的全量发布(序号 5)让两个客户端都应用版本 4。
服务端怎样被唤醒
长轮询请求带着客户端已知的序号到达。服务器先查一次库,若已有更新立即返回;否则挂起,直到被唤醒或超时。唤醒有三个来源:LISTEN 连接收到通知;兜底轮询发现 cfg.release 行数变化(只追加的表,行数变化就意味着有新发布);LISTEN 连接重建之后主动唤醒一次。
第三个来源来自 LISTEN 文档的一条规则。新的监听会话只会收到在其 LISTEN 提交时刻之后提交的事件,文档因此要求先执行并提交 LISTEN,再查询数据库状态,之后才依赖通知(PostgreSQL 16 LISTEN)。会话结束时监听注册自动清除。由此推出:LISTEN 连接断开到重建之间提交的发布,重建后的连接收不到。文档没有直接写“不补发”,但它规定的投递范围已经把这段时间排除在外。
等待循环要处理一个更细的窗口:查库时还没有新发布,查完、进入等待之前通知到了。若先查库再读计数器,这次唤醒会被漏掉,等待者一直睡到超时。实现先读计数器,再查库,再等计数器变化:
1 | |
图 15-2:通知丢失时的唤醒路径(主服务器:轮询 1s,LISTEN 重连延迟 4s)
sequenceDiagram
participant C as 客户端
participant S as ConfigServer
participant L as LISTEN 连接
participant DB as PostgreSQL
C->>S: GET /watch?known=5&timeout=20000
S->>DB: cfg.snapshot → head_seq=5,挂起等待
Note over L,DB: pg_terminate_backend(listener):57P01
L-->>S: listener lost,4s 后重连
DB->>DB: cfg.publish 提交 seq=6,NOTIFY 无监听者
S->>DB: 轮询 select count(*) 发现 5→6
S->>S: wake("poll")
S->>DB: cfg.snapshot → head_seq=6
S-->>C: 200,X-Wake: poll
C->>C: 6 > 5,接受;写缓存
C->>S: GET /watch?known=6(下一轮)
Note over L: 4s 后 LISTEN 重建,wake("reconnect") 复查一次
missed_notify_poll_fallback 重复 5 轮:找到服务器 LISTEN 连接的进程号,pg_terminate_backend 终止它,立即发布,等客户端收敛。每轮发布时刻相对轮询周期的相位不同:
1 | |
五轮全部由轮询唤醒,耗时 285–1212ms。重连延迟设为 4s,正是为了让轮询和重连复查在时间上分开,确认收敛来自轮询。耗时上限略超过 1000ms,多出的部分包含 psql 进程启动、一次快照查询和 HTTP 往返。服务器日志记录了每一次断线:listener lost: 57P01 FATAL: terminating connection due to administrator command。
负向对照是只靠通知的服务器(notify-only:不轮询,LISTEN 重连后也不复查),客户端长轮询超时设为 15s:
1 | |
故障后 6s 内客户端没有前进。这期间 LISTEN 已经在 1s 后重建,进程号变了,通知路径恢复正常,但错过的那次通知不会再来。客户端最终在 14444ms 前进,来源是 deadline:请求到达截止时间时,等待循环会再查一次库。长轮询的超时本身就是一次周期为 15s 的拉取,只要客户端在超时后重新请求、服务端在请求开始和截止时查库,“永不收敛”就退化为“延迟到一个长轮询周期”。若把超时调到 60s 或更长,或者服务器只在收到通知后才查库,这个延迟就会继续变长,甚至无界。
推、拉与两者结合
| 方案 | 正常延迟 | 通知丢失时 | 服务端负载 |
|---|---|---|---|
| 只推(通知触发) | 通知往返,实验中百毫秒量级 | 等下一次发布,或等长轮询超时复查;实验中 14.4s | 只在发布时查库 |
| 只拉(客户端周期轮询) | 平均半个轮询周期 | 不受影响 | 客户端数 ÷ 轮询周期,每次一次查询 |
| 推 + 服务端兜底轮询(本实现) | 通知往返 | 一个轮询周期内;实验中 ≤ 1212ms | 每周期一次 count(*),与客户端数无关 |
兜底轮询放在服务端而不是客户端,开销就从“客户端数 × 频率”变成“服务器实例数 × 频率”。代价是它只能发现“有变化”,还要再查快照;本实现中每个被唤醒的等待者各查一次快照,客户端很多时这一步会成为峰值负载,可以按 namespace 缓存快照来摊平,本篇没有实现。
全量快照还是增量
通知只告诉服务器“序号 6 出现了”,响应给客户端的是完整快照。另一种做法是让客户端从已知序号订阅发布日志,逐条应用差量,etcd 的 watch 就是这种模型:每次修改分配一个全局递增的 revision,watch 按 revision 顺序投递事件,断开后可以从最后收到的 revision 之后续订,前提是这段历史还没有被压缩;压缩后,从旧 revision 创建的 watch 会被取消并返回可用的最小 revision(etcd v3.4 API 保证、etcd v3.4 API)。增量订阅需要服务端保留历史,客户端需要处理“历史已不可用”的回退路径。对一份几百字节的配置,全量快照省掉了这两件事,离线客户端一步到位。本篇没有在配置场景下实验 etcd,它的续订与压缩行为在第 16 篇中实测。
客户端:只前进,可离线启动
客户端的接受规则只有三条:响应内容的 SHA-256 必须等于 checksum;head_seq 必须大于已知值;通过后先写缓存,再报告应用。
1 | |
旧响应的来源可以是负载均衡器后面一台落后的实例、一层把 /watch 结果缓存起来的代理,也可能是一次超时重试后才到达的早先回复。实验在客户端和服务器之间放了一个 HTTP 代理,它记下第一份 /watch 响应,被激活后把下一份真实响应换成这份旧的:
1 | |
正常客户端已知序号 13,收到序号 12 的响应后丢弃,下一轮请求拿到 14。关闭序号检查的对照客户端从 16 退回到序号 15 对应的版本 14,并把这份旧配置写进了缓存;它之所以随后又回到 17,是因为下一轮长轮询带的已知序号变成了 15,服务端立即返回最新值。回退期间的应用行为,以及此时进程重启读到的旧缓存,都是真实发生的错误。
图 15-3:客户端处理一份响应的状态迁移
stateDiagram-v2
[*] --> Startup
Startup --> Applied: GET /config 成功且校验通过
Startup --> CacheCheck: 服务端不可达
CacheCheck --> Applied: 缓存校验和一致(source=cache)
CacheCheck --> NoConfig: 缓存缺失或校验和不符(拒绝)
Applied --> Watching
NoConfig --> Watching: known=0
Watching --> Watching: 304 超时 / 连接失败后退避重试
Watching --> Rejected: 校验和不符 或 head_seq ≤ 已知值
Rejected --> Watching: 状态不变
Watching --> Persist: head_seq > 已知值
Persist --> Applied: tmp 写入 → fsync → os.replace → 目录 fsync
缓存写入采用临时文件、fsync、os.replace、再对目录 fsync 的顺序。Python 文档说明 os.replace 成功时的重命名是原子操作,这是 POSIX 的要求;对缓冲文件对象,要先 flush 再 fsync(Python 3.12 os)。读到的缓存要么是旧的完整文件,要么是新的完整文件。缓存里同时保存 checksum,加载时重新计算:
1 | |
实验把缓存中的 timeout_ms 从 1400 改成 9999、保留原校验和,并在服务器停机时启动客户端。客户端拒绝缓存,没有应用任何配置,服务器恢复后才从服务端取得序号 21。拒绝损坏缓存意味着这段时间进程没有配置可用,调用方要为此准备内置默认值,或者干脆拒绝启动;两者的取舍取决于这份配置缺失时业务能否安全运行,配置中心本身无法替业务决定。
离线与重启两个用例:
1 | |
客户端被 kill -9 期间连续发布 18、19、20,重启后只有一次应用事件,直接到 20。服务器被 kill -9 后:已在线的客户端记录了 4 次 server_unreachable 并按退避重试,期间继续使用内存中的配置;新启动的客户端从缓存读到序号 20;停机期间提交的序号 21 在服务器重启后约 0.78s 内被两者收敛,其中包括 JVM 启动、建立 LISTEN 连接和客户端退避间隔。
接口上有一处简化需要说明:/watch 用查询参数 known 携带已知序号,没有更新时返回 304。RFC 9110 把 304 定义为对条件请求的回答,即条件求值为假、无需传输表示(RFC 9110 §15.4.5)。严格的写法应把序号放进实体标签,由客户端在 If-None-Match 中携带;本实现借用了状态码而没有采用条件头,中间的 HTTP 缓存不会把它当作条件请求处理。
用例与证据
| 用例 | 类型 | 观察值(evidence/15/output.txt) |
|---|---|---|
normal_publish_notify |
正常 | 序号 1、2;两个客户端经通知唤醒,耗时 193/195ms(含 psql 启动) |
versions_append_only |
不变量 | UPDATE/DELETE 被触发器拒绝;发布角色无写权限 |
invalid_config_rejected |
边界 | 5 类非法输入全被拒,行数 2/2 不变,客户端 0 事件 |
rollback_is_new_release |
恢复 | 回退成为序号 3,指向版本 1 |
gray_subset_only / gray_then_full_converges |
边界 | 57/200 命中等于期望;全量发布后一致 |
missed_notify_poll_fallback |
故障 | 5 轮均由轮询唤醒,285–1212ms |
negative_notify_only_stuck |
负向对照 | 6s 内不收敛;14444ms 经截止复查前进 |
stale_response_rejected / negative_accept_any_regresses |
故障 / 负向对照 | 旧响应被拒;对照客户端回退到版本 14 |
offline_client_jumps_to_latest |
恢复 | 离线期间 3 次发布,重启后一步到 20 |
server_restart_cache_and_recover |
恢复 | 缓存启动;重启后约 0.78s 收敛到 21 |
corrupt_cache_rejected |
边界 | 校验和不符的缓存不被应用 |
环境:PostgreSQL 16.15、OpenJDK 21.0.12.1、pgJDBC 42.7.4、Python 3.12.3,4 核(evidence/15/env.txt)。服务器与客户端日志在 evidence/15/*.log,证据边界写在 evidence/15/LIMITS.md。
结论边界
实验支持的结论:发布前校验与不可变追加让非法配置无法成为发布记录;回退以新序号出现,客户端无需特殊处理;客户端只接受更大的序号,就能抵御旧响应;通知丢失时,服务端兜底轮询把收敛时间限制在一个轮询周期左右,而只靠通知的实现退化到长轮询超时;客户端离线或服务器停机后,系统靠数据库中的序号和本地缓存恢复,不依赖服务器内存。
没有覆盖的部分:配置服务器多实例、数据库主备切换与复制延迟;缓存写入中途崩溃(代码按原子替换编写,但没有做崩溃注入);大量客户端下长轮询的连接与查询开销。灰度只验证了命中集合的正确性,没有讨论灰度期间的指标观察和自动回退。
配置中心的客户端最怕“以为自己是新的”。服务发现中的地址集有同样的问题,而且更难:地址背后的进程可能已经暂停或死亡,注册中心要等租约过期才会删除它,这段时间内客户端手里的旧地址会造成多少次失败,是下一篇 16 服务发现中的旧地址 要测量的内容。






