后端系统工程 10:网关故障后的重连与路由收敛

本批导航:00 技术方案怎样接受验证|01 API 契约与业务不变量|02 负载模型与容量预算|03 单库基线与故障实验工具|04 持久化边界|05 长连接接入|06 发送确认|07 排序与增量同步|08 群消息扇出|09 多设备已读|10 网关故障与路由收敛|11 撤回与集成验收

第 05 篇处理的是单个连接的生命周期:握手、心跳、慢接收端,以及同一设备的新连接取代旧连接。那里的故障都是局部的,一次只影响一个连接。网关进程整体失效则不同。一个进程上挂着成千上万个连接,进程消失的瞬间它们同时断开,随后几乎同时尝试重连。这时有三个问题需要回答:

  1. 死掉的网关没有机会清理自己的路由记录,谁来清理,什么时候清理?
  2. 大量设备同时重连,怎样避免它们把剩下的网关和数据库一起压垮?
  3. 重连过程中网络会产生延迟和重复。某个旧握手在新连接建立之后才到达,怎样保证它不会把设备的归属改回已经失效的连接?

本篇在第 05 篇的路由声明之上增加三样东西:网关租约、握手准入和带抖动的重连退避。另外补上计划内下线(排空)的流程。所有结论都来自本地多进程实验:两个网关 JVM 进程共享一个 PostgreSQL 16 实例,测试进程用 SIGKILL 和 SIGTERM 结束网关。实验不涉及真实网络、负载均衡器或多台主机。

1. 需求与不变量

编号 不变量 观察位置
V1 任一时刻每台设备至多一条路由;路由版本只增不减 routes、route_floor
V2 设备的归属只能被比当前记录更新的声明改变,更新的判断依据是 (install_epoch, attempt),不看到达时间 claimRoute
V3 网关失效后,在租约时长加一个清扫周期之内,它名下的路由被删除 gateways.lease_until + 清扫
V4 每个网关在任意时间窗口内接受的握手数不超过令牌桶允许的上界;超出部分以 429 拒绝,不写路由 准入令牌桶
V5 重连成功的设备通过 sync 补齐断线期间的全部事件,补齐结果与断线前后连接到哪个网关无关 客户端游标与 head
V6 计划内下线时,网关先停止接受新握手,再通知客户端,最后释放自己的路由;进程退出时名下不留路由 关闭钩子

V1 和 V2 是第 05 篇 G 系列不变量在多进程下的延续。第 05 篇只在一个网关内验证了“新连接取代旧连接”,本篇关心旧声明跨进程迟到的情况。V5 不是新的机制,它直接继承第 07 篇的增量同步:网关不保存消息,换网关不会丢失任何可同步的状态。正因为如此,网关故障的影响才局限在连接层面:连接要重建,归属记录要收敛,消息数据不受影响。

2. 规模假设

第 02 篇的基线模型给出了故障预算:网关故障时,30% 的在线设备在 60 秒内重连,每台补拉约 25 条,折合约 25 000 行/s 的数据库读取,是基线下数据库读取最大的单项。这个数字成立的前提是重连被摊到 60 秒的窗口里。窗口缩短多少倍,瞬时读取就放大多少倍。所以本篇的核心约束是:

  • 重连窗口由服务端控制(准入速率),而不是由客户端的“运气”决定;
  • 客户端的退避策略要让被拒绝的请求在时间上分散开,否则准入只是把同一个尖峰推迟到下一个时刻。

实验规模远小于此:60 台设备,网关 gw-b 的准入令牌桶为速率 40 次/s、突发 10 次。参数故意设得很小,目的是让准入在几秒内真正起作用并且能被观测到,并不代表任何真实部署的取值。60 台设备的结果不能外推到 6 万台,第 10 节说明了原因。

3. 接口

网关之间不直接通信,故障处理涉及的接口只有客户端握手和几种关闭方式。握手沿用第 05 篇:

1
2
3
4
5
6
7
GET /ws?attempt=4 HTTP/1.1
Host: im.example.test
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: ...
Sec-WebSocket-Version: 13
Authorization: Bearer u-007-d1:tok-u-007-d1

