后端系统工程 05:长连接接入与连接归属

本批导航:00 技术方案怎样接受验证|01 API 契约与业务不变量|02 负载模型与容量预算|03 单库基线与故障实验工具|04 持久化边界|05 长连接接入|06 发送确认|07 排序与增量同步|08 群消息扇出|09 多设备已读|10 网关故障与路由收敛|11 撤回与集成验收

第 04 篇的存储层回答了“消息写到哪里”。要把消息送到设备,服务端需要一条可以主动下发数据的通道,并且知道每台设备此刻连在哪一个网关进程的哪一条连接上。本篇实现这条通道:基于 WebSocket 的网关进程 examples/im/src/im/Gateway.java,以及记录连接归属的 routes 表。

长连接的难点不在建立,而在于连接的状态不可靠:TCP 连接可能已经失效,两端却都不知道;对端可能还连着,但已经不读数据;同一台设备可能同时存在新旧两条连接,旧连接的清理逻辑可能误删新连接的记录。本篇针对这三类情况分别给出检测与处理方法,并用本地多进程实验验证。

1. 需求与不变量

编号 不变量 对应需求
G1 一台设备在任一时刻至多有一个当前路由,指向一条具体的连接 R7
G2 旧连接关闭时的清理只能删除属于它自己的路由 R7
G3 握手失败(版本不支持、凭据错误、准入拒绝)不写路由 —
G4 网关为每个连接缓冲的待发数据有上限;超过上限时断开该连接,不阻塞发送方,不无限增长内存 —
G5 失去响应的连接在有界时间内被发现并释放路由 —
G6 网关推送只是加速手段;被丢弃的推送可以通过同步从存储补回 R5

G6 决定了整个投递设计的方向:网关不是消息的持久化层。任何时候网关丢掉一帧(缓冲溢出、连接断开、进程崩溃),正确性都由存储和第 07 篇的同步协议保证;网关只负责在正常情况下让消息更快到达。这使得 G4 中的“断开连接”成为一种安全的降级手段。

2. 规模假设

按第 02 篇:峰值在线 200 000 台设备,心跳间隔 30 秒时网关集群每秒处理约 6 667 个心跳帧。单个网关进程能承载多少连接取决于每连接内存(U1,NOT_RUN)。

本实现每个连接使用两个虚拟线程(读、写各一),连接的待发队列上限默认 64 帧(IM_OUTBOX)。虚拟线程的选择基于 JEP 444:虚拟线程在阻塞 I/O 时让出载体线程,适合“大量连接、多数时间空闲”的负载。JEP 444 同时指出,虚拟线程在 synchronized 块内阻塞会钉住(pin)载体线程。网关的写线程在等待新数据时使用 ReentrantLock 与 Condition,而不是 synchronized 与 Object.wait,就是为了避开这一点。实验规模(数十个连接)不足以观察钉住造成的影响,这里的选择依据的是文档而不是测量。

3. 接口:握手与帧

3.1 握手

设备用 HTTP Upgrade 请求建立 WebSocket 连接(RFC 6455 §4):

1
2
3
4
5
6
7
GET /ws?attempt=3 HTTP/1.1
Host: im.example.test
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: <16 字节随机数的 base64>
Sec-WebSocket-Version: 13
Authorization: Bearer u-ana-d1:<设备令牌>

attempt 是设备本地单调递增的连接尝试编号,用于判断哪一次连接更新(第 10 篇详细讨论)。网关按以下顺序处理,任何一步失败都直接返回 HTTP 错误并关闭连接,不写路由:

顺序 检查 失败时 依据
1 方法为 GET,存在 Upgrade、Connection、Sec-WebSocket-Key 400 RFC 6455 §4.2.1
2 Sec-WebSocket-Version 为 13 426,并带 Sec-WebSocket-Version: 13 RFC 6455 §4.4
3 网关不在排空中 503,Retry-After: 1 第 10 篇
4 准入令牌桶有余量 429,Retry-After: 1 RFC 6585 §4,第 10 篇
5 设备令牌正确 401 —
6 路由声明被接受(本次尝试比已记录的更新) 409 第 10 篇
7 返回 101 与 Sec-WebSocket-Accept — RFC 6455 §4.2.2

Sec-WebSocket-Accept 是 Sec-WebSocket-Key 与固定 GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 拼接后的 SHA-1 摘要的 base64 编码。客户端必须校验它,否则无法确认对端理解 WebSocket 协议。

