很多团队第一次认真讨论 WebSocket,通常不是因为他们想研究协议本身,而是因为系统里出现了几个很具体的诉求:

  • 页面想收到服务端主动推送
  • 用户操作之后,希望另一端立刻看到变化
  • 轮询开始显得笨重,空请求越来越多
  • 大家直觉上觉得:“要实时,那就上 WebSocket”

这条路走得太自然了,自然到很容易把一件事想简单:

既然 HTTP 的问题在于“一问一答”,那只要换成“一直连着、双方都能说话”,实时性问题就差不多解决了。

我们后来踩坑,基本都从这个直觉出发。
所以这篇文章不讲 API,也不讲握手细节。我更想复盘的是:为什么这个直觉模型会吸引人,它从哪里开始失效,最后我们又是怎么把判断模型重新搭起来的。

一、先摆出那个最容易把人带偏的直觉模型

如果把很多人脑子里的 WebSocket 画成一句话,通常会是这样:

HTTP = 短连接 / 请求响应 / 不适合实时
WebSocket = 长连接 / 双向通信 / 适合实时

这个模型的问题不在于它完全错误,而在于它只描述了“通道形态”,却没有描述“系统语义”。

顺着这个模型继续往下想,很容易再推出三个看起来也很顺的判断:

  1. 连接一直在,所以延迟应该会更低。
  2. 底层是 TCP,所以消息可靠且有序,业务状态自然不会乱。
  3. 服务端能主动推,所以轮询带来的各种复杂度都没了。

这几个判断单看都不算离谱。
但如果把它们直接翻译成工程决策,后面就会连续遇到四类问题:

  • 消息明明“按序到达”,页面状态却越来越旧
  • 服务端明明“发成功了”,业务上却还是丢动作
  • 连接明明“没断”,用户却已经失去有效会话
  • 长连接明明“少了很多请求”,系统压力却反而更难控

也就是说,问题并不在 WebSocket 本身,而在于我们一开始把“连接持续存在”误当成了“业务持续正确运作”。

二、为什么系统会自然走到 WebSocket

如果只看表象,WebSocket 的出现确实像是在修补 HTTP 的天然别扭。

假设有一个页面,希望尽可能快地看到服务端状态变化。
如果我们用轮询,判断会很直接:

  • 每隔 T 秒请求一次
  • 服务端有变化就返回
  • 没变化也得回一个“没有变化”

这时延迟上界大致会变成:

数据变更到用户看到 ≈ 轮询间隔 T + 一次 RTT + 服务端处理时间

于是我们会自然做两件事:

  • 想让页面更新更快,就把 T 调小
  • T 一调小,空请求和连接开销就上来

如果继续往下推,就会发现轮询的问题并不只是“浪费几次请求”,而是谁有话要说这件事,需要不断重新协商
客户端每次都在问:“现在有东西了吗?”
服务端哪怕明明没有事,也要被迫回答一次。

顺着这个推导,长轮询会出现。
长轮询比短轮询聪明一点:没有新消息就先挂着,等有变化再回。
但它仍然保留着一个前提:主导节奏的还是请求,而不是连接。

当系统开始出现下面这些特征时,WebSocket 就会变得很有吸引力:

  • 会话持续时间长
  • 服务端主动推送很多
  • 客户端也会频繁上行
  • 单次消息很轻,但交互次数很多

推到这里,WebSocket 的价值会慢慢清楚一些。
它更像是在解决这个问题:

当双方都可能随时说话时,别再让每次发言都重新走一遍“请求是谁发起的、响应什么时候结束”的协商。

所以如果一定要先压一句话,我现在更愿意把它理解成:

WebSocket 解决的不是“实时”本身,而是“高频双向协作时,反复协商一次性请求边界”的成本。

这个说法一变,后面的判断就会稳很多。
因为一旦承认它解决的是“协作成本”,而不是“时效保证”,我们就会继续追问:那时效、顺序、可靠性、背压,到底是谁负责?

三、直觉模型是怎么一步步失效的

1. “有序到达”为什么不等于“状态正确”

我们一开始很容易有一个朴素判断:

  • WebSocket 跑在 TCP
  • TCP 保证字节流有序
  • 那我按发送顺序处理消息,状态自然不会乱

这个推理的问题,在“消息”和“状态”没有区分开的时候不明显。
但只要系统里传的是高频状态更新,它就会开始失效。

