从 HTTP 到长连接:为什么 IM 需要 WebSocket
HTTP 是"请求—响应"模型:客户端不问,服务端就没有开口的机会。IM 的诉求恰好相反——别人发来的消息要在几百毫秒内出现在对端屏幕上,而此刻用户可能什么都没做。这个"服务端主动推、双向低延迟、单连接低成本"的组合,靠短轮询堆出来的方案能跑通 demo,跑不动规模。
HTTP 的先天限制
一次 HTTP 交互的生命周期是"建连 → 请求 → 响应 → 结束"(HTTP/1.1 下 TCP 可复用,但语义仍是一问一答)。服务端要主动说一句话,只有两条路:
- 客户端不停地问(轮询、长轮询);
- 让连接长期挂住并由服务端随时写入(SSE、WebSocket)。
前者用 QPS 换实时性,后者用连接数换实时性。IM 的特点是连接数极多、单连接消息量很小、对延迟敏感,所以必须走后一条路。
四种"服务端推"方案
| 方案 | 方向 | 典型延迟 | 每消息开销 | 主要代价 |
|---|---|---|---|---|
| 短轮询(polling) | 客户端拉 | 轮询间隔的一半 | 一次完整 HTTP 往返 | QPS 随在线数线性上涨 |
| 长轮询(long polling) | 客户端拉 | 接近实时 | 一次 HTTP 往返 | 连接反复重建,服务端挂起请求占资源 |
| SSE(Server-Sent Events) | 仅服务端→客户端 | 实时 | 一个文本块 | 单向,上行仍需另一个 HTTP 请求 |
| WebSocket | 双向 | 实时 | 2~14 字节帧头 | 需处理连接与心跳治理 |
结论:IM 的上下行都频繁,只有 WebSocket 同时满足双向与低开销;SSE 适合"只推不收"的场景(如行情、通知流)。
轮询的代价:算一笔账
在线用户 : 10 万
轮询间隔 : 5s(已经压得很低,体验才勉强算"秒级")
轮询请求 QPS : 100000 / 5 = 20000 QPS
单次往返头部开销: 请求 + 响应 ≈ 1.2 KB(Cookie、UA、Token、日志字段)
纯头部带宽 : 20000 × 1.2 KB ≈ 24 MB/s ≈ 192 Mbps
有效消息占比 : 多数业务 < 1%
也就是说,99% 的流量花在"问有没有新消息"这件事本身。WebSocket 建连后,一条 100 字节的聊天消息在链路上就是"2 字节帧头 + 4 字节掩码键 + 载荷",中间没有 Cookie、没有 TLS 重新握手、没有连接重建。
| 传输一条 100 B 的聊天消息 | 字节数(不含 TLS 记录头) |
|---|---|
| HTTP 短轮询(含请求与响应头) | 约 1200 B |
| SSE(复用连接) | 约 120 B |
| WebSocket(客户端上行,带掩码) | 约 106 B |
| WebSocket(服务端下行,无掩码) | 约 102 B |
长轮询:用"挂起"换实时
长轮询把请求挂在服务端,有新消息或超时才返回,实时性接近长连接:
// 长轮询:没有新消息就挂起,来消息或超时后再返回
func pollHandler(store *MessageStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
uid := r.URL.Query().Get("uid")
select {
case msg := <-store.Wait(ctx, uid): // 有新消息,立即返回
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(msg)
case <-time.After(25 * time.Second): // 必须小于网关的读超时,否则拿到 504
w.WriteHeader(http.StatusNoContent) // 204:客户端马上再发起一次
case <-ctx.Done(): // 客户端断开或主动取消
return
}
}
}
挂起的请求仍然占着 goroutine 与连接(第 12 篇会讲为什么这不一定致命),但每次返回都要重新走一遍路由、鉴权、日志,成本远高于复用一条长连接的 WebSocket。
SSE:只解决下行
# 下行推送:SSE 使用 text/event-stream,浏览器可用 EventSource 直接消费
curl -N -H "Accept: text/event-stream" "https://im.example.com/sse?uid=1001"
# 输出示例(每条消息以空行结束)
event: message
data: {"from":"1002","text":"在吗"}
: keep-alive
SSE 简单、自带断线重连语义,但它是单向的:用户发消息还得再发一个 HTTP 请求,同一个会话的两条链路要各自处理与对齐,对 IM 反而是额外复杂度。
WebSocket:一次握手,长期双向
WebSocket 借用 HTTP 完成协商,随后在同一条 TCP 连接上运行自己的帧协议(RFC 6455):
# 用 curl 只能验证握手:能看到 101 就说明服务端升级逻辑通了
curl -i -N \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
http://127.0.0.1:8080/ws
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
curl 不会发掩码帧,所以它验证不了业务收发。要真正收发消息,用 wscat -c ws://127.0.0.1:8080/ws(需要 Node.js)或自己写一个 Go 客户端。握手与帧结构在第 02 篇展开。
什么时候不该用 WebSocket
- 只需要服务端推、不需要客户端频繁上行:SSE 更省事(自带重连、纯文本、可穿过部分只放行 HTTP 的中间件);
- 只做请求—响应且延迟要求不高:普通 HTTP 加缓存更简单;
- 内网服务间通信、需要背压与流控:gRPC 双向流(HTTP/2)的原生流控比自研更可靠;
- IoT 弱网设备、要求极小报文与 QoS 分级:MQTT 更合适。
常见坑与排查
- 网关掐断长连接:Nginx 的
proxy_read_timeout默认 60s,云负载均衡也常有 60~300s 的空闲超时。心跳间隔必须小于它,否则连接会周期性断开(第 06 篇)。 - 代理不透传 Upgrade:请求头被中间层剥离,客户端拿到 400 或 426,页面表现为"连不上但没有报错"。
- 移动端静默断连:切后台、4G/WiFi 切换后连接已死但应用不知道,只能靠心跳与重连发现(第 07 篇)。
- 只算吞吐不算连接成本:10 万长连接意味着每条连接一份读写缓冲、至少一个 goroutine 与一份内核缓冲,容量评估要按连接数算(第 11、12 篇)。
- 拿轮询的心智模型写长连接:把"每请求一次鉴权"照搬过来,握手期鉴权才是正确位置(第 08 篇)。
小结:HTTP 的问答模型让服务端无法主动说话,短轮询用 QPS 换实时、长轮询用挂起换实时,代价都随在线数放大;IM 双向且小消息密集,只有 WebSocket 能用一条长连接同时满足实时与低开销。选型之后真正的工作量在连接治理——心跳、重连、鉴权、背压、容量,这正是本教程后续章节的主线。