attempt 是设备本地持久保存、每次发起握手前加一的计数。网关可能给出的结果:

结果 含义 客户端动作
101 + hello 连接建立,路由声明已被接受 对每个会话执行 sync,然后 sub
429,Retry-After: 1 准入令牌不足 退避,等待时间不少于 Retry-After
503,Retry-After: 1 网关正在排空 退避后重连,通常会被负载均衡分到其他网关
409 声明不比已记录的新(attempt 过小) 本地 attempt 加一后重试;持续出现说明本地计数被回退
TCP 连接被拒或复位 网关进程不在 退避后重连

已建立的连接还可能收到:

1
{"t":"goaway","reason":"drain"}

紧随其后的是关闭帧 1001(going away,RFC 6455 §7.4.1)。被另一条连接取代时收到私有关闭码 4001(第 05 篇)。

RFC 6585 §4 定义了 429 Too Many Requests,并允许附带 Retry-After 说明多久之后再试;RFC 9110 §10.2.3 规定 Retry-After 可以是 HTTP 日期或秒数。WebSocket 握手本身就是一个 HTTP/1.1 请求,在升级之前拒绝时可以直接使用这些状态码。连接已经建立之后,就只能通过关闭码表达。IANA 的 WebSocket 关闭码注册表中有 1013(Try Again Later),适合“服务端过载,稍后再来”的场景。本实现在排空时使用 1001,没有使用 1013。

4. 数据模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
-- 当前归属,连接关闭或网关失效时删除
CREATE TABLE routes (
device_id text PRIMARY KEY REFERENCES devices,
gateway_id text NOT NULL,
conn_id text NOT NULL,
route_version bigint NOT NULL,
connected_at timestamptz NOT NULL DEFAULT clock_timestamp()
);

-- 每台设备被接受过的最高声明;不随路由删除
CREATE TABLE route_floor (
device_id text PRIMARY KEY REFERENCES devices,
max_version bigint NOT NULL DEFAULT 0,
max_epoch bigint NOT NULL DEFAULT -1,
max_attempt bigint NOT NULL DEFAULT -1
);

-- 网关存活租约
CREATE TABLE gateways (
gateway_id text PRIMARY KEY,
lease_until timestamptz NOT NULL
);

三张表分工明确。routes 是“现在归谁”,可以随时被删除;route_floor 是“曾经接受过的最高声明”,只升不降,是 V2 的判断依据;gateways 记录每个网关的租约截止时间,是 V3 的判断依据。

route_floor 与 routes 必须分开存放,这一点在网关故障时尤为关键。租约清扫会删除死网关名下的全部路由。如果“最高声明”跟着路由一起被删除,一个迟到的旧握手在清扫之后到达,就会面对一张空表,从而被当作首次连接接受。分开存放以后,删除路由只影响当前归属,不影响历史上限。

租约时间一律使用数据库时钟 clock_timestamp() 写入和比较。每个网关只与同一个数据库时钟比较,网关主机之间的时钟偏差不进入判断。这里仍然有一个隐含假设:网关进程内部的定时器大致准确,续约间隔明显小于租约时长。进程被长时间暂停(例如很长的 GC 停顿或虚拟机被冻结)时,租约可能在网关自己不知情的情况下过期,第 9 节会讨论这种情况的后果。

5. 正常路径

5.1 租约与清扫

每个网关有一个后台循环,周期为 max(100ms, min(ping 间隔, 租约时长/3))。每一轮做三件事:

  1. 续约:lease_until = clock_timestamp() + IM_LEASE_MS;
  2. 清扫:删除所有“租约已过期且不是自己”的网关名下的路由;
  3. 检查本进程连接的空闲与心跳(第 05 篇)。

清扫由每个存活网关各自执行,不需要选主。多个网关同时执行同一条 DELETE 是安全的:先提交的删除生效,后到的删除影响零行。

1
2
3
4
5
时间 →    0        500      1000     1500     2000 (ms,相对最后一次续约)
gw-a ── 续约 ─┤ SIGKILL
租约截止 = 最后一次续约 + 1500
gw-b ── 续约 ── 续约 ── 续约 ── 续约/清扫 ── 发现 gw-a 过期 → DELETE routes
└─ 最晚在截止后一个周期(≤500ms)内发生

