实时不是选一种传输协议,而是客户端、网关、协调存储与 真相源(SSOT) 之间关于 多快到达、以何顺序、是否持久、断网与发布时如何恢复 的 契约。

把实时当成「接个 Socket.IO」的产品,往往在上线后撞上:重连后消息乱序、在线状态造假、输入中指示器还在但聊天记录丢了、或一次滚动发布引发重连风暴打满 Redis。下文按设计空间组织:原语、读写路径、一致性、扩展与失败——便于在 UI 固化错误假设之前,先把 难路径 原型化。

1. 先写契约,再选栈

不同功能需要不同保证。请显式写清:

| 问题 | 决定 | |------|------| | 用户是否必须 立刻看见自己的写入? | 乐观 UI vs 服务端 ack | | 他人能否在 DB 提交前 看到? | 临时扇出 vs 事务 outbox | | 同一会话/文档是否 严格有序? | 单调 seq + 补齐 | | 丢失 对某些信号是否可接受? | 可 shed vs 至少一次 | | 离线 24h 后如何恢复? | 快照 + 增量 vs 全量回放 | | 谁有权 订阅 哪个频道? | 握手与按 topic 的 ACL |

若两个功能共一条连接但契约不同,应 拆分通道——否则一套背压策略会悄然伤害两者。

Client ──► Gateway ──► Fan-out 总线 ◄──► 其他 Gateway
              │              ▲
              │              │ 发布(提交后或按策略)
              ▼              │
         Catch-up API ◄── SSOT(真相源)

经验法则: 涉及资金、权限、审计的数据 先入 SSOT;线上只传 可推导事件,并带客户端可对账的序列号。

2. 三个原语(及各自优化目标)

生产栈可拆为传输、扇出、存储——但 层间接口 比选型品牌更重要。

| 原语 | 职责 | 常见选项 | |------|------|----------| | 传输 | 客户端 ↔ 边缘长连接 | WebSocket、SSE、HTTP/2 流、QUIC | | 扇出 | 跨连接/跨节点投递 | Redis Pub/Sub、Redis Streams、NATS、Kafka | | SSOT + 协调 | 持久、顺序锚点、 ephemeral 状态 | 关系库 + outbox;Redis 做 presence/限流 |

2.1 传输选型

| 传输 | 方向 | 优势 | 劣势 | |------|------|------|------| | WebSocket | 双向 | 低延迟;二进制帧 | 代理、LB 空闲断开、粘性会话陷阱 | | SSE | 服务端→客户端 | 走 HTTP;浏览器可自动重连 | 同通道无原生客户端上行 | | 长轮询 | 伪推送 | 兼容性最好 | 规模下延迟与负载高 | | QUIC 流 | 双向 | 弱网/mobile 友好 | 生态成熟度因平台而异 |

按 客户端约束(浏览器、App、企业代理)选型,而非教程默认 WS。许多应用采用 HTTP 写 + SSE/WS 下行——写路径更易做幂等与重试。

2.2 扇出语义

| 机制 | 投递 | 适用 | |------|------|------| | Pub/Sub | 发后即忘;离线即丢 | 输入中、实时光标(若必须要) | | Streams / 日志 | 至少一次;消费组 | 房间历史扇出、跨区中继 | | 无中心内存房间 | 单进程内 | 仅原型;尽早规划迁移 |

勿 单靠 Pub/Sub 投递聊天消息。应 DB 提交(或 outbox) 后再扇出,重连才有 durable 可对账的数据。

3. 写路径:事件如何进入系统

主流生产模式:

  1. 客户端经 HTTP/gRPC 提交变更(建议带幂等键);
  2. 应用校验,在短事务内写 SSOT;
  3. 同事务写 outbox 或领域事件;
  4. Relay 向扇出总线发布 {channel, seq, payload, trace_id};
  5. 网关向已订阅连接推送,队列 感知背压。

反模式: handler 里 socket.emit 同时写 DB——崩溃时出现 幽灵消息 或 丢消息。反模式: 提交前 publish——订阅者看见会回滚的数据。

乐观 UI 可在本地先渲染 pending,但以 服务端 seq 在 ack 时对账;勿用客户端自造 ID 作全局顺序。

