后端系统工程 E01:在线状态与分层心跳——一个带误差上界的观察结果
本文属于《后端系统工程:从接口契约到故障恢复》系列。文中提到的演练工程、测试与实验证据位于博客源码仓库的
batch-4/目录(examples/、evidence/等子目录);业务数据均为合成数据。
“在线”看起来是一个布尔值,实际上是服务端对远端设备的一次推断。设备离开的方式不止一种:主动关闭连接、进入隧道导致连接半开、所在的接入网关整机宕掉。每一种方式被服务端察觉的路径不同,耗时也不同。服务端能承诺的不是“状态永远正确”,而是“从设备事实上离开,到对外发布离线,最多经过多久”。
本篇用受控时钟实现三层心跳:连接层探测、网关到存储的租约续期、用户级视图的去抖发布,并为每种离开方式推导和测量误差上界。隐私粒度作为同一视图的另一个维度一起处理。
1. 需求与规模假设
- 用户可以有多台设备;任一设备在线,用户即在线。
- 订阅者只看到被允许的粒度:精确(在线/离线 + 分钟级最近在线)、粗略(在线/最近/很久以前)、隐藏(未知);非联系人与被拉黑者什么都看不到。
- 每种离开方式都有可计算的“发现离线”上界;正常心跳偶尔丢一次,不应导致误报离线。
- 设备换网关重连时,旧连接迟到的下线消息不能删除新连接的状态。
- 网关整批重启后,续租请求不应集中在同一秒到达存储。
教学参数:
| 参数 | 值 | 约束 |
|---|---|---|
| 客户端心跳间隔 | 30 秒(实际 27–30 秒随机) | — |
| 网关判定连接失效 | 75 秒未收到心跳,每 5 秒检查一次 | > 2 × 心跳间隔,容忍丢一次 |
| 网关续租间隔 | 60 秒,±10% 随机化 | — |
| 存储租约 TTL | 150 秒,每 5 秒清扫一次 | > 2 × 续租间隔,容忍一次续租失败 |
| 下线去抖 | 20 秒 | 短于去抖的断线重连不对外发布 |
规模假设:单机模型中最多 20 个网关、5000 个连接。这些数字只用来观察续租的时间分布,不代表存储能承受的写入量。
2. 为什么需要三层
TCP 自带的保活机制不足以承担这件事。RFC 9293 规定 TCP 保活是可选实现,如果实现,必须默认关闭,间隔默认不少于两小时,并且不能因为某一次探测没有响应就判定连接已死。这些规定是为了避免误杀连接,代价是发现得慢,所以应用层需要自己的心跳。
WebSocket 提供了应用层可用的探测帧。RFC 6455 规定收到 Ping 必须回复 Pong(已收到 Close 的情况除外),Ping 可以用作保活或检查对端是否仍有响应;Pong 也可以不经请求单向发送,作为单向心跳。本篇的连接层心跳对应后一种用法:客户端定期发送,网关只记录最后到达时间。
连接层只能回答“这条连接还活着吗”,而网关自身也会宕机。网关宕机时,它不会再替任何连接发送下线消息,所以存储端需要一个不依赖网关配合的机制:租约。网关定期为它持有的每个连接续租;网关消失,租约到期,存储自己把设备判为离线。
第三层是对外发布。设备级状态翻转很频繁,比如进电梯断开、出电梯重连。去抖期间如果设备重连成功,订阅者什么也看不到。
XMPP 的在线状态规范(RFC 6121)描述了相似的分工:客户端在离开前应当发送“不可用”状态,但服务端不能依赖这一点,资源无论正常还是异常变为不可用,服务端都必须广播不可用状态;服务端还可以在长时间收不到联系人消息时主动探测,以避免“幽灵”联系人。
---
title: 图 E01-1 三层心跳的状态归属与发现路径
---
flowchart TB
subgraph DEV ["设备"]
D["客户端:约每 30s 单向心跳"]
end
subgraph GW ["网关进程 g1"]
C["连接表:last_ping, epoch"]
K["每 5s 检查:超过 75s 判失效"]
R["每 60s ±10% 批量续租"]
end
subgraph STORE ["在线状态存储"]
L["租约表:(user, device) → epoch, gateway, expires"]
S["每 5s 清扫过期租约"]
end
subgraph VIEW ["视图"]
V["用户级:任一设备在线"]
G["离线去抖 20s 后发布"]
P["按隐私粒度裁剪后给订阅者"]
end
D --> C
C --> K -->|close/ping_timeout, 带 epoch| L
R -->|upsert, 带 epoch| L
L --> S -->|lease_expired| V
L -->|设备上线/下线| V --> G --> P
3. 契约:误差上界
从设备“事实上离开”到“发布离线”的最大延迟,由离开方式决定:
| 离开方式 | 发现路径 | 上界 |
|---|---|---|
| 主动关闭 | 网关立即删除租约 → 去抖 | 去抖 = 20 秒 |
| 连接半开 | 网关心跳超时 → 删除租约 → 去抖 | 75 + 5 + 20 = 100 秒 |
| 网关宕机 | 租约到期 → 清扫 → 去抖 | 150 + 5 + 20 = 175 秒 |
最后一行同时也是“误报在线”的最长时间:网关宕机后的这段时间里,订阅者看到的仍是在线。上界写进 bounds(),测试用实测值对照它。
对外的视图因此是带时间误差的观察结果。接口层面可以选择暴露这个误差,例如在离线状态旁边返回“最近在线(分钟)”,而不是精确到秒的时间戳。
4. 接口契约与负载估算
对订阅者暴露的不是“查询在线状态”的同步接口,而是一个快照加变更流。下表是接口契约;本篇模型只实现了其中的裁剪逻辑 view(),变更流与版本号没有实现,留作与第 05、10 篇集成时的接口约定:
| 操作 | 返回 | 语义 |
|---|---|---|
subscribe(viewer, targets) |
每个可见目标的当前裁剪后状态 | 不可见目标不出现在结果中,与“离线”区分 |
| 变更事件 | (target, status, version, observed_at_minute) |
version 在每个目标上单调递增,订阅端丢弃版本更小的事件 |
unsubscribe |
— | 幂等 |
version 解决的是投递乱序:设备快速上下线时,“离线”事件可能晚于随后的“在线”事件到达订阅端。订阅端只保留版本最大的那条,与存储端用 epoch 拒绝旧连接是同一个思路,只是作用在视图层。事件只携带分钟粒度的时间,精确到秒的离开时刻不出站。
三层心跳的请求量可以按系列第 02 篇的公式分别估算。以 5000 个在线连接为例:
1 | |
批量续租把存储的请求数从“连接数相关”变成“网关数相关”,每个请求携带的条目数仍与连接数成正比。随机化不改变平均值,只改变峰值,6.3 节的实验测量的就是峰值。订阅扇出通常是三者中最大的一项,因为一次状态变化要发给该用户的全部订阅者;本篇没有实现扇出,只在限制中列出。
5. 关键实现
5.1 epoch:旧连接不能删除新连接
每次连接分配单调递增的 epoch。存储只接受不小于当前最大 epoch 的续租,下线请求必须与当前 epoch 完全相等才生效:
1 | |
这里的 epoch 由模型的单一计数器分配。真实系统中,它需要来自一个能跨网关保证单调的来源,比如存储端在连接建立时分配,或者沿用第 10 篇路由版本的做法。
5.2 续租随机化
网关第一次续租的延迟在 [0, 间隔] 内均匀随机,之后每次在 间隔 × [0.9, 1.1] 内随机:
1 | |
5.3 去抖与多设备
用户的最后一台设备下线时,视图启动一个带代号的定时器;定时器到期时,只有代号仍然有效、设备集合仍然为空,才发布离线。中间任何一次上线都会作废这个代号。
5.4 隐私裁剪
view(viewer, target) 先判断可见性:非联系人或被拉黑者返回 None,表示“无可见信息”,而不是“离线”。如果对外返回“离线”,被拉黑者就能从“一直离线”推断出自己被屏蔽,所以两者必须区分。RFC 6121 的立场相同:在线信息只向用户批准过的实体披露,非订阅者发来的探测得到的是 unsubscribed,而不是在线状态。精确粒度也只给出分钟级的最近在线时间,粗略粒度只区分“最近一天内”和“更早”。
6. 实验
运行入口 examples/e01_presence/:python3 -m unittest test_presence -v,12 个用例通过;python3 report.py 生成检测延迟分布(evidence/E01/detection-latency.json)。时钟完全受控,随机数由固定种子与网关名的 CRC32 决定。初版代码用 Python 的 hash() 派生网关种子,字符串哈希每个进程随机,同一命令两次运行得到的网关宕机检测延迟分别是 130 秒和 170 秒;改为 CRC32 后结果稳定为 135 秒。可重放性也要靠测试检验,不能靠假设。
6.1 正常与边界
| 用例 | 观察 |
|---|---|
| 主动关闭(300 秒) | 320 秒发布离线,恰好等于去抖 |
| 丢一次心跳 | 不发布离线;75 秒阈值容忍一次缺失 |
| 两台设备先后离开 | 只在最后一台离开 20 秒后发布离线 |
| 关闭后 10 秒内重连 | 订阅者没有看到任何变化 |
| 旧网关的下线消息迟到 30 秒,设备已在新网关重连 | 下线被拒绝(rejected_stale=1),租约归属仍为新网关 |
6.2 三种离开方式的检测延迟(300 个设备,离开时刻随机)
| 离开方式 | 最小 | 中位数 | 最大 | 上界 |
|---|---|---|---|---|
| 主动关闭 | 20.0 | 20.0 | 20.0 | 20 |
| 连接半开 | 66.1 | 82.8 | 96.8 | 100 |
| 网关宕机 | 135.0 | 135.0 | 135.0 | 175 |
单位为秒。半开连接的延迟分布在 66–97 秒之间,取决于离开时刻落在心跳周期和检查周期的哪个位置。网关宕机的延迟全部相同,因为这个网关的所有租约是同一批续期的,宕机前最后一次续租时刻对所有设备一致。这是批量续租的副作用:同一网关上的设备会同时被判为离线,订阅者侧会在同一时刻收到一批状态变化。
6.3 网关集体重启后的续租分布
20 个网关同时启动,共 5000 个连接,统计存储端每秒收到的续租数峰值:
| 续租随机化 | 每秒峰值 | 最终有效租约 |
|---|---|---|
| 无 | 5000 | 5000 |
| ±10%,首次在一个间隔内均匀随机 | 750 | 5000 |
不加随机化时,所有网关在同一秒续租,并且之后每 60 秒重复一次。
6.4 负向对照:TTL 短于续租间隔
TTL 设为 50 秒、续租间隔 60 秒、不加随机化。1000 秒内,存储层出现 16 次租约过期。去抖为 20 秒时,订阅者看不到这些翻转,因为每次过期后 10 秒就被下一次续租恢复。把去抖设为 0,订阅者收到 33 次上下线发布。去抖掩盖了配置错误,存储写入和下游通知仍然持续翻转。因此“设备级翻转次数”应当单独观测,不能只看对外发布的状态。
6.5 用例与需求的对应
| 需求 | 用例 | 结论 |
|---|---|---|
| 多设备:任一在线即在线 | test_multi_device_offline_only_when_last_leaves |
通过 |
| 隐私粒度与不可见 | test_non_contact_and_blocked_get_nothing、test_exact_shows_minute_not_second、test_coarse_and_hidden |
通过;超过 86400 秒后粗略粒度变为“很久以前” |
| 上界可计算 | test_half_open_detected_within_bound、test_gateway_crash_detected_by_lease |
通过;另有 300 设备分布实验 |
| 丢一次心跳不误报 | test_no_false_offline_with_one_lost_ping |
通过 |
| 旧连接不覆盖新连接 | test_late_close_from_old_epoch_ignored |
通过 |
| 续租不集中 | test_jitter_spreads_synchronized_renewals |
通过 |
| 配置错误可被发现 | test_ttl_shorter_than_renew_causes_device_flaps |
通过(负向对照) |
网关宕机用例还断言了下界:离线不会早于“最后一次续租 + TTL”。上界保证订阅者终将看到离线,下界保证一次短暂的续租延迟不会被提前判为离线,两者缺一不可。
7. 方案比较
| 方案 | 发现网关宕机 | 存储写入量 | 误报风险 |
|---|---|---|---|
| 只靠连接关闭事件 | 发现不了(没有人发送关闭事件) | 最低 | 网关宕机后永久显示在线 |
| 每个连接直接向存储心跳 | 快 | 连接数 / 心跳间隔,最高 | 存储抖动直接变成在线状态抖动 |
| 网关批量续租 + TTL(本文) | TTL + 清扫 | 连接数 / 续租间隔 | TTL 与续租间隔配比不当时翻转 |
| 累积式故障检测(φ accrual) | 按历史到达间隔给出连续的怀疑程度 | 与心跳相同 | 需要选择阈值;本文未实现 |
Hayashibara 等人提出的 φ accrual 故障检测器输出的是连续的怀疑程度而不是布尔值,尺度随网络状况自动调整。它适合心跳间隔波动大的链路;本文的固定阈值更容易推导上界,所以采用后者。Chandra 与 Toueg 用完整性与准确性两个性质刻画不可靠的故障检测器,这两个性质正好对应本篇的两类错误:迟迟发现不了离线,以及误把在线判为离线。
8. 限制
- 模型单进程运行,没有模拟网络分区下网关仍在运行、但与存储失联的情况。此时网关认为连接健康,存储却让租约过期,两边状态不一致。
- 时钟是理想的,没有考虑网关与存储之间的时钟偏差;真实的 TTL 判断应只依赖存储端本地时钟。
- 订阅变更流、事件版本号、订阅者的扇出、限速与合并都没有实现;“同一时刻一批状态变化”的下游压力没有测量。
- 隐私规则只覆盖联系人、拉黑和三档粒度,未覆盖群组成员可见、对单个联系人隐藏等更细的策略。
- 去抖让所有离线发布至少晚 20 秒。“正在输入”一类需要秒级反馈、且错一次代价很小的状态,不应复用这条链路,而应在会话连接上直接传递,不经过租约和去抖。
- φ accrual 公式没有从原文核对(原文 PDF 无法提取正文),正文只引用摘要中的结论。
9. 来源
核对日期 2026-10-18。
- RFC 9293 §3.8.4 TCP Keep-Alives:可选、默认关闭、默认间隔不少于两小时、单次探测失败不能判定连接死亡。
- RFC 6455 §5.5.2–5.5.3:Ping 必须回复 Pong;Ping 可用作保活;可单向发送 Pong 作为单向心跳。
- RFC 6121 §3、§4.3、§4.5:在线信息只披露给已批准实体;服务端探测;无论正常或异常下线都必须广播不可用。
- Hayashibara, Défago, Yared, Katayama,The φ Accrual Failure Detector,JAIST IS-RR-2004-010(2004);SRDS 2004,DOI 10.1109/RELDIS.2004.1353004。摘要:输出连续尺度的怀疑程度。
- Chandra, Toueg,Unreliable Failure Detectors for Reliable Distributed Systems,JACM 43(2), 1996,DOI 10.1145/226643.226647。摘要:以完整性与准确性刻画。
本批导航
- 下一篇选修:E02 定时调度与日历语义
- 主线:36、37、38、39
- 相关章节:05 长连接接入与连接归属、10 网关故障后的重连与路由收敛