比如一个在线白板、一个行情面板,或者一个设备监控页面。
服务端连续产生三个状态:

S1: 温度 30
S2: 温度 31
S3: 温度 32

如果网络和消费速度都很好,按顺序到达当然没有问题。
但只要接收端稍微慢一点,或者中间出现一点积压,事情就变了:

队列里先堆着 S1,再堆 S2,再堆 S3
客户端忠实地按序处理
结果用户先看到 30,再看到 31,最后才看到 32

这里协议其实没有做错任何事。
它确实维持了顺序。
可业务真正关心的,往往不是“旧状态有没有被完整经历一遍”,而是“此刻界面是不是尽快收敛到最新状态”。

推到这里就会发现:
当数据本质上是“可覆盖状态”时,严格按序重放旧状态,反而可能变成时效性的敌人。

于是原来的模型就得改:

  • 对“事件”来说,按序往往很重要
  • 对“状态”来说,最新值往往比完整回放更重要

如果这两类东西在同一条 WebSocket 通道里被一视同仁,系统迟早会在“完整性”和“新鲜度”之间打架。

2. “发送成功”为什么不等于“业务成功”

第二个很常见的误判,是把通道层成功,当成业务层成功。

我们以前写过一种很顺手的逻辑:

服务端写出消息 -> 底层没有报错 -> 认为消息已经送达

这在日志里看起来很合理,因为你确实“发出去了”。
但继续往下拆,会发现里面至少隔着三层完全不同的语义:

  1. 字节是否离开了发送端应用进程
  2. 字节是否进入了对端内核缓冲区
  3. 对端应用是否真的解析、校验并处理了这条业务消息

WebSocket 能帮你走通前两层。
第三层,它并不替你定义。

这时如果消息是“刷新页面上的在线人数”,问题可能不大。
可如果消息是“扣减库存”“撤销订单”“开始执行任务”,事情立刻就敏感起来。
因为你需要知道的不是“线通不通”,而是“业务动作到底有没有落地”。

所以后来我们才慢慢把两个东西拆开:

  • 传输确认:链路有没有明显失败
  • 业务确认:对端是否按预期处理了这条命令

一旦这两个确认被混成一个,WebSocket 看上去很顺,系统语义却会变得很虚。

3. “连接还活着”为什么不等于“会话还有效”

长连接另一个非常会骗人的地方,是它特别容易让人产生“只要不断,就说明双方还在”的错觉。

可线上环境里,连接活着这件事远没有想象中单纯:

  • 移动网络切换了,旧连接可能变成半开状态
  • NAT 映射过期了,双方都以为自己还连着
  • 浏览器标签页挂起了,应用层心跳节奏被打乱
  • 服务端实例重启了,TCP 断开只是最轻的一种情况

如果系统拿“socket 还没报错”去等同“用户会话仍然有效”,就很容易出现这种怪事:

  • 页面上显示还在线,但消息已经不再可靠到达
  • 服务端还保留订阅关系,但用户其实早就切网了
  • 重连之后新旧会话重叠,状态同步开始打架

所以继续往下推,会发现长连接系统一定会长出额外的一层:

  • 心跳不是为了“显得完整”
  • 重连不是为了“恢复 TCP”
  • 会话恢复也不是为了“重新连上”

这些动作真正要解决的是:
当连接这件事不再是一问一答,而是持续存在时,系统必须自己判断“谁还活着、谁该作废、状态应该从哪里继续”。

4. “少请求”为什么不等于“更省系统资源”

很多人选择 WebSocket 时还有一个很自然的成本模型:

轮询 = 很多请求
WebSocket = 一条长连接
一条总比很多条便宜

这句话只在“只统计建连次数”时成立。
可只要把资源账本算全,长连接的成本结构就完全不是那个样子。

每个在线连接背后,通常都至少挂着这些东西:

  • 读写缓冲区
  • 定时器和心跳状态
  • 用户身份与订阅关系
  • 待发送消息队列
  • 连接所属节点上的内存占用

如果消息很稀疏,这些状态大多只是安静地躺着。
可一旦进入推送高峰,最先膨胀的通常不是 CPU,而是每条连接上的等待发送数据

于是你会看到一种很反直觉的场景:

  • 请求量比轮询时期少了
  • 服务端 QPS 看起来也没爆
  • 但内存持续上涨,尾延迟越来越差,慢客户端越来越多

