WebSockets vs. SSE应关注事件顺序与一致性
摘要
本文主张,比较WebSockets与Server-Sent Events时应聚焦于事件顺序和一致性问题,以避免产生不一致的用户界面,而非仅仅关注延迟或简洁性。
<p><a href="https://lobste.rs/s/wski9u/websockets_vs_sse_should_be_about">Comments</a></p>
查看缓存全文
缓存时间: 2026/08/26 15:29
# WebSockets 与 SSE 的核心差异在于事件排序和状态正确性
来源:https://dashbit.co/blog/websockets-vs-sse
- José Valim
- 2026年8月25日
- liveview (https://dashbit.co/blog/tags/liveview),websockets (https://dashbit.co/blog/tags/websockets),sse (https://dashbit.co/blog/tags/sse)
几天前,Django LiveView 的维护者 Andros 撰写了一篇题为"基于 WebSockets 的 HTML 实时方案:几乎无需 JavaScript 的实时单页应用"的文章(https://en.andros.dev/blog/ef4968f4/html-over-websockets-real-time-spas-with-barely-any-javascript/)。虽然该文全面概述了 WebSockets 和 LiveView 架构的优缺点(精简负载、简化技术栈等),但 Hacker News 讨论区的热门评论却指出:
> 对大多数应用而言,直接使用 SSE 配合原生的 HTTP 请求接口(Fetch)即可,无需通过 WebSocket 自行构建客户端请求逻辑。延迟其实相差无几,因为现代浏览器会在单个 TCP 连接上复用多个 HTTP 请求。
首先需要澄清的是:虽然 Fetch 确实会复用现有连接,但每个请求本质上仍是无状态的。这意味着每次请求仍需重复解密会话信息、从数据库或缓存中检索用户数据等操作。而 WebSocket 只需在建立连接时完成一次身份验证,所有用户数据均可保留在内存中,避免了这些重复开销。尤其对于 Phoenix LiveView 而言,它实际传输的是差异数据(diffs)而非完整 HTML(参见 https://dashbit.co/blog/latency-rendering-liveview),这能大幅减小传输负载。
但我认为这些优势都是次要的。关于"通过 WebSocket 传输 HTML"与"使用 SSE 配合 Fetch"的讨论,其本质应当聚焦于事件排序与状态正确性。当使用独立数据流时,事件接收和渲染的顺序极易错乱,导致用户体验混乱甚至误导决策。接下来我们将看到,修复这类问题通常意味着更复杂的客户端逻辑、更高的延迟,或两者兼而有之!
## 事件排序问题
假设某篇文章原有三个标签:"erlang"、"clojure"和"javascript"。此时你恰好添加了"elixir"标签,而另一用户同时删除了"javascript"标签。多数人预期的系统行为如下:
[加载事件序列...]
在此示例中,数据库最终保留"erlang"、"clojure"和"elixir"三个标签,界面也正确显示相同列表。一切看似正常!
但由于网络延迟、垃圾回收机制、代理服务器等因素影响,事件顺序也可能演变为:
[加载事件序列...]
这种情况下,删除操作虽然最先执行,但其结果数据包却较晚送达。这将导致界面短暂闪现包含"elixir"的标签列表,最终却只显示"erlang"和"clojure"。本质上,界面完全无视了"elixir"标签的添加操作。除非刷新页面或接收新事件,否则系统将永久保持错误状态。
有些人或许会辩解:"这没问题,属于最终一致性"。但这完全误解了最终一致性的定义。通常而言,最终一致性系统应保证:在无新操作时,所有数据副本最终会收敛至相同状态。而当前场景中,除非手动刷新或触发新事件,界面可能永久保持不一致状态。
上述问题的根源在于数据通过两个独立流传输,这并非 SSE 的固有缺陷。任何使用 WebSocket + Fetch 双通道更新同一界面组件的应用都可能遭遇竞态条件。关键区别在于 WebSocket 的双向通信特性,允许在单个连接上同时完成读写操作:
[加载事件序列...]
在此方案中,WebSocket 连接专门处理用户事件并重算更新结果,确保无论事件到达顺序如何,都能传递正确的标签列表。
## 处理并发请求
如前文所述,使用 SSE + Fetch 时可能产生两个并发请求。若两者都传输数据,由于缺乏因果排序保证,极易引发竞态条件。
最简方案是让其中单一通道专门负责数据更新。例如可以设定 Fetch 仅执行写操作而不传输标签列表,改为等待 SSE 接收完整列表。但这种做法会增加延迟——因为需要依赖队列系统在服务器间中转更新数据:
[加载延迟序列...]
这实际上类似于 Phoenix 长轮询的实现方式。不过在 Phoenix 环境中,由于支持分布式 Erlang 节点直连通信,可以省略一次数据中转:
[加载延迟序列...]
而双向连接方案则能彻底消除这些中转环节:
[加载延迟序列...]
WebSocket 的双向特性提供了低成本保障客户端操作与服务器响应排序的方案,无需为每个操作构建独立传输通道。
另一种选择是利用 SSE 通道发送"请刷新数据"的指令。这样服务器写操作无需额外中转,但会导致服务器负载增加——客户端必须主动发起新请求获取最新数据,而非通过 SSE 接收推送。同时必须谨慎避免并发请求问题(参见 https://dashbit.co/blog/remix-concurrent-submissions-flawed),应在客户端设置操作队列,并优先处理用户交互。
理论上也可通过客户端重排序来实现多通道协同,但这远比想象复杂。例如无法保证创建资源的事件总先于删除事件到达,也无法确保更新顺序与数据库操作序列一致。这正是 Electric(https://electric.ax/)等专用平台要解决的问题。
## 总结思考
下次讨论 WebSocket 与 SSE 选型时,请先自问:如何保证事件按正确顺序传递?如何避免向用户展示过时或错误数据?
无论采用 WebSocket 还是 SSE,只要存在多数据通道就可能产生竞态。WebSocket 提供的双向通道,可作为保障用户操作与服务器更新因果排序的简洁基础。SSE + Fetch 虽然也可实现,但往往因服务器内部数据中转而增加延迟。无论选择何种方案,Phoenix 结合 Elixir 和分布式 Erlang,都能帮助你简化架构、降低延迟,获得最优性价比。
> 本文由人类作者撰写,经大语言模型校对。交互示例均由编码代理在人工监督下生成。
相似文章
基于WebSocket的HTML:几乎不需要JavaScript的实时SPA
本文介绍了HTML over WebSockets模式,用于以极少的JavaScript构建实时SPA,并与HTTP和SSE变体进行了比较,同时追溯了其源自Phoenix LiveView的历史。
通过HTTP提供文件的三种方式:同步、epoll和io_uring
一篇技术文章,比较了通过HTTP提供文件的三种方法:同步的每个请求一个线程、基于epoll的异步I/O和io_uring,并附有C语言代码示例。
为套接字接口喝彩
这篇文章回顾了1983年发布的BSD 4.2套接字接口的历史影响,该接口标准化了网络访问,为现代在线服务铺平了道路。
引用 Luke Curley
技术评论:Luke Curley探讨WebRTC的设计如何通过激进丢弃音频数据包来优先保障低延迟,这与LLM语音应用中提示词准确度比速度更重要的需求相矛盾。他讲述了在浏览器限制下在Discord实现重传所面临的挑战。
并发服务器:第7部分 - Rust
本文是关于并发服务器系列文章的一部分,介绍了如何使用Rust实现并发网络服务器,涵盖了顺序、线程和事件驱动方法,并提供了代码示例。