准入检查放在鉴权之前,是因为鉴权需要查询数据库。在重连风暴中(第 10 篇),先用一个不访问数据库的令牌桶拒绝超额请求,可以避免数据库被握手查询压垮。

升级成功后,网关先发一帧 hello:

1
2
{"t":"hello","conn":"gw-a-ed6c4925","gw":"gw-a","route_version":1,
"user":"u-ana","device":"u-ana-d1","install_epoch":0}

3.2 帧规则

  • 客户端发给服务端的帧必须加掩码;服务端收到未加掩码的帧时必须关闭连接(RFC 6455 §5.1),本实现用关闭码 1002(协议错误)。
  • 控制帧(Ping、Pong、Close)的载荷不超过 125 字节,且不能分片(§5.5)。
  • 收到 Ping 必须回复载荷相同的 Pong(§5.5.2);Pong 也可以主动发送,作为单向心跳(§5.5.3)。
  • 应用层只使用文本帧;收到二进制帧时以 1003 关闭。单条消息不超过 64 KiB。

应用层自定义的关闭码使用 4000–4999 区间(RFC 6455 §7.4.2 规定此区间供私有使用):

关闭码 含义 客户端动作
1001 网关排空(going away) 换网关重连(第 10 篇)
1002 协议错误 视为客户端缺陷
4001 被同一设备的新连接取代 不自动重连(新连接已在别处)
4008 待发队列溢出,需要重新同步 重连后对所有会话执行同步

4. 数据模型:连接归属

1
2
3
4
5
6
7
8
9
10
11
12
13
14
CREATE TABLE routes (
device_id text PRIMARY KEY REFERENCES devices,
gateway_id text NOT NULL,
conn_id text NOT NULL,
route_version bigint NOT NULL,
connected_at timestamptz NOT NULL DEFAULT clock_timestamp()
);

CREATE TABLE route_floor (
device_id text PRIMARY KEY REFERENCES devices,
max_version bigint NOT NULL DEFAULT 0,
max_epoch bigint NOT NULL DEFAULT -1,
max_attempt bigint NOT NULL DEFAULT -1
);

routes 以 device_id 为主键,直接保证 G1:一台设备只能有一行。conn_id 由网关生成(网关标识加随机后缀),在全系统范围内唯一标识一条连接。route_version 每次接管加一。

route_floor 记录每台设备历史上被接受的最大版本与最大尝试编号。它与 routes 分开存放,是因为路由会在连接关闭时被删除,而“曾经接受过的最大尝试编号”不能随之丢失,否则一个延迟到达的旧握手会在路由被删除后被当作新连接接受。第 10 篇会用这张表处理迟到的旧注册。

网关进程内部还维护三张内存索引:byConnId、byDevice、byUser。推送时按用户查找本网关上的连接。内存索引只是本进程的视图,权威的归属记录在数据库中。

5. 正常路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
图 5-1  设备接入、接管与旧连接清理的时序
设备(新连接) 网关 gw-a PostgreSQL 旧连接(同设备)
| GET /ws?attempt=5 | | |
|---------------------->| 准入、鉴权 | |
| | claimRoute: 锁 route_floor 行 | |
| | attempt 5 > max_attempt 4 ? | |
| | UPDATE route_floor; UPSERT routes(conn=new, version=5) |
| | pg_notify('im_kick', 旧网关|旧conn) |
| |--------------------------------->| COMMIT |
| 101 + hello | | |
|<----------------------| register(): 同进程内找到旧连接 ---------- Close 4001 -------->|
| | | |
| | 旧连接退出: releaseRoute(device, conn=旧) |
| | DELETE FROM routes WHERE device_id=? AND conn_id=旧 |
| |--------------------------------->| 0 行(路由已属于新连接) |

接管时,旧连接可能在同一个网关进程中,也可能在另一个网关进程中。同进程的情况由 register 直接处理;跨进程的情况通过 pg_notify('im_kick', ...) 通知旧连接所在的网关,后者在监听线程中找到对应连接并以 4001 关闭。

5.1 心跳与空闲检测

网关的后台线程每隔一段时间检查所有连接:

  • 距离上次发出 Ping 超过 IM_PING_MS,发送一个 Ping。
  • 距离上次收到任何数据(包括 Pong)超过 IM_IDLE_MS,判定连接失效,关闭套接字。

同时,读线程的套接字设置了读超时(SO_TIMEOUT = max(1000, IM_IDLE_MS)),在没有任何入站数据时也会抛出超时异常。两种机制任一触发都会关闭连接、释放路由。