推到最后,问题会落在一个更本质的地方:

WebSocket 没有让压力消失,它只是把压力从“请求洪峰”转移成了“连接生命周期管理”和“持续流量不匹配”。

四、极端情况一来,问题会暴露得更快

上面的推导在普通网络里已经成立。
一旦把场景推到边界条件,很多误判会更明显。

1. 高延迟:连接在,交互却还是慢

很多人第一次遇到高延迟网络,会下意识说:
“都上 WebSocket 了,为什么操作反馈还是拖?”

如果继续拆,会发现高延迟从来不是“有没有长连接”能消掉的。

假设一个业务交互是这样的:

客户端发一个动作 -> 服务端校验 -> 服务端回一个确认 -> 客户端再允许下一步

这本质上仍然是一个应用层的请求-响应回路。
只要这个回路存在,每次交互就仍然受 RTT 约束。

也就是说,WebSocket 省掉的是反复建边界的成本,
它并不会把“跨网络往返一次”变成“本地函数调用”。

高延迟一来,两个问题就会被放大:

  • 心跳超时阈值如果还按局域网经验设置,就会误判掉线
  • 如果通道里还堆着大量旧状态,真正重要的新状态到达得更晚

所以在高延迟场景里,系统更该关心的通常不是“有没有长连接”,而是:

  • 哪些消息必须一来一回确认
  • 哪些消息可以乐观更新
  • 哪些旧状态应该直接被覆盖掉

2. 高丢包:可靠传输有时会把旧消息保护得太好

WebSocket 跑在 TCP 上,这通常会带来一种安全感。
但在高丢包场景里,这种“可靠”有时会把问题换个形式放大。

因为只要底层有一段数据没到,后面的数据哪怕更“新”,也得先等前面的缺口补上。
这就是为什么很多高频状态流在丢包时,会出现一种很别扭的体验:

  • 不是彻底收不到
  • 而是先卡一小段
  • 然后突然补回来一串已经过时的消息

如果业务传的是聊天消息、审计日志、交易指令,这种保护通常值得。
可如果业务传的是鼠标轨迹、光标位置、行情瞬时快照,这种保护就未必还划算。
因为用户需要的不是“旧轨迹绝不缺一段”,而是“现在的位置尽快准确”。

推到这里,问题就不再是“WebSocket 靠不靠谱”,而会变成:

对当前这类数据,完整性和新鲜度,到底谁排在前面?

只要这个优先级没想清楚,技术选型就会一直摇摆。

3. 高负载:真正先爆的常常不是协议,而是扇出

WebSocket 一旦进入高并发场景,很多团队一开始盯着的是“连接数上限”。
可线上更容易先出问题的,往往是扇出成本

比如某个热点房间有十万订阅者。
服务端来了一个更新,如果做法是“来一条推一条,给每个连接都写一份”,那系统压力会迅速变成:

一次业务更新 -> N 条连接写操作 -> N 份排队缓冲

如果更新频率再高一点,问题不会停在网络带宽上,还会继续往系统内部渗透:

  • 事件循环更忙
  • 节点之间负载更不均
  • 某些热点连接的发送队列开始积压
  • 连接粘在某个实例上,实例级热点变得明显

这时你会慢慢意识到,WebSocket 系统真正要管的从来不只是“连上多少人”,而是:

  • 谁订阅了谁
  • 一条消息要扇出到哪里
  • 慢消费者出现时,压力在哪一层被拦住

4. 系统背压:慢消费者最终会逼你表态

背压这个词,很多文章一提就过去,但它恰好是 WebSocket 长连接系统最绕不开的问题。

只要生产速度长期大于消费速度,系统就一定得回答一个很现实的问题:

多出来的那部分消息,最后准备放哪?

通常只有四种去处:

  1. 放进内存队列里,等消费者慢慢追
  2. 阻塞生产者,让上游也慢下来
  3. 丢掉一部分消息
  4. 直接断开慢消费者

这四种没有一种是“协议自动帮你选好”的。
而且不同业务里,答案完全不同。

如果是交易指令,你通常不敢轻易丢。
如果是在线人数、设备状态、光标位置,你又很难接受无限排队。
如果是一个公共直播房间,你也不太可能让一个慢用户拖住整个上游。