图 10-1 租约过期到路由回收的时间关系(IM_LEASE_MS=1500,清扫周期 500ms)

从 SIGKILL 到路由被删除的时间有一个上界:最后一次续约发生在 SIGKILL 之前不到一个周期,租约在续约后 1500ms 到期,清扫最迟在到期后一个周期内执行。因此上界约为 1500 + 500 = 2000ms,下界约为 1500 − 500 = 1000ms。实验观测到 1711ms,落在这个区间内。

5.2 风暴下的重连

1
2
3
4
5
6
7
8
设备 ──┐
设备 ──┼── 断线 ── sleep(random(0, 100)) ── 握手 ──→ gw-b 准入令牌桶
设备 ──┘ │
┌── 有令牌 ──────┴── 无令牌 ──┐
▼ ▼
claimRoute(attempt+1) 429 Retry-After
▼ ▼
101 + hello → sync → sub sleep(max(jitter, 下限)) → 再握手

图 10-2 网关失效后单台设备的重连流程

客户端在检测到断线后不立即重连,而是先等待 random(0, base),再发起握手。被 429 拒绝后,等待时间为 max(random(0, min(cap, base·2^n)), Retry-After),其中 n 是本轮已失败的次数。连接成功后,客户端先对每个会话执行 sync,再订阅推送。第 07 篇保证 sync 从本地游标开始,所以断线期间写入的 10 条消息会全部补回。

5.3 路由声明与跨网关踢出

重连到 gw-b 的握手调用 claimRoute。新声明的 attempt 比 route_floor.max_attempt 大,因此被接受。如果设备的旧路由仍然存在(例如 gw-a 的租约还没过期),事务内会执行 pg_notify('im_kick', 旧网关|旧连接|设备|新版本)。旧网关如果还活着,会在监听线程中找到这个连接,并以 4001 关闭它。如果旧网关已经死亡,通知无人接收,旧路由由租约清扫删除。新路由已经通过 UPSERT 覆盖了旧行,所以清扫只会删除那些没有重连的设备留下的路由。

需要说明一点:在本实现中,消息推送不依赖 routes。每个网关都监听 im_events,收到通知后只推给本进程内存索引里的连接(第 06、08 篇)。routes 的作用是记录设备的权威归属、提供跨网关踢出的地址,以及给在线状态一类的查询提供依据。死网关的残留路由不会导致消息丢失,但会让“设备在线”的判断出错,也会让踢出通知发往一个不存在的进程。如果改成按路由定向投递,残留路由就会直接变成投递黑洞,这时 V3 的时限就是投递中断时长的一部分。

5.4 计划内排空

1
2
3
4
5
6
7
SIGTERM
│
├─ draining = true → 新握手一律 503 Retry-After: 1
├─ lease_until = now() → 其他网关的下一轮清扫即可回收
├─ 对每个连接:发送 goaway,然后发送 Close 1001
├─ 等待连接清空(上限 3s) → 连接退出时 releaseRoute(device, conn)
└─ releaseGateway(gw) → 删除残留路由,关闭监听端口,进程退出

图 10-3 SIGTERM 触发的排空顺序

顺序很重要。先停止准入,排空过程中就不会有新设备被分配进来。先把自己的租约设为立即到期,即使后续步骤被打断,其他网关也会尽快回收路由。goaway 放在关闭帧之前发送,客户端由此可以区分“网关要下线”和“连接出了问题”,前者无需退避太久。

6. 方案比较

6.1 怎样发现网关已死

方案 发现速度 误判风险 依赖
不处理,等设备重连时覆盖 只覆盖重连的设备;不回来的设备永久残留 无 无
进程退出时自行清理 只覆盖正常退出 无 SIGKILL、断电、OOM 时不会执行
数据库租约 + 存活网关清扫(本实现) 租约时长 + 一个周期 网关长时间停顿后被误判为死亡 数据库时钟
外部协调服务的会话/租约 取决于会话超时 类似 额外组件
编排系统的健康检查 + 回调 取决于探测周期 网络分区时误判 编排系统

