fix: stop writes to closed cluster sessions

This commit is contained in:
richjohnrambo
2026-10-02 00:28:02 +08:00
parent d43b1b63d3
commit a406d63154
3 changed files with 98 additions and 0 deletions
@@ -743,3 +743,67 @@ Run ID:`s900m-mixed-r5-1001`。本轮在 R4 相同的 900 条 WebSocket、135
- `loadtest/im-pressure/results/s900m-mixed-r5-1001/metrics.jsonl`
- `loadtest/im-pressure/results/s900m-mixed-r5-1001/server-metrics/`
- `loadtest/im-pressure/results/s900m-mixed-r5-1001/im-server-{0,1,2}.log`
## 25. 900 人混合 R6/R7 顺序修复发版复验
### 25.1 R6:顺序修复通过,公网瞬时断连导致整轮失败
Run ID:`s900m-mixed-r6-1001`。本轮首次验证同步 Master-to-Proxy 顺序投递版本,跨 Pod 乱序已从 R5 的 1 次降为 0,但压测机在 15:42:13 至 15:42:18 UTC 同时对两个入口 IP 发生 TCP 写超时。
| 指标 | R6 |
|---|---:|
| 就绪连接 | 900 / 900 |
| 发送尝试 / ACK | 1798 / 1785 |
| socket 结果未知 | 13 |
| 压测期断连 | 11,均为 write timeout |
| 实时投递 | 401361 / 404542 |
| 实时缺口 | 3181,全部历史可查 |
| 重复 / 乱序 | 0 / 0 |
| 历史回查 | 1790 / 1790 |
| MySQL 精确命中 | 1790 / 1790 |
| MySQL 缺失 / 错配 / 重复 / 额外 | 0 / 0 / 0 / 0 |
11 次断连分布在 `216.236.31.189` 和 `216.236.31.191` 两个入口,并集中于约 5 秒窗口;三个服务 Pod 均 Ready、零重启,负载期无 Topic/Cluster/WS 队列满、Kafka 错误或 gob 错误。13 条 socket 结果未知中有 5 条被对端观察并精确落库,另外 8 条未落库且未形成额外数据,符合请求在进入服务端前失败的特征。
R6 的顺序修复目标已通过,但整轮因断连、发送未知和实时缺口判定失败。三条目标 IP 路由复查均为 `en1` 直连,未经过 VPN/TUN。为排除一次性公网抖动,随即执行相同参数的 R7。
### 25.2 R7:消息业务门禁全部通过
Run ID:`s900m-mixed-r7-1001`。运行前对三个入口 IP 的 HTTPS 预检均返回 200,TLS 总耗时约 1.0 至 1.1 秒。
| 指标 | R7 |
|---|---:|
| 就绪连接 | 900 / 900 |
| 发送尝试 / 最终接受 | 1798 / 1798 |
| 明确拒绝 / 结果未知 | 0 / 0 |
| 超过 20 秒 / 晚到 / 最终未到 ACK | 74 / 0 / 0 |
| 精确 ACK P95 | 16795.372ms |
| 单聊对端实时投递 | 1350 / 1350 |
| 群聊实时投递 | 403200 / 403200 |
| 实时总投递 | 404550 / 404550 |
| WS 匹配回调总数(含单聊回显) | 405900 / 405900 |
| 重复 / 乱序 / 断连 | 0 / 0 / 0 |
| 历史回查 | 1798 / 1798 |
| MySQL 精确命中 | 1798 / 1798 |
| MySQL 缺失 / 错配 / 重复 / 额外 | 0 / 0 / 0 / 0 |
| Pod Ready / 重启 | 3 / 3,0 重启 |
R7 的 `report.json` 为 `passed=true`。R5 暴露的同 Topic 跨 Pod 乱序在 R6 和 R7 连续两轮均为 0,证明单 session 排他 drain 与同步 RPC 顺序修复有效。压测端 A/B 回调队列峰值为 156/150,最大等待 13.281/25.038ms,结算时均为 0。
按 R7 起压前和负载结束快照计算增量:服务端新增 1798 条 ctrl ACK,enqueue/write 平均约 82.43/82.89ms,P95 均在 500ms 桶内;`Cluster.TopicProxy` 新增 7943 次调用,平均约 2.27ms、P95 在 10ms 桶内。负载期无新增 RPC error、内部队列失败、Kafka lag、queue full、gob 错误或 Pod 重启,说明同步 RPC 没有形成负载期瓶颈。关闭后服务端 active WS 回落为 0,压测进程和 FD 均完全释放。
### 25.3 关闭阶段 multiplex 队列竞态
R6 和 R7 均在最后一批连接关闭后记录 1 次 `s.queueOut: session's send queue full 8:grpQ5_hPdAz3cE-im-server-0`。该事件发生在业务发送、实时静默、历史回查和 summary 生成之后,没有影响上述消息结果,但违反“渐进关闭阶段也不得队列满”的严格运维门禁。
根因是 proxy stop/detach 后 multiplex worker 已退出并从 SessionStore 删除,而 Topic 的异步 unsubscribe 尚未完成,短时间内仍可能通过旧指针调用 `queueOut`。旧 session 未标记 terminating,消息进入无人消费的 send channel 并最终填满。修复已在本地完成:Cluster session 清理开始时先原子标记 terminating,并排空残留 channel;后续对旧指针的写入会被直接丢弃,不再进入失去 drain owner 的队列。新增关闭竞态测试和顺序测试均在普通模式连续 20 次、race 模式连续 10 次通过。
因此当前结论分为两层:
- 900 人混合消息业务门禁已经通过,跨 Pod 顺序修复有效。
- 完整运维门禁还需发布关闭竞态补丁后做一次 R8,重点验证业务指标继续全绿且渐进关闭阶段 `queue full=0`。
新增产物:
- `loadtest/im-pressure/results/s900m-mixed-r6-1001/`
- `loadtest/im-pressure/results/s900m-mixed-r7-1001/`
+5
View File
@@ -1398,6 +1398,11 @@ func (sess *Session) clusterWriteLoop(forTopic string) {
terminate := true
defer func() {
if terminate {
// The topic may retain this multiplexing session briefly while its
// unsubscribe request is processed. Reject writes to the stale session
// instead of filling a send queue which no longer has a drain owner.
atomic.StoreInt32(&sess.terminating, 1)
sess.purgeChannels()
sess.closeRPC()
globals.sessionStore.Delete(sess)
sess.inflightReqs = nil
+29
View File
@@ -4,6 +4,7 @@ import (
"net"
"net/rpc"
"sync"
"sync/atomic"
"testing"
"time"
@@ -83,3 +84,31 @@ func TestClusterWriteLoopPreservesTopicOrder(t *testing.T) {
t.Fatalf("cluster responses arrived out of order: %v", proxy.received)
}
}
func TestClusterWriteLoopRejectsWritesAfterTermination(t *testing.T) {
previousSessionStore := globals.sessionStore
globals.sessionStore = NewSessionStore(time.Minute)
t.Cleanup(func() { globals.sessionStore = previousSessionStore })
sess := &Session{
sid: "multiplex-termination-test",
proto: MULTIPLEX,
send: make(chan any, 1),
stop: make(chan any, 1),
detach: make(chan string, 1),
subs: make(map[string]*Subscription),
}
sess.stop <- nil
sess.clusterWriteLoop("grp-termination-test")
if atomic.LoadInt32(&sess.terminating) == 0 {
t.Fatal("terminated multiplexing session still accepts writes")
}
if !sess.queueOut(&ServerComMessage{TenantID: types.TenantID(1)}) {
t.Fatal("write to terminated multiplexing session should be discarded")
}
if len(sess.send) != 0 {
t.Fatalf("terminated multiplexing session retained %d outbound messages", len(sess.send))
}
}