也就是说,一旦系统进入背压,团队最终一定得对外表态:

  • 我们更怕丢,还是更怕旧?
  • 我们更怕阻塞全局,还是更怕牺牲局部用户?
  • 我们愿不愿意让“跟不上节奏”的连接被淘汰?

很多 WebSocket 系统真正的稳定性分水岭,不在建连成功那一刻,而在这里。

五、工程 trade-off:不是“要不要 WebSocket”,而是“拿它承载什么语义”

如果只问“实时推送用什么”,答案很容易流于口号。
可只要把语义拆开,选择就会清楚很多。

方案一:轮询 / 长轮询

如果业务满足下面几个条件,轮询其实没有那么差:

  • 更新不频繁
  • 能容忍秒级甚至更长的陈旧
  • 客户端主要是读,很少主动上行
  • 希望尽量复用现有 HTTP 基础设施、缓存和鉴权链路

这时继续用轮询,系统虽然不优雅,但边界很清楚。
服务端不用长期维护大量会话状态,负载均衡也更简单,排障手段也更成熟。

它的问题也很明确:
一旦交互密度升高,请求协商成本会越来越像系统噪音。
所以不选它,通常不是因为“它落后”,而是因为它让高频协作这件事显得越来越笨重

方案二:SSE

如果业务本质是“服务端持续推,客户端偶尔用普通 HTTP 上报动作”,SSE 往往比很多人想象中更合适。

顺着语义来看,它有一个天然优势:

  • 既然主要是单向推送,那就没必要为了“可能的双向”引入一整套双向连接管理复杂度

也就是说,如果你只是做通知流、日志流、状态流,客户端上行并不重,
SSE 往往比 WebSocket 更贴合问题本身。

不选 SSE 的原因也很具体:

  • 客户端需要高频上行
  • 双向交互需要共享一个连续会话
  • 你不想把“推送”和“上报”拆成两套通道管理

方案三:WebSocket

当系统满足下面这些条件时,我们通常才会更坚定地选 WebSocket

  • 双向都很活跃
  • 会话持续时间长
  • 交互很多,而且消息普遍很轻
  • 我们愿意显式承担连接管理、会话恢复、背压控制这几类复杂度

注意这里最关键的一句不是“它最先进”,而是“我们愿意承担它暴露出来的复杂度”。
因为 WebSocket 真正换来的,不只是能力,还有责任:

  • 你要自己定义业务确认
  • 你要自己决定慢消费者怎么处理
  • 你要自己把状态流和事件流分开
  • 你要自己做断线恢复和重同步

所以后来我们在很多系统里更倾向于一个更保守的判断:

只有当“持续双向会话”本身就是业务核心,而不是一种实现偏好时,WebSocket 才值得被选上来。

在 WebSocket 内部,还要再做一次 trade-off

真正难的地方还不止于“选不用它”,而在于“用了之后,怎么承载消息”。

我们至少试过两种做法。

第一种做法是最顺手的:

所有变化都当事件
按产生顺序逐条发送
客户端按到达顺序逐条应用

它的好处很诱人:

  • 模型简单
  • 调试直观
  • 看起来最“可靠”

但它后来暴露的问题也最集中:

  • 高频状态会把旧值一条条排队
  • 慢消费者会不断积压
  • 重连之后补历史成本很高

第二种做法,是后来慢慢修出来的:

把消息分成“命令 / 事件 / 状态”
命令重确认、重幂等
事件按序消费
状态允许合并、覆盖、降采样

它实现上更麻烦,因为通道不再是一个“什么都按同一规则处理”的盒子。
但越往后做,越会觉得这个复杂度是值得的。
因为真正复杂的不是代码,而是业务本身的语义差异。
如果系统不在设计上承认这种差异,复杂度也不会消失,只会在线上以积压、错序、假成功的形式出现。

六、一次很典型的错误实践,以及它是怎么被修正的

我们以前做过一种非常典型、也非常容易写出来的 WebSocket 方案:

数据库状态有变化
-> 服务端把变化事件原样推给客户端
-> 客户端收到什么就按顺序改本地状态
-> 写出去没报错就算成功
-> 谁慢谁自己追

刚开始这套方案甚至看起来挺漂亮:

  • 没有轮询
  • 页面刷新很快
  • 开发量也不大

问题是流量一上来,系统就开始出现三种很烦的现象:

  1. 页面偶尔会“回跳”
    新状态刚展示出来,旧消息又把它覆盖回去。
  2. 某些用户越挂越慢
    发送队列积压之后,他们收到的都是越来越旧的消息。
  3. 重连之后风暴明显
    因为之前没有版本和恢复点,只能粗暴全量同步。