自行清理和租约要同时使用。自行清理让正常下线的回收速度不受租约时长影响(实验中 gw-b 退出时路由已全部释放),租约负责兜住非正常死亡。

租约时长是检测速度与误判风险之间的折中:越短,死网关的路由残留时间越短;但网关一次稍长的停顿也会被判为死亡,此时它名下的设备会被其他网关清扫,而这些设备的连接其实仍然连在原网关上。这种误判不会破坏 V1/V2。被误删路由的设备只是暂时“看起来不在线”。原网关恢复后,下一次续约会把租约写回,但已经删除的路由不会自动恢复。本实现没有在续约时重新登记自己的连接,这是一个已知缺口,见第 10 节。

6.2 新旧声明怎样比较

方案 迟到的旧声明 需要的状态
最后写入者胜出(直接 UPSERT) 覆盖新归属 无
比较到达时间或网关本地时间戳 时钟偏差和网络延迟都可能导致误判 时间戳
比较客户端单调计数 (install_epoch, attempt)(本实现) 被拒绝 客户端持久计数 + route_floor
网关分配全局递增令牌(fencing token) 被拒绝 全局计数器

“谁更新”应该由发起方的因果顺序决定,而不是由服务端收到请求的时间决定。设备自己最清楚哪一次握手是最新的:attempt 4 一定是在放弃 attempt 3 之后发起的。服务端只需要保证比较和写入是原子的:锁住 route_floor 行,比较,然后在同一事务内写入。

install_epoch 放在 attempt 之前比较,是为了处理重装:重装后的设备 attempt 从头计数,但 epoch 更大(第 09 篇),所以新安装的第一次握手仍然胜出。代价是客户端必须持久保存 attempt。客户端如果从旧备份恢复导致 attempt 回退,握手会一直得到 409,需要客户端按“409 则加一后重试”处理,或者由服务端在 409 中返回当前上限。本实现采用前者。

6.3 重连时机

策略 被拒绝请求的分布 风险
立即重连 全部集中在故障时刻 尖峰最高
固定间隔 尖峰按固定周期重复出现 尖峰不衰减
指数退避,无抖动 尖峰周期拉长,但每次仍然同步 同一批客户端反复同时到达
指数退避 + 全抖动(本实现) random(0, min(cap, base·2^n)) 单个客户端可能等待较久
等抖动 / 去相关抖动 介于二者之间 实现略复杂

AWS Architecture Blog 的《Exponential Backoff and Jitter》比较了 Full Jitter、Equal Jitter 和 Decorrelated Jitter 三种做法。结论是:只加指数退避而不加抖动,竞争者仍然成批到达;加入随机化之后,总调用次数和完成时间都明显下降。本实现使用其中的 Full Jitter。

为了直观对比,Ch10.backoffModel() 包含一个离散模型。它不涉及网络,只是一个调度模拟:1000 个客户端在 t=0 同时断线,服务端每 10ms 只接受 5 个握手,其余被拒绝并按策略重试,base=100ms,cap=3200ms。模型输出写在 evidence/im-10/result.json 的 backoff_model 字段中:

策略 总尝试次数 首槽峰值 最后一个客户端连上的时刻
指数退避,无抖动 100 500 1000 623 900 ms
指数退避 + 全抖动 5 257 1000 4 580 ms

两者的首槽峰值相同,都是 1000,因为所有客户端都在 t=0 发起第一次尝试。抖动改变的是此后的行为。无抖动时,被拒绝的 995 个客户端会以完全相同的延迟一起回来,每次仍然只有 5 个成功。退避到达 cap 之后,每 3.2 秒才有一批 5 个成功。这是模型计算结果,用来说明机制,不是网络测量。实际系统中网络抖动本身会带来一些随机性,但不能依赖这种随机性。

客户端第一次重连前也应该随机等待,否则首槽峰值无法削减。实验中的客户端在第一次握手前等待 random(0, 100ms),离散模型中则没有。

6.4 准入放在哪里

