Files
im_server/docs/test/IM-group2k-r1-群聊压测问题分析-20260927.md
T

108 lines
5.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# IM 2000 人群聊压测问题分析
日期:2026-09-27
Run ID:`group2k-r1-20260927-01`
场景:A/B 各 1000 个 WebSocket,各 500 个发送者发 2 条,计划发送 2000 条群消息。
## 1. 结论
本轮失败,且不能仅解读为 WebSocket 丢包。核心问题是 2000 人群每条消息触发 O(群成员数) 的额外用户 `me` Topic 路由,导致 Topic master、集群通道和 MySQL 连接池连锁积压。服务端处理吞吐下降后,发送端先经历 ACK 超时,随后 Topic 队列写满并返回 503。
同时,压测程序对 ACK 超时的口径不完整:超时后服务端仍可能接受、广播并落库,不能把 `ctrl_timeout` 直接当作明确拒绝。
## 2. 客户端结果
| 指标 | A | B | 合计 |
|---|---:|---:|---:|
| WS READY | 1000 | 1000 | 2000 |
| 发送尝试 | 1000 | 1000 | 2000 |
| 收到 2xx ACK | 16 | 19 | 35 |
| 503 | 224 | 272 | 496 |
| ACK 超时 | 732 | 709 | 1441 |
| socket 错误 | 28 | 0 | 28 |
| 断线 | 21 | 0 | 21 |
| ACK 台账内实时缺失 | 735 | 0 | 735 |
| ACK 台账内历史落库缺失 | 0 | 0 | 0 |
| 未在 ACK 台账中的实时投递 | 368270 | 377000 | 745270 |
35 条 ACK 消息理论上应产生 70000 次本轮投递,实际共记录 69265 次,其中 A 端少 735 次。但这不是服务端实际接受的全量;额外 745270 次投递来自“未及时收到 ACK,但服务端已处理”的消息。
## 3. 服务端证据
### 3.1 实际接受和落库
- `im_message_publish_stage_duration_seconds_count{kind="group",stage="save",result="ok"}` 增加 488。
- `im_kafka_message_persist_total{kind="group",result="batch_ok"}` 增加 488。
- 群消息 Kafka 批持久化平均约 46.6ms,488 条全部 batch success。
因此,服务端至少接受并落库 488 条,而不是客户端 ACK 台账中的 35 条。其中至少 453 条属于“已接受但 ACK 未在 20 秒内返回”。
### 3.2 队列溢出
| Pod | 日志 | 次数 |
|---|---|---:|
| `im-server-2` | `s.publish: sub.broadcast channel full` | 496 |
| `im-server-2` | `hub: topic's broadcast queue is full` | 1655 |
| `im-server-1` | `cluster: proxy channel full` | 47 |
群 Topic `grp3Ita5tyBIMs` 的 master 在 `im-server-2`。其 `clientMsg` 队列容量只有 192,proxy 响应队列容量为 128。队列满时会立即返回 503 或丢弃 proxy 响应。
### 3.3 每条群消息的路由放大
`Topic.saveAndBroadcastMessage` 在每条消息中同步执行:
1. `presSubsOffline("msg", ..., offlineOnly=true)` 遍历 `t.perUser` 的 2000 个成员。
2. 为每个成员构建一条用户 `me` Topic presence 消息。
3. `routeUserServerMessage` 同时走 Tinode 原生 Cluster 路由和 Redis user fanout。
4. 最后才向群 Topic 的会话广播 `{data}`。
488 条已接受群消息产生了约 98 万次额外用户路由:
- `realtime_fanout_events_total{stage="publish",target_type="user"}` 增加 979979。
- `realtime_fanout_events_total{stage="receive",target_type="user"}` 增加 894360。
- Redis 接收后真正本地投递只有 666 次,`no_local_session` 为 893694。
- Cluster route 约增加 109 万次。
这证明绝大多数用户 fanout 在当前全员已订阅群 Topic 的场景中完全无效,却阻塞了 Topic master 的串行处理协程。
### 3.4 MySQL 连接等待
3 个 Pod 的 `meeting_im` 连接池在测试窗口中合计:
- 等待次数增加 4011。
- 等待时间增加约 35399 秒。
- DB `exec ok` 增加 3453,平均耗时约 501ms。
连接池每 Pod 最大 24 个连接。这不是本轮第一个堵点,但会进一步拖慢群权限检查、未读数更新和其他业务 SQL。仅提高连接数不会消除 O(成员数) 的放大。
## 4. 压测程序口径问题
1. `ctrl_timeout` 和 socket 错误属于发布结果未知,不是明确拒绝。
2. recorder 能够统计 `unexpected_messages`,但 merge 报告没有汇总该字段,也没有将它加入门禁。
3. 历史回查只检查 ACK 台账,因此只检查了 35 条,无法单独证明其他超时消息的落库情况。
4. 群聊应增加“已 ACK”、“已明确拒绝”、“结果未知”、“未 ACK 但已观测广播”和“最终落库”五种状态。
## 5. 改进顺序
### P0
1. 停止在原生 Cluster 已开启时对同一条 user message 再做 Redis user fanout,避免双通道重复路由。
2. 重构群消息的 unread presence:已订阅该群 Topic 的用户不再生成额外 `me` 通知;未订阅但在线的成员使用节点级批量 fanout,不得在 Topic master 协程中逐人跨 Pod 路由。
3. 为 Topic `clientMsg`、proxy 响应队列和 session send queue 增加 length/capacity/drop Prometheus 指标。
4. 压测 merge 报告增加结果未知及 unacked-observed 口径,并把 `unexpected_messages` 纳入失败门禁。
### P1
1. 检查群消息未读数更新和 Push receipt 是否对 2000 人逐人访问 Redis/MySQL,改为批处理或延迟合并。
2. 为 `CanPost` 的全员禁言和单人禁言结果增加短 TTL 缓存,管理操作时精确失效。
3. P0 完成后先跑 100、500、1000、2000 人群阶梯,每档检查 ACK P95、Topic 队列水位、Cluster route 数、DB pool wait 和 Kafka lag,不直接重跑全量。
## 6. 本轮验收结论
- 连接建立:通过,2000/2000 READY。
- 发布成功率:失败。
- ACK 延迟:失败,已超过 20 秒超时窗口。
- 实时广播:失败,存在队列丢弃和断线。
- Kafka 持久化:已处理的 488 条全部成功,但该结论不代表计划的 2000 条均被接受。
- 总体:失败,必须完成 P0 后按阶梯重测。