外观
协同编辑实时链路选型:WebSocket 与 SSE 实测取舍
问题背景:协同编辑不是“能推送”就够了
协同编辑的核心链路并不复杂:客户端持续产生本地操作,服务端负责接收、排序、冲突处理、广播,并把最新版本推回所有在线用户。真正难的是在弱网、切后台、代理超时、服务端发布重启等情况下,仍然保证消息不丢、不重、顺序不错。
很多团队一开始会把问题简化成“选 WebSocket 还是 SSE”。但在协同编辑里,这个问题应该拆成两层:
- 上行写入:用户操作通常需要携带版本号、操作 ID、文档 ID、鉴权信息,并要求服务端确认;
- 下行同步:服务端需要推送远端操作、在线状态、光标位置、系统通知等事件。
WebSocket 是全双工长连接,适合双向高频通信;SSE(Server-Sent Events)本质上是 HTTP 长连接,服务端到客户端单向推送,简单、易穿透代理,但客户端上行仍需走普通 HTTP 请求。
方案对比:实时性、连接成本与重连语义
WebSocket:适合高频双向操作
WebSocket 在 HTTP Upgrade 后不再受普通请求头开销影响,客户端和服务端都可以主动发送消息。对于字符级协同编辑、光标共享、评论气泡、多人拖拽等高频交互,WebSocket 的延迟通常更低,连接内还可以复用鉴权、心跳、压缩和订阅管理。
代价是实现复杂度更高:服务端要维护连接注册表,处理连接漂移、网关负载均衡、重复连接、空闲断开、背压和广播风暴;客户端也要实现指数退避、版本同步和 ACK 重试。
SSE:适合下行事件流和基础设施保守的环境
SSE 使用 text/event-stream,浏览器原生支持自动重连和 Last-Event-ID。如果协同编辑的上行操作频率不高,或者团队希望写入接口继续保持 HTTP 语义,例如配合限流、幂等键、审计日志和普通网关规则,那么 SSE 更容易落地。
它的限制也明显:单向通信,上行必须 POST;浏览器、代理、CDN 对缓冲和超时的行为需要显式配置;二进制支持不如 WebSocket 自然;标签页连接数在旧环境中也可能受限。
| 维度 | WebSocket | SSE |
|---|---|---|
| 通信方向 | 全双工 | 服务端到客户端单向 |
| 上行操作 | 连接内发送 | HTTP POST/PUT |
| 实时性 | 高频双向场景更优 | 下行足够,上行多一次请求 |
| 浏览器重连 | 需自行实现 | 原生支持,也可手动增强 |
| 代理兼容 | 需支持 Upgrade | 标准 HTTP,但要关闭缓冲 |
| 服务端复杂度 | 高 | 中低 |
| 推荐场景 | 字符级协同、光标、在线状态 | 通知流、轻量协同、只读协作面板 |
选型决策框架:不要只看协议名
我的判断标准是:
- 单用户每秒上行操作是否明显超过 5 次,例如每次按键都要同步,优先 WebSocket;
- 是否需要服务端主动推送大量低延迟状态,并且客户端也要频繁回传,优先 WebSocket;
- 上行是否天然适合 REST API,例如保存块级操作、审批、评论,SSE + POST 更简单;
- 基础设施是否对 Upgrade 不友好,或者团队缺少长连接治理经验,可以先用 SSE;
- 无论选哪种,协同编辑的正确性都不能依赖“连接不断”,必须有文档版本号、操作 ID、服务端 ACK 与断线后的状态恢复。
Go 实战:用 WebSocket 实现协同编辑核心链路
下面示例基于 Go 标准库和 gorilla/websocket,演示最小可运行的协同服务:接收操作、按文档版本顺序广播、维护在线连接,并通过心跳降低代理误杀概率。
先初始化:
bash
mkdir collab-ws && cd collab-ws
go mod init collab-ws
go get github.com/gorilla/websocket服务端代码:
go
package main
import (
"log"
"net/http"
"sync"
"github.com/gorilla/websocket"
)
// Client 表示一个文档订阅连接
type Client struct {
hub *Hub
conn *websocket.Conn
send chan []byte
docID string
}
// Hub 管理某个文档下的所有连接
// 生产环境应按 docID 分片,并放到 Redis Pub/Sub 或消息队列后面
type Hub struct {
mu sync.RWMutex
clients map[string]map[*Client]struct{}
register chan *Client
unregister chan *Client
}
func newHub() *Hub {
return &Hub{
clients: make(map[string]map[*Client]struct{}),
register: make(chan *Client),
unregister: make(chan *Client),
}
}
func (h *Hub) run() {
for {
select {
case c := <-h.register:
h.mu.Lock()
if h.clients[c.docID] == nil {
h.clients[c.docID] = make(map[*Client]struct{})
}
h.clients[c.docID][c] = struct{}{}
h.mu.Unlock()
case c := <-h.unregister:
h.mu.Lock()
if set := h.clients[c.docID]; set != nil {
delete(set, c)
close(c.send)
if len(set) == 0 {
delete(h.clients, c.docID)
}
}
h.mu.Unlock()
}
}
}
func (h *Hub) broadcast(docID string, msg []byte) {
h.mu.RLock()
defer h.mu.RUnlock()
for c := range h.clients[docID] {
select {
case c.send <- msg:
default:
// 发送队列满说明慢消费者已经追不上,直接关闭,由客户端重连恢复
go func(client *Client) { h.unregister <- client }(c)
}
}
}
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true }, // 生产环境必须校验 Origin
}
func serveWs(hub *Hub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
docID := r.URL.Query().Get("docId")
if docID == "" {
http.Error(w, "docId required", http.StatusBadRequest)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
client := &Client{
hub: hub, conn: conn, docID: docID,
send: make(chan []byte, 64),
}
hub.register <- client
go client.writePump()
client.readPump()
}
}
func (c *Client) readPump() {
defer func() {
c.hub.unregister <- c
_ = c.conn.Close()
}()
for {
_, msg, err := c.conn.ReadMessage()
if err != nil {
return
}
// 真实系统在这里做:
// 1. 校验用户权限;2. 校验 opId 幂等;3. 检查 baseVersion;
// 4. OT/CRDT 转换;5. 持久化;6. 广播带新版本的 ACK。
// 示例直接回显,消息格式必须由服务端统一。
c.hub.broadcast(c.docID, msg)
}
}
func (c *Client) writePump() {
defer c.conn.Close()
for msg := range c.send {
if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
}
}
func main() {
hub := newHub()
go hub.run()
// 静态页面用于本地验证
fs := http.FileServer(http.Dir("./static"))
http.Handle("/", fs)
http.HandleFunc("/ws", serveWs(hub))
log.Println("listen on http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}新建 static/index.html:
html
<!doctype html>
<html>
<body>
<input id="text" placeholder="输入协同内容" style="width:300px" />
<pre id="log"></pre>
<script>
const docId = "doc-1";
let ws;
let retry = 0;
function connect() {
ws = new WebSocket(`ws://${location.host}/ws?docId=${docId}`);
ws.onopen = () => {
retry = 0;
log("连接成功,应先拉取最新版本和未确认操作");
};
ws.onmessage = (event) => {
log("收到:" + event.data);
};
ws.onclose = () => {
const delay = Math.min(1000 * 2 ** retry++, 10000);
log(`${delay}ms 后重连,同时保留本地待确认操作`);
setTimeout(connect, delay);
};
}
document.getElementById("text").addEventListener("input", (e) => {
if (ws?.readyState !== WebSocket.OPEN) return;
const op = {
opId: crypto.randomUUID(), // 幂等键,服务端去重
docId,
baseVersion: 1, // 应由本地状态维护
text: e.target.value
};
ws.send(JSON.stringify(op));
});
function log(msg) {
document.getElementById("log").textContent += msg + "\n";
}
connect();
</script>
</body>
</html>运行:
bash
go run main.go打开两个浏览器窗口访问 http://localhost:8080,即可看到输入内容被实时广播。这个示例只演示连接和消息分发,生产环境必须补上操作持久化、版本推进、ACK 和重连补拉。
SSE 实现要点:把重连位置交给事件 ID
如果选择 SSE,服务端应设置如下响应头:
go
func sseHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") // 告诉 Nginx 不要缓冲事件流
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "stream unsupported", http.StatusInternalServerError)
return
}
lastID := r.Header.Get("Last-Event-ID")
// 根据 lastID 从事件表或消息队列补拉缺失事件,再进入实时推送循环。
_ = lastID
w.Write([]byte("retry: 3000\n\n"))
flusher.Flush()
}客户端写法很简单:
javascript
const es = new EventSource("/events/doc-1");
es.addEventListener("op", (e) => {
console.log("服务端事件 ID:", e.lastEventId, e.data);
});但要注意:Last-Event-ID 只能说明“客户端收到哪里”,不能替代业务确认。协同编辑的写入仍建议使用 POST,请求体包含 opId 与 baseVersion,服务端基于二者做幂等和冲突检测。
避坑总结:实时协议之外的 5 个关键细节
1. 把“连接成功”误当成“状态一致”
常见现象是断线重连后,用户看到旧文档,随后本地操作把远端内容覆盖。修正方式是连接建立后先执行 snapshot + missed ops:根据本地版本号拉取快照和缺失操作,再恢复发送队列。不要只依赖 socket 的 open 事件。
2. 消息只广播、不 ACK,弱网下重复插入
用户网络抖动时客户端可能重试同一个操作。如果服务端只按内容处理,就可能出现重复字符或重复评论。应为每个操作生成全局唯一 opId,服务端在文档维度建立唯一约束或短期去重表,重复请求返回第一次处理结果。
3. 慢消费者拖垮整个协程或广播循环
典型错误是向每个连接的 channel 直接写入,某个用户网络差导致阻塞,最终整个文档广播卡住。正确做法是限制每个连接的发送队列,队列满即断开并让客户端通过版本号补拉;广播逻辑也要避免持有大锁执行网络 I/O。
4. 心跳间隔不考虑网关超时
很多云负载均衡器、Nginx、企业代理会在 60 秒左右回收空闲连接。WebSocket 建议设置 20 到 30 秒心跳或协议级 ping;SSE 也应定期发送注释帧 : keepalive\n\n 并立即 flush,否则可能出现客户端以为在线、服务端早已清理的半开连接。
5. 多实例部署后只在本机内存广播
单机示例能跑通,不代表多副本可用。当两个用户连接到不同 Pod 时,本机 map 无法完成跨实例广播。生产环境应引入 Redis Pub/Sub、NATS、Kafka 等消息通道,网关上配置黏性会话只能降低重连成本,不能作为消息正确性的基础。
最终建议
协同编辑如果追求字符级低延迟、频繁双向交互和丰富在线状态,WebSocket 是更稳妥的主链路;如果业务以服务端事件通知、低频块级编辑为主,或者组织希望最大化复用 HTTP 网关与 REST 写入语义,SSE + 幂等 POST 能以更低成本上线。
真正决定体验的并不是协议本身,而是版本模型、幂等操作、ACK、补拉和慢消费者治理。把这些机制设计清楚,即使未来从 SSE 迁移到 WebSocket,业务 API 和冲突处理层也不需要推倒重来。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。