# 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 后按阶梯重测。 ## 7. P0 修复实现 本轮分析后的代码修复如下: 1. 群消息的 `{pres what=msg}` 不再由 Topic master 对每个成员逐条跨 Pod 路由。master 将收件人清单合并为一条 `group_unread/users` Redis Pub/Sub 消息;来源 Pod 本地投递一次,其他 Pod 各消费一次。 2. 每个 Pod 只扫描一次本地 WebSocket:已订阅该群 Topic 的连接依靠正常 `{data}` 广播,不重复发送 `me` presence;同一用户未订阅该群的其他设备仍收到 `me` presence,保留多开语义。 3. Redis fanout 未启用或批量发布失败时,自动回退到原来的逐用户 Topic 路由,避免静默漏掉未读通知。 4. 原生 Tinode Cluster 开启时,普通 user message 不再重复发布 Redis user fanout;session control 仍保留 Redis 跨 Pod 广播。 5. `routeSrv` 改为有界非阻塞写入,队列满时记录错误和指标,不再无限阻塞 Topic 处理链路。 6. 新增 `im_internal_queue_enqueue_total`、`im_internal_queue_depth`、`im_internal_queue_capacity`,覆盖 `session_topic_publish`、`hub_route_server`、`topic_client`、`topic_server`、`cluster_topic_proxy`。 修复后的复杂度从“每条消息约 2000 次 Cluster route + 2000 次 Redis publish”降为“一次 Redis publish + 每 Pod 一次本地 session 扫描”。2000 人都订阅群 Topic 时,批量 unread 在本地扫描后不会产生重复 WebSocket 帧。 ## 8. 复测观测项 复测继续使用 `group2k-r1` 相同发送模型,并重点检查: ```promql sum by (queue, result) (rate(im_internal_queue_enqueue_total[1m])) histogram_quantile( 0.95, sum by (le, queue) (rate(im_internal_queue_depth_bucket[1m])) ) sum by (stage, kind, target_type, status) ( increase(realtime_fanout_events_total[5m]) ) ``` 验收要求: - `session_topic_publish`、`topic_client`、`topic_server`、`cluster_topic_proxy` 的 `result="full"` 均为 0。 - `group_unread/users` 的 publish 数应接近服务端实际接受的群消息数,不再乘以群成员数。 - 群压测窗口内普通 `message/user` Redis publish 不再随群成员数增长。 - `im_cluster_route_total` 不应再出现每条群消息约 2000 次的线性放大。 - 2000 条计划消息需分别核对 ACK、未知结果、实时观测和最终落库,不能只按 ACK 台账判断落库率。