指标与日志接入

IM 的故障表现往往是“慢”而不是“挂”:投递延迟从 30ms 涨到 2s、某台网关的连接数悄悄掉了三成、重发队列在堆积——这些都不抛异常,只会在指标曲线上显形。本章给出 im-go 的 Prometheus(指标系统)指标定义、Go 侧 promhttp 接入代码、Grafana 面板与告警阈值,以及日志该记什么、怎么采样。

本章目标

  • 定义连接、消息、可靠性、资源四类指标,且标签基数可控;
  • 在 cmd/gateway 与 cmd/logic 里用 promhttp 暴露 /metrics,并与业务端口隔离;
  • 给出 Grafana 面板的 PromQL 与告警阈值建议。

指标设计:四类信号

类别指标类型标签回答的问题
连接im_online_connections / im_conn_total / im_conn_duration_secondsGauge / Counter / Histogramgateway_id,result现在多少人在线、建连断连是否抖动
消息im_upstream_msg_total / im_downstream_msg_totalCountermsg_type / stage上行吞吐、下行按 push/offline 拆分
消息im_msg_deliver_latency_secondsHistogramstage,result端到端投递耗时分位
可靠性im_ack_pending / im_msg_resend_totalGauge / Countergateway_id / attempt待确认堆积、重发量与次数分布
可靠性im_deliver_failed_total / im_dedup_hit_totalCounterreason / key_type放弃投递的原因、去重命中
离线im_offline_backlog / im_offline_pull_sizeGauge / Histogramshard / 无收件箱积压、单次拉取条数
资源process_resident_memory_bytes、go_goroutinesGauge无内存与协程数(默认收集器提供)

标签基数是唯一的红线:uid、conv_id、msg_id、conn_id 绝不能做标签——百万在线 × 每人一个标签就是百万条时间序列,Prometheus 会先炸;维度只保留枚举型(stage、result、reason)与数量由机器数决定的 gateway_id。

指标定义与埋点

// pkg/metrics/metrics.go:统一在独立 registry 里定义,避免默认注册表导致重复注册 panic
var Registry = prometheus.NewRegistry()

var (
	OnlineConns = promauto.With(Registry).NewGaugeVec(prometheus.GaugeOpts{
		Name: "im_online_connections", Help: "当前在线连接数,按接入机实例统计"}, []string{"gateway_id"})
	MsgDeliverLatency = promauto.With(Registry).NewHistogramVec(prometheus.HistogramOpts{
		Name: "im_msg_deliver_latency_seconds", Help: "logic 收到上行到网关写出的耗时",
		Buckets: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2}}, []string{"stage", "result"})
	DownstreamMsg = promauto.With(Registry).NewCounterVec(prometheus.CounterOpts{
		Name: "im_downstream_msg_total", Help: "下行消息条数,stage=push/offline"}, []string{"stage"})
	DeliverFailed = promauto.With(Registry).NewCounterVec(prometheus.CounterOpts{
		Name: "im_deliver_failed_total", Help: "投递失败,reason=ack_timeout/no_route/conn_closed"}, []string{"reason"})
)

func init() { // 进程指标(CPU、内存、GC、goroutine、fd)是排障主力,务必注册
	Registry.MustRegister(collectors.NewGoCollector(), collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))
}

// gateway/writer.go:延迟只在写出之后观测,失败也记一条,否则 P99 只统计成功路径会“很好看”
// 连接数则用 sampleLoop 每秒采样一次写入 OnlineConns,不在建连/断连的热点路径上抢锁
func (w *Writer) writeFrame(f *Frame) {
	start, result := time.Now(), "ok"
	if err := w.conn.WriteMessage(websocket.BinaryMessage, f.Payload); err != nil {
		result = "err"
		metrics.DeliverFailed.WithLabelValues("conn_closed").Inc()
	}
	metrics.MsgDeliverLatency.WithLabelValues("push", result).Observe(time.Since(start).Seconds())
}

promhttp 接入

/metrics 走独立端口(如 :9101)并只绑定内网地址,不要挂在业务网关的 :8080 上——否则抓取流量会混进业务 QPS,一旦公网可达还等于把内部拓扑暴露出去。Prometheus 侧只要一个 job_name: im-gateway、scrape_interval: 15s 的抓取任务指向各实例的 9101。

// cmd/gateway/main.go
func startMetrics(addr string) *http.Server {
	mux := http.NewServeMux()
	mux.Handle("/metrics", promhttp.HandlerFor(metrics.Registry, promhttp.HandlerOpts{
		EnableOpenMetrics: true, MaxRequestsInFlight: 4, Timeout: 5 * time.Second,
	}))
	mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
	srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 3 * time.Second}
	go func() { _ = srv.ListenAndServe() }() // 监听 127.0.0.1:9101,与业务端口分离
	return srv
}

日志该记什么

