引言
实时能力已经从「加分项」变成「默认项」:聊天、协作编辑、行情推送、任务进度、系统通知,都要求服务端能主动把数据推给客户端。在 TypeScript 里做实时通信,难点从来不是「连得上」,而是连得稳、推得对、类型不塌——连接会断、消息会重、顺序会乱,而类型系统要能把这些边界都表达出来。
浏览器与 Node 侧可用的方案主要有三类:WebSocket、SSE(Server-Sent Events)与轮询/长轮询。它们的取舍不是简单的性能数字,而是通信方向、代理穿透性、重连语义与工程复杂度的组合。选错方案,后面会用大量补丁去填。
本文聚焦 TypeScript 实时通信的工程落地:从选型讲起,覆盖 WebSocket 与 SSE 的类型安全设计、消息协议的判别联合、心跳与重连退避、断线补偿与去重、背压、多路复用、服务端广播,最后给出可观测与压测实践。
目录
- 1. 实时通信的技术选型
- 2. WebSocket 的类型安全设计
- 3. SSE 与单向流式推送
- 4. 消息协议与判别联合
- 5. 心跳、重连与退避策略
- 6. 断线补偿与消息去重
- 7. 背压与消息队列
- 8. 多路复用与订阅管理
- 9. 服务端广播与房间模型
- 10. 可观测、压测与生产实践
1. 实时通信的技术选型
1.1 三类方案
WebSocket 基于 HTTP 升级握手,之后承载全双工的文本或二进制帧;SSE 是一条普通的 HTTP 长响应,服务端持续写入 text/event-stream 格式的数据;轮询则是客户端定时发起请求拉取增量。
1.2 能力对比
| 维度 | WebSocket | SSE | 轮询 |
|---|---|---|---|
| 通信方向 | 双向 | 服务端单向 | 客户端拉取 |
| 协议 | ws 需升级 | 普通 HTTP | 普通 HTTP |
| 自动重连 | 需自建 | 内建 | 无 |
| 二进制 | 支持 | 仅文本 | 支持 |
| 代理穿透 | 偶有阻碍 | 良好 | 良好 |
| 实现复杂度 | 高 | 低 | 最低 |
1.3 选型原则
只需要服务端推送(通知、进度、行情快照)时优先 SSE,因为它复用 HTTP 语义、天然支持断线重连与 Last-Event-ID;需要双向低延迟(聊天、协作、游戏)时用 WebSocket;只是低频更新则轮询最省事。
生产系统常见「WebSocket 上行 + SSE 下行」或「SSE 主通道 + 轮询兜底」的组合:上行命令走 WebSocket 保证低延迟,下行广播走 SSE 降低连接管理成本。
一句话总结:先按通信方向选方案,再按复杂度取舍——SSE 用普通 HTTP 做单向推送且内建重连,WebSocket 提供双向低延迟但连接管理要自建。
2. WebSocket 的类型安全设计
2.1 用字面量约束事件名
type ClientEvent = "chat.send" | "room.join" | "room.leave" | "typing"
type ServerEvent = "chat.message" | "room.joined" | "user.typing" | "error"
用字面量联合而不是 string,让 socket.on("...") 的拼写错误在编译期就暴露,而不是上线后变成静默无响应的死连接。
2.2 用映射类型约束载荷
interface ServerPayloads {
"chat.message": { id: string; roomId: string; text: string; at: number }
"room.joined": { roomId: string; members: string[] }
"user.typing": { roomId: string; userId: string }
error: { code: string; message: string }
}
type Handler<K extends keyof ServerPayloads> = (payload: ServerPayloads[K]) => void
事件名与载荷被绑定在同一张映射里,新增事件时若忘了补载荷类型,编译期即报错。
2.3 收发封装
class TypedSocket {
constructor(private ws: WebSocket) {}
on<K extends keyof ServerPayloads>(ev: K, fn: Handler<K>) {
this.ws.addEventListener("message", (e) => {
const msg = JSON.parse(e.data) as { type: K; data: ServerPayloads[K] }
if (msg.type === ev) fn(msg.data)
})
}
}
2.4 断言不等于校验
JSON.parse(...) as T 只是声明,编译器不会为它做任何检查。跨网络的数据永远是 unknown,边界处必须用 zod 之类的 schema 再校验一次,否则类型安全只是幻觉。
一句话总结:用字面量联合约束事件名、用映射类型约束载荷——但
as只是声明,网络边界必须再跑一次运行时校验。
3. SSE 与单向流式推送
3.1 服务端最小实现
app.get("/events", (req, res) => {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
})
const send = (event: string, data: unknown, id?: string) => {
if (id) res.write(`id: ${id}\n`)
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
}
})
3.2 事件格式要点
每条消息由 id:、event:、data: 字段加一个空行结束;data 中的换行必须拆成多行 data:;只有空行才代表一条消息结束,缺少空行客户端会一直缓冲。
3.3 客户端消费
const es = new EventSource("/events")
es.addEventListener("chat.message", (e) => {
const payload = JSON.parse((e as MessageEvent).data)
render(payload)
})
es.onerror = () => { /* 浏览器会自动按 Retry-After 重连 */ }
3.4 SSE 的固有约束
浏览器对同域 HTTP/1.1 连接数有限制,SSE 会长期占用一条;HTTP/2 下多路复用缓解了该问题。此外 SSE 只能单向、只能传文本,二进制需 Base64 编码。
一句话总结:SSE 用
event/data/id字段描述消息、空行分隔——浏览器自动重连并携带Last-Event-ID,但连接数限制与「仅文本单向」是其固有约束。
4. 消息协议与判别联合
4.1 判别联合是协议的核心
type ClientMessage =
| { type: "chat.send"; roomId: string; text: string }
| { type: "room.join"; roomId: string }
| { type: "room.leave"; roomId: string }
| { type: "typing"; roomId: string }
每个分支都有一个字面量 type 字段作为判别键,switch (msg.type) 后 TypeScript 会自动把 msg 收窄到对应分支。
4.2 穷尽性检查
function handle(msg: ClientMessage) {
switch (msg.type) {
case "chat.send": return sendChat(msg)
case "room.join": return joinRoom(msg)
case "room.leave": return leaveRoom(msg)
case "typing": return typing(msg)
default: return assertNever(msg) // 新增分支时此处编译报错
}
}
const assertNever = (x: never): never => { throw new Error(String(x)) }
4.3 协议版本与前向兼容
协议里带上 v 版本字段;服务端对未知 type 要忽略而非崩溃,客户端对未知事件同理。这样新旧客户端可以灰度共存,避免升级期间全量掉线。
4.4 与 schema 校验配合
判别联合在编译期收窄,运行时仍需 zod 的 discriminatedUnion("type", [...]) 校验,两者用同一份定义生成,避免手写漂移。
一句话总结:用带
type判别键的联合类型描述协议——assertNever保证穷尽,未知消息要忽略而非崩溃,编译期收窄与运行时校验缺一不可。
5. 心跳、重连与退避策略
5.1 为什么需要心跳
中间设备(NAT、负载均衡、代理)会静默回收空闲连接,客户端却以为还连着。心跳(应用层 ping/pong 或 WebSocket 协议层 ping)能主动探测并保活。
5.2 应用层心跳
function startHeartbeat(ws: WebSocket, interval = 30_000) {
let alive = true
ws.on("pong", () => { alive = true })
const timer = setInterval(() => {
if (!alive) { ws.terminate(); return }
alive = false
ws.ping()
}, interval)
ws.on("close", () => clearInterval(timer))
}
5.3 指数退避加抖动
function backoff(attempt: number, base = 500, cap = 30_000) {
const exp = Math.min(cap, base * 2 ** attempt)
const jitter = exp * (0.5 + Math.random() * 0.5) // 抖动避免惊群
return jitter
}
5.4 重连的坑
无退避的立即重连会在服务端重启时形成重连风暴;不带抖动则大量客户端同时重连;不区分「正常关闭」与「异常断开」会在主动登出后仍不断重连;不限制最大尝试次数会让离线用户无限消耗电量。
一句话总结:心跳探测假死连接,重连必须指数退避加抖动——区分正常关闭与异常断开,并设置最大尝试次数与联网状态感知。
6. 断线补偿与消息去重
6.1 断线期间的消息会丢
WebSocket 断开到重连成功之间的广播,客户端收不到。补偿方式是服务端保留一段消息历史,客户端重连时带上最后收到的序号,服务端补发差额。
6.2 用序号做增量补偿
interface Envelope<T> { seq: number; type: string; data: T }
// 客户端重连
socket.emit("resume", { roomId, lastSeq })
// 服务端
const missed = history.filter((m) => m.seq > lastSeq)
missed.forEach((m) => send(m))
6.3 幂等去重
补偿与实时推送可能重叠,客户端必须去重:维护一个已处理 seq 的有序集合,或对消息携带的 messageId 做幂等判断。「至少一次」投递 + 幂等消费 = 事实上的「恰好一次」。
6.4 补偿的边界
历史保留窗口有限(内存或 Redis 列表),超出窗口只能触发全量刷新而非增量补偿;补偿要设上限,避免一次补发几十万条把客户端打爆。
一句话总结:用单调递增序号做断线增量补偿,用幂等键做去重——历史窗口有限,超出即降级为全量刷新,补偿条数必须设上限。
7. 背压与消息队列
7.1 慢消费者问题
服务端广播速度超过客户端消费速度时,缓冲区会不断增长,最终吃光内存。这就是背压要解决的问题。
7.2 发送队列与水位线
class Sender {
private queue: string[] = []
private flushing = false
constructor(private ws: WebSocket, private high = 1000) {}
push(msg: string) {
if (this.queue.length >= this.high) {
this.dropOldest() // 或断开连接,按业务选择
}
this.queue.push(msg)
void this.flush()
}
private async flush() {
if (this.flushing) return
this.flushing = true
while (this.queue.length) {
const chunk = this.queue.shift()!
if (!this.ws.write(chunk)) {
await once(this.ws, "drain") // 等待底层缓冲排空
}
}
this.flushing = false
}
}
7.3 背压策略选择
| 策略 | 适用 | 代价 |
|---|---|---|
| 丢最旧 | 行情快照、状态同步 | 丢中间态 |
| 丢最新 | 实时性优先 | 用户漏更新 |
| 合并 | 状态类消息 | 实现复杂 |
| 断连 | 严重超载 | 用户掉线重连 |
7.4 WebSocket 的 bufferedAmount
浏览器侧用 ws.bufferedAmount 判断积压,超过阈值就暂停发送;Node 侧用 socket.write() 的返回值与 drain 事件,二者语义一致。
一句话总结:背压的本质是「生产快于消费」——用队列加水位线控制,按业务选择丢最旧、丢最新、合并或断连,切勿无限缓冲。
8. 多路复用与订阅管理
8.1 为什么要多路复用
一个页面可能同时订阅聊天、通知、在线状态,若每个业务各开一条连接,很快触达浏览器连接上限。正确做法是共用一条连接,用频道区分订阅。
8.2 订阅协议
type ClientMessage =
| { type: "sub"; channel: string; params?: Record<string, unknown> }
| { type: "unsub"; channel: string }
type ServerMessage =
| { type: "data"; channel: string; data: unknown }
| { type: "ack"; channel: string; subId: string }
8.3 服务端订阅表
const subscriptions = new Map<string, Map<string, Set<string>>>()
// channel -> (subId -> clientIds)
function subscribe(channel: string, subId: string, clientId: string) { /* ... */ }
function unsubscribeAll(clientId: string) { /* 断线时清理,防泄漏 */ }
8.4 泄漏与清理
断线时必须清理该客户端的所有订阅,否则订阅表只增不减;unsub 与重复 sub 要幂等;订阅参数要校验,避免用任意 channel 名做未授权访问。
一句话总结:一条连接承载多个频道,用
sub/unsub管理订阅——断线务必清理订阅表,sub/unsub要幂等,频道名必须鉴权。
9. 服务端广播与房间模型
9.1 房间即订阅分组
class Room {
private members = new Set<Client>()
join(c: Client) { this.members.add(c) }
leave(c: Client) { this.members.delete(c) }
broadcast(msg: ServerMessage, except?: Client) {
const raw = JSON.stringify(msg)
for (const m of this.members) if (m !== except) m.send(raw)
}
}
9.2 单机与多实例
单进程内用内存 Map 维护房间即可;多实例部署时连接分散在不同进程,必须引入 Redis Pub/Sub 或专用消息总线做跨实例广播,否则同房间用户看不到彼此的消息。
9.3 广播风暴与扇出
一个万人大房间的广播会形成 1 万次写操作,必须考虑:批量合并、按分片广播、给广播加限流;对大房间用「拉取 + 版本号」代替「推送每一帧」。
9.4 一致性
广播不保证顺序与送达,业务若要求强一致(如订单状态机)应走请求-响应而非广播;广播只用于「尽力而为」的状态同步。
一句话总结:房间是服务端的订阅分组,单机用内存、多实例必须走 Pub/Sub——大房间要防扇出风暴,强一致场景不要依赖广播。
10. 可观测、压测与生产实践
10.1 关键指标
| 指标 | 含义 | 关注点 |
|---|---|---|
| 在线连接数 | 容量水位 | 突增突降 |
| 重连率 | 连接稳定性 | 高于阈值即异常 |
| 消息端到端延迟 | 实时性 | P95/P99 |
| 发送队列深度 | 背压信号 | 持续增长 |
| 广播扇出耗时 | 大房间性能 | 随人数增长 |
10.2 压测要点
压测要模拟真实行为:带心跳的长连接、周期性重连、真实消息比例(读写混合),而不是只建连不发消息。关注「N 条连接下的内存占用」与「广播耗时随房间人数的曲线」。
10.3 常见踩坑清单
忘记 Cache-Control: no-transform 导致代理缓冲 SSE;心跳间隔大于负载均衡空闲超时导致连接被回收;重连无退避造成重连风暴;断线补偿无上限打爆客户端;多实例未接 Pub/Sub 导致消息只到部分用户;订阅表随断线泄漏。
10.4 上线纪律
连接层与业务层解耦,连接只负责收发与订阅管理;所有消息走 schema 校验;广播与推送都带 traceId 便于串联;容量按「单连接内存 × 目标连接数」估算并留余量。
一句话总结:实时系统的生产化 = 类型化协议 + 心跳退避 + 序号补偿 + 背压控制 + 订阅清理 + 跨实例广播——用重连率、队列深度与端到端延迟三个指标守住线上。
延伸阅读
- 事件流与增量数据处理
- 异步控制与并发治理
- 边界数据的运行时校验
- Node 后端的服务化实践
- 追踪与指标接入
- TypeScript 专题 — TypeScript 专题
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。