实时不是选一种传输协议,而是客户端、网关、协调存储与 真相源(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. 写路径:事件如何进入系统
主流生产模式:
- 客户端经 HTTP/gRPC 提交变更(建议带幂等键);
- 应用校验,在短事务内写 SSOT;
- 同事务写 outbox 或领域事件;
- Relay 向扇出总线发布
{channel, seq, payload, trace_id}; - 网关向已订阅连接推送,队列 感知背压。
反模式: 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,而非随机丢。
建议优先级(越靠上越先丢):
- 输入中 / ephemeral 指示
- Presence 增量
- 已读回执
- 非关键元数据
- 消息体与状态变更
此外:
- 按 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. 演进路径
- 阶段 A — 原型 — 单节点内存房间、HTTP 写;摸清延迟 UX;
- 阶段 B — 生产核心 — SSOT + outbox、Redis 扇出、无状态网关、catch-up API;
- 阶段 C — 规模 — 独立网关池、Streams/消费组、热 room 分片、区域边缘;
- 阶段 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 会重放」;
- 为「简单」做 全局顺序。
小结
生产级实时是 带延迟预算的系统工程:
- 契约优先 — 按功能定义持久、顺序范围、可丢 tolerance;
- SSOT + outbox → 扇出 → 网关 — 非 socket 先行写;
- Live 尾部 + HTTP 补齐 — 重连是一等路径;
- 分片顺序 — 提交时服务端 seq;缺口修复;
- 一致性分级 — ephemeral presence ≠ 聊天消息;
- 有优先级的背压 — 有意 shed 与合并;
- 无状态网关、共享总线、可观测端到端延迟;
- CRDT/OT 仅在并发编辑需要时引入。
尽早原型化 重连与发布 路径——UI 抛光会掩盖难以反悔的错误契约。传输是简单部分;负载下的恢复 才区分 Demo 与产品。