后来回头看,这个方案的问题并不是“写得不够仔细”,而是它把三个本来就不同的问题硬压成了一个问题:

  • 状态同步
  • 事件通知
  • 命令执行

修正过程反而不是一步到位,而是被线上现象一点点逼出来的。

第一步,我们先给状态加了版本号。
这样客户端至少可以知道:如果后来的消息版本更旧,就不该再覆盖当前状态。

第二步,我们开始允许某些状态型消息只保留最新值。
比如在线人数、设备状态、光标位置,这些都不值得在发送队列里无限排队。

第三步,我们把命令的“发出”和“处理完成”分开。
服务端写出去只代表链路没立即失败;真正的业务完成,要靠应用层回执、幂等键、超时重试策略来兜。

第四步,我们给每条连接设置发送队列上限。
一旦慢到超过阈值,要么降采样,要么踢掉重连,而不是让整台机器替它无上限垫内存。

第五步,我们在重连后优先恢复“最新快照”,而不是盲目补整段历史。
因为对很多状态型页面来说,追旧账并不能带来更正确的结果,只会延长恢复时间。

如果把这次修正压成一句话,后来越来越像这样:

我们不是在修一个协议实现,而是在承认:不同语义的数据,根本不该共享同一种传输策略。

七、最后沉淀出来的,不只是 WebSocket 经验,而是一个通用判断模型

后来再看 WebSocket,我们越来越少问“这个技术适不适合实时”,而会先问下面五个问题。
这五个问题不只适用于 WebSocket,拿去看 SSE、消息队列、gRPC stream,甚至很多异步系统都一样有用。

1. 你传输的到底是“事件”,还是“状态”

如果是事件,通常更怕丢、更怕乱。
如果是状态,通常更怕旧、更怕排队。
这一步不分清,后面所有“可靠性”讨论都会混乱。

2. 业务真正优先的是“完整性”,还是“新鲜度”

只要完整性排第一,重传、按序、回放就更值得。
只要新鲜度排第一,合并、覆盖、降采样、丢旧保新就会更重要。
很多技术争论,本质上都只是这两个优先级没有先说清楚。

3. 当生产速度大于消费速度,谁来为差额买单

是排队,是阻塞,是丢弃,还是断连。
系统迟早得选一个。
如果设计阶段不选,线上最终也会替你选,只是那时选项通常最差。

4. 你说的“成功”,到底停在哪一层

  • 发出成功
  • 送达成功
  • 处理成功
  • 持久化成功

这几个如果不拆开,重试、幂等、告警都会变得非常模糊。

5. 断连之后,你想恢复的是“连接”,还是“语义”

有些系统只需要重建通道。
有些系统需要恢复订阅。
有些系统要从某个版本继续。
还有些系统根本不值得补历史,直接给最新快照就够了。

这一步想清楚之后,再去看任何通信技术,心里都会稳很多。
因为你不再是在问“它支不支持长连接”,而是在问“它把哪类复杂度暴露给了我,而我愿不愿意接住”。

八、复盘到最后,WebSocket 更像什么

如果还沿用最早那个直觉模型,WebSocket 很容易被理解成:

让系统变实时的一根网线

可只要把前面的推导走完,那个印象就会慢慢变掉。
它更像是一种持续协商中的会话通道

  • 它让双方不用每次重新建立一次性请求边界
  • 它适合承载持续、频繁、双向的交互
  • 它把很多原本藏在请求-响应模型背后的问题,直接摊到系统设计面前

也正因为这样,WebSocket 既不该被神化,也不该被轻视。
如果业务核心真的是持续双向协作,它会非常顺手。
可如果团队只是把它当成“实时性开关”,那它多半不会消掉复杂度,只会把复杂度从表面挪到更深的地方:

  • 从请求数,挪到连接管理
  • 从接口设计,挪到消息语义
  • 从单次响应时延,挪到持续背压治理

所以后来再有人问我,WebSocket 到底值不值得上,我心里的起点通常已经不是“它快不快”。
我更想先追问一句:

你想解决的,到底是“请求太频繁”,还是“系统真的需要一条持续、双向、可管理的会话通道”?

这两个问题表面很像,工程答案通常完全不同。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