可观测性:指标、日志与链路追踪
IM 系统最典型的故障场景是"用户说消息没收到"——而服务端所有接口都返回成功。这类问题只能靠可观测性闭环解决:指标告诉你哪里不对劲,日志告诉你发生了什么,链路追踪告诉你卡在哪一跳。本章给出指标清单(含准确名字与 label 设计)、日志字段规范,以及 traceId 从接入层贯穿逻辑层的落地方式。
IM 可观测性的三个特殊之处
- 连接是长生命周期的:请求级指标(QPS、成功率)不够用,必须单独看连接数、连接存活时长、重连速率。
- 投递是异步多跳的:上行 ACK 快不代表下行送达,必须分别度量"上行处理时长"与"端到端投递延迟"。
- 降级是常态:路由表过期、推送延迟都属预期内的退化,指标要能区分"正常退化"与"真故障"。
指标清单
命名统一前缀 ims_,单位后缀遵循 Prometheus 约定(_seconds、_total、_bytes):
| 指标名 | 类型 | 口径 | 用途与阈值 |
|---|---|---|---|
ims_gateway_conn_current | Gauge | 已鉴权且心跳未超时的连接数 | 容量水位,超过单机 8 万即扩容 |
ims_gateway_conn_total{result} | Counter | result=auth_ok/auth_fail/closed | 连接成功率与重连风暴识别 |
ims_gateway_conn_duration_seconds | Histogram | 连接从建立到断开的存活时长 | 大量秒级断开说明客户端异常重连 |
ims_uplink_msg_total{type,result} | Counter | 上行消息条数,result=ok/ratelimited/rejected | 上行 QPS 与风控拦截率 |
ims_delivery_latency_seconds | Histogram | 发送方 ACK 到接收方 ACK 的时间差 | 端到端投递延迟 P99 < 200ms |
ims_delivery_fail_total{reason} | Counter | reason=offline/timeout/route_missing/send_err | 投递失败归因,route_missing 突增即路由表异常 |
ims_ack_timeout_total | Counter | 下行发出后 hold 窗口内未收到 ACK | 半开连接与弱网规模 |
ims_offline_backlog_size | Gauge | 离线队列待投递消息数 | 积压 > 10 万即告警 |
ims_offline_backlog_lag_seconds | Gauge | 队列头部消息的堆积时长 | 比条数更能反映用户真实等待时间 |
ims_push_submit_total{vendor,result} | Counter | 提交厂商通道的条数,result=ok/rate_limited/invalid_token | 分通道成功率,定位单厂商故障 |
ims_push_arrive_total | Counter | 厂商回执判定为已送达的条数 | 到达率分子,与上一个指标同分母 |
ims_push_arrive_ratio | Gauge | arrive_total ÷ submit_total(见 21 章的到达率口径) | 到达率 < 90% 告警,< 80% 电话 |
ims_route_lookup_latency_seconds | Histogram | 路由表查询耗时 | P99 > 20ms 说明 Redis 有问题 |
ims_mq_consume_lag | Gauge | MQ 各消费组堆积 | 扇出/落库消费是否跟得上 |
go_goroutine_count、go_gc_duration_seconds | Gauge/Summary | 运行时指标 | 连接泄漏的先行指标 |
label 纪律:只允许低基数标签(type、result、vendor、gateway_id 的前缀或分片号),严禁把 uid、conv_id、msg_id 放进 label,否则时间线数量会以百万计把 Prometheus 打爆。需要按用户排查的问题交给日志与追踪。
关键告警规则
groups:
- name: im-slo
rules:
- alert: DeliveryP99High
expr: histogram_quantile(0.99, sum(rate(ims_delivery_latency_seconds_bucket[5m])) by (le)) > 0.2
for: 5m
labels: { severity: P1 }
annotations: { summary: "端到端投递 P99 超过 200ms" }
- alert: PushArriveRateLow
expr: sum(rate(ims_push_arrive_total[10m])) / sum(rate(ims_push_submit_total[10m])) < 0.9
for: 10m
labels: { severity: P0 }
- alert: OfflineBacklogGrowing
expr: ims_offline_backlog_lag_seconds > 60
for: 5m
labels: { severity: P1 }
- alert: ConnDropSpike
expr: sum(rate(ims_gateway_conn_total{result="closed"}[1m])) > 3000
for: 2m
labels: { severity: P0 }
告警分三级:P0(大面积掉线、投递失败率 > 1%、推送到达率 < 80%)电话;P1(P99 超阈值、离线积压、单分片写入延迟高)即时通讯;P2(容量水位、慢查询增长)工单。所有告警必须写清"看到后第一步做什么",否则半夜被叫醒的人只会重启服务。
日志规范
结构化 JSON,字段名固定,禁止自由发挥:
{"ts":"2026-02-11T10:23:45.123+08:00","level":"error","trace_id":"a1b2c3d4e5f6",
"span":"logic.send","uid":10086,"device_id":"d-8f2a","conv_id":7788,
"msg_id":184910233,"seq":90231,"gateway_id":"gw-03","cost_ms":83,"code":5003,
"err":"mysql write timeout","msg_hash":"9f2c1a4b"}
四条纪律:正文不入日志,只记 msg_id 与 msg_hash;心跳与探活日志直接关掉(它们会占掉 90% 的量);正常路径 1% 采样、错误路径 100% 保留;日志保留分级(错误 30 天、info 7 天、debug 只在按需开启时有)。
日志量必须算过,否则磁盘先炸:
5 万 QPS × 每条 3 行 × 每行 400B = 60 MB/s ≈ 5 TB/日(全量,不可接受)
采样后:正常 1% + 错误全留 → 约 100 GB/日,保留 7 天 ≈ 700 GB
压缩比按 5:1 估算 → 实际磁盘约 150 GB
链路追踪:traceId 贯穿 gateway → logic
traceId 在接入层收到帧的那一刻生成(客户端已带 trace_id 时优先沿用,便于端到端串联),此后所有内网调用必须透传:
// gateway:为每条上行消息建立 trace 上下文
func (h *Handler) onFrame(conn *Conn, frame *Frame) {
traceID := frame.Header.TraceID
if traceID == "" {
traceID = trace.NewID() // 32 位十六进制随机串
}
ctx := trace.WithContext(context.Background(), traceID)
ctx = metadata.AppendToOutgoingContext(ctx, "x-trace-id", traceID) // 透传给 logic
resp, err := h.logic.OnMessage(ctx, req)
log.From(ctx).Info("uplink", zap.Int64("cost_ms", time.Since(start).Milliseconds()))
}
异步链路最容易断链,必须显式传播:写 MQ 时把 traceId 放进消息属性(Kafka header、RocketMQ 的 property),消费端读出后重建 span。一条消息的完整 span 树应该是:gateway.recv → logic.send → route.lookup → mysql.insert → mq.produce → fanout.consume → gateway.push,任何一环缺失都意味着这里断链,排查时只能靠猜。
采样策略用尾部采样(tail-based):错误请求全留、耗时超阈值的全留、正常请求 1%,由 collector 侧决定,而不是在客户端 SDK 侧提前丢弃——否则一次故障恰好没被采样到,就是白干。
排障手册:用户说"消息没收到"
| 现象 | 先看指标 | 常见原因 |
|---|---|---|
| 消息延迟高 | ims_delivery_latency_seconds P99 + ims_mq_consume_lag | 落库慢、扇出积压、GC 停顿 |
| 大面积掉线 | ims_gateway_conn_current 断崖 + conn_total{result="closed"} 飙升 | 发布、网关过载、负载均衡抖动 |
| 推送收不到 | ims_push_arrive_ratio + 分厂商 push_submit_total{result} | token 失效、厂商限流、配额超限 |
| 个别用户漏消息 | ims_delivery_fail_total{reason="route_missing"} | 路由表过期、重连期间投递空窗 |
| 消息重复 | ims_uplink_msg_total 与消息表去重命中数 | 客户端重发、幂等键失效 |
排障固定路径:按 msg_id 或 trace_id 依次确认——上行是否落库 → 路由是否有节点 → 下行是否发出 → ACK 是否收到 → 是否转推送 → 厂商回执状态。六步走完还没结论,再去看机器资源,而不是一上来就重启。
常见坑
- label 基数爆炸:把
uid写进 label,Prometheus 内存几分钟就见底。 - 只看平均值:平均 50ms 掩盖不了 P99 是 5s,IM 的一切延迟指标都要看分位。
- 把连接数当在线用户数:多端登录与半开连接会让两者差 3 倍以上(01 章的口径)。
- 日志打明文消息与 token:既泄密又让日志量翻倍。
- 只埋同步链路:MQ 之后的操作完全不追踪,异步问题只能靠猜。
- 监控自己没被监控:Prometheus 磁盘写满导致全部告警静默,必须给监控链路本身配外部探活。
- 告警没有动作指引:只报"P99 超标"而不给排查入口的告警,等于制造噪音。
小结:IM 的可观测性以"连接 + 投递"两条主线组织指标,指标名与 label 必须低基数可控,端到端投递延迟 P99、投递失败率、离线积压时长与推送到达率是四个必须告警的黄金指标;日志用固定字段的结构化 JSON 并配合采样把日增量压到可承受范围;追踪的关键是 traceId 在接入层生成、经 gRPC 元数据与 MQ 消息属性全程透传,形成 gateway 到 push 的完整 span 树,并配合尾部采样保证故障不丢样本。