4. 读路径: live 流 + 补齐

重连才是真考验。每条 durable 流需要:

  • 重连握手或并行 HTTP API 携带 since_seq / since_revision;
  • 缺口检测 — seq 跳号则先拉 [last+1, current) 再消费 live 帧;
  • 幂等应用 — 同一 (channel, seq) 重复投递不重复 UI 行;
  • 快照边界 — 超过 N 天或 M 条后提供紧凑快照 + 尾部增量。

Live WS/SSE 承载 尾部;HTTP catch-up 承载 历史。混在同一无差别通道上,只能在缓冲膨胀与静默丢失之间二选一。

5. 顺序:范围与 ID

在 会话、文档或分片 维度定义顺序——非全局。全局序号贵且常不符合用户心智。

| 方案 | 场景 | |------|------| | 每 channel 服务端单调 seq | 聊天、Feed、通知 | | 每文档 version/revision | 协作文档;配合同步/合并策略 | | 墙钟时间戳 | 仅展示——不可作正确性依据(时钟漂移) | | CRDT / OT | 并发编辑自动合并 |

seq 在 提交时 于 SSOT 或 outbox relay 分配,而非网关收到时——否则多写者负载下会乱序。

6. 一致性光谱

实时介于「快」与「对」之间。按功能命名层级:

| 层级 | 行为 | 示例 | |------|------|------| | 单资源强一致 | 写后读见已提交态 | 发消息 → 历史 API 可查 | | 旁观者最终一致 | 亚秒级 skew 可接受 | Feed 扇出延迟 | | Ephemeral 尽力而为 | 允许 bounded 丢失 | 输入中、粗粒度 presence | | 读己之写 | 作者必见自己的操作 | 乐观发送 + 服务端 ack |

Presence 与输入中不是聊天。 用更弱保证、独立通道,避免背压为保光标而丢消息。

7. 在线状态:信号,而非 spam

Presence 回答:谁在场、在哪个资源上、具备什么能力?(浏览、编辑、管理员等。)

设计要点:

  • 心跳间隔 × TTL 倍数 — 如 30s 心跳、90s TTL 判离线;
  • 勿只依赖 disconnect — 移动端后台、半开 TCP 会撒谎;
  • 批量与节流 — 每房间 500ms–2s 聚合,而非每次鼠标移动;
  • 能力字段 — {userId, resourceId, mode, lastSeen},非单一 online 布尔;
  • 重建路径 — 网关重启后 presence 为派生态,须能从心跳或 SSOT 会话表 重算。

除非产品 就是 实时光标,60Hz 位置广播 会在负载下挤占真实 payload。

8. 背压即产品行为

出口队列满时,系统在发出信号——应 有意 shed,而非随机丢。

建议优先级(越靠上越先丢):

  1. 输入中 / ephemeral 指示
  2. Presence 增量
  3. 已读回执
  4. 非关键元数据
  5. 消息体与状态变更

此外:

  • 按 channel 合并(保留最新输入中、最新 presence);
  • 上限 每连接发送缓冲;向客户端标记 degraded;
  • 客户端 UX —「重连中…」「显示缓存消息」、部分关闭 live 能力,优于静默丢失。

背压策略应写在 网关代码与 runbook,而非只出现在事故复盘。

9. 连接生命周期与重连风暴

9.1 握手

尽早绑定:userId、deviceId、协议版本、auth 过期、可订阅房间。校验 Origin(防 CSWSH)、WSS、短效 access token + 在 socket 外刷新。

9.2 重连

  • 客户端指数退避 + jitter;
  • Resume token 或 since_seq,避免全量回放;
  • 事故期间对握手做 服务端限流;
  • 发布 优雅排空:停接新连接 → 等 idle → 终止——避免同步 thundering herd。

9.3 横向扩展

| 模式 | 权衡 | |------|------| | 粘性会话 | 简单;发布痛苦;负载不均 | | 无状态网关 + 共享扇出 | 推荐;任意节点;依赖共享总线 | | 按 room/user 分片 | 热 room 隔离;路由变复杂 |

