WebSocket 架构与并发模型
适用范围:
Mud.Feishu.WebSocket本文把源码中分散的锁策略注释(WebSocketConnectionManager.cs的类级<remarks>、ReconnectionOrchestrator的闸门注释等) 升格为模块级正式约定。新增/修改代码必须逐条自检本文的"架构不变量"。
1. 组件与职责
| 组件 | 职责 | 关键约束 |
|---|---|---|
WebSocketConnectionManager | 连接生命周期(建立/关闭/替换 socket)、收发原语 | 事件一律在锁外触发;释放后入口确定性抛 ObjectDisposedException |
FeishuWebSocketClient | 认证、消息接收与分发调度、心跳与重连编排 | 接收路径承担背压;Task.Run 不传取消令牌 |
BinaryMessageProcessor | 二进制帧增量接收、ProtoBuf/JSON 解析、去重与 ACK | 装配串行化;在途处理数有硬上界 |
MessageRouter | 按消息类型选择 IMessageHandler | 处理器超时后仍观察其异常 |
FeishuWebSocketConcurrencyService | 并发上界(背压闸门)、热更新、关停等待 | 信号量不随 Dispose 释放 |
ReconnectionOrchestrator | 重连编排、熔断、冷却、指标 | 闸门持有期内派发事件,保证"至多一轮重连在途" |
HeartbeatManager | 发送应用层 ProtoBuf Ping;应用服务端下发的 PingInterval | 不据 Pong 判死(对齐 Python SDK) |
2. 锁策略
| 锁 | 保护对象 | 说明 |
|---|---|---|
WebSocketConnectionManager._connectionLock | 连接生命周期(建立/断开/替换 socket) | 不可重入;内部方法一律调用无锁版本 DisconnectCoreAsync |
WebSocketConnectionManager._sendLock | 发送(文本 + 二进制) | 与生命周期解耦:慢发送不阻塞连接/重连。ClientWebSocket 同一时刻只允许一个未完成 SendAsync |
BinaryMessageProcessor._processLock | 整条二进制消息的装配(防止 A→B 帧片段交错) | 不可重入;ProcessBinaryDataAsync 先取"在途槽位"再取本锁 |
FeishuWebSocketClient._connectLock | 连接/断开的编排(含后台任务收尾) | 重连路径经 FeishuWebSocketManager 串行化 |
FeishuWebSocketConcurrencyService._semaphoreLock | 信号量热替换 | 仅保护替换动作 |
释放策略(I9):本模块从不访问 SemaphoreSlim.AvailableWaitHandle,因此这些信号量不随 Dispose 释放 (不释放 = 无 OS 句柄泄漏;释放 = 与在途 WaitAsync/Release 构成 ObjectDisposedException 竞态)。
唯一登记例外(R2/WS2-06):
FeishuWebSocketConcurrencyService在配置热更新时原子替换信号量, 并延迟释放旧信号量——保留时长为max(60 秒, 2 × MessageHandlerTimeoutMs)。 该例外是必要的:在途租约仍持有旧信号量的引用,归还时会调用其Release(); 保留时长必须 ≥ 租约最长合法持有时间(MessageHandlerTimeoutMs),否则慢处理器归还租约时会抛ObjectDisposedException。公式与 Webhook 侧FeishuWebhookConcurrencyService完全一致(跨模块对齐)。 计算入口:FeishuWebSocketConcurrencyService.ResolveLegacySemaphoreRetention(int)(纯函数,可单测)。 新增任何"延迟释放信号量"的代码前必须先在此处登记。
3. 连接代次与断线声明(I2)
_disconnectedFired是实例级唯一标志,不区分连接代次。- 断线声明必须携带 socket 身份:
NotifyDisconnected(args, owner),owner != null时只接受当前_webSocket的声明。 - 旧接收循环的迟到异常因此被丢弃:不触发
Disconnected、不递减连接计数、不吞掉后续真实断线。 DisconnectCoreAsync在"原子占位"前同样做 owner 校验,并在ConnectAsync的成功/失败两条路径上 由调用方在锁外派发事件(失败路径先派发、后以原类型/原堆栈抛出异常)。
4. 重连闸门(I11/I12)
TryReconnectAsync
├─ 熔断打开 / 未启用自动重连 → false
├─ Interlocked.CompareExchange(gate, 1, 0) != 0 → "重连已在进行中" → false ← 锁外,快速失败
├─ await _reconnectLock(仅保护一轮重连内部状态)
│ └─ 冷却期 → 循环:ShouldContinueReconnect → 退避 → ReconnectAsync → 成功则复位计数
├─ finally 释放 _reconnectLock
├─ 在闸门持有期内、锁外派发 ReconnectSucceeded/Failed/LimitReached
└─ finally: Volatile.Write(gate, 0) ← 任何路径(含提前 return、回调抛异常)都必须复位ReconnectState.IsReconnecting是闸门的派生值(单一真源,不再维护第二个布尔字段)。- 订阅者约定:严禁在回调内同步阻塞(会占住闸门,使后续重连被跳过);重入
TryReconnectAsync会 立即返回false(既不阻塞也不会开启嵌套重连轮次)。 - 达到重连上限后打开熔断(
IsCircuitOpen),仅在连接成功时关闭。
5. 事件时序
| 事件 | 触发时机 | 是否在锁外 |
|---|---|---|
Connected | 新连接握手成功(若有旧连接,先 Disconnected 再 Connected) | 是 |
Disconnected | ① 服务端关闭帧 ② 客户端主动断开 ③ 接收循环异常 ④ 接收循环因取消退出但 socket 仍为 Open(R2/P0-1 新增) ⑤ 握手失败但旧连接已关 | 是(最多一次/代次) |
Error | 可恢复/不可恢复错误 | 是 |
ReconnectSucceeded/Failed/LimitReached | 一轮重连结束后 | 是(闸门持有期内) |
MessageReceived | 收到文本帧后,在并发租约内、处理任务体中(R2/WS2-08 变更) | 是;可能并发、可能乱序,属观测钩子 |
MessageReceived 线程契约(R2 行为变更,务必知悉):该事件不再在接收循环线程上同步派发, 而是在取得并发租约后的任务体内派发(与《背压模型》§6 一致)。因此:
- 慢订阅者不会阻塞接收管道;它只占用一个并发槽位(形成反压),代价是该连接的可处理并发度下降。
- 事件可能并发执行、且不保证与帧到达顺序一致。
- 需要"顺序处理"或"受
MessageHandlerTimeoutMs保护"的业务逻辑,必须改用IMessageHandler(经MessageRouter)。
主动断开的顺序约束(R2/I13):FeishuWebSocketClient 的停机路径先结束连接(由 WebSocketConnectionManager.DisconnectCoreAsync 完成"占位 + 关闭握手"),再取消接收循环令牌。 顺序颠倒会产生两类问题:① 接收循环因取消退出时 socket 仍 Open,会按"接收循环被取消"补发 Disconnected,把本端主动重连描述成异常断线;② 接收循环异步退出与 DisconnectCoreAsync 的原子占位互相抢先,事件有可能一次都不派发。
僵尸连接(R2/P0-1):若接收循环已结束而 WebSocketState 仍为 Open,模块会把它判为僵尸态:
Disconnected在取消路径被补发(I13);FeishuWebSocketHostedService的周期性检查与FeishuWebSocketHealthCheck均会判定异常并触发重连 (存活探针字段见《故障排查手册》§2);- 判定的唯一确定性组合是"
State == Open且接收循环已结束"——"长时间无帧"不判死 (飞书长连接空闲时本就没有事件帧,客户端心跳是单向的)。
6. 背压模型(I10)
接收循环 ──await AcquireAsync(并发闸门,槽位耗尽即挂起 = TCP 反压)──▶ 拷贝本次帧 ──▶ Task.Run
├─ MessageReceived 派发(观测钩子)
├─ 认证闸门(AuthGateTimeoutMs)
├─ 业务处理(可含 ProtoBuf 装配)
└─ finally 归还槽位R2/WS2-08:
MessageReceived已从"接收循环线程同步派发"移入上述任务体。 改造前它在AcquireAsync之前同步调用订阅者,慢订阅者会直接堵住整条接收管道 (不读帧、不回 ACK、不推进心跳),与本节"接收路径承担背压"的设计意图相反—— 背压应向上游 TCP 施加,而不是被下游回调反噬。
- 排队任务与消息副本数一并受
MaxConcurrentHandlers约束(此前只约束"处理中",排队无界)。 Task.Run不得传入取消令牌:令牌已取消时委托不会执行,而租约所有权已移交 → 永久泄漏 → 接收管道卡死。MaxConcurrentHandlers = 0表示无限制,此时接收侧永不阻塞(与改造前行为一致)。- I10:消息处理器不得反向依赖接收循环的推进(例如在同一 socket 上做请求-响应式等待),否则构成循环等待。
- 副作用:背压期间不读帧、不回 ACK,服务端重投窗口随之变长;去重层(EventId/SeqID)负责兜底。
7. 架构不变量清单(评审硬门槛)
| # | 不变量 |
|---|---|
| I1 | 用户事件一律在锁外触发(SemaphoreSlim/Monitor 均不可重入) |
| I2 | 断线声明必须携带连接代次(socket 身份),跨代次通知一律丢弃 |
| I3 | 持锁范围内禁止 await 长耗时操作(握手、重连循环、消息处理) |
| I4 | 公开 API 传递共享缓冲区时必须在文档中声明生命周期 |
| I5 | 新增配置项必须同步 Validate() + 4 份 Readme + CHANGELOG |
| I6 | AOT:禁止反射版 JsonSerializer;protobuf 必须走 FeishuWebSocketProtoModel.Instance |
| I7 | netstandard2.0 不得使用 net5+ API(必要处条件编译) |
| I8 | 每个 .cs 必须第 1 行以 6 行版权头开始 |
| I9 | 信号量不随 Dispose 释放(未访问 AvailableWaitHandle) |
| I10 | 处理器不得依赖接收循环的推进 |
| I11 | 闸门释放必须与获取严格配对(任何路径都在最外层 finally 复位);所有权移交不得依赖"委托一定执行" |
| I12 | IsXxx 状态标志只允许单一真源(不得同时维护字段与闸门两套状态) |
| I13 | 连接终止路径穷尽占位:任何"使 socket 不再被读取"的路径(服务端关闭帧、客户端主动断开、接收循环异常、接收循环因取消退出、Dispose/DisposeAsync)都必须经过 TryClaimDisconnected;主动断开必须先占位、后关闭握手 |
| I14 | 接收循环唯一性由原子占位保证(不得依赖"先检查后使用"的字段读);循环 Task 必须登记到 _receiveTask 以纳入停机等待与存活判定 |
| I15 | 调用方 CancellationToken 只约束建连阶段(握手 + 认证),不构成连接生命周期;连接生命周期由客户端自持 CTS 控制 |
| I16 | 配置面双向约束:数值配置必须同时校验下界与上界;所有 TimeSpan 在转换为 CancellationTokenSource/Task.Delay 前必须经过钳制(Core/TimeSpanGuards.cs)。netstandard2.0 禁用 Math.Clamp |
I13–I16 的落地位置:
WebSocketConnectionManager(I13 的占位与顺序、I2 身份校验)、FeishuWebSocketClient(I14 的原子占位与任务登记、I15 的自持 CTS、连接存活性派生)、Core/TimeSpanGuards.cs+FeishuWebSocketOptions.Validate()(I16)。 对应的源码级守卫测试见Tests/Mud.Feishu.WebSocket.Tests/ContractGuards/WebSocketContractGuards.cs。
8. 单应用装配前提(SeqID 去重键的口径)
本模块在同一进程内只装配一个 WebSocket 连接,且只绑定默认应用:
FeishuWebSocketManager.ResolveCurrentContext()使用IFeishuAppManager.GetDefaultApp(), 不遍历GetAllApps();IFeishuWebSocketClient与IFeishuSeqIDDeduplicator均为单例(FeishuWebSocketServiceBuilder注册), 因此二者各只有一份;FeishuAppManager为"每个应用"创建的IServiceScope只用于装配 HTTP/认证/令牌上下文, 不产出 WebSocket 客户端。
由此可以解释一个容易被误读的实现细节:SeqID 去重键是裸 SeqID(无应用/租户维度)。 在有前提的装配下(单连接 + 默认应用)它不可能误伤——不同应用的消息不会进入同一个去重集合。
⚠️ 升级前提:若将来引入多应用 WebSocket 装配(每应用一个客户端/连接), 必须同时给 SeqID 去重键加入应用维度(
TryMarkAsProcessedAsync(seqId, scopeKey))。 否则 A 应用已处理的 SeqID 会抑制 B 应用的同号消息,表现为静默事件丢失 (无异常、无告警——"重复"是被当作正常路径跳过的)。 该耦合由守卫SeqIdDeduplication_ShouldStaySingleAppScoped_OrGainAppDimension守护: 一旦源码中出现多应用装配,"裸 SeqID 调用点"的断言会一并失败并指向本节。