# IM 单聊压测 Pod 日志根因分析与改进方案 日期:2026-09-27 ## 1. 结论 `p2p-20260927-01` 暴露了两个相互独立的 P0 故障: 1. **Tinode 原生 Cluster 转发能力在突发流量下耗尽**,造成 8133 次 publish 拒绝或超时。 2. **Kafka 异步落库写入 Snowflake ID 时与 MySQL 32 位 `INT` 字段不兼容**,造成客户端已 ACK 的消息没有落入 MySQL。 两者都不是 Pod 崩溃、Pod 重启或集群节点断开导致。实时投递接近成功不代表持久化成功,本轮必须同时判定 Cluster 投递和 MySQL 落库不通过。 直接故障链路: 1. 客户端连接所在 Pod 不是 P2P Topic 的 master 时,请求进入 proxy-to-master 转发。 2. 当前每个远端节点只有 1 个同步 RPC 发送协程,队列容量只有 64。 3. 队列满后仅等待 20ms,随即返回 `cluster: load exceeded`。 4. Topic proxy 将该错误转换为 WebSocket `502 cluster unreachable`。 5. 反向投递进入 `hub.routeSrv` 时,4096 容量队列也出现满载;当前 RPC 调用方忽略 `rejected` 结果,存在静默丢弃路径。 因此,本轮 `40.665%` 的发送失败率是 Cluster 内部排队与串行 RPC 的结果。Redis/Kafka 没有参与这条 Topic proxy-to-master 请求路径,不是发送失败的故障点;但 Kafka 消费到 MySQL 的异步持久化链路另有独立故障。 ## 2. 压测结果对照 | 指标 | 结果 | |---|---:| | WS Ready | 2000/2000 | | Publish 尝试 | 20000 | | 服务端 ACK | 11867 | | 拒绝或超时 | 8133 | | ACK 成功率 | 59.335% | | ACK 后应到达 | 11867 | | ACK 后实际到达 | 11859 | | 实时丢失 | 8 | | ACK 后到达率 | 99.9326% | | 重复/乱序/断连 | 0 / 2 / 1 | | ACK 平均/最大延迟 | 3.079s / 18.715s | | 投递平均/最大延迟 | 4.178s / 21.741s | 8 条丢失全部集中在 `B -> A` 用户序号 0959,与 A 机记录到的 1 次 WS 断连同时出现。 ### 2.1 MySQL 直接核验 2026-09-27 通过 Pod 当前的只读数据库连接,对租户 `allrich`(`tenant_id=8`)进行了两层核验: | 核验项 | 结果 | |---|---:| | 客户端收到 ACK | 11867 | | `im_008_messages` 按本次 `client_msg_id` 前缀命中 | 0 | | `im_008_messages` 按 `content.run_id=p2p-20260927-01` 命中 | 0 | | `im_messages` 按 `content.run_id=p2p-20260927-01` 命中 | 0 | | ACK 消息精确匹配 `client_mid + topic + seq` | 0/11867 | | 本轮已生成 Kafka 死信(核验时点) | 27 | | 死信错误 | `Error 1264: Out of range value for column 'id'` | 结论:修复前,本轮 ACK 消息的 **MySQL 落库率为 0%**。27 是首次核验时已完成重试并转入死信的数量,不是最终总数;当时其余消息仍位于 Kafka backlog 或处理中。 ### 2.2 修复执行与数据恢复结果 2026-09-27 已在本地和测试环境执行 `docs/sql/IM-消息表Snowflake-ID-BIGINT修复SQL.sql` 对应迁移: - 本地 `meeting_im.im_messages.id` 已由 `INT` 改为 `BIGINT NOT NULL AUTO_INCREMENT`。 - 测试环境 `meeting_im.im_messages` 以及 `im_001_messages` 至 `im_009_messages` 共 10 张表已全部改为 `BIGINT NOT NULL AUTO_INCREMENT`,后置校验异常数为 0。 - 修复后 Kafka backlog 自动恢复落库 11835 条;本轮已有 32 条消息进入死信,通过系统 `kafka.dead_letter.replay` 命令完成重放。 使用原始 ACK ledger 对恢复后的数据库逐条校验: | 核验项 | 最终结果 | |---|---:| | ACK ledger | 11867 | | 数据库本轮消息行 | 11867 | | `client_mid + topic + seq` 精确匹配 | 11867 | | 缺失 | 0 | | 错配 | 0 | | 重复 | 0 | | 拒绝/超时请求意外落库 | 0/8133 | 因此本轮已确认 ACK 消息最终 100% 落库。这里的最终结果是执行字段迁移、等待 Kafka backlog 排空并重放死信后的恢复结果,不代表故障发生时的异步持久化链路正常。 ## 3. Kubernetes 现状 | 项目 | 实际值 | |---|---| | Namespace | `im-server-system` | | Workload | `StatefulSet/im-server` | | 副本 | 3/3 Ready | | Pod | `im-server-0/1/2` | | 节点分布 | 3 个 Pod 在 3 个不同 K8s Node | | 重启 | 全部为 0 | | 镜像 | 3 Pod 使用相同 digest | | CPU request/limit | `100m / 500m` | | Memory request/limit | `128Mi / 256Mi` | | Cluster 端口 | 12000 | Pod 没有 OOM/Kill,内存 cgroup 没有 `oom` 或 `oom_kill`。未观察到集群 TCP 断开、`node not connected`、panic 或 fatal。 ## 4. Pod 日志证据 压测发送窗口:2026-09-26 18:02:45Z 至 18:03:24Z(北京时间 02:02:45 至 02:03:24)。 `im-server-0` 的可用完整窗口日志中: | 日志 | 次数 | |---|---:| | `route broadcast request from proxy to master failed - cluster: load exceeded` | 2259 | | `cluster Route: server busy` | 3889 | | `cluster: node ... not connected` | 0 | | cluster RPC connection error | 0 | | panic/fatal | 0 | `im-server-2` 当前保留的日志从 18:03:19Z 才开始,仅最后约 5 秒已有 496 次 `cluster Route: server busy`。`im-server-1` 当前日志从 18:15:47Z 开始,本次发送窗口已被轮转覆盖。 因此 2259 和 4385(3889+496)都是 **可见日志下界**,不是整个集群的全量。这也是日志量与客户端 8133 次拒绝/超时不完全相等的主要原因之一。 错误从发送开始后立即出现,`im-server-0` 前 10 秒每秒约有 180-226 条 Cluster 过载日志,说明它不是某个后台定时任务引起,而是与本轮 publish 突发直接相关。 ## 5. 代码级根因 ### 5.1 proxy-to-master 队列过小 `server/cluster.go` 将 `clusterProxyToMasterBuffer` 固定为 64,`clusterP2MTimeout` 固定为 20ms。3 节点时 `len(c.nodes)==2`,扩容逻辑不生效,所以每个远端 peer 实际只有 64 个缓冲位。 队列满后,`proxyToMasterAsync` 在 20ms 后返回 `cluster: load exceeded`。`topic_proxy.go` 随即给客户端返回 502。日志与代码路径一致。 ### 5.2 每个 peer 只有一个同步 RPC worker `p2mSenderLoop` 只启动一次,循环内调用同步 `endpoint.Call`。该 peer 上任何一个较慢的 TopicMaster RPC 都会阻塞后续所有 Topic。在 1000 对用户同时 publish 时,64 队列很快被填满。 ### 5.3 接收侧有静默丢弃路径 `Cluster.Route` 在 `globals.hub.routeSrv` 满时设置 `rejected=true`并打印 `cluster Route: server busy`。但 `ClusterNode.route` 只检查 RPC error,传入的 bool 命名为 `unused`,没有检查被远端拒绝的结果。 这意味着发送方可能认为 RPC 成功,但接收方已经因队列满而丢弃消息。本轮 8 条 ACK 后实时丢失与此风险方向一致,但因全量日志已部分轮转,无法将每一条 Client MID 与具体 server-busy 日志逐条对齐。 ### 5.4 资源限制会放大排队 每个 Pod 的 CPU limit 只有 500m,同时承担 1000 量级 WS、Topic goroutine、net/rpc、MySQL 持久化和实时投递。当前 cgroup 累计存在 CPU throttling,但没有压测时间窗的 Prometheus 历史样本,所以只能将 CPU 限制定义为高概率放大因素,不将其单独认定为根因。 ### 5.5 Kafka 异步落库主键类型不兼容 当前代码在 Kafka 入队前通过 `store.Store.GetUid()` 生成 Snowflake 消息 ID。消费者批量落库时,`TenantMessageSaveBatch` 显式把该 ID 写入 `im_008_messages.id`。 线上实际字段类型为: | 表 | `id` 类型 | |---|---| | `im_messages` | `INT` | | `im_008_messages` | `INT` | Snowflake ID 超过有符号 32 位 `INT` 上限,批量插入立即报 1264。代码降级为逐条处理时仍调用同一个批量写入方法,因此不会绕过该错误;达到重试上限后消息进入 `im_kafka_dead_letters`。与此同时,WebSocket 202/ACK 在 Kafka publish 成功后就返回,因而客户端会观察到“ACK 成功、实时广播成功、历史和数据库没有消息”。 这是数据库 DDL 与当前异步持久化代码契约不一致。仓库初始化 SQL 也仍把 `im_messages.id` 定义为 `INT`,因此新环境同样会复现。 ## 6. 次要问题 ### 6.1 403 permission denied 两台 agent 保留的前 1000 条错误中,A 有 11 条、B 有 24 条 403。多个相同用户序号在 A/B 两侧同时出现,例如 0082、0175、0304、0401、0773。这表明少量 P2P Topic 的发布权限或好友/Topic 初始化状态不一致,与 Cluster 过载是独立问题。 ### 6.2 历史回查程序串行阻塞 压测程序对 1000 个 Topic 串行调用 history GET,每次控制应答最长等 20 秒,导致收尾耗时达 13-19 分钟。原始 `history_found=0` 本身不能证明 MySQL 丢数据;本报告新增的 MySQL 直查与 Kafka 死信证据已经独立确认落库失败。 ### 6.3 日志保留不足 压测后不到 20 分钟,`im-server-1` 和 `im-server-2` 的关键窗口已部分或全部被容器日志轮转覆盖。没有集中日志时,无法完整重建三 Pod 时间线。 ## 7. 改进方案 ### P0:修复 Cluster 转发实现 1. 将 cluster buffer、enqueue timeout、worker 数改为可配置项,不再使用 64/20ms 硬编码。 2. 每个 peer 使用有界并发 RPC worker pool。为保证同一 Topic 的顺序,应按 `tenant_id + topic` 做固定分片,同一 Topic 始终进入同一 worker。 3. 首轮建议值:每 peer 16 个 worker、总 buffer 4096、enqueue timeout 200-500ms。该数值必须通过阶梯压测定标,不应作为无上限扩容。 4. 修复 `ClusterNode.route`:检查远端 `rejected` 并返回明确 overload error,禁止静默成功。 5. 对已持久化的 ServerComMessage 建立有界重试(例如 3 次指数退避)。超过重试上限要记录可检索的 tenant/topic/seq/target pod,并由客户端重连后历史同步补齐。 6. 将 overload 与 node unreachable 分开:队列满返回 503/`cluster overloaded`,只有节点断连才返回 502/`cluster unreachable`。 ### P0:恢复 Kafka 消息持久化 1. 暂停新的压力流量,避免继续扩大 Kafka backlog 和死信数量。 2. 审核并执行 `docs/sql/IM-消息表Snowflake-ID-BIGINT修复SQL.sql`,把基础表及所有 `im_NNN_messages.id` 改为 `BIGINT NOT NULL AUTO_INCREMENT`。保留 `AUTO_INCREMENT` 是为了兼容 Kafka 关闭时不显式传 ID 的同步写入路径。 3. 变更后先发 1 条带唯一 `client_mid` 的探针消息,确认 Kafka 消费、MySQL 行、Topic seq 和历史查询一致。 4. 再通过现有死信管理接口重放本轮 `message.persist.p2p` 死信;Kafka 中尚未提交的 backlog 会由消费者继续处理。不要直接删除死信或重置 offset。 5. 以本轮 11867 个 ACK ledger 做最终精确核对,要求 `client_mid + topic + seq` 为 11867/11867,重复为 0。 6. 增加启动前 schema contract 检查:Kafka 持久化启用时,若私聊消息基础表或任何租户分表的 `id` 不是 BIGINT,Pod readiness 必须失败并输出明确的表名和字段类型。 7. 增加 `kafka_message_persist_total{result=batch_ok|single_ok|retry|dead}`、消费 lag、死信累计量和最老 backlog age 告警。客户端 ACK 语义应明确为“Kafka 已接收(pending)”,服务端另行提供最终持久化状态;若产品要求 ACK 等于已落库,则必须改为同步确认或增加持久化回执。 ### P0:调整部署资源与入口 1. 测试环境首轮将每 Pod request 调整为 `cpu: 1, memory: 512Mi`,limit 调整为 `cpu: 2, memory: 1Gi`;运行阶梯压测后再根据 P95 调整。 2. 核对客户端 Service/Ingress:`sessionAffinity: None`,WebSocket upstream 不使用 `ip_hash`,建议 `least_conn` 或等价的长连接均衡策略。 3. 在压测开始前记录每 Pod `ws_connections_active`,单 Pod 连接数不得超过平均值的 1.3 倍。 4. readiness 增加 `LiveClusterNodes == TotalClusterNodes-1` 和 cluster queue 长时间高水位检查,但不应因瞬时高水位造成 Pod 反复摘除。 ### P1:增加可观测性 新增以下 Prometheus 指标,必须带 `pod` 和 `peer` 维度,Topic 只记 hash: - `im_cluster_p2m_queue_length/capacity` - `im_cluster_p2m_enqueue_total{result=ok|timeout}` - `im_cluster_rpc_inflight` - `im_cluster_rpc_duration_seconds` - `im_cluster_route_total{result=ok|hub_full|peer_rejected|disconnected}` - `im_cluster_proxy_response_total{result=ok|channel_full|unknown_topic}` - `im_ws_connections_active{pod}` - `im_message_persist_duration_seconds` 告警建议: - queue 使用率连续 30s 超过 70%:预警;超过 90%:严重。 - `enqueue timeout` 或 `hub_full` 1 分钟内大于 0:严重。 - `LiveClusterNodes` 低于期望值:严重。 - WS 连接不均衡比超过 1.3:预警。 日志应接入 Loki/ELK 等集中系统,至少保留 7 天。限速相同 overload 日志,用计数指标补全被限速的数量,避免大量重复日志反过来抢占 CPU 和 IO。 ### P1:修复压测程序 1. history 回查改为 32-64 有界并发,总收尾超时不超过 3 分钟。 2. 分开统计 `publish_502`、`publish_503`、`publish_403`、`publish_ctrl_timeout`、`socket_error`,不再只保留前 1000 条文本错误。 3. 对所有 1000 对用户做发布权限预检,修复 403 后再开始正式计时。 4. 在两台压测机启动前、发送中、收尾后自动抓取 Pod/Redis/Kafka/MySQL 指标快照。 5. 历史落库验证增加 MySQL 直查作为独立通道,以 `tenant_id + topic + client_mid/seq` 分批校验。 ## 8. 复测顺序与通过门槛 1. 200 对、500 对、1000 对分三档,每档至少跑 3 轮。 2. 每档同时执行“均匀爬坡”和“同时突发”两种模式。 3. 最终 1000 对场景通过门槛: - publish ACK 成功率不低于 99.99%。 - ACK 后对端到达率为 100%,重复为 0。 - Cluster enqueue timeout、hub full、peer rejected 均为 0。 - ACK P95 < 500ms,对端投递 P95 < 1s。 - MySQL 落库校验 100%。 - Kafka consumer lag 在发送停止后 60 秒内归零,死信新增为 0。 - 单 Pod CPU P95 < 70%,内存 P95 < limit 的 70%,无持续 CPU throttling。 ## 9. 本次调查限制 - 调试 ServiceAccount 可读 Pod/StatefulSet/log,但不可读 Service/Endpoint 和 metrics-server 历史数据。 - `im-server-1/2` 的压测窗口日志已部分轮转,日志次数只能作为下界。 - 未获得当时的 CPU、内存、网络、MySQL、Redis、Kafka 时序监控,不对这些组件的历史利用率做无证据推断。 - Kafka backlog 的精确条数无法通过当前调试 ServiceAccount 直接读取;死信 27 条只代表核验时已经完成失败重试的部分。 即使存在上述限制,现有证据已足以分别确认 Cluster 转发过载和 Kafka 持久化主键溢出。修复并完成死信/backlog 恢复前,本轮数据不能视为已持久化。 ## 10. P0/P1 实施状态 2026-09-27 已完成代码与发布配置改造: - Cluster 使用按 `tenant_id + topic` 固定分片的 16 worker、每 peer 总队列 4096、入队超时 300ms;远端 `hub_full` 有 3 次指数退避重试,过载返回 503,节点断连保持 502。 - TopicMaster 的客户端 publish 接收队列满时会通过 multiplex session 回 503,不再只记录日志后等待客户端超时;同类过载日志每秒限速,完整次数由指标保留。 - Kafka 启用时,启动阶段校验基础私聊表及所有活动租户私聊分表的 `id` 必须为 `BIGINT NOT NULL AUTO_INCREMENT`,不满足时进程不会进入 Ready。 - 增加 Cluster 队列/RPC/路由、WebSocket 连接、Kafka 持久化结果/lag/backlog age/死信及持久化耗时指标和 PrometheusRule 告警。 - `/healthz` 检查全部 Cluster peer,并仅在队列超过 90% 持续 30 秒时摘除 readiness;新增 `/livez` 供 liveness 使用,避免 Cluster 短暂异常触发 Pod 重启。 - 测试环境发布流水线应用 `request=1C/512Mi`、`limit=2C/1Gi`,设置 Cluster 参数、Service `sessionAffinity=None`,并在存在 `Ingress/im-server` 时设置 NGINX `least_conn`。 - 压测程序增加全量 P2P 预检、48 worker 历史回查、3 分钟总超时、publish 错误分类、指标快照阶段标记、P95/ACK 成功率门禁及 MySQL 独立精确核验。 当前调试 kubeconfig 对 StatefulSet/Service/Ingress 没有 patch 权限,因此现存 Pod 资源尚未被本地会话直接修改;以上部署项会在新提交触发有权限的 Gitea deploy runner 后生效。生效后必须按第 8 节执行 200/500/1000 对阶梯复测,不能用单元测试替代容量验收。 ## 11. 200 对阶梯复测结果 Run ID:`p2p-200-20260927-01`,A/B 两台压测机各模拟 200 个用户,共 200 对、400 条 WebSocket 连接。 | 指标 | 结果 | |---|---:| | WS Ready | 400/400 | | Publish 尝试 | 4000 | | 服务端 ACK | 3890 | | 403 拒绝 | 110 | | 502 / 503 / ctrl timeout / socket error | 0 / 0 / 0 / 0 | | ACK 后应到达 / 实际到达 | 3890 / 3890 | | ACK 后到达率 | 100% | | 实时丢失 / 重复 / 乱序 / 断连 | 0 / 0 / 0 / 0 | | Kafka P2P `batch_ok` | 3890 | | Kafka consumer lag / backlog age | 0 / 0 | | MySQL 精确匹配 | 3890/3890 | | MySQL 缺失 / 错配 / 重复 / 非 ACK 落库 | 0 / 0 / 0 / 0 | 本轮没有再出现 `cluster: load exceeded`、`server busy`、502 或 503,说明 Cluster P0 改造在 200 对档位已生效。Kafka 消费指标和 MySQL 对 ACK ledger 的独立精确查询均确认 3890 条消息已全部落库。 压测报告当时的 `history_found=2213`、`persistence_lost=1677` 不是真实落库丢失。旧实现首轮查询后仅等待 2 秒,再进行两次 2 秒重试,并未持续到配置的 3 分钟截止时间。已改为在 `history_timeout` 内持续重试,并按 Topic 合并缺失区间查询,避免过早判定和大量单条请求。 ### 11.1 403 根因与修复 110 次 403 稳定集中在用户序号 `0018`、`0043`、`0065`、`0082`、`0175`、`0183` 的 6 个 P2P Topic,与 Cluster 转发无关。`EnsureP2P` 已将 MySQL 中双方订阅修复为 `JRWPA`,但已加载 Topic 的 `perUser` 仍保留旧权限,因此预检通过后 WebSocket publish 仍被内存态拒绝。 已增加两层修复: 1. HTTP `EnsureP2P` 成功后向 Topic master 发布内部权限刷新事件,原生 Cluster 由 `routeSrv` 路由,非原生多 Pod 模式由 Redis realtime fanout 路由。 2. P2P publish 发现内存态无写权限时,先从 MySQL 重载该用户订阅再判定,作为刷新事件延迟或丢失时的自愈保障。数据库仍无写权限时依然返回 403。 ### 11.2 当前未达标项 - ACK P95 和到达 P95 均落在 2 秒直方图上界,未达 500ms / 1s 门槛。 - ArgoCD 已将 StatefulSet 资源回滚为 `request 100m/128Mi`、`limit 500m/256Mi`,且 liveness 仍使用 `/healthz`。压测期间存在 CPU throttling 累计,部署配置必须在 GitOps 声明源中固化,否则应用后仍会被回滚。 - 403 修复发布后应重跑 200 对;只有 ACK 成功率、实时到达率、MySQL 精确落库均达标,才进入 500 对档位。 ## 12. 200 对第三轮复测结果 Run ID:`p2p-200-20260927-03`。压测前修复了压测工具复用固定 `Idempotency-Key` 而跳过好友关系校验的问题,并重新核对、修复 200 对测试账号。 | 指标 | 结果 | |---|---:| | WS Ready | 400/400 | | Publish 尝试 / ACK | 4000 / 4000 | | Publish 拒绝 / 403 / 502 / 503 | 0 / 0 / 0 / 0 | | ACK 后应到达 / 实际到达 | 4000 / 4000 | | 实时到达率 | 100% | | 历史查询 | 4000/4000 | | MySQL 精确匹配 | 4000/4000 | | MySQL 缺失 / 错配 / 重复 / 非 ACK 落库 | 0 / 0 / 0 / 0 | | 丢失 / 重复 / 断连 | 0 / 0 / 0 | | 乱序 | 1 | | Cluster 队列采样最大值 | 0 | | Kafka 峰值 lag / 最旧 backlog age / 最终 lag | 3028 / 92.624s / 0 | 结论:跨 Pod 发送、ACK 后实时投递、Kafka 最终消费和 MySQL 落库的数据正确性已通过 200 对验证。结果报告仍为不通过,原因是 B 端记录 1 次单连接 Topic seq 回退,以及 ACK P95 和投递 P95 均落入 1-2s 区间。 资源采样显示,三个 Pod 峰值内存低于 94MiB,但 Pod 0/1/2 在本轮窗口内的 CPU throttled 增量约为 7.62s / 0.78s / 8.51s。实际 StatefulSet 仍是 `request 100m/128Mi`、`limit 500m/256Mi`,且 liveness 仍是 `/healthz`。因此延迟和 Kafka 追平速度尚不能作为扩容到 500 对的验收基线。 已增加 `deploy/argocd/im-server-test/statefulset-performance-patch.yaml`,要求 ArgoCD 声明源固化 `request 1C/512Mi`、`limit 2C/1Gi`和 `/livez` liveness。部署流水线改为等待 ArgoCD 回写后严格校验,如果再被回滚则直接失败。压测工具保留乱序为失败门禁,并新增最多 100 条 `receiver/topic_hash/previous_seq/current_seq/client_mid` 明细,用于下轮精确定位。 ## 13. GitOps 资源生效后的第四轮复测 Run ID:`p2p-200-20260927-04`。ArgoCD Application `im-server-test` 的真实配置仓库已固化 `request 1C/512Mi`、`limit 2C/1Gi` 和 `/livez`,三个 Pod 完成滚动后均为 Ready、零重启。 | 指标 | 结果 | |---|---:| | WS Ready | 400/400 | | Publish 尝试 / ACK | 4000 / 4000 | | Publish 拒绝 / 403 / 502 / 503 | 0 / 0 / 0 / 0 | | ACK 后应到达 / 实际到达 | 4000 / 4000 | | 实时丢失 / 重复 / 乱序 / 断连 | 0 / 0 / 0 / 0 | | 历史查询 | 4000/4000 | | MySQL 精确匹配 | 4000/4000 | | MySQL 缺失 / 错配 / 重复 / 非 ACK 落库 | 0 / 0 / 0 / 0 | | 精确 ACK P50 / P95 / P99 / Max | 368.189 / 566.683 / 725.654 / 1600.975 ms | | 投递 P95 直方图上界 | 1000 ms | | Cluster 队列采样最大值 | 0 | | Kafka message persist 峰值 lag / 归零耗时 | 2946 / 发送结束后 113s | | Pod 0/1/2 CPU throttled 增量 | 0 / 0 / 0 usec | | Pod 0/1/2 峰值内存 | 87.86 / 80.80 / 85.18 MiB | 结论:消息正确性全部通过,第三轮出现的 1 条 seq 回退没有复现。资源扩容完全消除了本轮 CPU throttling,证明旧的 500m CPU limit 是延迟放大因素之一;精确 ACK P95 从第三轮的 1006.288ms 降至 566.683ms,下降约 43.7%,但仍比 500ms 门槛高约 13.3%,因此报告只因 ACK 延迟门禁判定失败。 Kafka `im-server.message_persist` lag 在发送后峰值为 2946,并在发送结束后约 113 秒归零,超过 60 秒目标。历史 API 和 MySQL 最终均确认 4000 条完整落库,说明这是落库延迟而不是消息丢失。进入 500 对测试前,应优先检查 message persist consumer 的批量写入、worker/partition 并行度和 MySQL 写入耗时。 连接集中关闭期间,三个 Pod 分别记录 10、14、22 次 `failed to close login audit ... Deadlock found`,共 46 次。该问题未影响消息表,但会导致登录审计结束状态不完整。登录审计关闭更新应增加 MySQL 1213/1205 有界重试,并检查更新条件和索引以缩小锁范围。 ## 14. 持久化并行与审计死锁修复后的第五轮复测 Run ID:`p2p-200-20260927-05`。本轮已将 `im.message.persist` 和 `im.group.message.persist` 固化为 3 分区、3 副本;同一 Topic 继续作为 Kafka key,保证会话内顺序。服务端同时按 batch 内最大 Seq 合并 Topic 和发送者已读位点更新,并为登录审计关闭增加复合索引和 MySQL 死锁重试。 | 指标 | 结果 | |---|---:| | WS Ready | 400/400 | | Publish 尝试 / ACK | 4000 / 4000 | | ACK 后应到达 / 实际到达 | 4000 / 4000 | | 丢失 / 重复 / 乱序 / 断连 | 0 / 0 / 0 / 0 | | 历史查询 / MySQL 精确匹配 | 4000/4000 / 4000/4000 | | MySQL 缺失 / 错配 / 重复 / 非 ACK 落库 | 0 / 0 / 0 / 0 | | 总体精确 ACK P50 / P95 / P99 / Max | 375.314 / 547.516 / 670.317 / 2845.347 ms | | A 机精确 ACK P95 | 408.155 ms | | B 机精确 ACK P95 | 594.533 ms | | Pod 0/1/2 Kafka 峰值 lag | 676 / 251 / 143 | | 最慢 Kafka 分区归零时间 | 发送结束后约 52.5s | | Cluster 队列峰值 / CPU throttled 增量 | 0 / 三 Pod 均为 0 | | 登录审计关闭死锁 | 三 Pod 均为 0 | | Kafka batch failure / Pod 重启 | 0 / 0 | 结论:三分区已由三个 Pod 实际并行消费,单 Pod 峰值 lag 从第四轮的 2946 降至 676,最慢消费者在 60 秒门槛内归零。消息正确性、最终落库、会话内顺序、CPU 限流和登录审计死锁均通过。 报告仍因 ACK P95 门禁显示失败。直方图只能给出 1000ms 上界,按 4000 条 ACK 原始时间戳和 nearest-rank 口径计算的实际 P95 为 547.516ms。A 机已经低于 500ms,而 B 机为 594.533ms,且 B 机存在 2.845s 最大长尾;两台机器使用相同公共入口和相同服务端版本,因此继续修改服务端前,应先采集 B 机到 `api.118im.com` 的 DNS、TCP、TLS、WebSocket RTT 与时钟偏差,并使用定向 Pod 入口做 A/B 对照,区分公网链路长尾和服务端处理耗时。 ## 15. 500 对第一轮容量测试 Run ID:`p2p-500-20260927-01`。A/B 各建立 500 条 WebSocket,共 500 对、1000 条连接和 10000 次 Publish。正式发压前补齐了第 201–500 对中缺失的 9 对好友权限,两端预检和 Ready 均通过。 | 指标 | 结果 | |---|---:| | WS Ready | 1000/1000 | | Publish 尝试 / ACK | 10000 / 9946 | | Publish 失败 | 54(44 条服务端 500/other,10 条 socket error) | | Publish 成功率 | 99.46% | | ACK 后应到达 / 实际到达 | 9946 / 9936 | | 实时丢失 / 重复 / 乱序 / 断连 | 10 / 0 / 0 / 1 | | 实时到达率 | 99.8995% | | 总体精确 ACK P95 / Max | 2429.954 / 3636.045 ms | | 压测期历史回查 | 8580/9946 | | Kafka 完全排空后 MySQL 精确匹配 | 9946/9946 | | MySQL 缺失 / 错配 / 重复 / 非 ACK 落库 | 0 / 0 / 0 / 0 | | Pod 0/1/2 Kafka 峰值 lag | 2490 / 3234 / 2745 | | Kafka 归零时间 | 发送结束后约 4 分钟 | | Pod 0/1/2 峰值内存 | 148.1 / 130.5 / 122.4 MiB | | Pod 0/1/2 CPU throttled 增量 | 0 / 0 / 0 | | Pod 重启 | 0 | 本轮不通过,不得升级到 1000 对。根因是 MySQL 连接容量与 IM Server 连接池上限不匹配: - MySQL `max_connections=170`,本轮 `Max_used_connections=172`,`Connection_errors_max_connections=27`。 - IM Server 每 Pod `max_open_conns=64`,3 个 Pod 的理论连接池上限已达 192,还未计入 admin server、其他服务和运维连接。 - 本轮 Pod 0/1 分别记录 153/19 次 `Error 1040: Too many connections`,包括 50 次 `failed to save message`和 44 次 P2P 关系检查失败;这些错误与 44 条服务端 500 同时出现。 - A 机用户 190 的 WebSocket 断开,产生 10 次 socket error,并导致对端已 ACK 的 10 条消息未能实时投递到该连接。 - 压测结束阶段 Pod 2 记录 282 次群 Topic broadcast queue full,发生在大量会话同时清理期,需要与消息发送链路分开处理。 压测阶段的 `history_found=8580`不是最终丢库:历史回查结束时 Kafka 仍在积压,等 lag 归零后从 Pod 内使用 ACK ledger 独立查询 `im_008_messages`,9946 条全部精确匹配。因此当前是“已接受消息最终零丢失,但持久化延迟超标”。 下一轮 500 对复测前必须先完成: 1. 统一规划 MySQL 全局连接预算,覆盖 IM Server、admin server 及其他共库服务;单个服务的连接池上限不能独立设置到总库容量。 2. 修正 IM Server 每 Pod 的 `max_open_conns/max_idle_conns`,或在验证 MySQL CPU、内存、线程和 PXC 限制后提高 `max_connections`;必须保留至少 20% 全局余量。 3. 压测中增加 DB pool `OpenConnections/InUse/Idle/WaitCount/WaitDuration`和 MySQL `Threads_connected/Threads_running/Max_used_connections/Connection_errors_max_connections` 指标。 4. 复测要求 Publish ACK 10000/10000、实时丢失和断连为 0、Kafka 60 秒内归零、MySQL 1040 和清理期 queue full 为 0,然后才能进入 1000 对。 ### 15.1 为什么 Kafka 可以积压,但本轮仍会报错 Kafka 积压只能保护“已经成功写入 Kafka”的消息。正常情况下,consumer 落库变慢只会使 lag 上升,消息会在 Kafka 中等待,不应导致 Publish 报错。本轮的 54 条失败发生在 Kafka 之前: 1. 普通文本消息先同步查询青少年模式设置,但当前规则无论查询结果都允许消息继续进入内容审核。 2. P2P 发布在写 Kafka 前调用 `CanSendP2P`,旧实现即使 Redis 已命中好友关系,仍会读取 MySQL 用户和好友记录。 3. 入口校验、Kafka consumer、历史查询和登录审计共用 MySQL 连接预算。连接耗尽后,入口校验直接返回 500,这些消息没有进入 Kafka,因此无法通过“后续慢慢消费”恢复。 ### 15.2 IM 侧改造 2026-09-27 已完成以下修复: - 普通消息不再读取青少年模式表;通话的夜间禁用规则仍保留数据库查询。 - P2P 热路径先查双向黑名单缓存,再使用好友正缓存;缓存未命中或 Redis 异常时才回源 MySQL。新增拉黑会同时清理双向好友正缓存,避免安全状态陈旧。 - `meeting_im` 每 Pod 连接池设为 `max_open=24/max_idle=8`,`hotgo` 注册表库独立设为 `max_open=4/max_idle=2`。3 个 Pod 的 IM 理论总上限由 384 降为 84;结合 Workbench 30、Manager 20 的上限,整套服务理论上限为 134,在当前 MySQL 170 连接上限下保留 36 条全局余量。 - 连接池参数改为环境变量,并同时固化到应用仓库及 ArgoCD 真实配置源。发布流水线会在 ArgoCD 回写后校验四个连接池参数。 - 新增 `im_db_pool_*` 指标,包括 max/open/in-use/idle/wait count/wait duration/关闭计数,并为连接池 85%/95% 使用率配置预警和严重告警。 这些修复的目标是先让入口稳定完成缓存校验和 Kafka enqueue,把 MySQL 慢转化为可观测、可恢复的 Kafka lag,而不是 Publish 500。如果复测时 `im_db_pool_wait_total` 持续增长或 Kafka 仍无法在 60 秒内归零,再根据 PXC 的 CPU/内存/线程容量将 MySQL `max_connections` 持久化提高到 300–400,不使用临时 `SET GLOBAL`代替配置源。 ## 16. 500 对连接池改造后复测 Run ID:`p2p500-fix-20260927-02`。A/B 各建立 500 条 WebSocket,共 500 对、1000 条连接和 10000 次 Publish。三个 IM Pod 均已使用 `meeting_im=24/8`、`hotgo=4/2` 的连接池配置。 | 指标 | 改造前 | 本轮 | |---|---:|---:| | WS Ready | 1000/1000 | 1000/1000 | | Publish ACK | 9946/10000 | 10000/10000 | | 服务端 500 / socket error | 44 / 10 | 0 / 0 | | 实时到达 | 9936/9946 | 10000/10000 | | 实时丢失 / 重复 / 乱序 / 断连 | 10 / 0 / 0 / 1 | 0 / 0 / 0 / 0 | | ACK P50 / P95 / P99 / Max | - / 2429.954 / - / 3636.045 ms | 662.062 / 1567.569 / 2620.878 / 3156.025 ms | | 压测期历史回查 | 8580/9946 | 6738/10000 | | Kafka 排空后 MySQL 精确匹配 | 9946/9946 | 10000/10000 | | MySQL 缺失 / 错配 / 重复 / 非 ACK 落库 | 0 / 0 / 0 / 0 | 0 / 0 / 0 / 0 | | Pod 0/1/2 Kafka 峰值 lag | 2490 / 3234 / 2745 | 2259 / 2875 / 2326 | | Kafka 最慢归零 | 约 4 分钟 | 发送结束后约 4 分 30 秒 | | MySQL 1040 | 172 次 Pod 日志记录 | 0 | | Pod 重启 | 0 | 0 | 结论分为两部分: 1. 入口和实时正确性修复有效。10000 次 Publish 全部被接受,实时投递、最终落库、顺序、重复和断连门禁全部通过;连接池上限把全局 MySQL 连接耗尽转化成了进程内排队,没有再出现 `Too many connections`。 2. 容量门禁仍未通过,暂不升级到 1000 对。ACK P95 仍为 1567.569ms,Kafka 未在 60 秒内归零,压测期历史回查因落库未追平只看到 6738 条。 本轮新增指标进一步定位出两个瓶颈: - 发送阶段 `hotgo` 注册库连接池出现 5841 次新增等待,累计等待约 210.25 秒。`TenantAdapterFor` 当前在命中已创建的租户 adapter 之前仍执行 `tenantByID`,使共库路由的高频操作重复查询 `im_tenant`;4 条注册库连接因此成为 ACK 热路径瓶颈。 - 发送结束 30 秒后,两台 agent 同时以默认 48 worker 执行历史核验,形成 96 路历史查询。三个 `meeting_im` 连接池随后全部达到 24/24;主库新增 6780 次连接等待,累计等待约 11010.16 秒。历史查询与 Kafka persistence 共用连接池,直接延长了 lag 排空时间。 - Pod 2 有一个 100 条批次因 `context deadline exceeded` 降级为逐条写入,100 条最终均成功;三个 Pod 共记录 8 个数据库超时相关日志,没有死信,最终数据库精确核验为 10000/10000。 下一轮前应完成: 1. 为租户元数据增加带失效机制的进程内缓存;`TenantAdapterFor` 命中有效缓存时不得每次读取 `hotgo.im_tenant`,租户停用或配置变更通过现有配置事件主动失效,并保留短 TTL 兜底。 2. 压测程序把实时发压和持久化核验拆成两个阶段:先观察 Kafka 自然排空,再以每机 8–16 个 worker 执行历史核验,避免核验流量反向干扰被测落库吞吐。同时另设“历史读取并发压测”场景验证读写混合容量。 3. persistence 与在线历史查询至少增加独立并发预算;若继续共用连接池,应为 persistence 保留连接或限制历史查询并发,避免读请求耗尽全部 24 条连接。 4. 完成上述改造后仍以 500 对复测,门禁保持 ACK 10000/10000、实时错误为 0、Kafka 60 秒内归零、最终数据库 10000/10000,再决定是否升级到 1000 对。 ### 16.1 路由缓存和分阶段核验改造 2026-09-27 已完成下一轮复测所需代码调整: - MySQL `TenantAdapterFor` 增加 1 分钟租户路由有效期;有效期内直接复用 tenant adapter,不再读取 `hotgo.im_tenant`。 - 同一租户的缓存刷新使用 singleflight 合并,TTL 到期瞬间最多执行一次注册库查询,避免并发击穿。 - `tenant.updated`、`tenant.status.changed`、`tenant.limits.updated`、`tenant.features.updated` 和 `cache.tenant.invalidate` 继续使用现有 Kafka 广播事件;`InvalidateTenantConfigCache` 现在同时使数据库路由缓存失效。失效与刷新并发时通过 generation 校验阻止旧查询重新写入有效缓存。 - 压测配置新增 `history_settle`,默认 5 分钟。实时 drain 完成后先等待该窗口,再发起历史查询,从而单独测量 Kafka 自然排空时间。 - 历史查询默认并发从每机 48 降为 12;历史读取高并发能力后续使用独立读写混合场景验证,不再污染消息落库吞吐结果。 ## 17. 路由缓存发布后 500 对复测与延迟改造 Run ID:`p2p500-r3-20260927-03`。A/B 各 500 条 WebSocket、每人发送 10 条消息,总计 10000 次 Publish。 | 指标 | 结果 | |---|---:| | WS Ready | 1000/1000 | | Publish ACK | 10000/10000 | | 实时到达 / 丢失 | 10000/10000 / 0 | | 历史回查 / 落库丢失 | 10000/10000 / 0 | | 重复 / 乱序 / 断连 | 0 / 0 / 0 | | 总体精确 ACK P95 | 1308.150 ms | | A / B 精确 ACK P95 | 1121.2 / 1519.2 ms | | 原报告投递 P95 | 2000 ms(受双机时钟偏差污染) | | Kafka 最终 lag | 0 | | `hotgo` pool wait | 0 | 租户路由缓存已经消除 `hotgo` 热路径等待,消息正确性和最终落库全部通过。剩余 ACK 延迟集中在消息 4–6 的突发积压阶段。发压窗口内,`meeting_im` 连接池出现明显等待;同时 Kafka persistence consumer 正在使用同一连接池。Cluster Route RPC 平均约 1–2ms,P2M 队列始终为 0,因此跨 Pod RPC 不是秒级延迟来源。 原投递 P95 不能作为服务端结论:压测程序使用发送机绝对时间戳在接收机计算单向耗时。A 只接受了 5777 个非负样本,而 B 接受了全部 10000 个样本,说明 B 时钟领先 A;A 丢弃负值,B 则把时钟偏差计入延迟。 本次完成以下改造: 1. 压测程序新增同机与跨机两个投递直方图;`delivery_local_p95_ms` 作为门禁,跨机负延迟计入 `cross_machine_negative_latency_samples`,不再静默丢弃后继续生成误导性 P95。 2. 压测消息显式携带 `sender_machine`,旧消息仍可从 `client_mid` 兼容识别来源。 3. 好友关系首次数据库确认后双向回填 Redis,减少双向聊天后续回源。 4. 新增 `im_message_publish_stage_duration_seconds{kind,stage,result}`,覆盖 `relationship_check`、`redis_idempotency`、`redis_sequence`、`kafka_produce` 和整体 `save` 阶段。 5. 新增 `log_client_packets` 配置,默认关闭逐包 INFO 日志,避免每条消息额外 JSON 解析和容器 stdout I/O;需要诊断时才短期开启。 下一轮仍使用 500 对复测。重点比较 `relationship_check`、`kafka_produce` 和 `save` 的 P95,并以 `delivery_local_p95_ms` 判断投递延迟;跨机延迟只有在 chrony/NTP 偏差确认小于 5ms 后才用于验收。数据库连接池暂不扩大,避免再次超过 MySQL 全局 170 连接预算;若 `relationship_check` 已稳定但 pool wait 与 Kafka lag 仍持续增长,再设计 persistence 独立连接预算或批量更新方案。