fix: harden single-machine load handling

This commit is contained in:
richjohnrambo
2026-09-29 17:52:13 +08:00
parent ab73b69870
commit 4b00b5ae37
24 changed files with 1030 additions and 144 deletions
@@ -0,0 +1,184 @@
# IM 单机 60% 容量压力测试方案
日期:2026-09-29
本方案首轮执行结果见 [IM-单机60%容量压力测试结果-20260929.md](./IM-单机60%容量压力测试结果-20260929.md)。
## 1. 目标与口径
本方案用一台物理压测机完成单聊、群聊和混合场景,容量按双机正式方案全局规模的 60% 计算。
单机不等于只运行一个 agent。为保留现有好友对和投递契约,同一台物理机上同时运行逻辑 agent A 和 agent B,每个 agent 承载 600 个连接,合计 1200 个 WebSocket。Coordinator 仍使用 `expected-agents=2`。
| 项目 | 双机正式方案 | 单机 60% 方案 |
|---|---:|---:|
| 物理压测机 | 2 | 1 |
| 逻辑 agent | 2 | 2 |
| 每个 agent 连接 | 1000 | 600 |
| WebSocket 总连接 | 2000 | 1200 |
| 好友对 | 1000 | 600 |
| 群成员 | 2000 | 1200 |
## 2. 场景容量
### 2.1 单聊
- 600 对好友,A/B 各 600 人。
- 每个用户发送 10 条,每秒 1 条。
- 总发送尝试 12000 条,是双机 20000 条的 60%。
- 每条 ACK 消息应在配对用户的 WebSocket 到达一次。
### 2.2 群聊
- 1200 个在线群成员。
- A/B 各取前 300 个用户作为发送者,合计 600 个发送者。
- 每人发送 2 条,全局每 100ms 一条,合计 1200 条群消息,耗时约 120 秒。
- 全部发送被 ACK 时,期望实时投递 `1200 x 1200 = 1,440,000` 次。
### 2.3 单聊和群聊混合
- 前 180 对好友互发,每人 5 条,共 1800 条单聊。
- A/B 各 150 个群发送者,每人 2 条,共 600 条群消息。
- 群消息期望实时投递 `600 x 1200 = 720,000` 次。
- 单聊和群聊同时开始,不分两轮单独执行。
工具按 `users_per_machine` 等比计算发送者数量。`users_per_machine=1000` 时保持原双机参数,设为 600 时自动使用上述单机参数。
## 3. 文件隔离
单机方案不复用双机的运行文件,避免把 1200 人结果和 2000 人结果混在一起。
```text
loadtest/im-pressure/config.single-machine.json
loadtest/im-pressure/manifest.single-machine.json
loadtest/im-pressure/results/<RUN_ID>/
```
从模板生成本地配置:
```bash
cp loadtest/im-pressure/config.single-machine.example.json \
loadtest/im-pressure/config.single-machine.json
chmod 600 loadtest/im-pressure/config.single-machine.json
```
修改租户、HTTP/WS 地址和注册手机前缀。A/B endpoint 可以填同一个公网入口;若需确认跨 Pod 投递,应分别填写两个定向入口。
## 4. 一次性数据准备
```bash
source loadtest/im-pressure/.env.test
go build -o loadtest/im-pressure/im-pressure ./loadtest/im-pressure
ulimit -n 100000
./loadtest/im-pressure/im-pressure prepare \
-config loadtest/im-pressure/config.single-machine.json \
-manifest loadtest/im-pressure/manifest.single-machine.json
./loadtest/im-pressure/im-pressure verify \
-config loadtest/im-pressure/config.single-machine.json \
-manifest loadtest/im-pressure/manifest.single-machine.json
```
`verify` 必须输出 1200 个账号、600 对好友和 1200 个群成员。单机 manifest 可以复用已注册的 `lta0001..0600` 和 `ltb0001..0600` 账号,但必须使用独立群 Topic。
## 5. 正式运行
以群聊为例:
```bash
export RUN_ID='single60-group-r1-YYYYMMDD-01'
export SCENARIO='group'
mkdir -p "loadtest/im-pressure/results/${RUN_ID}"
```
终端 1,启动 coordinator:
```bash
./loadtest/im-pressure/im-pressure coordinator \
-listen :8090 \
-run-id "${RUN_ID}" \
-scenario "${SCENARIO}" \
-expected-agents 2 \
-output "loadtest/im-pressure/results/${RUN_ID}/coordinator.json"
```
终端 2,启动逻辑 agent A:
```bash
./loadtest/im-pressure/im-pressure agent \
-config loadtest/im-pressure/config.single-machine.json \
-manifest loadtest/im-pressure/manifest.single-machine.json \
-machine a \
-coordinator http://127.0.0.1:8090 \
-metrics-listen :9105 \
-output "loadtest/im-pressure/results/${RUN_ID}/agent-a-summary.json"
```
终端 3,启动逻辑 agent B:
```bash
./loadtest/im-pressure/im-pressure agent \
-config loadtest/im-pressure/config.single-machine.json \
-manifest loadtest/im-pressure/manifest.single-machine.json \
-machine b \
-coordinator http://127.0.0.1:8090 \
-metrics-listen :9106 \
-output "loadtest/im-pressure/results/${RUN_ID}/agent-b-summary.json"
```
两个 agent 都报告 `600/600 READY` 并稳定通过 30 秒屏障后才会开始。任一 agent 未满连接时 coordinator 会撤销开始时间。
## 6. 指标采集
终端 4 在起压前至少 2 分钟启动:
```bash
./loadtest/im-pressure/im-pressure collect \
-duration 20m -interval 5s \
-coordinator http://127.0.0.1:8090 \
-coordinator-output "loadtest/im-pressure/results/${RUN_ID}/coordinator.json" \
-url agent-a=http://127.0.0.1:9105/metrics \
-url agent-b=http://127.0.0.1:9106/metrics \
-url node=http://127.0.0.1:9100/metrics \
-output "loadtest/im-pressure/results/${RUN_ID}/metrics.jsonl"
```
同时采集 Redis、Kafka、MySQL、Pod CPU/内存/网络、Kafka consumer lag、MySQL 连接池等指标。如本机没有 `node-exporter :9100`,删除 `node` URL,但不影响 agent 指标采集。
## 7. 报告和独立落库核验
```bash
./loadtest/im-pressure/im-pressure merge \
-a "loadtest/im-pressure/results/${RUN_ID}/agent-a-summary.json" \
-b "loadtest/im-pressure/results/${RUN_ID}/agent-b-summary.json" \
-output "loadtest/im-pressure/results/${RUN_ID}/report.json"
export MYSQL_DSN='readonly:password@tcp(mysql-host:3306)/meeting_im?parseTime=true'
export TENANT='006' # 必须与 config.single-machine.json 中的 tenant 一致
./loadtest/im-pressure/im-pressure verify-db \
-coordinator "loadtest/im-pressure/results/${RUN_ID}/coordinator.json" \
-tenant "${TENANT}" \
-dsn-env MYSQL_DSN \
-registry-db hotgo \
-output "loadtest/im-pressure/results/${RUN_ID}/db-verification.json"
```
## 8. 验收门禁
- READY 必须是 `1200/1200`,统计窗口内断连为 0。
- publish 明确拒绝和结果未知均为 0,成功率至少 99.99%。
- ACK P95 不高于 500ms,同机投递 P95 不高于 1000ms。
- 实时缺失、重复、乱序、未知本轮消息均为 0。
- ACK 台账和无 ACK 但已观测台账的历史回查命中率必须 100%,独立 MySQL 核验丢失、错配、重复均为 0。
- 实时发送、历史回查和渐进关闭三个阶段分别统计 Topic/Proxy/Session 队列满,不能只看整轮总数。
## 9. 单机特有风险
1. 两个 agent 共用一台机器的 CPU、内存、FD、NAT 和出口带宽,测到的是“单机压测端 + 服务端”综合上限,不能代替双机最终验收。
2. 正式前做 120/600/1200 连接三档阶梯,观察 agent CPU、`go_goroutines`、FD、接收速率和 Ping/Pong 断连,确认压测端本身没有先饱和。当前工具已将投递观测按 `client_mid` 分片、接收账号顺序状态独立加锁,并使用原子延迟直方图;仍需用指标证明本机有余量。
3. 两个 agent 必须使用不同指标端口,不能同时绑定 `:9105`。
4. 压测域名如被 VPN/TUN 接管,起压前必须确认所有当前 IP 的路由都指向物理网卡。
5. 单机 60% 通过后只能进入双机 100% 验收,不能直接得出 2000 连接容量通过的结论。
断连时同时查看 agent summary 中的 `disconnect_reasons` 和 `disconnect_samples`。前者按 timeout、abnormal close、connection reset 等原因分类计数,后者最多保留 100 条带账号与时间的原始错误样本,用于区分压测端读循环阻塞、网络中断和服务端主动关闭。
@@ -0,0 +1,119 @@
# IM 单机 60% 容量压力测试结果
日期:2026-09-29
## 1. 结论
本轮使用一台物理压测机同时运行逻辑 agent A/B,每侧 600 个 WebSocket,合计 1200 连接。60% 容量的正确性和性能总验收未通过。
- 单聊实时和持久化正确性通过,但 ACK P95 和投递 P95 未达标。
- 群聊在 1200 条群消息、144 万设计投递量下出现 ACK 超时、连接重置和分钟级投递延迟。
- 混合场景明显越过容量拐点,737 个连接断开,仅 44.83% 发送得到 ACK。
- 独立 MySQL 核验确认:三个场景的所有 ACK 消息均精确落库,0 错配、0 重复;所有“无 ACK 但 WS 已观测”消息也均已落库。
- 压测工具本轮峰值 RSS 不超过 127 MB,每个 agent 峰值 FD 614,未显示本机内存或 FD 先饱和。
- 混合实时阶段的群 Topic 所在 `im-server-0` 出现 broadcast queue full,服务端 Topic 处理能力是已证实的瓶颈之一。
## 2. 环境与口径
- 租户:`allrich`
- HTTP/WS 入口:`api.118im.com`
- 物理压测机:1 台
- 逻辑 agent:A/B 各2个中的一个,每侧 600 连接
- 账号:1200,好友对:600,群成员:1200
- API 域名当前 3 个 IP 均经 `en1` 和局域网网关直连,未走 VPN/TUN
- IM Server:3 Pod,无重启;每 Pod request `1 CPU/512Mi`,limit `2 CPU/1Gi`
- 独立群与 manifest:`loadtest/im-pressure/manifest.single-machine.json`
准备阶段最终验证为 `accounts=1200 pairs=600 group_members=1200`。群批量邀请曾出现入口 502 和幂等任务长时间处理中,工具通过按实际成员补齐缺失用户完成恢复。
## 3. 结果汇总
| 场景 | Run ID | ACK | 实时投递 | 断连 | ACK P95 | 投递 P95 | 独立 DB 核验 | 结果 |
|---|---|---:|---:|---:|---:|---:|---:|---|
| 单聊 | `s60-p2p-r2-0929` | 12000/12000 | 12000/12000 | 0 | 1341.555ms | 2000ms | 12000 ACK 全部精确命中 | 性能未通过 |
| 群聊 | `s60-group-r1-0929` | 1145/1200 | 1350508/1374000 ACK 台账 | 47 | 11207.859ms | 94621.882ms | 1145 ACK + 39 无 ACK 落库 | 未通过 |
| 混合 | `s60-mixed-r1-0929` | 1076/2400 | 234829/263657 ACK 台账 | 737 | 17491.634ms | 101226.392ms | 1076 ACK + 1017 无 ACK 落库 | 未通过 |
表中群聊和混合的“期望投递”只按有 ACK 的 ledger 计算。发送结果未知的消息不能在没有 seq 时直接加入期望投递分母,因此该分母小于设计投递量。
## 4. 分场景记录
### 4.1 单聊
R1 的历史查询将 `limit=10` 错误地按目标消息数计算,没有计入 P2P Topic 中双方交错的 seq,造成 4619 条假缺失。修复后 R2 结果:
- 12000 条全部 ACK,无结果未知。
- 12000 次对端投递全部到达,无重复、乱序、断连。
- 12000 条历史回查全部命中,持久化缺失 0。
- ACK P95 1341.555ms,超过 500ms 门禁;同机投递 P95 落在 2000ms 桶,超过 1000ms 门禁。
### 4.2 群聊
- 发送 1200 条,1145 条 ACK,43 条 ACK 超时,12 条 socket 结果未知,无明确拒绝。
- 47 个连接断开:22 个 abnormal close,25 个 connection reset by peer。
- ACK 台账下实时缺失 23492 次,这些消息最终均可从历史查到。
- 55 条结果未知中,39 条被接收端实时观测,且全部被独立 SQL 确认落库;另 16 条既无 ACK、无 WS 观测,也无数据库记录。
- 断连发生时间为 `03:40:10Z` 至 `03:41:15Z`,与群实时发送阶段重合。
### 4.3 混合
- 设计发送为 1800 条单聊和 600 条群聊,合计 2400 条。
- 1076 条 ACK,1324 条结果未知,其中 1036 条 ACK 超时、288 条 socket 失败,无明确拒绝。
- 737 个连接断开:69 个 abnormal close,668 个 connection reset by peer。
- ACK 台账下实时缺失 28828 次,其中 28633 次对应消息最终已查到持久化记录。
- 历史验证 ledger 含 2002 条,命中 1331 条,缺失 671 条全部为 P2P。独立 SQL 确认所有 1076 条 ACK 消息和全部 926 条 WS 已观测的无 ACK 消息均已落库,因此这 671 条是 verifier 连接掉线和 3 分钟总预算造成的验证不完整,不是数据库丢失。
- 1324 条结果未知中,1017 条实际落库:926 条被 WS 观测,另有 91 条未被 WS 观测但已落库。剩余 307 条无 ACK、无 WS 观测、无数据库记录。
### 4.4 独立 MySQL 核验
核验租户 ID 为 8,消息表为 `im_008_messages` 和 `im_008_group_messages_202609`。
| 场景 | ACK expected | 精确命中 | ACK 缺失 | 错配 | 重复 | 无 ACK 但落库 |
|---|---:|---:|---:|---:|---:|---:|
| 单聊 | 12000 | 12000 | 0 | 0 | 0 | 0 |
| 群聊 | 1145 | 1145 | 0 | 0 | 0 | 39 |
| 混合 | 1076 | 1076 | 0 | 0 | 0 | 1017 |
`verify-db` 对无 ACK 但落库的行按 unexpected 报告,所以群聊和混合命令会以非零状态结束;这不代表数据错误,而是发送结果未知但服务端已持久化的重要诊断证据。
## 5. 服务端观测
- `im-server-0/1/2` 全部 Running,本轮重启数均为 0。
- 混合实时阶段 `03:53:00Z..03:53:02Z`,`im-server-0` 记录 55 次 `topic's broadcast queue is full`。
- 混合渐进关闭末尾 `04:03:45Z`,`im-server-0` 再记录 375 次同类队列满。当前 60 秒 close ramp 仍未完全避免关闭风暴。
- 同一观测窗口的 `03:45:13Z`,`im-server-2` 在 `serveIMFriendRequestAction` (`server/im_http.go:6256`) 出现一次 nil pointer panic。该端点不属于群实时发送路径,不将其归因为本轮断连,但需单独修复。
- 当前 kubeconfig 无权读取 metrics-server,未能取得负载窗口的 Pod CPU/内存历史值。
## 6. 压测工具修复
1. 新增单机 60% 配置和执行方案,A/B 各 600 连接。
2. 群聊和混合发送者按 `users_per_machine` 比例缩放,1000 用户的原参数保持不变。
3. 投递观测按 `client_mid` 分为 64 片,每个接收账号独立维护 seq,延迟直方图改为原子计数,移除 WS 读循环中的全局锁争用。
4. summary/report 新增 `disconnect_reasons` 和最多 100 条 `disconnect_samples`。
5. 群创建后立即 checkpoint manifest;批量邀请失败后重读实际成员,仅邀请缺失用户。
6. P2P 历史查询 limit 按 seq 区间扩展,上限 200,修复双方消息交错造成的假缺失。
## 7. 已实施改进
1. 好友申请接受和自动接受路径增加 store 空结果保护。底层异常返回 `nil, nil` 时记录租户、申请、操作人和状态并返回 HTTP 500,不再触发进程 panic。
2. Hub 向已满的 Topic client queue 投递失败时,立即向原发送连接返回 `503 service unavailable`,并记录 topic、tenant、队列长度和容量。客户端可据此重试,压测也不再把这类请求归为等待超时的未知结果。
3. 群消息权限检查改为复用群设置缓存,并为个人禁言增加 Redis 正向和负向缓存;新增禁言和解除禁言会同步更新缓存,到期禁言会自动淘汰。此改动减少群 Topic 串行处理路径上的 MySQL 查询。
4. `message_publish_stage_duration_seconds` 新增 `group/permission_check` 和 `*/realtime_fanout` 阶段,下轮可区分权限查询、持久化和实时扩散耗时。
5. A/B 两个逻辑 agent 按账号顺序交错关闭连接。`close_ramp=60s` 时每个 agent 的本地关闭过程约 120 秒,A/B 相差半个时隙,整体约每 100ms 关闭一个连接,避免两侧同时下线形成双倍尖峰。
6. 已增加空结果防 panic、Topic queue 满返回 503、双 agent 关闭时隙的自动化测试。
## 8. 复测顺序
1. 发版后先跑单机群聊 60%,验收实时发送阶段和关闭阶段 `topic_client` enqueue failed 均为 0;若仍过载,发送端必须收到 503,不能再出现对应的纯 ACK timeout。
2. 对比 `permission_check`、`save`、`realtime_fanout` 三阶段的 P50/P95/P99,确认群权限缓存是否消除了数据库检查瓶颈。
3. 群聊通过后再跑单机混合 60%,要求 ACK、历史和独立 SQL 三方账本一致,连接断开数为 0。
4. 补齐 Prometheus/metrics-server 权限并同步保存 Pod CPU、内存、网络、Kafka lag、Redis、MySQL 指标;在此之前不进行双机 100% 最终验收。
## 9. 产物
- 方案:`docs/test/IM-单机60%容量压力测试方案.md`
- 单聊 R2:`loadtest/im-pressure/results/s60-p2p-r2-0929/`
- 群聊 R1:`loadtest/im-pressure/results/s60-group-r1-0929/`
- 混合 R1:`loadtest/im-pressure/results/s60-mixed-r1-0929/`
结果目录包含 coordinator、A/B summary、合并 report 和 agent 指标 JSONL。