系列导航:上一篇 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
2
3
4
5
6
7
8
9
perform pg_advisory_xact_lock(hashtext('cfg:' || ns));   -- 同一 namespace 的发布串行
c := content_text::jsonb; -- 非法 JSON 在这里抛 22P02
perform cfg.validate(c); -- 未知键、类型、范围:22023
perform cfg.validate_rule(r);
insert into cfg.config_version(namespace, content, checksum)
values (ns, c, encode(sha256(convert_to(c::text, 'UTF8')), 'hex')) returning version_id into v;
select coalesce(max(release_seq), 0) + 1 into s from cfg.release where namespace = ns;
insert into cfg.release(namespace, release_seq, version_id, rule, kind) values (ns, s, v, r, 'publish');
perform pg_notify('cfg_release', ns || ':' || s);

咨询锁保证 max + 1 不会被两个并发发布算成同一个值;即便锁漏掉,(namespace, release_seq) 主键也会让第二个事务失败,而不是产生重复序号。pg_notify 放在事务里有一个直接好处:PostgreSQL 规定事务内的通知要等事务提交才投递,事务中止则什么都不发(PostgreSQL 16 NOTIFY)。校验失败导致的回滚因此不会唤醒任何客户端。通知只带 ns:seq,内容由服务器回表读取,这也符合文档的建议:payload 默认要短于 8000 字节,大块数据应放在表里,只传记录的键。

不可变由触发器保证,对表属主同样生效。versions_append_only 用例分别以超级用户执行 UPDATE、DELETE,以发布角色执行 UPDATE:

1
CASE versions_append_only  PASS update_version='ERROR:  cfg.config_version is append-only' | delete_release='ERROR:  cfg.release is append-only' | pub_role_update='ERROR:  permission denied for table release'

invalid_config_rejected 依次提交截断的 JSON、越界的 timeout_ms=20000、多出一个未知键、把数字写成字符串,以及一条 percent=150 的灰度规则。五次全部被拒,发布记录与版本行数前后都是 2/2,在线客户端在随后 1.5s 内没有产生任何状态事件:

1
CASE invalid_config_rejected  PASS releases/versions before=2/2 after=2/2 client_events=0 | bad_json:invalid input syntax for type json ; out_of_range:timeout_ms must be an integer in [50, 10000] ; unknown_key:unknown key x ; wrong_type:timeout_ms must be an integer in [50, 10000] ; bad_rule:percent must be in [0, 100]

校验放在服务端事务里,而不是只放在发布工具里,是因为发布入口可能不止一个:控制台、脚本、批量迁移。只要它们都通过 cfg.publish,规则就只有一份。代价是 schema 变更需要改数据库函数,而且 PL/pgSQL 不适合表达很复杂的跨键约束;规则复杂到一定程度时,常见做法是把校验器做成独立服务,再由发布函数调用它的结果。本篇不涉及这一层。

回退调用 cfg.rollback('app', 1),追加一条 kind=rollback 的全量记录,指向版本 1:

1
CASE rollback_is_new_release  PASS rollback_seq=3 to_version=1 client_version=1 kind=rollback latency_ms=74 releases=1:1:publish,2:2:publish,3:1:rollback bad_target=version 999 does not belong to namespace app

序号继续增大到 3,客户端按“序号更大即接受”的规则收敛到旧内容。如果回退被实现成“把 release 表最新一行改回旧版本”,序号不变,已经应用了序号 2 的客户端会把序号 2 的回退响应当作重复而丢弃,回退在这些客户端上就不会生效。

灰度:序号前进与内容生效分开

灰度规则写在发布记录上:{"percent": n} 按客户端标识的 md5 前 32 位取模 100 分桶,桶号小于 n 即命中;{"labels": [...]} 按标签命中,两者取“或”。快照函数返回两个序号:head_seq 是该 namespace 的最新发布序号,release_seq 是对这个客户端命中的最新一条记录。

客户端用 head_seq 判断响应新旧,用 version_id 判断内容是否变化。未命中灰度的客户端收到灰度发布的响应时,已知序号前进,内容不变;如果它只看 release_seq,每次长轮询都会因为“服务端有更新的序号”立即返回,形成忙等。