为什么不依赖 TCP keepalive?RFC 1122 §4.2.3.6 规定,keepalive 默认必须关闭,若启用,默认间隔不得少于两小时;Linux 的 tcp_keepalive_time 默认值为 7200 秒(tcp(7) 手册)。这个时间尺度对消息服务没有意义。应用层心跳的间隔可以按产品需求设定,并且能检测到“TCP 连接正常但对端应用已经停止处理”的情况,这是 TCP keepalive 无法发现的:对端内核会正常应答 keepalive 探测。

5.2 发送路径的背压

推送不由发送请求的线程直接写套接字。网关的通知监听线程收到 im_events 通知后,查询事件与可见用户,把帧放进每个目标连接的有界队列(ArrayBlockingQueue)。每个连接有一个专门的写线程从队列中取帧写出。

1
2
3
4
5
6
7
8
9
10
11
// examples/im/src/im/Gateway.java(节选)
void offerJson(Map<String, Object> msg) {
if (closed.get() || pendingClose != null) return;
if (!outbox.offer(Ws.text(Json.write(msg), false))) { // 非阻塞
gw.counterOutboxOverflow.incrementAndGet();
gw.log("outbox_overflow", this, Json.map("capacity", gw.outboxCapacity));
offerClose(4008, "outbox overflow, resync required", true); // 丢弃队列,紧急关闭
return;
}
wake();
}

offer 不阻塞:队列满时立即返回失败,网关丢弃该连接的全部待发数据,发送 4008 关闭帧。写线程的每次写操作还带一个看门狗:超过 IM_WRITE_TIMEOUT_MS 仍未写完,直接关闭套接字。这覆盖了队列未满、但对端已经完全停止读取、内核发送缓冲区也已写满的情况。

6. 方案比较

6.1 失活检测

方案 检测时间 能发现的情况 代价
只依赖 TCP 错误 发送数据后由重传超时决定;Linux 默认 tcp_retries2=15,tcp(7) 给出的估算约 924.6 秒 发送方向的失效 空闲连接永远不会被发现
TCP keepalive(默认参数) 2 小时以上 对端主机消失 无法发现应用层卡死
TCP keepalive(调短)或 TCP_USER_TIMEOUT 可配置 对端主机消失、发送卡住 依赖操作系统参数,无法发现应用层卡死
应用层 Ping/Pong + 空闲超时 约 idle + ping 以上全部,包括对端应用停止处理 每连接每个心跳周期两帧

本实现采用最后一种。检测时间的上界约为 IM_IDLE_MS 加一次后台检查间隔。RFC 1122 还提醒,单个探测未得到应答不应被视为连接失效,因为探测可能丢失;本实现的空闲阈值是 Ping 间隔的数倍(实验中为 4 倍:1200 ms 对 300 ms),一次 Pong 丢失不会导致断开。

6.2 慢接收端

方案 发送方 网关内存 慢设备
同步写套接字 被慢设备阻塞 小 收到全部数据(慢)
无界队列 不阻塞 无上限,最终耗尽 收到全部数据(可能永远收不完)
有界队列,满时丢弃最旧帧 不阻塞 有界 静默丢失,设备不知道
有界队列,满时断开并要求重新同步 不阻塞 有界 重连后通过同步补齐

“满时丢弃最旧帧”看起来更温和,但设备无从得知丢了哪些帧。由于第 07 篇的客户端按连续序号检测缺口,它最终能发现缺口,但要等到下一条推送到达才会发现。断开连接是一个明确的信号:设备收到 4008 后立即重新同步,恢复路径和网关崩溃后的恢复路径相同,不需要单独设计。

6.3 旧连接的清理条件

方案 SQL 并发接管时
按设备删除 DELETE FROM routes WHERE device_id=? 旧连接关闭晚于新连接注册时,删除新连接的路由,设备变为不可达
按设备与连接删除 DELETE FROM routes WHERE device_id=? AND conn_id=? 旧连接只能删除属于自己的行;路由已被接管时删除 0 行

这是一个典型的“检查与操作不原子”的问题:旧连接的清理逻辑隐含的前提是“路由仍然是我的”,按设备删除时这个前提没有被检查。把条件写进 WHERE 子句,让检查与删除在同一条语句中完成。

7. 验证

运行方式:真实 PostgreSQL 16,网关作为独立 JVM 进程运行在 127.0.0.1,测试客户端在测试进程内。网关参数:IM_PING_MS=300 IM_IDLE_MS=1200 IM_OUTBOX=16 IM_WRITE_TIMEOUT_MS=1500 IM_SNDBUF=4096。参数故意设得很小,让超时与溢出在几秒内发生。结果见 evidence/im-05/result.json。

