1. 为什么这次我没再选轮询,而是压上 WebSocket
在线沟通模块做到一定规模,绕不开一个决定:下行消息到底走什么通道送出去。我前后参与过三个内部沟通工具的搭建,前两个用短轮询顶着,第三个终于把架构改成WebSocket 实时双向聊天,上线后最直接的收益是消息端到端延迟从 1.8 秒量级压到 90 毫秒以内,同时服务端因为不再有海量无效请求,CPU 曲线反而更平。这个标题听起来像入门教程,但真正落地的时候,让人掉头发的从来不是握手那十分钟,而是心跳怎么设、断线怎么重连、消息怎么保证不漏不重、单机撑到几万连接后怎么横向扩展。下面我把自己踩过的坑、调过的参数、写过的核心代码都摊开讲一遍。
这篇内容适合三类人看。第一类是刚接到"做一个聊天功能"需求、还在犹豫技术选型的开发者;第二类是已经把 WebSocket 跑起来了,但线上偶尔丢消息、偶尔连不上、说不清原因的人;第三类是想知道一个实时系统从单机 demo 走到可上线服务,中间到底要补哪些工程细节的人。我不打算只给你一段"echo server"的示例代码,因为那东西和你真正需要交付的系统之间,隔着心跳、重连、顺序、幂等、限流、观测这一整套东西。
先说清楚一个前提:WebSocket 不是银弹。它解决的是"服务端需要主动、低延迟地把数据推给客户端,同时客户端也要能随时上行"这一类问题。如果你的场景只是"每 30 秒刷新一次看板",那短轮询或者服务端推送事件完全够用,硬上 WebSocket 只会给你增加连接管理的复杂度。判断标准很简单:当"下行推送的实时性"和"连接数成本"这两个指标中至少有一个成为瓶颈时,才值得切换。
1.1 从短轮询到长连接,我经历的四个阶段
第一阶段是短轮询,前端setInterval每 2 秒请求一次接口,后端返回比lastMsgId新的消息。这套东西的好处是简单到不需要任何额外知识,坏处是空请求占比极高。我印象很深的一次压测:2000 个在线用户、平均每 2 秒请求一次,就是 1000 QPS 的空查询,数据库连接池被这些"什么都没查到"的请求占满,真正有新消息时的响应反而变慢。而且延迟的期望值是轮询间隔的一半,最坏情况就是一个完整间隔,用户体感就是"发出去要等一下才出现"。
第二阶段是长轮询,请求挂起 25 秒,有新消息立刻返回,超时则返回空。延迟确实下来了,但代价是服务端要维持大量挂起的连接,在同步阻塞的框架里一个请求占一个线程,几千个挂起就把线程池吃干净。改成异步框架后好一些,可每次消息返回都要重建一次 HTTP 请求,头部开销和 TLS 握手的重复成本一点都不便宜。
第三阶段是服务端推送事件,单向推送,上行仍然走普通请求。这个方案在"通知流"场景里非常合适,我到现在也推荐用它做消息通知。但它对聊天来说有个硬伤:上行的发送和下行接收是两条不同的通道,两边的顺序、状态、错误处理都得各写一套,客户端代码会变得很别扭。
第四阶段就是 WebSocket。一次 HTTP 升级握手之后,同一条 TCP 连接上双向收发,消息边界由帧协议自己划好,前端只需要维护一个对象和几个事件回调。这套模型和聊天的语义天然吻合,所以最终选了它。我把四个方案的对比整理成表,你在做选型的时候可以直接对照。
| 方案 | 下行延迟 | 上行方式 | 连接成本 | 适用场景 |
|---|---|---|---|---|
| 短轮询 | 间隔的一半(期望) | 普通请求 | 每次请求全量开销 | 低频刷新、看板 |
| 长轮询 | 接近实时 | 普通请求 | 挂起连接占用资源 | 兼容性要求极高的老系统 |
| 服务端推送事件 | 实时 | 另开请求通道 | 单向长连接 | 通知、日志流、行情 |
| WebSocket | 实时 | 同连接 | 双向长连接 | 聊天、协同编辑、多人状态同步 |
1.2 那次握手到底交换了什么
很多人把 WebSocket 握手当成黑盒,一旦代理层配错就完全不知道从哪儿查。其实它简单得可以手算:客户端发一个带Upgrade: websocket和Connection: Upgrade头的 HTTP 请求,附上一个随机 base64 字符串放在Sec-WebSocket-Key里;服务端把这个 key 拼上一段固定的协议标识字符串,做 SHA-1 再 base64,写回Sec-WebSocket-Accept,返回 101 状态码,连接就从 HTTP 切换到 WebSocket 帧协议。
为什么绕这么一圈,而不是直接返回 101?因为早期网络中间件(代理、缓存)对协议切换的认知很粗糙,如果不做一次"密钥回验",攻击者可以构造一个看似合法的升级请求,让中间设备把本该属于 A 用户的响应缓存下来发给 B 用户。这个 key 的随机性和不可预测性,本质上是在确认"对面这个服务端真的读懂了我这次请求",而不是照着缓存硬回。理解这一点,你排查 400 和 403 的时候就有方向了——400 通常是升级头没透传到后端,403 通常是后端的来源校验把你拦了。
代理层的坑我再强调一次,这是新手最容易卡住的地方。反向代理必须先显式放行升级头,Nginx 里要配proxy_set_header Upgrade $http_upgrade;和proxy_set_header Connection "upgrade";,只要其中一行漏了,客户端会看到握手失败或者连接立刻被断。而且 HTTP/1.1 是必须的,如果你在前置层强制降级到 1.0,升级流程根本走不通。我见过一个案例,排查了两天,最后发现是中间某一层把Connection头改写成了keep-alive。
2. 协议层的关键参数,定错了后面全是坑
WebSocket 在 TCP 之上自己做了一层帧协议,每个帧有操作码、掩码标志、负载长度这几个字段。这部分内容大部分教程一带而过,但线上出问题的往往就是这几个字段的默认值没设对。比如客户端发往服务端的帧必须加掩码,服务端发往客户端的帧不能加掩码,长度字段分 7 位、16 位、64 位三种编码方式,控制帧的负载不能超过 125 字节。这些是协议强制的,你手写实现才会碰到,用现成库通常帮你处理好了。
真正需要你自己拍板的是三件事:心跳怎么发、消息体多大、连接活多久。这三件事没有标准答案,但有一个判断逻辑——先看你链路中间有哪些设备会主动断开空闲连接,然后把心跳周期设成那个值的三分之一到一半。云上的负载均衡常见空闲超时是 60 秒,某些代理是 350 秒,运营商侧的 NAT 映射有的只有 5 分钟。取最严格的那个,60 秒的三分之一就是 20 秒,我实际用 25 秒,留一点余量。
2.1 心跳:浏览器那一侧的隐藏限制
这里有个特别容易被忽略的事实:浏览器的 WebSocket API 不给你发 ping 帧的能力。new WebSocket()出来的对象只有send、close,没有任何方法能发协议级的 ping。而服务端是可以发 ping 的,浏览器收到 ping 会自动回一个 pong,这个动作在底层完成,你的 JavaScript 代码完全感知不到。
这个限制直接决定了心跳方案的设计。如果只靠服务端 ping 保活,好处是链路不会因为空闲被踢,坏处是前端根本不知道连接还活着——当网络切换、或者服务端进程假死时,前端可能几分钟都察觉不到,用户看到的现象就是"消息发出去了但没有回应"。
所以我的做法是双层心跳:服务端定期发协议级 ping,用来维持链路和清理死连接;前端定时发应用层心跳消息(一个普通的 JSON 文本帧),服务端立即回一个 ack。前端维护一个lastAckTime,超过两个心跳周期没收到 ack 就主动close()并触发重连。这样前端的"活"检测和服务端的"死连接清理"各自独立,互不干扰。
// 前端应用层心跳,25 秒一次,2 个周期无 ack 判定假死 const HEARTBEAT_INTERVAL = 25000; const HEARTBEAT_TOLERANCE = 2; let lastAck = Date.now(); let heartbeatTimer = null; function startHeartbeat(ws) { stopHeartbeat(); heartbeatTimer = setInterval(() => { if (Date.now() - lastAck > HEARTBEAT_INTERVAL * HEARTBEAT_TOLERANCE) { // 主动关闭,交给 onclose 走重连流程 ws.close(4000, 'heartbeat timeout'); return; } if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ping', ts: Date.now() })); } }, HEARTBEAT_INTERVAL); }服务端那边用读超时来做清理会更干净,比业务层写一堆判断可靠。每次读到任意一帧(包括 pong)就把读截止时间往后推,超过设定值就认为对端已死,直接关连接释放文件描述符。这个机制我在 Go 里用SetReadDeadline实现,在 Node 里用socket.setTimeout或者给连接对象挂一个时间戳,思路是一样的。
注意:如果前端页面长时间在后台标签页,很多浏览器会节流定时器,1 分钟才跑一次任务的情况很常见。这会导致心跳发不出去,被服务端误判为死连接。解决方式是在
visibilitychange事件里做一次立即校验,页面重新可见时先检查连接状态,必要时直接重建连接。
2.2 消息信封:别让业务字段裸奔在协议上
我在第一个版本犯的错,就是直接把业务消息体丢进ws.send()里,前端拿到JSON.parse之后靠猜字段名判断类型。等到要加"已读回执""正在输入"这些状态时,整个协议就崩了,前后端为了兼容老版本写了一堆 if。
正确做法是定义一个固定的信封结构,业务内容全部塞进payload,协议层字段固定不变。我用的字段是这几个:
| 字段 | 类型 | 说明 |
|---|---|---|
type | string | 消息类型,如msg、ack、ping、read、typing |
id | string | 客户端生成的唯一消息 ID,用于幂等去重 |
seq | int64 | 服务端分配的全局单调序号,用于排序和断点续拉 |
ts | int64 | 服务端落库时间戳,毫秒 |
room | string | 会话或房间标识 |
payload | object | 业务内容,结构由 type 决定 |
这个设计有两个隐性收益。一是seq让客户端有了"我收到哪儿了"的准确游标,重连之后只需要上报最大 seq,服务端把之后的消息补回来就行,不用重新拉整个历史。二是id让幂等成为可能:客户端超时重发时带上同一个 id,服务端在插入前查一下这个 id 是否已处理,直接返回原来的 seq,用户就不会看到重复气泡。
消息体大小必须设上限,这是防攻击也是防自己人。默认不限制的话,一个用户上传一个 8MB 的 JSON 就能让服务端读缓冲暴涨,几个这样的人同时发就能把内存打满。我在服务端统一设 64KB 的读限制,超过直接关连接;文本消息里只放文字,图片和文件走 HTTP 上传后再把 URL 通过 WebSocket 发出去。这个"大文件走 HTTP、小消息走 WebSocket"的分工,是我认为最省心的组合。
2.3 压缩开关,我的建议是关掉
协议里有permessage-deflate这个压缩扩展,很多库默认开启或者示例里开着。它的压缩率对小文本消息来说通常只有 30% 左右,但代价是每个连接要维护一个压缩上下文,CPU 开销随连接数线性增长。内部系统的带宽通常不是瓶颈,而 CPU 是。我在一个 8000 连接的实例上做过对比,关掉压缩后单核处理能力提升接近 20%,消息延迟的 P99 从 45ms 降到 32ms。
只有一个场景我会建议开:跨公网、用户网络很差、消息体偏大且冗余度高(比如长文本同步)。这种时候省下的带宽对用户体验的影响,比服务端多花的 CPU 更值。
3. 服务端实现:连接管理才是真正的核心
把 WebSocket 服务端写跑起来只要二十行,但写成一个能长时间稳定运行的服务,核心全在连接管理上。我用 Go 实现过,也用 Node 实现过,结论是:语言不是关键,关键是"每个连接一个读协程 + 一个写协程 + 一个带缓冲的发送队列"这个结构。这个结构一旦定下来,后面加限流、加广播、加优雅关闭都是在这个骨架上挂东西。
先说为什么读写必须分离。WebSocket 连接上的读操作是阻塞的,你要一直等对端发数据;写操作如果和读在同一个流程里,一旦某个客户端网络差、TCP 发送窗口满了,写就会阻塞,整个读流程也卡住,心跳处理不了,最后被判定超时。分离之后,读协程只负责收和分发,写协程只负责从队列取数据发出去,互不拖累。
3.1 一个够用的连接结构
type Client struct { ID string RoomID string conn *Conn // WebSocket 连接对象 send chan []byte // 有缓冲的发送队列 closeOnce sync.Once } // 读循环:只管收,收到就交给业务分发 func (c *Client) readLoop(hub *Hub) { defer c.close(hub) c.conn.SetReadLimit(64 * 1024) for { c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) msgType, data, err := c.conn.ReadMessage() if err != nil { return // 超时、对端关闭、协议错误,统一走关闭流程 } hub.dispatch(c, msgType, data) } } // 写循环:只从队列取,串行写,绝不并发 func (c *Client) writeLoop() { ticker := time.NewTicker(25 * time.Second) defer ticker.Stop() for { select { case data, ok := <-c.send: if !ok { c.conn.WriteMessage(CloseMessage, nil) return } c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) if err := c.conn.WriteMessage(TextMessage, data); err != nil { return } case <-ticker.C: c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) if err := c.conn.WriteMessage(PingMessage, nil); err != nil { return } } } }这段代码里有三个细节值得单独拎出来讲。第一个是SetReadLimit,它做的事情是限制单帧最大长度,超过就直接报错断开,不给恶意客户端撑爆内存的机会。第二个是closeOnce,因为关闭流程可能从读协程、写协程、外部踢人三个地方触发,用sync.Once保证清理逻辑只跑一次,避免重复关闭 channel 引发 panic。第三个是写超时,如果对端长时间不读,写操作会一直挂着,设一个 10 秒的截止时间能保证写协程不会永久卡死。
注意:几乎所有 WebSocket 库都不允许对同一个连接并发写。如果你的广播逻辑是"遍历房间成员挨个
WriteMessage",在某些实现里就会踩到并发写的 panic。要么所有写操作都收敛到写协程的队列里,要么给连接对象加一把写锁。我选前者,因为队列还能顺便做背压控制。
3.2 房间模型与慢消费者处理
聊天系统的房间结构很朴素,一个map[string]map[string]*Client,外层 key 是房间 ID,内层是连接 ID。难点不在数据结构,在锁的粒度和广播的阻塞行为。如果用一个全局大锁保护整个 map,几万连接下锁竞争会非常明显;我的做法是每个房间一把读写锁,加房间时锁一次,广播时对每个房间加读锁遍历成员。
广播里最容易被忽略的是慢消费者。设想一个房间 200 人,其中一个人的网络很差,TCP 接收窗口一直满,写操作卡住。如果你的发送队列是有缓冲的,比如容量 256,那这个人最多把队列堆满,堆满之后怎么办?两个选择:阻塞等待,或者丢弃并踢人。阻塞等待会把整个广播流程拖死,因为广播是串行的。所以我选后者——队列满了就直接关闭这个连接,让客户端重连。重连之后它会用seq游标补齐消息,一条不丢,体验上只是延迟了一下。
这个"踢掉慢消费者"的策略听起来很粗暴,但它是保证整体可用的必要手段。我在压测里验证过一个数据:单房间 500 人、每秒 20 条广播,只要有一个队列容量不足的连接卡住,整个房间的消息延迟会从 20ms 飙到 2 秒以上,因为写协程一直被那个卡住的发送拖住。加上队列满即断之后,P99 稳定在 40ms 以内。
3.3 单机能撑多少,怎么算这笔账
承连接数不是拍脑袋,是可以估的。以 Go 为例,每个连接至少占用:一个读协程加一个写协程的栈(初始 2KB 到 4KB,视实现而定)、一个读缓冲(默认 4KB 到 8KB)、一个写队列(容量乘以单条消息平均大小,我给 64 条、平均 512 字节,约 32KB)。加上连接对象本身和 map 的索引开销,一个连接保守估 60KB 到 100KB。10 万连接就是 6GB 到 10GB 内存,这个量级单机完全扛得住,前提是你的消息大小和队列容量没失控。
真正先撞墙的往往不是内存,是文件描述符上限和内核参数。默认的ulimit -n是 1024,跑几百个连接就报too many open files,必须提到 65535 以上。还有somaxconn和tcp_max_syn_backlog,连接建立速率高的时候这两个值太小会导致握手失败。这些参数写进部署清单,别等到线上才想起来。
| 资源项 | 经验值 | 说明 |
|---|---|---|
| 单连接内存 | 60KB ~ 100KB | 含协程栈、读写缓冲、发送队列 |
| 文件描述符 | 连接数 + 500 | 留出日志、数据库连接等余量 |
| 心跳周期 | 25 秒 | 需小于链路空闲超时的 1/2 |
| 发送队列容量 | 64 ~ 256 | 越大越抗抖动,越占内存 |
| 单帧大小上限 | 64KB | 文本消息足够,大文件走 HTTP |
| 单机连接数建议 | 5 万 ~ 10 万 | 超过就分区,别硬堆 |
3.4 横向扩展:连接落在不同机器上怎么办
单机再能撑也有上限,而且单机没有容灾。当连接分散在多台机器上时,立刻出现一个新问题:A 用户的连接在节点一,B 用户的连接在节点二,A 发的消息怎么送到 B 那里?
我的方案分两层。第一层是路由表,把"连接 ID 到节点 ID"的映射写在一个共享存储里,同时设一个较短的过期时间,靠心跳续期。这样任何节点要推消息给某个用户,先查路由表知道该投递到哪个节点。第二层是节点间广播,用发布订阅机制,每个节点订阅一个公共频道,消息统一发到频道里,所有节点收到后各自检查"这个房间里有没有我持有的连接",有就推。
这个组合有个明显的冗余问题:每条消息会被所有节点收到,节点越多浪费越多。所以我在订阅频道上做了分片,按房间 ID 哈希到若干个频道,每个节点只订阅自己持有房间的那部分。节点数在 10 以内的时候其实不用分片,直接全订阅最简单,我建议先用最简单的版本跑起来,真有瓶颈再分。
消息落库和推送的顺序也要想清楚。我的做法是先落库拿到 seq,再推送。这样即使推送失败,客户端重连后按 seq 拉取也能补上;如果反过来先推后落,推送成功但落库失败,用户看到了消息但刷新就没了,这种"幽灵消息"排查起来极其痛苦。
4. 客户端:重连策略决定用户体感
服务端写得再稳,客户端体验也可能是灾难。用户对聊天工具最敏感的其实不是延迟,是"消息发出去有没有反馈"。我见过太多实现,断网之后一直转圈,用户根本不知道发生了什么,只能不停刷新页面。这一节讲讲前端这边几个必须处理好的点。
4.1 重连不是简单地 setInterval
最朴素的重连写法是setTimeout(connect, 3000),固定 3 秒重试。这在服务端重启的场景下会造成惊群——几万个客户端同时每 3 秒重连一次,服务端刚起来就被打垮,然后又挂,循环往复。必须用指数退避加随机抖动。
// 指数退避 + 抖动,上限 30 秒 function backoff(attempt) { const base = Math.min(30000, 1000 * Math.pow(2, attempt)); // 抖动范围 ±30%,避免大量客户端同时重连 const jitter = base * (0.7 + Math.random() * 0.6); return Math.floor(jitter); }attempt从 0 开始,每次成功连接后清零。第一次大约 1 秒,第二次 2 秒,第三次 4 秒,一路到 30 秒封顶。抖动是必须的,否则同一时刻断线的客户端会在同一时刻重连,形成规律性的流量尖峰。
还有几个状态必须区分清楚,不能一律当"断开"处理:online/offline事件代表设备网络状态变化,这时候应该立即重连而不是等退避;visibilitychange代表页面切前后台,后台期间心跳可能被节流,回到前台要主动做一次健康检查;readyState是CONNECTING的时候不要重复调new WebSocket,会创建一堆僵尸连接。
4.2 乐观更新与顺序校准
用户点了发送,如果等服务器确认再渲染,体感会有明显延迟,尤其是网络差的时候。所以前端要做乐观更新:本地先生成一条带临时 ID 的消息插入列表,状态标为"发送中",服务端返回 ack 后把临时 ID 替换成真实的seq和时间戳,状态改为"已发送"。这个模式大家都熟,坑在顺序。
WebSocket 走的是单条 TCP 连接,正常情况下消息是有序的,但如果你在客户端做了并发上传、或者服务端有多个写入来源(比如同时从发布订阅和直连两条路拿到消息),就可能出现乱序。我的做法是渲染层永远按seq排序,新消息进来先放进一个待定缓冲,检查它的seq是否等于当前最大seq加一,是就渲染,不是就等。等待超过 1 秒还没补齐,就主动发起一次拉取请求把缺口填上。
这个机制在弱网下救过我好几次。用户的手机进电梯、出电梯,连接断了又重连,中间漏掉的消息通过seq缺口检测 + 补拉全部找回来了,用户完全无感。
提示:本地临时消息和服务端消息的 ID 空间要分开,别用同一个字段。我见过一个实现,临时 ID 用时间戳,结果和服务端的 seq 冲突,导致消息反复横跳。
4.3 长列表的渲染与滚动锚定
聊天记录一多,DOM 节点数量上来之后,滚动会明显掉帧。到几千条的时候,普通的v-for渲染就开始卡了。我的处理是分两块:虚拟列表只渲染视口内的几十条,滚动锚定保证加载历史消息时视口不跳动。
滚动锚定的实现思路是:插入历史消息前记录当前scrollHeight和scrollTop,插入后把scrollTop调整为新的 scrollHeight - 旧的 scrollHeight + 旧的 scrollTop。这样视觉上内容位置不变,用户不会因为加载历史而"被弹走"。另外图片消息一定要预设占位高度,等图片加载完再撑开,否则一次加载十几张图,滚动位置会跳得亲妈都不认识。
5. 上线之后:那些只有跑起来才会遇到的问题
本地跑通和线上稳定之间,差的是一整套排查经验。这一节我按"现象—原因—处理"的方式整理,都是真实遇到过不止一次的问题,你遇到类似症状可以直接对照。
5.1 常见故障速查表
| 现象 | 最可能的原因 | 排查与处理 |
|---|---|---|
| 握手返回 400 | 代理层没透传升级头 | 检查反向代理的Upgrade、Connection配置 |
| 握手返回 403 | 来源校验不通过 | 检查后端允许的来源列表,确认域名是否匹配 |
| 101 成功后立刻断开 | 握手完成后没有立即启动读写循环 | 确认服务端在升级后马上进入读循环 |
| 连接稳定但收不到消息 | 发送队列满被丢弃,或路由表过期 | 看服务端日志是否有队列满告警,检查路由续期 |
| 隔几十秒断一次 | 心跳周期大于链路空闲超时 | 把心跳调到超时的三分之一左右 |
| 消息重复出现 | 重发时没有幂等去重 | 用客户端消息 ID 在服务端做去重 |
| 消息顺序错乱 | 多路写入或客户端未按 seq 排序 | 渲染层按 seq 排序并做缺口补拉 |
| 服务端 CPU 突然打满 | 开启了消息压缩,或广播风暴 | 关压缩,检查房间广播是否形成环路 |
| 大量连接同时掉线 | 单节点过载或触发了连接数上限 | 看连接数曲线,做分批重连和节点扩容 |
| 内存持续上涨 | 连接对象没被正确释放 | 检查关闭流程是否清理了 map 索引和定时器 |
5.2 几个特别难查的问题
第一个是"连接对象泄漏"。表现是服务重启前内存缓慢上涨,连接数看着正常但进程 RSS 一直涨。原因通常是关闭流程只关了连接,没从房间的 map 里删掉,对象一直被引用着。这类问题没法靠猜,得有指标。我给每个节点暴露了几个关键数字:当前连接数、房间数、发送队列总积压量、每秒读写帧数、平均写延迟。有这几个数在,泄漏基本上一眼就能看出来——连接正常但房间数只增不减,那就是清理没做。
第二个是"优雅关闭时的消息丢失"。服务发布新版本需要重启,如果直接kill,正在途中的消息全丢。我的做法是分三步:先停止接受新连接(把监听关掉),然后给所有连接发一条"服务即将重启"的控制帧,等一小段时间让客户端主动断开并进入退避重连,最后再关闭剩余连接。客户端收到这条控制帧之后会用正常的退避策略重连到新节点,整个过程用户可能只感觉到一次短暂的状态变化。
第三个是"跨节点的消息环路"。节点收到消息后既落库又发布到订阅频道,其他节点收到频道的消息之后如果也落库并再次发布,就会形成无限循环。这个 bug 一旦触发,整个集群的流量会瞬间打满。防的办法很简单:频道里传的消息带一个来源节点标识,收到自己发的消息直接丢弃;同时明确"只有接收客户端消息的那个节点负责落库,其他节点只负责推送给本地连接"这个职责划分。
第四个是协议兼容。前后端不是同时发布的,新版本前端连上老版本服务端,或者反过来,都会出现字段缺失。我的经验是协议信封里加一个版本号,服务端对未知的type直接忽略并记一条日志,不要报错断开。前端对缺失的字段做默认值兜底。这套"宽容接收、严格发送"的原则,能让灰度发布期间的不一致窗口平稳度过。
6. 压测怎么压才有意义
很多人压 WebSocket 就是起一万个连接看着都连上了就下结论。这没什么参考价值,因为真实场景里连接是"连上之后一直在发小消息"。我用的压测脚本通常模拟三件事同时发生:连接建立、心跳维持、消息收发。连接建立速率要单独测,因为它考验的是握手和文件描述符分配;心跳考验的是定时器和空转开销;消息收发考验的是广播和落库。
指标上我只看四个:建连成功率、消息端到端 P99 延迟、单节点 CPU 使用率、发送队列积压量。P99 比平均值有意义得多,平均值 20ms 但 P99 是 3 秒,说明有一批用户体验极差,这批人往往就是网络条件最差的,恰恰最需要照顾。
一个反直觉的经验:压测时最容易先崩的往往是日志。每收到一条消息打一行日志,一万连接每秒几十条消息就是几十万行,磁盘 IO 直接成为瓶颈。所以生产环境的消息日志必须是采样或者异步批量落盘,绝不能在热路径上同步写。我现在的习惯是热路径只打计数器和错误日志,正常消息的详细日志用采样率控制在千分之一。
还有一个容量估算的例子,帮你建立数量级的感觉。假设一个协作工具,峰值 5 万在线,人均每分钟发 2 条消息、收 30 条(群聊放大效应),那么下行是每分钟 150 万条、每秒 2.5 万条。单条消息平均 500 字节,下行带宽约 12MB/s,这个量级一块千兆网卡就能吃下。真正的压力在广播的分发次数:如果平均每人在 20 人群里,一条消息要推 20 次,那就是每秒 50 万次写操作,这时候就需要考虑按房间分片、按节点分区了。算清楚这个数,你就知道该横向扩几个节点,而不是凭感觉。
7. 我个人踩过最深的一个坑
最后分享一个我觉得最值得说的教训。早期版本里,我把"消息落库"和"推送给在线用户"写在了同一个事务里,想着保证一致性。结果遇到一个情况:某个房间成员多、落库慢,一次广播在事务里卡了 200ms,这期间所有其他消息都在排队,整个房间的延迟雪崩。
后来我改成落库和推送完全解耦:客户端消息进来,先写库拿到 seq,写入成功就往内部队列里丢一个推送任务,推送给在线连接是异步的,失败就重试几次,再失败就放弃——反正客户端重连时会按 seq 补偿。这样单条消息的处理路径从"必须等所有在线成员推送完"变成"落库成功即返回 ack",P99 延迟直接从几百毫秒降到 30ms 以内。
这个改动让我明白一件事:实时系统里的"一致性"要靠客户端的补偿机制来兜底,而不是靠在服务端做同步等待。服务端的职责是尽快把消息持久化并尽力推送,客户端的职责是用 seq 游标把缺口补齐。两边各司其职,系统才既快又稳。反过来说,如果你在设计阶段就想着"服务端保证一次推送成功",那基本注定要在并发量上来之后重写一遍。
如果后面还要继续扩展,我会往两个方向走:一是把在线状态和输入中提示这类瞬时信息单独走一条不落库的轻量通道,避免它们污染消息主链路;二是把消息存储换成按会话分区的结构,让历史消息拉取能走独立路径,不和实时推送抢资源。这两块做完,这套系统支撑到十万级在线应该问题不大。