事件必备字段级别
连接建立 / 断开event=conn_open|conn_close、uid、device_id、gateway_id、conn_id、client_ip、reasoninfo
上行处理与异常event=msg_up|deliver_fail|dedup_hit|pull_slow、trace_id、uid、conv_id、msg_id、seq、reason、attempt、cost_ms、rowsinfo / warn

两条纪律:成功路径不打印消息体(体积与隐私双输,只在失败时按 1% 采样打印截断 body);日志必须带轮转(lumberjack 单文件 200MB、保留 7 天),否则磁盘写满会是下一个故障源。

Grafana 面板与 PromQL

面板PromQL
在线连接数(按实例堆叠)sum by (gateway_id) (im_online_connections)
建连/断连速率sum by (result) (rate(im_conn_total[5m]))
投递延迟 P50/P95/P99histogram_quantile(0.99, sum by (le, stage) (rate(im_msg_deliver_latency_seconds_bucket[5m])))
投递失败率 / 重发率sum(rate(im_deliver_failed_total[5m])) / clamp_min(sum(rate(im_downstream_msg_total[5m])), 1)(重发率把分子换成 im_msg_resend_total)
待确认堆积 / 离线积压 / 内存sum by (gateway_id) (im_ack_pending)、topk(5, im_offline_backlog)、process_resident_memory_bytes{job="im-gateway"}/1024/1024

histogram_quantile 必须配 sum by (le, ...):先对单实例桶求分位再取平均,会得到没有意义的值。

告警阈值建议

告警表达式要点阈值 / 持续级别
网关不可达up{job="im-gateway"} == 01 分钟P0
投递失败率过高失败总数 / 下行总数> 1%,2 分钟P0
在线数骤降 / 投递 P99 偏高与 10 分钟前比 / stage="push" 的分位值< 80% 持续 3 分钟 / > 500ms 持续 3 分钟P1
待确认堆积 / 重发率sum(im_ack_pending) / 重发总数 ÷ 下行总数> 5 万 / > 5%,5 分钟P2
离线积压 / 单机内存sum(im_offline_backlog) / process_resident_memory_bytes> 500 万 / > 3GB,15 分钟P2
# deploy/prometheus/im-rules.yml:表里每一条都按这个结构落地
groups:
  - name: im-alerts
    rules:
      - alert: IMDeliverFailRateHigh
        expr: sum(rate(im_deliver_failed_total[5m])) / clamp_min(sum(rate(im_downstream_msg_total[5m])), 1) > 0.01
        for: 2m
        labels: { severity: P0 }

分级规则:P0 打电话(在线数归零、失败率超线),P1 群里通知(延迟与骤降),P2 进工单(水位与趋势);每条告警都要能对应一条可执行命令(第 15 篇排障手册),否则运维会习惯性忽略。

验证方式

curl -s http://127.0.0.1:9101/metrics | grep -c '^im_'          # 期望 > 10,只有三四条说明埋点没接上
curl -s http://127.0.0.1:9101/metrics | grep -E '^im_(online_connections|ack_pending)'
# 期望:im_online_connections{gateway_id="gw-1"} 128
promtool check rules deploy/prometheus/im-rules.yml              # 期望:SUCCESS: 1 rules found
# 行为验证:kill 掉一台网关,15 秒内 im_online_connections{gateway_id="gw-1"} 掉到 0 并触发告警

常见坑

  • 标签基数爆炸:为“方便排查”给指标加 uid 或 msg_id 标签,几小时内 Prometheus 就 OOM;按用户查要用日志,不要用指标。
  • 在热点路径写 Gauge:每条连接都维护连接数 Gauge 会引入锁竞争,改为定时采样;Counter 的 Inc() 则无此问题。
  • 只在成功路径 Observe:失败请求不记延迟,P99 会非常好看,故障时反而“指标正常”;失败也要记 result="err"。
  • 重复注册 panic / 桶选得不对 / 端口混用:多个包在 init() 往同一 registry 注册同名指标会让进程起不来;默认桶最细 5ms、最粗 10s,看不出 IM 关心的 5~50ms 差异,桶应在业务阈值附近加密;/metrics 与业务端口混用会污染业务指标、公网可达还会泄露实例信息,拿日志算 QPS 的口径也永远对不上指标。

小结:监控的目标是让“慢”也能被看见。im-go 的指标分连接、消息、可靠性、资源四类,用独立 registry 定义并只保留枚举型标签(uid、msg_id 严禁做标签),/metrics 走内网独立端口由 promhttp 暴露;埋点集中在建连断连、写帧完成、落库完成与重发扫描四处,连接数用每秒采样而非逐条维护;日志结构化并统一字段与 trace_id,只记事件与关键 ID、不记全量消息体;Grafana 面板用 histogram_quantile(0.99, sum by (le, ...)) 出 P99 曲线,告警阈值按“用户能感知”定(P99 > 500ms、失败率 > 1%、待确认 > 5 万、重发率 > 5%),并定期演练确认它真的会响。

笔记加载中…