后端系统工程 05:长连接接入与连接归属
后端系统工程 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 | |
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 | |
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 | |
routes 以 device_id 为主键,直接保证 G1:一台设备只能有一行。conn_id 由网关生成(网关标识加随机后缀),在全系统范围内唯一标识一条连接。route_version 每次接管加一。
route_floor 记录每台设备历史上被接受的最大版本与最大尝试编号。它与 routes 分开存放,是因为路由会在连接关闭时被删除,而“曾经接受过的最大尝试编号”不能随之丢失,否则一个延迟到达的旧握手会在路由被删除后被当作新连接接受。第 10 篇会用这张表处理迟到的旧注册。
网关进程内部还维护三张内存索引:byConnId、byDevice、byUser。推送时按用户查找本网关上的连接。内存索引只是本进程的视图,权威的归属记录在数据库中。
5. 正常路径
1 | |
接管时,旧连接可能在同一个网关进程中,也可能在另一个网关进程中。同进程的情况由 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 | |
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 时消息处于什么状态,推送丢失时由谁发现、怎样补发,以及设备的送达游标怎样随确认前进。
参考资料
- RFC 6455,The WebSocket Protocol:§1.3 与 §4.2.2(
Sec-WebSocket-Accept与 GUID)、§4.4(不支持的版本返回 426 并列出支持的版本)、§5.1(客户端帧必须加掩码,否则关闭连接)、§5.5(控制帧载荷 ≤125 字节)、§5.5.2–5.5.3(Ping/Pong)、§7.4.1(1001、1002、1003 等关闭码)、§7.4.2(4000–4999 私有区间)。https://www.rfc-editor.org/rfc/rfc6455 - RFC 6585,§4 429 Too Many Requests。https://www.rfc-editor.org/rfc/rfc6585
- RFC 1122,§4.2.3.6 TCP Keep-Alives(默认关闭;间隔默认不少于两小时;单个探测无应答不得视为连接失效)。https://www.rfc-editor.org/rfc/rfc1122
- Linux man-pages,tcp(7)(
tcp_keepalive_time默认 7200 秒、tcp_retries2默认 15、TCP_USER_TIMEOUT)。https://man7.org/linux/man-pages/man7/tcp.7.html - JEP 444,Virtual Threads(JDK 21;在
synchronized块中阻塞会钉住载体线程)。https://openjdk.org/jeps/444 - PostgreSQL 16 文档,LISTEN 与 NOTIFY(通知只投递给正在监听的会话;先 LISTEN 再查询当前状态)。https://www.postgresql.org/docs/16/sql-listen.html 、https://www.postgresql.org/docs/16/sql-notify.html