1
2
CASE gray_subset_only          PASS gray_seq=4 canary=applied(version=3) c-000=head_advanced(version=1) synthetic_hits=57/200 expected_by_bucket=57 stable_on_repeat=True
CASE gray_then_full_converges PASS full_seq=5 canary=applied:v4 plain=applied:v4

带 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ConfigServer.handle(/watch)
while (true) {
long seen;
synchronized (mon) { seen = wakeCounter; } // 先读计数器
Object[] s = snapshot(ns, client, labels); // 再查库
if ((Long) s[0] > known) { reply(ex, 200, (String) s[1], source); return; }
if (System.nanoTime() >= deadline) { reply(ex, 304, "", "timeout"); return; }
synchronized (mon) {
while (wakeCounter == seen) {
long left = (deadline - System.nanoTime()) / 1_000_000;
if (left <= 0) break;
mon.wait(left);
}
source = (wakeCounter == seen) ? "deadline" : lastWake;
}
}

图 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
CASE missed_notify_poll_fallback  PASS rounds=5 latency_ms=[910, 285, 376, 1212, 1013] sources=['watch:poll'] poll_ms=1000 reconnect_ms=4000 listener_lost_logged=5

五轮全部由轮询唤醒,耗时 285–1212ms。重连延迟设为 4s,正是为了让轮询和重连复查在时间上分开,确认收敛来自轮询。耗时上限略超过 1000ms,多出的部分包含 psql 进程启动、一次快照查询和 HTTP 往返。服务器日志记录了每一次断线:listener lost: 57P01 FATAL: terminating connection due to administrator command。

负向对照是只靠通知的服务器(notify-only:不轮询,LISTEN 重连后也不复查),客户端长轮询超时设为 15s:

1
CASE negative_notify_only_stuck  PASS expectStuck seq=11 converged_within_6.0s=False relistened_pid_changed=True longpoll_timeout_ms=15000 eventually_converged_ms=14444 via=watch:deadline

故障后 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
2
3
4
5
# client.py accept()
known = state["head_seq"] if state else -1
if not args.accept_any and snap["head_seq"] <= known:
emit("rejected_stale", source=source, offered_head=snap["head_seq"], known_head=known)
return False

旧响应的来源可以是负载均衡器后面一台落后的实例、一层把 /watch 结果缓存起来的代理,也可能是一次超时重试后才到达的早先回复。实验在客户端和服务器之间放了一个 HTTP 代理,它记下第一份 /watch 响应,被激活后把下一份真实响应换成这份旧的:

1
2
CASE stale_response_rejected        PASS known_head=13 offered_head=12 event=rejected_stale regressions=0 then_head=14 replayed=1
CASE negative_accept_any_regresses PASS expectRegression known_head=16 replayed_head=15 regressed_to_version=14 regression_events=1 later_head=17

正常客户端已知序号 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
CASE corrupt_cache_rejected  PASS startup=cache_rejected:checksum_mismatch applied_from_cache=0 after_server_back=watch:initial:head=21

实验把缓存中的 timeout_ms 从 1400 改成 9999、保留原校验和,并在服务器停机时启动客户端。客户端拒绝缓存,没有应用任何配置,服务器恢复后才从服务端取得序号 21。拒绝损坏缓存意味着这段时间进程没有配置可用,调用方要为此准备内置默认值,或者干脆拒绝启动;两者的取舍取决于这份配置缺失时业务能否安全运行,配置中心本身无法替业务决定。

离线与重启两个用例:

1
2
CASE offline_client_jumps_to_latest    PASS head_before_kill=17 published_while_offline=[18, 19, 20] restart_events=[('applied', 'server', 20)] intermediate_applied=0
CASE server_restart_cache_and_recover PASS cold_start_source=cache cold_start_head=20 published_while_down=21 a_unreachable_events=4 recovered_ms=a:781,cold:784

客户端被 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 服务发现中的旧地址 要测量的内容。