本文属于《后端系统工程:从接口契约到故障恢复》系列。文中提到的演练工程、测试与实验证据位于博客源码仓库的 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
2
3
网关收到的心跳     = 5000 ÷ 30 秒 ≈ 167 次/秒(只更新内存中的 last_ping)
存储收到的续租 = 5000 ÷ 60 秒 ≈ 83 次/秒(批量提交时请求数按网关数计)
对外发布的状态变化 ≈ 用户上下线频率 × 平均订阅者数(去抖后)

批量续租把存储的请求数从“连接数相关”变成“网关数相关”,每个请求携带的条目数仍与连接数成正比。随机化不改变平均值,只改变峰值,6.3 节的实验测量的就是峰值。订阅扇出通常是三者中最大的一项,因为一次状态变化要发给该用户的全部订阅者;本篇没有实现扇出,只在限制中列出。

5. 关键实现

5.1 epoch:旧连接不能删除新连接

每次连接分配单调递增的 epoch。存储只接受不小于当前最大 epoch 的续租,下线请求必须与当前 epoch 完全相等才生效:

1
2
3
4
5
6
7
8
9
def remove(self, user, device, epoch, reason) -> bool:
k = (user, device)
cur = self.leases.get(k)
if cur is None or cur.epoch != epoch: # 旧连接的迟到下线不得删除新连接的租约
self.rejected_stale += 1
return False
del self.leases[k]
self.on_change(user, device, False, reason)
return True

这里的 epoch 由模型的单一计数器分配。真实系统中,它需要来自一个能跨网关保证单调的来源,比如存储端在连接建立时分配,或者沿用第 10 篇路由版本的做法。

5.2 续租随机化

网关第一次续租的延迟在 [0, 间隔] 内均匀随机,之后每次在 间隔 × [0.9, 1.1] 内随机:

1
2
3
4
5
def _schedule_renew(self, first=False):
base = self.p.renew_interval
j = self.p.renew_jitter
d = self.rng.uniform(0, base) if (first and j > 0) else base * self.rng.uniform(1 - j, 1 + j) if j else base
self.c.after(d, self._renew)

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。摘要:以完整性与准确性刻画。

本批导航