WebSocket 工程化实战:连接只是最简单的部分

原文:https://dev.to/surajrkhonde/websocket-engineering-part-1-the-connection-is-the-easy-part-558b(作者 @surajrkhonde)

刚接触 WebSocket 时,这个想法看起来几乎过于简单。

HTTP 的工作方式如下:

Client
  │
  │ request
  ▼
Server
  │
  │ response
  ▼
Client


WebSocket 看起来更适合实时系统:

Client
  │
  │ persistent connection
  │
  ▼
Server


连接一次。

保持连接打开。

服务器可以在任意时候推送消息。

非常适合:

* 聊天

* 交易价格

* 通知

* 多人游戏

* 仪表盘

* 实时追踪

看起来,这就是核心思路。

但接下来会出现另一个问题:

连接断开时会发生什么?

这个问题改变了对 WebSocket 的看法。


WebSocket 连接不是永久性的

画出这样的架构很容易:

Browser ─────────────── Node.js Server
          WebSocket


然后下意识认为这条线会一直存在。

生产环境并不是这样。

连接可能因为用户从 Wi-Fi 切换到移动数据而消失。

笔记本电脑可能进入睡眠。

手机进入电梯后可能失去信号。

服务器可能在部署期间重启。

负载均衡器可能关闭空闲连接。

代理可能执行自己的超时策略。

网络路径本身也可能发生变化。

因此,真实生命周期更接近这样:

connect
   ↓
communicate
   ↓
disconnect
   ↓
recover
   ↓
reconnect
   ↓
communicate again


这是第一个重要的认知转变。

在实时系统中,断开连接不是边缘情况,而是正常运行的一部分。


一个小型交易示例

想象我们正在构建一个实时交易界面。

服务器在持续发送价格:

BTC  → 117000
BTC  → 117010
BTC  → 117025
BTC  → 117040


浏览器通过一条 WebSocket 连接接收这些价格。

一切看起来都很好。

然后用户的 Wi-Fi 消失了。

Browser ─────X───── Server


这时已经出现了不少问题。

浏览器能立刻知道连接断开吗?

服务器知道吗?

浏览器应该立即重连吗?

如果因为服务器重启,同时有 50,000 个用户断开连接,会发生什么?

在这个用户断开连接期间,服务器产生了哪些价格?

如果用户在连接消失前刚刚发送了一笔订单,会怎样?

服务器收到了吗?

客户端应该重新发送吗?

如果重新发送会产生两笔订单怎么办?

突然间:

new WebSocket(url)


成了整个系统里最不值得关注的部分。


重连 Socket 不等于恢复应用

这个区分非常重要。

假设连接断开,五秒后我们成功创建了另一条连接。

Old connection

        ↓

New connection


在传输层,我们恢复了。

但应用恢复了吗?

不一定。

假设客户端已经收到了:

message 101
message 102
message 103


然后连接消失了。

在连接断开期间,服务器产生了:

message 104
message 105
message 106
message 107


客户端重连。

接下来到达的消息是:

message 108


现在客户端拥有:

101
102
103
108


104–107 去哪里了?

WebSocket 连接又恢复健康了。

应用状态并不健康

这才是更深层的问题。


有两类不同的恢复问题

把这两类问题分开看会很有帮助。

传输层恢复

能否重新建立一条 WebSocket 连接?

connection lost
      ↓
reconnect
      ↓
connected


状态恢复

重连之后:

Who is this client?

Which channels was it subscribed to?

Which messages did it miss?

What was the last event it received?

Did it have unacknowledged outbound messages?

What state should be restored?


传输层恢复相对容易。

状态恢复才是真正系统设计的开始。

我在学习的那份 WebSocket 重连指南正好做了这种区分:重连传输层比较直接,而在重连后同步客户端/服务器状态才是更难的问题。


网络可能在不告而别的情况下失效

我曾有另一个假设:

如果客户端断开,服务器肯定会立刻收到 close 事件。

并不总是如此。

一次干净关闭可能是这样:

Client
   │
   │ CLOSE
   ▼
Server
   │
   │ CLOSE ACK
   ▼
Client


双方都知道连接已经结束。

但想象有人走进电梯。

Wi-Fi 直接消失。

可能不会有像样的告别数据包。

Client
   X
   X
   X
Server


服务器可能继续认为这个套接字仍然存在。

这又带来另一个问题:

服务器如何知道一个连接是否真的还活着?

这个问题最终会引向心跳、ping/pong 帧、僵尸连接和代理空闲超时。

但首先要面对另一个问题。


如果所有客户端同时重连会怎样?

想象一台服务器有:

