指标与日志接入
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_seconds | Gauge / Counter / Histogram | gateway_id,result | 现在多少人在线、建连断连是否抖动 |
| 消息 | im_upstream_msg_total / im_downstream_msg_total | Counter | msg_type / stage | 上行吞吐、下行按 push/offline 拆分 |
| 消息 | im_msg_deliver_latency_seconds | Histogram | stage,result | 端到端投递耗时分位 |
| 可靠性 | im_ack_pending / im_msg_resend_total | Gauge / Counter | gateway_id / attempt | 待确认堆积、重发量与次数分布 |
| 可靠性 | im_deliver_failed_total / im_dedup_hit_total | Counter | reason / key_type | 放弃投递的原因、去重命中 |
| 离线 | im_offline_backlog / im_offline_pull_size | Gauge / Histogram | shard / 无 | 收件箱积压、单次拉取条数 |
| 资源 | process_resident_memory_bytes、go_goroutines | Gauge | 无 | 内存与协程数(默认收集器提供) |
标签基数是唯一的红线: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、reason | info |
| 上行处理与异常 | event=msg_up|deliver_fail|dedup_hit|pull_slow、trace_id、uid、conv_id、msg_id、seq、reason、attempt、cost_ms、rows | info / 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/P99 | histogram_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"} == 0 | 1 分钟 | 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%),并定期演练确认它真的会响。