WebSockets vs. SSE应关注事件顺序与一致性

Lobsters Hottest 新闻

摘要

本文主张,比较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,都能帮助你简化架构、降低延迟,获得最优性价比。 > 本文由人类作者撰写,经大语言模型校对。交互示例均由编码代理在人工监督下生成。

相似文章

为套接字接口喝彩

Hacker News Top

这篇文章回顾了1983年发布的BSD 4.2套接字接口的历史影响,该接口标准化了网络访问,为现代在线服务铺平了道路。

引用 Luke Curley

Simon Willison's Blog

技术评论:Luke Curley探讨WebRTC的设计如何通过激进丢弃音频数据包来优先保障低延迟,这与LLM语音应用中提示词准确度比速度更重要的需求相矛盾。他讲述了在浏览器限制下在Discord实现重传所面临的挑战。

并发服务器:第7部分 - Rust

Eli Bendersky

本文是关于并发服务器系列文章的一部分,介绍了如何使用Rust实现并发网络服务器,涵盖了顺序、线程和事件驱动方法,并提供了代码示例。