令牌桶放在网关握手的最前面,位于鉴权之前(第 05 篇握手步骤 4)。被拒绝的握手不访问数据库,不写路由,只消耗一次 TCP 连接和几十字节的响应。如果放在鉴权之后,每个被拒绝的请求都已经做过一次数据库查询,准入就保护不了数据库。代价是令牌可能被未鉴权的请求耗尽。这需要在更前面的层(连接级限速、按来源地址限速)处理,本实现没有涉及。

7. 关键实现

路由声明(Store.claimRoute,节选):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
exec(c, "INSERT INTO route_floor(device_id) VALUES (?) ON CONFLICT DO NOTHING", dev);
// SELECT max_version, max_epoch, max_attempt FROM route_floor WHERE device_id=? FOR UPDATE
...
if (connId.equals(prevConn)) {
return new RouteClaim(true, floorVersion, prevGw, prevConn);
}
boolean newer = who.installEpoch() > floorEpoch
|| (who.installEpoch() == floorEpoch && attempt > floorAttempt);
if (!newer) {
return new RouteClaim(false, floorVersion, prevGw, prevConn); // 网关返回 409
}
long version = floorVersion + 1;
exec(c, "UPDATE route_floor SET max_version=?, max_epoch=?, max_attempt=? WHERE device_id=?", ...);
exec(c, "INSERT INTO routes(...) VALUES (?,?,?,?) ON CONFLICT (device_id) DO UPDATE SET ...", ...);
if (prevGw != null) {
// SELECT pg_notify('im_kick', prevGw|prevConn|device|version)
}

INSERT ... ON CONFLICT DO NOTHING 保证 route_floor 行存在,随后的 SELECT ... FOR UPDATE 让同一设备的并发声明在这一行上串行化。PostgreSQL 文档对行级锁的说明是:FOR UPDATE 锁住的行,在当前事务结束前不能被其他事务以 FOR UPDATE 锁定、更新或删除。因此两条并发声明只会依次比较,不存在“都读到旧上限、都认为自己更新”的情况。通知在事务内发出,按 PostgreSQL 的语义在提交之后才投递,所以旧网关不会因为一个最终回滚的声明而踢掉连接。

清扫(Gateway.housekeeping,节选):

1
2
3
renewLease();   // lease_until = clock_timestamp() + leaseMs
DELETE FROM routes WHERE gateway_id IN
(SELECT gateway_id FROM gateways WHERE lease_until < clock_timestamp() AND gateway_id <> ?)

准入(Gateway.TokenBucket):

1
2
3
4
5
6
7
synchronized boolean tryAcquire() {
long now = System.nanoTime();
tokens = Math.min(burst, tokens + (now - last) / 1e9 * rate);
last = now;
if (tokens >= 1) { tokens -= 1; return true; }
return false;
}

令牌桶的上界很容易推出:任意长度为 T 的窗口内,接受数不超过 burst + rate·T。250ms 的窗口对应 10 + 40×0.25 = 20。由于实验按固定的 250ms 分桶统计,而令牌是连续补充的,检查时额外允许 1 的取整余量,阈值为 21。

退避(Reconnect):

1
2
3
4
5
6
public long nextDelayMs(long retryAfterMs) {
long ceiling = Math.min(capMs, baseMs << Math.min(failures, 20));
failures++;
long jittered = (long) (random.nextDouble() * ceiling);
return Math.max(jittered, retryAfterMs);
}

Math.min(failures, 20) 防止移位溢出。Retry-After 作为下限而不是固定值:多个客户端如果都严格等待 Retry-After 指定的秒数,又会在同一时刻回来,所以它应该作为抖动的下界,而不是替代抖动。

排空(Gateway.drainOnShutdown,由 Runtime.addShutdownHook 注册):

1
2
3
4
5
6
7
8
if (!draining.compareAndSet(false, true)) return;
UPDATE gateways SET lease_until = clock_timestamp() WHERE gateway_id = ?;
for (Conn conn : byConnId.values()) {
conn.offerJson(Json.map("t", "goaway", "reason", "drain"));
conn.offerClose(1001, "going away");
}
// 等待 byConnId 清空,最多 3 s
long leftover = store.releaseGateway(gatewayId);

JVM 收到 SIGTERM 时启动关闭序列并运行关闭钩子;SIGKILL 不能被进程捕获,内核直接终止进程,钩子没有机会运行。这就是排空与崩溃两条路径的分界:前者由网关自己完成清理,后者完全依赖租约。

