实时通信

WebSocket 把请求-响应换成一条长存活的双向连接 — 代价是这条连接随时会断,断了消息就丢。协议本身没有重试也没有去重。围绕这一事实,展开重连、消息可达性、分发粒度、多实例 fan-out、半死连接检测的取舍。项目里的 /ws 实现见 agent/realtime。

What — WebSocket 是什么

WebSocket 是 TCP 上的全双工长连接协议。HTTP 握手升级一次后,服务端和客户端都能主动推送,不再走请求-响应往返。

跟 HTTP 的三点本质差异:

  • 一条长连接 — 长存活、有状态,而不是每次操作建立一次
  • 服务端能主动发 — 客户端不需要轮询就能收到事件
  • 没有重试 / 去重 — 连接断开时,传输中的消息直接丢

长连接、双向、不保证投递 — 后面所有约束都从这三点推出来。

Why — 何时该用,何时别用

适用场景

  • 服务端要主动推 — 用户没交互的时刻也要收到事件(后台任务完成、协作文档的远端编辑)
  • 高频小消息 — 流式 LLM 响应、实时光标;5 秒轮询太慢,1 秒轮询又太重
  • 双向且关联 — 一次会话内多轮来回,HTTP 反复握手成本高(TLS、鉴权中间件)
  • 延迟敏感 — P99 < 200ms 的事件通知场景

不适用场景

  • 请求-响应即结束 — 表单提交、CRUD 查询;HTTP 简单可靠,负载均衡和缓存基础设施成熟
  • 数据可以最终一致 — 用户 5 分钟后看到新数据也能接受的场景,React Query refetch + 客户端轮询便宜得多
  • 只接收不发送 — 单向推送用 SSE (Server-Sent Events) 更简单;上 WS 是过度工程
  • 要求强投递保证 — WS 不重传;强投递需求要走可靠队列 + HTTP 拉取,或自己实现 ACK + 重放(成本极高)

SSE 是单向推送,HTTP/2 长流是另一种单向方案 — 都比 WS 简单。只有当客户端也要持续发,才是 WS 真正的领地。

How — 设计时绕不过的事

下面这些不依赖具体实现 — 任何 WS 系统都会撞上。

1. 连接会断 — 重连必须设计在协议里

WS 连接断的常见原因:

  • 网络抖动 / NAT rebind / VPN 切换
  • 浏览器 tab 切后台、笔记本进入睡眠
  • 服务端滚动升级 / OOM / 主动 close
  • 中间代理(公司网络、CDN)60 秒空闲超时主动剥离

不存在“WS 永远在线”。每个 WS 客户端必须设计:

  • 重连策略 — 指数退避 + jitter(1s → 2s → 4s → 8s → 封顶),否则 server 重启时所有客户端同时砸过来,形成 thundering herd
  • 重新订阅 — 重连不等于恢复;原来 join 的 room、subscribe 的 channel 都要重新发
  • 状态恢复 — 客户端要假设“断线期间错过了消息”,连上后主动拉一次最新状态(如 invalidate query 重取)

2. WS 不保证投递 — 关键状态不能只靠 WS

client → server:订阅 task X 的事件
server → client:task X 已完成
                 ↑ 这条发出去的瞬间 client 连接断了 — 消息永久丢失

WS 帧没有重试、没有去重,断了就丢。两条路:

  • WS 当“提示信号”,数据库做事实之源 — 收到事件后 invalidate / refetch 权威数据,不依赖事件内容本身
  • 客户端持 cursor,能重放 — 每条消息带 sequence number,重连后客户端发“我看到 N,给我 N+1 起的”,server 从持久化重放(实现成本高,少数场景才值得)

项目走第一条 — task:event 只带 taskId + kind,客户端收到后 invalidate 任务列表重取。

3. 三种分发模式 — 选错就出 bug

模式用法典型场景
Per-connectionsend(connectionId, msg)HITL 回复、错误响应、点对点回包
Per-room(显式)broadcast(roomId, msg) + join多人协作:多个 client 订阅同一会话
Per-user(隐式)sendToUser(userId, msg)通知:该用户开着的任意 tab 都收到

选错就出 bug:用户开两个 tab,一个在 chat,一个在 task 详情;task:event 用了 per-connection 而不是 per-user → 只有一个 tab 收到,另一个永远显示旧状态。

判定原则:

  • 任意 tab 都该收 → per-user
  • 订阅了才收 → per-room
  • 谁问谁收 → per-connection