业务层 不应 持有 socket 句柄——仅网关订阅与推送。

10. 协作:何时引入 CRDT / OT

并非所有实时应用都需要 CRDT。在以下情况用 服务端权威 + seq 即可:

  • 每条消息单行单写;
  • 冲突少,「最后提交 wins + 审计」可接受。

在以下情况考虑 OT/CRDT:

  • 多用户并发编辑同一字段;
  • 离线编辑在重连时合并;
  • 按键级延迟需隐藏往返。

常见 混合:文档体 CRDT/OT、评论 seq 聊天、光标 ephemeral presence——三种契约、三条通道。

11. 多区域与延迟

  • 网关 靠近用户;SSOT 除非投入多主冲突设计,否则保持单区域权威;
  • 跨区扇出带来 可见 lag——产品文案与 UI 需对齐预期;
  • CRDT 利于跨 lag 合并编辑;聊天 seq 仍倾向单 ordering 分片或可接受的 fork+repair 语义。

度量 P99 推送延迟(提交 → 客户端渲染),而非仅 ping。

12. 可观测性:测什么

HTTP 写与 WS 推共用 trace_id:

| 指标 | 用途 | |------|------| | 活跃连接数 / churn | 发布与事故探测 | | 握手失败 / 鉴权拒绝 | 攻击或配置漂移 | | 每连接队列深度 | 背压预警 | | 提交 → 扇出 → 送达延迟 | 端到端 SLO | | Catch-up API P99 体量与耗时 | 重连健康 | | 缺口拉取频率 | 顺序或 relay bug | | 按优先级丢弃计数 | 调 shed 策略 |

生产日志记 channel、seq、userId(脱敏)、event type——非全量 payload。

13. 安全接缝

  • 订阅 ACL — 握手验 JWT 不够;按 topic 校验房间成员;
  • 对 publish 与 handshake 限流;
  • Payload 大小上限;二进制或 JSON Schema 校验;
  • Redis key / 频道名 租户隔离(tenant:{id}:room:{id});
  • 审计向大量用户广播的管理操作。

room ID 泄露不应在无服务端 subscribe 授权时获得访问。

14. 演进路径

  1. 阶段 A — 原型 — 单节点内存房间、HTTP 写;摸清延迟 UX;
  2. 阶段 B — 生产核心 — SSOT + outbox、Redis 扇出、无状态网关、catch-up API;
  3. 阶段 C — 规模 — 独立网关池、Streams/消费组、热 room 分片、区域边缘;
  4. 阶段 D — 协作深度 — 仅在产品需要处上 CRDT/OT;聊天仍走 seq。

每阶段需 带重连的压测(模拟发布、Wi‑Fi 抖动、万人同 room)——非仅稳态 HTTP benchmark。

15. 反模式

  • 全局总线 单一优先级 承载一切;
  • 无 seq — 客户端用 Date.now() 排序;
  • 把 Redis Pub/Sub 当 消息存储;
  • Socket 与 DB 双写 无 outbox;
  • 粘性会话 无排空计划;
  • 负载下 静默丢弃 且无客户端降级态;
  • 上帝 room — 百万订阅单频道无边缘聚合;
  • 在 SSOT 存 每连接一行;
  • 跳过 catch-up API 因为「WS 会重放」;
  • 为「简单」做 全局顺序。

小结

生产级实时是 带延迟预算的系统工程:

  1. 契约优先 — 按功能定义持久、顺序范围、可丢 tolerance;
  2. SSOT + outbox → 扇出 → 网关 — 非 socket 先行写;
  3. Live 尾部 + HTTP 补齐 — 重连是一等路径;
  4. 分片顺序 — 提交时服务端 seq;缺口修复;
  5. 一致性分级 — ephemeral presence ≠ 聊天消息;
  6. 有优先级的背压 — 有意 shed 与合并;
  7. 无状态网关、共享总线、可观测端到端延迟;
  8. CRDT/OT 仅在并发编辑需要时引入。

尽早原型化 重连与发布 路径——UI 抛光会掩盖难以反悔的错误契约。传输是简单部分;负载下的恢复 才区分 Demo 与产品。