类型 检查 预期 观测
边界 05-handshake-bad-version 版本 8 → 426,带 Sec-WebSocket-Version: 13 HTTP/1.1 426 Upgrade Required
边界 05-handshake-bad-token 错误令牌 → 401 HTTP/1.1 401 Unauthorized
边界 05-no-route-after-401 401 后路由为空 null
正常 05-connect-owns-route 101 与 hello;路由的 conn 等于 hello.conn version=1, attempt=1
正常 05-heartbeat-keeps-alive 应答 Ping 的客户端在 2000 ms 后(超过空闲阈值 1200 ms)仍保持连接 true
边界 05-unmasked-frame-1002 发送未加掩码的帧 → 关闭码 1002 1002
边界 05-stale-attempt-409 尝试编号不大于已记录值 → 409 HTTP/1.1 409 Conflict
失败 05-half-open-detected 客户端停止读取与应答,路由在 1200+300+1500 ms 内释放 1207 ms
失败 05-slow-receiver-bounded 慢接收端(接收缓冲 4096 字节、停止读取);发送方 120 条全部成功;慢连接被断开 120/120,outbox_overflow=1
恢复 05-slow-receiver-recovers-by-sync 设备重连后同步,得到全部 120 条 120
失败 05-old-close-keeps-new-route 新连接接管后旧连接收到 4001;旧连接清理后路由仍属于新连接 version=5,旧连接关闭码 4001
正常 05-new-conn-receives-push 新连接收到后续推送 true
负对照 05-control-unconditional-release 网关使用按设备删除(IM_UNSAFE_RELEASE=true):旧连接关闭后新连接的路由被删除,而新连接仍然存活 路由 null,新连接存活

半开连接检测用了 1207 ms,略大于空闲阈值 1200 ms,构成是空闲阈值加上后台检查的间隔与路由删除的耗时。慢接收端实验中,每条消息约 3 KB,待发队列上限 16 帧,接收端停止读取后,发送端的 120 次 send 全部在超时前得到 sent 回复:发送请求只写数据库,不等待推送完成,慢设备无法拖慢发送方。

负对照的结果说明了 G2 被破坏时的具体后果:新连接仍然存活,设备认为自己在线,但路由表里已经没有它,后续依赖路由的操作(例如第 10 篇的跨网关定向通知)找不到它。这种状态不会自行恢复,直到设备因为其他原因重连。

8. 故障与恢复

故障 检测 处理 恢复
客户端进程消失,TCP 半开 空闲超时 关闭套接字,按 conn_id 删除路由 设备重启后重连并同步
客户端停止读取 待发队列溢出或写超时 4008 关闭,丢弃队列 重连并同步(G6)
同一设备重复连接 路由声明 新连接接管,旧连接收到 4001 无需恢复
旧连接清理晚于新连接注册 — 条件删除影响 0 行 无需恢复
通知监听连接断开 监听线程捕获异常 重新建立连接、重新 LISTEN 向所有连接下发 resync
网关进程崩溃 网关租约过期(第 10 篇) 其他网关回收其路由 设备重连到其他网关

监听连接断开这一行需要额外说明。PostgreSQL 的 NOTIFY 只投递给正在监听的会话;监听连接断开期间提交的事务所产生的通知,重新 LISTEN 之后不会补发。PostgreSQL 文档在 LISTEN 的说明中给出了正确的模式:先执行 LISTEN 并提交,再查询数据库的当前状态,用后续通知跟进变化。本网关的做法是:每次(重新)建立监听后,向本网关上的所有连接发送 resync,让客户端自己查询当前状态。这个路径有代码,但本批实验没有注入监听连接断开(NOT_RUN)。

9. 限制

  • 只实现了 RFC 6455 中本系列需要的部分:文本帧、分片接收、控制帧、关闭握手。没有协商扩展(例如压缩)与子协议,没有 TLS。生产部署需要在网关前终止 TLS,或在网关内实现;这部分为 NOT_RUN。
  • 所有实验在单机回环网络上运行。真实网络中的丢包、延迟和 NAT 超时没有模拟。
  • 每连接内存(U1)没有测量。
  • 认证使用“设备标识:令牌”的简化形式,令牌以 SHA-256 摘要存储并用常量时间比较;令牌的签发、轮换与吊销不在范围内。

下一篇在这条通道上处理发送确认:客户端收到 sent 时消息处于什么状态,推送丢失时由谁发现、怎样补发,以及设备的送达游标怎样随确认前进。

参考资料