实时通信
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-connection | send(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 命中本机?