跳到主内容

协同编辑实时链路选型:WebSocket 与 SSE 实测取舍 ​

问题背景:协同编辑不是“能推送”就够了 ​

协同编辑的核心链路并不复杂:客户端持续产生本地操作,服务端负责接收、排序、冲突处理、广播,并把最新版本推回所有在线用户。真正难的是在弱网、切后台、代理超时、服务端发布重启等情况下,仍然保证消息不丢、不重、顺序不错。

很多团队一开始会把问题简化成“选 WebSocket 还是 SSE”。但在协同编辑里,这个问题应该拆成两层:

  • 上行写入:用户操作通常需要携带版本号、操作 ID、文档 ID、鉴权信息,并要求服务端确认;
  • 下行同步:服务端需要推送远端操作、在线状态、光标位置、系统通知等事件。

WebSocket 是全双工长连接,适合双向高频通信;SSE(Server-Sent Events)本质上是 HTTP 长连接,服务端到客户端单向推送,简单、易穿透代理,但客户端上行仍需走普通 HTTP 请求。

编辑客户端操作 / 光标 / 重连WebSocket双向长连接协同服务排序 / 广播 / 存储SSE + HTTP POST下行推送,上行请求

方案对比:实时性、连接成本与重连语义 ​

WebSocket:适合高频双向操作 ​

WebSocket 在 HTTP Upgrade 后不再受普通请求头开销影响,客户端和服务端都可以主动发送消息。对于字符级协同编辑、光标共享、评论气泡、多人拖拽等高频交互,WebSocket 的延迟通常更低,连接内还可以复用鉴权、心跳、压缩和订阅管理。

代价是实现复杂度更高:服务端要维护连接注册表,处理连接漂移、网关负载均衡、重复连接、空闲断开、背压和广播风暴;客户端也要实现指数退避、版本同步和 ACK 重试。

SSE:适合下行事件流和基础设施保守的环境 ​

SSE 使用 text/event-stream,浏览器原生支持自动重连和 Last-Event-ID。如果协同编辑的上行操作频率不高,或者团队希望写入接口继续保持 HTTP 语义,例如配合限流、幂等键、审计日志和普通网关规则,那么 SSE 更容易落地。

它的限制也明显:单向通信,上行必须 POST;浏览器、代理、CDN 对缓冲和超时的行为需要显式配置;二进制支持不如 WebSocket 自然;标签页连接数在旧环境中也可能受限。

维度WebSocketSSE
通信方向全双工服务端到客户端单向
上行操作连接内发送HTTP POST/PUT
实时性高频双向场景更优下行足够,上行多一次请求
浏览器重连需自行实现原生支持,也可手动增强
代理兼容需支持 Upgrade标准 HTTP,但要关闭缓冲
服务端复杂度高中低
推荐场景字符级协同、光标、在线状态通知流、轻量协同、只读协作面板

选型决策框架:不要只看协议名 ​

我的判断标准是:

  1. 单用户每秒上行操作是否明显超过 5 次,例如每次按键都要同步,优先 WebSocket;
  2. 是否需要服务端主动推送大量低延迟状态,并且客户端也要频繁回传,优先 WebSocket;
  3. 上行是否天然适合 REST API,例如保存块级操作、审批、评论,SSE + POST 更简单;
  4. 基础设施是否对 Upgrade 不友好,或者团队缺少长连接治理经验,可以先用 SSE;
  5. 无论选哪种,协同编辑的正确性都不能依赖“连接不断”,必须有文档版本号、操作 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 和冲突处理层也不需要推倒重来。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。