8. 验证

运行方式:真实 PostgreSQL 16.15,两个网关作为独立 JVM 进程运行在 127.0.0.1(gw-a、gw-b,排空阶段再启动 gw-c),60 个测试客户端运行在测试进程的虚拟线程中。网关参数:IM_LEASE_MS=1500 IM_PING_MS=500 IM_IDLE_MS=3000,gw-b 另设 IM_ADMIT_RATE=40 IM_ADMIT_BURST=10。命令:scripts/lab.sh 10(或 all)。结果见 evidence/im-10/result.json,网关日志见 evidence/im-10/run.log,时间线见 evidence/im-10/events.jsonl。验证级别为本地多进程。

实验流程:60 台设备连接 gw-a 并订阅,测试进程写入 5 条消息;SIGKILL gw-a;断线期间再写入 10 条(head 变为 76,包含 61 个 join 事件);等待 gw-b 回收 gw-a 的路由;60 台设备以全抖动退避(base 100ms、cap 3200ms、每台种子固定)重连 gw-b,被 429 拒绝时以 200ms 为下限;重连成功后同步并订阅。

类型 检查 预期 观测
正常 10-baseline-routes 故障前 60 条路由全部属于 gw-a 60
恢复 10-sigkill-recovery SIGKILL 后 gw-a 的路由经租约过期被回收;60 台设备全部归属 gw-b 且游标追平 head 回收 1711ms;重连开始后最晚一台 1447ms 连上;gw-b 路由 60;head 76,全部追平
边界 10-admission-bounds-accept-rate 每 250ms 接受数 ≤ 21;客户端统计的 429 次数等于网关计数器 尝试峰值 60,接受峰值 16;429 共 116 次,与网关一致
失败 10-late-old-route-rejected 以 gw-a 的名义、用比当前小 1 的 attempt 为设备 u-000-d1 调用 claimRoute(直接调用存储层,模拟一个在网络中延迟的旧握手),被拒绝 accepted=false;归属仍为 gw-b,version 2,attempt 4
负对照 10-control-last-writer-wins 用无条件 UPSERT 执行同一迟到声明 路由被改写为 gw-a / gw-a-ghost,version 3
正常 10-drain-sigterm SIGTERM gw-b:60 个客户端都收到 goaway 和 1001;gw-b 退出时名下无路由;全部重连 gw-c 退出码 143;goaway 60;1001 60;残留 0;从 SIGTERM 到全部重连完成 839ms

每 250ms 的握手尝试分布为 {0:60, 250:49, 500:41, 750:10, 1000:11, 1250:5},共 176 次,等于 60 次成功加 116 次 429。第一个窗口的 60 次尝试来自第一次重连前的 random(0,100ms):100ms 的初始抖动仍然落在同一个 250ms 窗口内。准入在这一步起作用:60 次尝试中只有一部分被接受,其余在后续窗口中逐步消化。

结合事件时间线,还有三点值得说明。

第一,负对照之后,实验尝试用当前 attempt 把路由改回 gw-b,events.jsonl 中的 route_restored 记录显示 accepted=false。原因是负对照的无条件写入只推进了 max_version,没有改变 max_attempt,而恢复声明的 attempt 与 max_attempt 相等,不满足“严格更新”。这个被劫持到 gw-a-ghost 的路由随后被 gw-b 的清扫删除,因为 gw-a 的租约早已过期(run.log 中第二条 routes_reclaimed count=1)。这正好说明租约清扫是第二道防线:即便归属被错误写入一个死网关,也会在一个清扫周期内被删除,但在此期间设备的归属是错的。负对照检查只断言“劫持发生”,不依赖这次恢复。

第二,max_reconnect_ms=1447 的计时起点是重连风暴开始的时刻,也就是测试等到路由回收之后才开始计时。在真实系统中,客户端检测到断线的时间与租约回收无关:客户端通过 TCP 复位或读失败立即发现断线,可以在路由回收之前完成重连。重连时 claimRoute 直接覆盖旧路由,不需要等待清扫。实验先等待回收再重连,只是为了把两个时长分开测量。

