可观测性:指标、日志与链路追踪

IM 系统最典型的故障场景是"用户说消息没收到"——而服务端所有接口都返回成功。这类问题只能靠可观测性闭环解决:指标告诉你哪里不对劲,日志告诉你发生了什么,链路追踪告诉你卡在哪一跳。本章给出指标清单(含准确名字与 label 设计)、日志字段规范,以及 traceId 从接入层贯穿逻辑层的落地方式。

IM 可观测性的三个特殊之处

  • 连接是长生命周期的:请求级指标(QPS、成功率)不够用,必须单独看连接数、连接存活时长、重连速率。
  • 投递是异步多跳的:上行 ACK 快不代表下行送达,必须分别度量"上行处理时长"与"端到端投递延迟"。
  • 降级是常态:路由表过期、推送延迟都属预期内的退化,指标要能区分"正常退化"与"真故障"。

指标清单

命名统一前缀 ims_,单位后缀遵循 Prometheus 约定(_seconds、_total、_bytes):

指标名类型口径用途与阈值
ims_gateway_conn_currentGauge已鉴权且心跳未超时的连接数容量水位,超过单机 8 万即扩容
ims_gateway_conn_total{result}Counterresult=auth_ok/auth_fail/closed连接成功率与重连风暴识别
ims_gateway_conn_duration_secondsHistogram连接从建立到断开的存活时长大量秒级断开说明客户端异常重连
ims_uplink_msg_total{type,result}Counter上行消息条数,result=ok/ratelimited/rejected上行 QPS 与风控拦截率
ims_delivery_latency_secondsHistogram发送方 ACK 到接收方 ACK 的时间差端到端投递延迟 P99 < 200ms
ims_delivery_fail_total{reason}Counterreason=offline/timeout/route_missing/send_err投递失败归因,route_missing 突增即路由表异常
ims_ack_timeout_totalCounter下行发出后 hold 窗口内未收到 ACK半开连接与弱网规模
ims_offline_backlog_sizeGauge离线队列待投递消息数积压 > 10 万即告警
ims_offline_backlog_lag_secondsGauge队列头部消息的堆积时长比条数更能反映用户真实等待时间
ims_push_submit_total{vendor,result}Counter提交厂商通道的条数,result=ok/rate_limited/invalid_token分通道成功率,定位单厂商故障
ims_push_arrive_totalCounter厂商回执判定为已送达的条数到达率分子,与上一个指标同分母
ims_push_arrive_ratioGaugearrive_total ÷ submit_total(见 21 章的到达率口径)到达率 < 90% 告警,< 80% 电话
ims_route_lookup_latency_secondsHistogram路由表查询耗时P99 > 20ms 说明 Redis 有问题
ims_mq_consume_lagGaugeMQ 各消费组堆积扇出/落库消费是否跟得上
go_goroutine_count、go_gc_duration_secondsGauge/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 树,并配合尾部采样保证故障不丢样本。

笔记加载中…