后端系统工程 10:网关故障后的重连与路由收敛
后端系统工程 10:网关故障后的重连与路由收敛
本批导航:00 技术方案怎样接受验证|01 API 契约与业务不变量|02 负载模型与容量预算|03 单库基线与故障实验工具|04 持久化边界|05 长连接接入|06 发送确认|07 排序与增量同步|08 群消息扇出|09 多设备已读|10 网关故障与路由收敛|11 撤回与集成验收
第 05 篇处理的是单个连接的生命周期:握手、心跳、慢接收端,以及同一设备的新连接取代旧连接。那里的故障都是局部的,一次只影响一个连接。网关进程整体失效则不同。一个进程上挂着成千上万个连接,进程消失的瞬间它们同时断开,随后几乎同时尝试重连。这时有三个问题需要回答:
- 死掉的网关没有机会清理自己的路由记录,谁来清理,什么时候清理?
- 大量设备同时重连,怎样避免它们把剩下的网关和数据库一起压垮?
- 重连过程中网络会产生延迟和重复。某个旧握手在新连接建立之后才到达,怎样保证它不会把设备的归属改回已经失效的连接?
本篇在第 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 | |
attempt 是设备本地持久保存、每次发起握手前加一的计数。网关可能给出的结果:
| 结果 | 含义 | 客户端动作 |
|---|---|---|
101 + hello |
连接建立,路由声明已被接受 | 对每个会话执行 sync,然后 sub |
429,Retry-After: 1 |
准入令牌不足 | 退避,等待时间不少于 Retry-After |
503,Retry-After: 1 |
网关正在排空 | 退避后重连,通常会被负载均衡分到其他网关 |
409 |
声明不比已记录的新(attempt 过小) |
本地 attempt 加一后重试;持续出现说明本地计数被回退 |
| TCP 连接被拒或复位 | 网关进程不在 | 退避后重连 |
已建立的连接还可能收到:
1 | |
紧随其后的是关闭帧 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 | |
三张表分工明确。routes 是“现在归谁”,可以随时被删除;route_floor 是“曾经接受过的最高声明”,只升不降,是 V2 的判断依据;gateways 记录每个网关的租约截止时间,是 V3 的判断依据。
route_floor 与 routes 必须分开存放,这一点在网关故障时尤为关键。租约清扫会删除死网关名下的全部路由。如果“最高声明”跟着路由一起被删除,一个迟到的旧握手在清扫之后到达,就会面对一张空表,从而被当作首次连接接受。分开存放以后,删除路由只影响当前归属,不影响历史上限。
租约时间一律使用数据库时钟 clock_timestamp() 写入和比较。每个网关只与同一个数据库时钟比较,网关主机之间的时钟偏差不进入判断。这里仍然有一个隐含假设:网关进程内部的定时器大致准确,续约间隔明显小于租约时长。进程被长时间暂停(例如很长的 GC 停顿或虚拟机被冻结)时,租约可能在网关自己不知情的情况下过期,第 9 节会讨论这种情况的后果。
5. 正常路径
5.1 租约与清扫
每个网关有一个后台循环,周期为 max(100ms, min(ping 间隔, 租约时长/3))。每一轮做三件事:
- 续约:
lease_until = clock_timestamp() + IM_LEASE_MS; - 清扫:删除所有“租约已过期且不是自己”的网关名下的路由;
- 检查本进程连接的空闲与心跳(第 05 篇)。
清扫由每个存活网关各自执行,不需要选主。多个网关同时执行同一条 DELETE 是安全的:先提交的删除生效,后到的删除影响零行。
1 | |
图 10-1 租约过期到路由回收的时间关系(IM_LEASE_MS=1500,清扫周期 500ms)
从 SIGKILL 到路由被删除的时间有一个上界:最后一次续约发生在 SIGKILL 之前不到一个周期,租约在续约后 1500ms 到期,清扫最迟在到期后一个周期内执行。因此上界约为 1500 + 500 = 2000ms,下界约为 1500 − 500 = 1000ms。实验观测到 1711ms,落在这个区间内。
5.2 风暴下的重连
1 | |
图 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 | |
图 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 | |
INSERT ... ON CONFLICT DO NOTHING 保证 route_floor 行存在,随后的 SELECT ... FOR UPDATE 让同一设备的并发声明在这一行上串行化。PostgreSQL 文档对行级锁的说明是:FOR UPDATE 锁住的行,在当前事务结束前不能被其他事务以 FOR UPDATE 锁定、更新或删除。因此两条并发声明只会依次比较,不存在“都读到旧上限、都认为自己更新”的情况。通知在事务内发出,按 PostgreSQL 的语义在提交之后才投递,所以旧网关不会因为一个最终回滚的声明而踢掉连接。
清扫(Gateway.housekeeping,节选):
1 | |
准入(Gateway.TokenBucket):
1 | |
令牌桶的上界很容易推出:任意长度为 T 的窗口内,接受数不超过 burst + rate·T。250ms 的窗口对应 10 + 40×0.25 = 20。由于实验按固定的 250ms 分桶统计,而令牌是连续补充的,检查时额外允许 1 的取整余量,阈值为 21。
退避(Reconnect):
1 | |
Math.min(failures, 20) 防止移位溢出。Retry-After 作为下限而不是固定值:多个客户端如果都严格等待 Retry-After 指定的秒数,又会在同一时刻回来,所以它应该作为抖动的下界,而不是替代抖动。
排空(Gateway.drainOnShutdown,由 Runtime.addShutdownHook 注册):
1 | |
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 篇的需求卡对照,完成集成验收。
参考资料
- AWS Architecture Blog,Marc Brooker,Exponential Backoff And Jitter(Full Jitter、Equal Jitter、Decorrelated Jitter 的定义与比较)。https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
- RFC 6585,Additional HTTP Status Codes,§4 429 Too Many Requests(可附带 Retry-After)。https://www.rfc-editor.org/rfc/rfc6585
- RFC 9110,HTTP Semantics,§10.2.3 Retry-After(HTTP 日期或秒数),§15.6.4 503 Service Unavailable。https://www.rfc-editor.org/rfc/rfc9110
- RFC 6455,The WebSocket Protocol,§7.4.1 定义的关闭码(1001 Going Away)与 §7.4.2 私有区间 4000–4999。https://www.rfc-editor.org/rfc/rfc6455
- IANA,WebSocket Protocol Registries,Close Code Number Registry(1013 Try Again Later)。https://www.iana.org/assignments/websocket/websocket.xhtml
- PostgreSQL 16 文档,13.3.2 Row-Level Locks(
FOR UPDATE)。https://www.postgresql.org/docs/16/explicit-locking.html - PostgreSQL 16 文档,NOTIFY 与 LISTEN(提交后投递,未监听期间的通知不补发)。https://www.postgresql.org/docs/16/sql-notify.html
- PostgreSQL 16 文档,9.9.5 Current Date/Time(
clock_timestamp()与now()的区别)。https://www.postgresql.org/docs/16/functions-datetime.html - Java SE 21 API,
Runtime(类说明中的 Shutdown Sequence:收到操作系统信号等外部事件时启动关闭序列并运行已注册的钩子;halt不启动关闭序列)。https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Runtime.html - Linux man-pages,signal(7)(SIGKILL 与 SIGSTOP 不能被捕获、阻塞或忽略)。https://man7.org/linux/man-pages/man7/signal.7.html