第三,网关返回的是 Retry-After: 1(1 秒),而测试客户端并没有解析这个响应头,只在收到 429/503 时使用固定的 200ms 下限。也就是说,本实验验证的是准入上界和抖动退避,没有验证“客户端遵守服务端给出的 Retry-After 数值”。排空阶段的客户端直接连接 gw-c,没有经过 gw-b 的 503 分支,因此 503 路径在本实验中也没有被触发。这两项记为 NOT_RUN。

9. 故障与恢复

故障 处理 结果 验证
网关进程被 SIGKILL / OOM 租约过期,存活网关清扫;设备重连覆盖路由 路由在租约 + 一个周期内收敛;消息经 sync 补齐 10-sigkill-recovery PASS
重连风暴 首次重连前抖动;令牌桶准入;全抖动退避 接受速率有界 10-admission-bounds-accept-rate PASS(本地 60 台)
迟到的旧握手 (epoch, attempt) 比较 409,归属不变 10-late-old-route-rejected PASS
路由被错误写到死网关 租约清扫 一个周期内删除 时间线观察,无独立断言
计划内下线 关闭钩子:停止准入 → 租约到期 → goaway/1001 → 释放路由 退出时无残留 10-drain-sigterm PASS
关闭钩子超时(连接 3s 内未清空) releaseGateway 强制删除残留路由 客户端由读失败发现断线 代码路径存在,未注入
网关长停顿后复活 续约写回租约;被清扫的路由不会自动恢复 部分设备暂时“不在线”,直到重连 NOT_RUN
网关与数据库网络分区 网关无法续约,租约过期,路由被清扫;网关仍持有连接但无法写库 设备的发送请求失败,重试后最终重连 NOT_RUN
数据库不可用 握手鉴权失败返回 503;租约、清扫均停止 恢复后下一轮续约 NOT_RUN(第 06 篇只测了库重启前后的确认)
客户端本地 attempt 回退 409 后加一重试 可能需要多次重试 代码路径存在,未单独测试
迟到的旧握手经由真实网关到达 网关握手中的 claimRoute 409 第 05 篇 05-stale-attempt-409 经真实握手验证同一判断;跨网关延迟到达 NOT_RUN

网关长停顿这一行值得展开。假设 gw-a 停顿 3 秒,期间 gw-b 清扫了它的路由。gw-a 恢复后,它的连接在本进程中仍然存在,推送仍然能到达这些设备,因为推送不依赖 routes。设备感觉不到任何异常,但数据库中这些设备没有路由。两种补救办法:一是网关在续约时发现自己的租约曾经过期,就对本进程的全部连接重新登记路由,或者直接断开它们让设备重连;二是把判断交给设备,靠心跳超时触发重连。本实现两者都没有做,属于已知缺口。它不违反 V1/V2,因为设备至多一条路由,只是这条路由暂时缺失。

10. 限制

  • 规模:60 台设备、单机、回环网络。令牌桶上界是确定性的,可以由公式验证;但真实规模下的重连完成时间取决于网络往返、TLS 握手成本和数据库补拉压力,这些都没有测量。
  • 抖动模型是离散模拟(模型级),没有在网络上复现 1000 个客户端。
  • 客户端没有解析 Retry-After,503 分支未被触发(NOT_RUN)。
  • 没有负载均衡器。真实部署中,重连会经过负载均衡器分配到存活网关,负载均衡器自身的健康检查周期会叠加到恢复时间上。
  • 准入只按网关进程限速,没有按来源、按用户限速,也没有在网关之间协调总准入速率。
  • 网关停顿后的路由重新登记、网络分区、数据库不可用期间的握手行为均未验证。
  • IM_LEASE_MS=1500 是为了让实验在几秒内结束而选的取值,不是推荐值。生产取值需要权衡 GC 停顿分布与可接受的残留时长,本批没有相关数据。

下一篇处理消息的后半生:撤回怎样追加为新事件并删除原文,保留策略怎样删除旧事件而不破坏序号连续性与幂等,备份恢复后怎样检查不变量。最后把 04–11 的全部检查与第 00 篇的需求卡对照,完成集成验收。

参考资料