50,000 WebSocket clients


一次部署重启了服务器。

所有 50,000 个连接几乎同时消失。

最简单的重连代码可能类似:

socket.onclose = () => {
  connect();
};


看起来合理。

但现在:

Server restarts
      ↓
50,000 clients disconnect
      ↓
50,000 clients reconnect
      ↓
recovering server gets hammered


我们通过制造另一个故障修复了一个故障。

如果每个客户端每秒重试一次:

1 second  → 50,000 attempts
2 seconds → 50,000 attempts
3 seconds → 50,000 attempts


正在恢复的服务器可能永远得不到足够的喘息空间来完成恢复。

这时就会出现另一个分布式系统概念:

惊群问题(thundering herd problem)。

有趣的是:这其实不是“WebSocket 概念”。

它会出现在缓存中。

队列中。

数据库重试中。

分布式锁中。

服务重试中。

现在又出现在 WebSocket 重连中。

技术会变化。

故障模式会重复。


这正是深入探究的价值所在

一开始,WebSocket 看起来像一个 API:

const ws = new WebSocket(url);


然后它变成了网络。

然后变成了分布式系统。

然后变成了可靠性。

然后变成了状态管理。

然后变成了消息送达保证。

然后变成了可观测性。

一些我已经在其他地方见过的概念又开始出现:

ACK
retry
backoff
jitter
idempotency
buffering
sequence numbers
timeouts
TTL
distributed state


例如,ACK 和重试立刻让我想到 RabbitMQ。

RabbitMQ 消费者可能已经处理了一条消息,但丢失了 ACK。

Broker 可能会再次投递这条消息。

于是重复处理就变得可能。

WebSocket 也可能制造几乎相同的不确定性:

Client sends message
       ↓
Server receives it
       ↓
Server processes it
       ↓
ACK travelling back
       ↓
connection dies


客户端知道什么?

什么都不知道。

也许服务器已经处理了。

也许没有。

重试可以防止消息丢失。

重试也会带来重复风险。

于是我们又谈回了 幂等性

技术不同。

分布式系统问题却是同一个。

比起死记 API,我觉得这部分更有意思。


我目前的心智模型

我不再把 WebSocket 架构简单看成:

Client ↔ Server


我更愿意把它看成:

                 ┌─────────────────────┐
                 │     Connection      │
                 │      Lifecycle      │
                 └──────────┬──────────┘
                            │
                  connect / disconnect
                            │
                            ▼
                 ┌─────────────────────┐
                 │      Heartbeat      │
                 │  Is the peer alive? │
                 └──────────┬──────────┘
                            │
                            ▼
                 ┌─────────────────────┐
                 │    Reconnection     │
                 │ backoff + jitter    │
                 └──────────┬──────────┘
                            │
                            ▼
                 ┌─────────────────────┐
                 │    State Recovery   │
                 │ session / replay    │
                 └──────────┬──────────┘
                            │
                            ▼
                 ┌─────────────────────┐
                 │ Message Reliability │
                 │ ACK / retry / IDs   │
                 └──────────┬──────────┘
                            │
                            ▼
                 ┌─────────────────────┐
                 │    Observability    │
                 │ errors / close code │
                 └─────────────────────┘


现在,WebSocket 对我来说不再只是:

“一条持久连接”

而更像是:

一条我应该预期它会断开、并且需要知道如何恢复的连接。

这是一个更可靠的设计前提。


第一课

如果只能从这部分保留一个观点,我会选这个:

WebSocket disconnecting is normal.

Reliable recovery is the feature.


生产环境中的 WebSocket 系统不应该围绕这个问题设计:

How can I keep this connection alive forever?


更好的问题是:

When this connection fails,
how does my system recover without
losing state, duplicating work,
or overwhelming itself?


这个问题会引出这个系列后续的内容。


下一篇:心跳与僵尸连接

还有一种奇怪的故障,我们还没有解决。

想象一下:

Server thinks client is connected

but

Client disappeared 5 minutes ago


没有关闭握手。

没有有效流量。

只有一个看起来还活着、实际上已经死掉的 socket。

像幽灵一样。

一条 僵尸连接

在第 2 篇中,我想深入讨论心跳为什么存在,以及为什么实际上有三个不同层级:

WebSocket protocol ping/pong
Application-level heartbeat
TCP keepalive


它们听起来像同一件事。

但其实不是。

在生产环境中,这些差异很重要。

原文:https://dev.to/surajrkhonde/websocket-engineering-part-1-the-connection-is-the-easy-part-558b(作者 @surajrkhonde)

发布评论
全部评论(0)