4. 多实例必须 Redis pub/sub

单进程时所有连接都在本进程 in-memory map 里,直接 send 就行。多实例(滚动部署、负载均衡)下:

  • 用户连到实例 A,任务在实例 B 完成
  • 实例 B 的 sendToUser(userId) 在本机找不到这个 userId 的连接 → 消息丢失

必须有跨实例 fan-out:Redis pub/sub / NATS / Kafka。每个实例订阅 user:{userId} channel,实例 B 发布后所有实例都收到,再各自检查“这个 userId 在我这里有没有连接”。

单实例跑通的代码在多实例下大概率不工作。启动接线时就把 Redis 装上,别等 bug 复现再加。

5. TCP 不会告诉你连接已经死了

TCP 连接的“死亡”分两种:

  • 优雅 close — FIN 包送达,两端都知道。好处理。
  • 半死 (Half-open) — 中间链路被切断,两端都以为连接还在。read 不报错,write 短期内也不报错 — 你以为消息发出去了,其实卡在 OS buffer。

应用层 ping/pong 是识别 half-open 的唯一办法:

  • Server 周期发 ping(项目取 25s),client 必须回 pong
  • 双方各自跟踪 lastActivityAt
  • Server 端 sweeper — 超过阈值(2-3 个心跳周期)→ 视为僵尸,主动 close
  • Client 端 watchdog — 超过阈值没收到任何消息 → 主动 close 并触发重连

两端独立,别假设一端的 ping 会唤醒另一端的判断。server 驱动优于 client 驱动的原因:server ping 抵达本身就证明 server 还活着;client 驱动只能证明 client 活着。

6. 客户端版本永远滞后 — 事件 shape 要能演进

服务端发新版本时,客户端可能还在用旧版本。三类破坏性变更:

  • 字段删除 → 旧客户端逻辑断
  • 字段类型变更 → 旧客户端类型错
  • kind 取值变更 → 旧客户端 switch case 漏分支

设计上:

  • 加字段安全 — 客户端要能对未知字段优雅 fallback
  • 删字段 / 改语义需要版本化 — 加 protocolVersion 字段,或者新增 type 而不是改原有 type 的语义
  • discriminator 用字符串,不用数字 enum — 数字 enum 改值就静默 broken;字符串 "task:completed" 即使新增也不冲突

WS 协议要当 public API 来对待 — 只加不删,只新增 enum,不改既有语义

7. 服务端中途死亡会留下脏状态 — reconciler 来清

server 进程在 stream 进行中死亡时,数据库里 task 还标记为 active,但实际没有进程在跑 — UI 表现就是 sidebar 永远转圈。

需要一个 reconciler:周期扫描数据库标记为 active 的任务,看是否还有进程在心跳(分布式锁 / TTL);没有心跳就把状态改成 errored,通过 WS 推一次失败事件。

reconciler 本身也要幂等(多实例并发跑安全),还要有启动延迟(让同时启动的其他实例先 boot)。zombie 到清理的最大延迟 ≈ lock TTL + 扫描周期

项目实现

回到具体接线:

  • 单一 /ws 端点 — 三种分发模式(per-connection / per-room / per-user)共享同一连接
  • 四层可靠性 — server ping 25s + client watchdog 35s + sweeper 30s 周期 / 90s 僵尸阈值 + 带 jitter 的指数退避
  • Redis pub/sub — 多实例 fan-out;惰性订阅(用户连进来才订阅 channel)
  • Task reconciler — 60s 启动延迟 + 2min 周期;TTL 90s 的 task-lock 作为“进程还活着”的唯一依据
  • 协议 shape{domain}:{subkind} 命名空间;task:event 只带 taskId + kind,客户端用作刷新提示

详细参数、close codes、Mermaid 时序图、文件接线见 → 实时通信架构

写新 WS 事件前的自检

  • 事件丢了用户会不会发现?关键状态变更是不是该设计成“提示信号 + 客户端 refetch”而不是把数据塞进 WS?
  • 分发选对了吗?任意 tab 都该收 → per-user;订阅了才收 → per-room;点对点 → per-connection
  • 客户端断线 30s 重连后,这个事件怎么“补”回来?补不回来的话,reconnect 时要不要 invalidate 一次?
  • 服务端发版改 shape 会不会让旧客户端炸?加字段是安全的,删 / 改语义需要版本化。
  • 多实例下这条 send 走 Redis pub/sub 了吗?还是只 in-memory 命中本机?
这页有帮助吗?