写在最前面:这一章和前面有什么不一样
前面七章,我们一直在写代码:搭框架、接数据库、发消息、做点赞。从这一章开始,画风变了——我们要回答一个新问题:代码上线跑起来之后,怎么知道它健不健康?
这是"写代码"到"运维系统"的转折点。你不需要提前懂任何监控工具,本章会把每一个陌生名词(可观测性、指标、PromQL、链路追踪、Span……)就地讲清楚,不会让你去搜。
学完这一章,你的服务才算是"可运维"的:你能看见它的体温、能查到一次慢请求卡在哪、能在它快挂的时候收到告警。
如果你想要「高密度速查版」,可以看原版第 08 章 ;两版知识点完全一致,只是讲法不同。
一、本章导读
上一章我们把 Kafka 拉进了系统,内容社区已经能异步处理点赞、发帖这类消息了。到这里,功能上"该有的都有"了。
但系统一旦上线,新的难题立刻浮现:某个接口变慢、某次请求失败、某个服务节点悄悄挂掉,你怎么知道是哪里出了问题? 靠用户投诉?等用户来骂你,已经晚了。靠翻日志?日志像大海捞针,没有"全局视角"你根本不知道该往哪捞。
这一章要建立的,是后端工程的"感知系统"——可观测性(Observability)。它由三根支柱构成:
- 日志(Log) 回答"发生了什么具体的事故"
- 指标(Metrics) 回答"系统整体表现怎么样"
- 链路追踪(Trace) 回答"这一次请求到底经历了什么"
我们会用 Prometheus 做指标埋点,用 OpenTelemetry 把一次请求从头到尾穿起来,用 Grafana 把数据画成能看的大盘和能叫人的告警。学完本章,你的服务才真正"可运维",也为后面微服务阶段的链路追踪打下基础。
下一章我们继续做业务——榜单与分布式任务调度。但在那之前,先把"眼睛和耳朵"装上。
开读前先看一眼全局:可观测性三支柱——指标(Metrics)、日志(Logging)、追踪(Tracing)。本章要做的就是给业务代码装上这三类探针,再汇聚到一块 Grafana 看板、一套告警规则。之后每一章出了问题,都靠下面这条链路排查。
flowchart LR
A["业务代码埋点"] --> B["Metrics 指标
QPS / 延迟 / 错误率"]
A --> C["Logging 日志
结构化输出"]
A --> D["Tracing 链路
trace 贯穿一次请求"]
B --> E["Prometheus
定时拉取存储"]
C --> F["日志收集"]
D --> G["Trace 收集"]
E --> H["Grafana 看板"]
F --> H
G --> H
E --> I["Alertmanager
阈值触发告警"]这张图怎么读:源头只有一个是 A(业务代码埋点),它扇出三条支线——B(Metrics 指标)、C(Logging 日志)、D(Tracing 链路);三条线各自走收集链路,其中指标线最特殊:先由 E(Prometheus)定时拉取存储,再分两路——去 H(Grafana 看板)做可视化、去 I(Alertmanager)做阈值告警;日志(F)与链路(G)只汇入 Grafana 统一展示。排查问题时把这张图正着走一遍:先在 Grafana 发现异常,再顺着对应支柱往下钻。
二、本章知识点大纲
先把地图看一遍,知道这一章要往哪走、走到哪算够。
必会(学完必须能独立写出来)
- 用
prometheus/client_golang在 Go 程序里定义并注册一个 Counter / Gauge / Histogram,并在业务代码里Inc()/Observe()。 - 写一个 Gin 中间件,用 Histogram 统计每个接口的响应时间 P99,用 Gauge 统计当前活跃请求数。
- 用 GORM 的 Callback 机制统计每个表的 SQL 耗时(Histogram)。
- 用
redis.Hook统计 Redis 命令数与缓存命中率。 - 用 OpenTelemetry 手动给一个函数打 Span(
Start+defer End),并在 Zipkin 里看到它的耗时横条。
理解(能讲清原理和取舍,不要求背代码)
- 可观测性三支柱各自回答什么问题、怎么配合定位一个线上故障。
- 拉模型(Prometheus)和推模型(Zabbix / StatsD)的区别与取舍。
- 四种指标类型的适用场景,以及为什么多实例部署要优先用 Histogram 而不是 Summary。
- 什么是分位数 P99,为什么平均值会"骗人"。
context.Context在链路追踪里的作用——TraceID / SpanID 是怎么跨进程传播的。
了解(知道有这回事,面试能说出名字和一句话定位)
- PromQL 是 Prometheus 的查询语言,Grafana 的每个面板本质上就是一条 PromQL。
- namespace / subsystem / name 是 Prometheus 指标的命名三段式。
- 分段式错误码设计,以及"只有 5 开头(服务端错误)才值得告警"。
前置依赖与补课清单
本章用到的技术里,有一部分是前文从没讲过的。它们在正文里都会以「补课胶囊」就地补齐,不用你去别处查。
| 需要你已掌握 | 本章会不会补 | 补在哪一节 |
|---|---|---|
| Gin / GORM / Redis 基本用法(第 02-06 章) | ❌ 不补,这是真前提 | — |
| Docker / Docker Compose 起服务(前面用过) | ⚠️ 只补 Prometheus 的启动命令 | 3.3.2 节 |
| 什么是"监控"、什么是"可观测性" | ✅ 会补 | 3.1 节 补课胶囊 |
| 什么是日志 / 指标 / 链路追踪 | ✅ 会补 | 3.2 节 |
| 什么是分位数 P99 | ✅ 会补 | 3.6 节 补课胶囊 |
| 什么是 Trace / Span / TraceID / SpanID | ✅ 会补 | 3.11 节 补课胶囊 |
context.Context 是什么 | ✅ 会补 | 3.13 节 补课胶囊 |
不需要你提前懂的:Prometheus、PromQL、OpenTelemetry、Grafana、W3C Trace Context 协议。本章从零讲。
预计时长与学习成果
预计时长:5 ~ 6 小时(精读 + 跟着敲全部代码);如果只求主干,2.5 小时可过一遍核心概念。
学完你能跑出来的东西(这是本章的验收标准,做到才算过关):
一个挂了 Prometheus 指标 + OTel 链路追踪的 Gin 服务:访问几个接口后,能在
/metrics里看到响应时间 P99、活跃请求数、GORM 查询耗时、Redis 命中率,能在 Zipkin 里看到一条完整调用链,能在 Grafana 里配一条"活跃请求数超阈值持续 N 分钟"的告警。
flowchart LR
A["3.1 可观测性是什么"] --> B["3.2 三支柱"]
B --> C["3.3 Prometheus 基础
拉模型 / 安装 / PromQL"]
C --> D["3.4 四种指标类型"]
D --> E["3.7 Gin 埋点"]
E --> F["3.8 GORM 监控"]
F --> G["3.9 错误码 / 3.10 Redis"]
G --> H["3.11~3.15 OTel 链路追踪"]
H --> I["3.16 Grafana 告警"]
I --> J["第四节 跟着写
可观测的 Gin 服务"]
J --> K["第七节 面试问答"]这张图怎么读:这是本章的学习路线,箭头即先后顺序:A→B 先补两个认知概念(可观测性是什么、三支柱怎么分工);C→D 进入 Prometheus 基础(拉模型、安装、PromQL)与四种指标类型;E→G 是三条无侵入埋点实战——Gin 中间件、GORM Callback、Redis Hook,中间穿插错误码设计;H(3.11~3.15)把 OTel 链路追踪从概念讲到 ctx 传播与跨服务;最后 I(3.16 Grafana)把数据画成看板并配告警,收束到"第四节 跟着写"与"第七节 面试问答"。埋点三节(E→G)与链路五节(H)是动手重点。
三、正文
3.1 补课胶囊:什么是"可观测性"
一句话:可观测性 = 不用改代码、不用重新发布,光靠系统对外暴露的"行为数据"就能推断它内部到底怎么了的能力。
类比:监控像"我知道温度计坏了要盯哪个灯"——你提前知道要盯什么;可观测性像"医生用 CT + 验血 + 听诊,从一堆你没预料到的症状里反推出病因"——你没预料到的问题也能查。
最小示例:下面这三行,就是可观测性在问的三个问题——
日志:2023-01-20 14:11 ERROR 用户 123 登录失败,cause=DB timeout
指标:当前活跃请求数 857,P99 响应时间 2100ms(平时 80ms)
链路:一次 /login 请求里,卡在"查 DB 用户表"那一步 2 秒
回到主线:监控和可观测性经常混用,但区别很重要——监控是"盯已知的红灯",可观测性是"从未知的症状反推病因"。本章三支柱就是可观测性的具体落地。
先抛问题:很多新手以为"接个监控平台"就等于"系统可观测了"。其实不一定。如果你的平台只能显示你预先埋好的几个指标,那它只是"监控"——一旦出了你没预料到的幺蛾子(比如某个你没想到会慢的接口突然变慢),你就两眼一抹黑。可观测性要的是:不管出什么稀奇古怪的问题,你都能从数据里把原因挖出来。
给个直觉:把系统想象成一辆你没法直接掀开引擎盖的车。
- 监控 = 仪表盘上你自己装的那几个红灯:机油灯亮了 = 机油问题。但你没装"水温灯"?那水温炸了你也不知道。
- 可观测性 = 给车装了完整的传感器 + 黑匣子 + 轨迹记录仪,任何异常你都能回放分析,哪怕是你从没见过的故障。
小收口:可观测性是目标,三支柱(日志/指标/链路)是达成目标的三种数据。下面一节就拆解这三根柱子各自干啥。
3.2 可观测性三支柱:日志 / 指标 / 链路追踪
先抛问题:既然要"感知系统",那到底要感知哪几类信息?为什么是三根柱子、少一根行不行?
给个直觉:可观测性就像给一辆车上 仪表盘 + 行车记录仪 + 导航轨迹:
- 仪表盘(Metrics 指标):告诉你"车速、油量"这种整体数字,一眼看出不对劲。
- 导航轨迹(Tracing 链路):记录"你从哪拐到哪",出问题能定位到哪一段路。
- 行车记录仪(Logging 日志):记下"具体发生了什么事故",比如"追尾了,对方车牌 xxx"。
三者配合,你才能在车坏了时既知道"坏了",又知道"坏在哪、怎么坏的"。
最小可运行示例:一张表先区分三者回答的问题:
| 支柱 | 回答什么 | 典型工具 | 数据特征 |
|---|---|---|---|
| Logging 日志 | 「发生了什么」 | ELK、Loki | 离散事件,可读性强,量大 |
| Metrics 指标 | 「整体表现如何」 | Prometheus、Grafana | 时序数据,可聚合,低成本 |
| Tracing 链路追踪 | 「请求怎么走的」 | Jaeger、Zipkin、OpenTelemetry | 树形结构,跨服务 |
逐行讲解 / 三者怎么配合定位问题:
flowchart TD
A[系统报警] --> M[Metrics 看整体
发现 P99 飙升]
M --> T[Tracing 定位
慢在哪一跳]
T --> L[Logging 查细节
具体错误是什么]一句话记住:Metrics 告诉你"有问题",Tracing 告诉你"问题在哪一段",Logging 告诉你"具体的错误是什么"。一个完整的可观测性体系三者缺一不可——缺指标你不知道"全局坏没坏",缺链路你不知道"坏在哪个服务",缺日志你不知道"具体什么错"。
常见坑:
小收口:三支柱里,本章重点讲指标(Prometheus)和链路(OpenTelemetry)。日志你前面已经用 log / zap 写过不少了,它就是"发生了什么"的记录,这里不再展开。下一节进 Prometheus。
3.3 Prometheus 基础
Prometheus 是当今最主流的**指标(Metrics)**采集与存储系统。它既负责"去哪采数据",也自带时序数据库存数据,还能写 PromQL 查询。
3.3.1 拉模型 vs 推模型(补课胶囊)
一句话:拉模型 = Prometheus 主动去你的应用"拉"数据;推模型 = 你的应用主动把数据"推"给监控中心。
类比:拉模型像抄表员每月上门读电表;推模型像你每月自己跑营业厅报电表数。
最小示例:Prometheus 的核心配置就是"告诉它去哪些地址拉"——
# prometheus.yml 里的 scrape_configs,每个 job 就是一组要拉取的地址
scrape_configs:
- job_name: "webook"
static_configs:
- targets: ["host.docker.internal:8081"] # 应用暴露 /metrics 的地址
回到主线:Prometheus 选了拉模型,理解这个选择才能理解后面"应用为什么要暴露 /metrics 接口"。
先抛问题:应用产生了指标数据,监控中心怎么拿到?两条路——推和拉。
给个直觉:
- 推模型(Zabbix / StatsD 用):每个应用自己配置"往哪推、推失败怎么办"。服务一多,每个应用都得配一遍推送地址,配置会爆炸;而且应用挂了,它就停止推送,监控中心反而以为"一切正常、只是没数据"。
- 拉模型(Prometheus 用):应用只管暴露一个
/metrics接口,Prometheus 统一去各个地址定时拉。新增一个服务?只在 Prometheus 配一个 target 即可。
一张图看清拉模型数据流:
flowchart LR
App[应用暴露 /metrics] -->|Prometheus 定时拉取| P[(Prometheus
时序数据库)]
P --> G[Grafana 查询展示]
P --> A[告警规则评估]为什么是"拉"而不是"推"(工程视角):
/metrics 就行。代价是 Prometheus 自己要扛采集压力,超大规模时得用分片(Thanos / Cortex)。常见坑:
/metrics 请求可能被挤占,Prometheus 拉取超时,指标缺失。本章 3.3.3 会教你怎么单独起一个端口暴露指标。小收口:记住一句话——Prometheus 是拉模型,应用要暴露 /metrics,Grafana 只负责查和画。接下来把它跑起来。
3.3.2 Docker 安装与最小配置
先抛问题:怎么把 Prometheus 跑起来?前面章节你用过 Docker,这里直接上 docker-compose。
最小可运行示例:
# 文件路径: docker-compose.yaml(项目根目录)
services:
prometheus:
image: prom/prometheus
ports:
- "9090:9090" # Prometheus 自己的查询界面
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml # 挂载配置文件
配套的配置文件:
# 文件路径: prometheus.yml
global:
scrape_interval: 15s # 全局采集间隔,高并发应用不宜太频繁
evaluation_interval: 15s # 告警规则评估间隔
scrape_configs:
- job_name: "webook"
static_configs:
- targets: ["host.docker.internal:8081"] # 应用 /metrics 端口
跑起来:
docker compose up -d prometheus
预期输出:终端打印 Container ... Started,没有报错。浏览器打开 http://localhost:9090 能看到 Prometheus 的查询界面(顶部有输入框)。如果打不开,先 docker ps 确认容器在跑、docker logs <容器名> 看报错。
逐行讲解:
| 行 | 代码 | 为什么这么写 |
|---|---|---|
image | prom/prometheus | 官方镜像 |
ports | 9090:9090 | 容器 9090 映射到本机 9090 |
scrape_interval | 15s | 每 15 秒去拉一次应用的 /metrics;太频繁会拖累业务 |
targets | host.docker.internal:8081 | host.docker.internal 是 Docker 里访问"宿主机"的地址(Mac/Windows);Linux 上若 Prometheus 和 Go 在同一网络,可用服务名 |
常见坑:
localhost:8081。Docker 容器里的 localhost 指的是容器自己,不是你的宿主机。要用 host.docker.internal 才能访问到宿主机上跑的 Go 程序。Linux 上没这个域名,得把 Go 程序也放进同一个 Docker 网络。小收口:Prometheus 起来了,但还没东西可拉——因为你的 Go 程序还没暴露 /metrics。下一步就做这件事。
3.3.3 Go 应用暴露指标
先抛问题:应用怎么把指标交给 Prometheus 拉?
最小可运行示例:用官方 SDK 的 promhttp.Handler(),在一个独立端口暴露 /metrics:
// 文件路径: cmd/server/main.go(节选)
package main
import (
"net/http"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
func main() {
// ... 业务 server 启动在 :8080
// 单独起一个 goroutine + 独立端口暴露 metrics,避免业务请求干扰
go func() {
// promhttp.Handler() 会自动采集 Go runtime 指标
http.Handle("/metrics", promhttp.Handler())
// 监听 :8081,Prometheus 的 target 就指向这里
_ = http.ListenAndServe(":8081", nil)
}()
}
浏览器访问 http://localhost:8081/metrics,预期输出:一大段文本,每行一个指标,例如:
# HELP go_goroutines Number of goroutines that currently exist.
# TYPE go_goroutines gauge
go_goroutines 12
# HELP go_memstats_alloc_bytes Number of bytes allocated...
go_memstats_alloc_bytes 1.234567e+06
这些
go_goroutines、go_memstats_*是 SDK 自动采集的 Go runtime 指标(goroutine 数、内存、GC 等),你一行埋点代码都没写就有了。这就是"先暴露、后埋点"的便利。
逐行讲解:
| 行 | 代码 | 为什么这么写 |
|---|---|---|
go func() | 独立 goroutine | 不让 metrics server 阻塞业务启动 |
promhttp.Handler() | 内置 handler | 自动输出所有已注册指标 + Go runtime 指标 |
:8081 | 独立端口 | 与业务 :8080 隔离,避免互相挤占 |
常见坑:
go 起 goroutine,直接在主 goroutine 调 ListenAndServe。这会阻塞后面所有业务 server 的启动代码,程序"卡住不动"。也别在主 goroutine 之后才起——顺序无所谓,但一定要在独立 goroutine 里。小收口:现在你的应用能被 Prometheus 拉到了。但默认只有 Go runtime 指标,真正的业务指标(请求耗时、订单数)还得你自己定义。而且拉到的数据怎么查?这就引出 PromQL。
3.3.4 PromQL 入门:五条最常用的查询
先抛问题:数据进了 Prometheus,怎么取出来看?总不能每次都去翻 /metrics 原始文本吧。
补课胶囊 · PromQL 一句话:PromQL 是 Prometheus 自己的查询语言,类似 SQL 但专为"时序数据"设计。 类比:
/metrics是仓库里的原始货物,PromQL 是你写的"取货单"——“给我最近 5 分钟订单创建的速率”。 最小示例:Grafana 里的每一个面板,背后就是一条 PromQL。你在http://localhost:9090顶部的输入框里直接写就行。
先记这五条,日常 90% 的看板都够用:
# 1. 直接查指标名:拿到所有实例的原始值(比如当前活跃请求数)
webook_http_active_requests
# 2. 按 label 过滤:只看 POST 且路径是 /users/:id 的序列
webook_http_request_duration_seconds_count{method="POST", pattern="/users/:id"}
# 3. rate():把 Counter 转成"每秒增长率"——算 QPS 的标准姿势
rate(shop_order_created_total[5m])
# 4. histogram_quantile():用 Histogram 的桶算 P99(注意里面要套 rate)
histogram_quantile(0.99, rate(webook_db_query_duration_seconds_bucket[5m]))
# 5. sum() + by():把多实例、多 label 的序列聚合后再按维度拆分
sum(rate(shop_order_created_total[5m])) by (status)
每条的输出说明(用文字描述,避免编造具体数值):
- 第 1 条:返回一个或一组"瞬时值"。比如你的服务有 3 个实例,会返回 3 行,每行是某个实例当前的活跃请求数。
- 第 2 条:只返回满足条件的那几条序列。花括号
{}里是 label 过滤条件,像 SQL 的WHERE。 - 第 3 条:
rate(xxx[5m])返回"最近 5 分钟窗口内,该 Counter 每秒平均增长多少"。对订单数来说就是 QPS。 - 第 4 条:返回"最近 5 分钟数据库查询耗时的 P99 分位数(秒)"。这是全章最重要的查询之一。
- 第 5 条:
sum(...) by (status)先按status把多实例聚合,再按状态维度拆分,得到"每种状态码的 QPS"。
两条主线你只要记住就抓住了核心:
rate(xxx_total[5m]) 就像看汽车仪表盘上的"当前时速"——里程表(Counter)本身只会一直变大,取它在最近 5 分钟窗口里的增量再除以时间,才是有意义的"速度"。方括号里的 [5m] 就是这个观察窗口。- Counter 类指标必须套
rate()再看:原始值是个只增不减的累计数,直接画出来只是一条一路向上的直线,没意义。 - P99 要用
histogram_quantile包住rate:这也从查询侧解释了为什么多实例场景必须选 Histogram——Summary 在客户端就把分位数算死了,服务端没法再聚合。
小收口:PromQL 你先混个脸熟,后面埋点时会反复用到第 3、4 条。现在该正式学四种指标类型了——这是 Prometheus 的全部"词汇量"。
3.4 Prometheus 四种指标类型
先抛问题:Prometheus 里到底能记哪几种"数"?为什么有的指标叫 _total、有的叫 _seconds?选错类型,后面查询和告警全错。
一张图先区分四种指标的"性格":
flowchart TD
C[Counter
只增不减
累计次数]
G[Gauge
可增可减
瞬时值]
H[Histogram
分桶计数
算分位/P99]
S[Summary
直接算分位
不可跨实例聚合]3.4.1 Counter 计数器(只增不减)
给个直觉:Counter 像"里程表"——车开得越多数字越大,你不会把里程表往回拨。所以它适合记"累计发生了多少次",配合 rate() 求"每秒多少次",就能得到 QPS。
最小可运行示例:
// 文件路径: internal/metrics/order.go
package metrics
import "github.com/prometheus/client_golang/prometheus"
// 定义一个 Counter:订单创建总数
var orderCounter = prometheus.NewCounter(prometheus.CounterOpts{
Namespace: "shop", // 部门 / 大业务
Subsystem: "order", // 子系统 / 模块
Name: "created_total", // 指标名,Counter 习惯加 _total 后缀
Help: "累计创建的订单数",
})
func init() {
// 必须注册,否则 Prometheus 拉不到这个指标
prometheus.MustRegister(orderCounter)
}
// 业务里调用
func OnOrderCreated() {
orderCounter.Add(1) // 或 Inc(),两者等价(Inc 就是 Add(1))
}
典型场景:订单创建总数、HTTP 请求总数、错误发生次数。
常见坑:
Add(-1) 就违背了它的设计(client_golang 会直接 panic,且语义错乱、配 rate() 也会算出负数速率)。“在线人数"会增会减,那是 Gauge 的活。小收口:Counter 管"累计次数"。下面这个会上下波动的,交给 Gauge。
3.4.2 Gauge 度量(可增可减)
给个直觉:Gauge 像"温度计"——随时在变,可高可低。所以"当前正在处理的请求数"用 Gauge 最贴切:Inc() 请求进来、defer Dec() 请求结束,你就能实时看到系统承压情况。
最小可运行示例:
// 文件路径: internal/metrics/http.go
package metrics
import "github.com/prometheus/client_golang/prometheus"
// 当前正在处理的 HTTP 请求数
var activeReqGauge = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: "webook",
Subsystem: "http",
Name: "active_requests",
Help: "当前正在处理的 HTTP 请求数",
})
func init() { prometheus.MustRegister(activeReqGauge) }
典型场景:当前在线人数、当前活跃请求数、数据库连接数、goroutine 数。
Counter vs Gauge 一句话:Counter 只上不下,Gauge 上下都行。
小收口:Counter 和 Gauge 都是"一个数"。但"响应时间"不是一个数能表达的——它可能 10ms,也可能 2s,你怎么描述"分布"?这就引出 Histogram 和 Summary。
3.4.3 Histogram 柱状图(分桶计数)
给个直觉:Histogram 像"把快递按重量分筐"——不记每个包裹的精确重量,而是数"≤1kg 的有几个、≤5kg 的有几个、≤10kg 的有几个"。最后你虽然不知道精确分布,但能算出"99% 的包裹都 ≤10kg"(这就是 P99 的雏形)。
最小可运行示例:
// 文件路径: internal/metrics/db.go
package metrics
import "github.com/prometheus/client_golang/prometheus"
// Buckets 是「上界」集合,例如 0.01, 0.05, 0.1, 0.5, 1, 5 表示
// <= 10ms 的个数、<= 50ms 的个数、... 最后一个隐藏桶是 +Inf
var queryHist = prometheus.NewHistogram(prometheus.HistogramOpts{
Namespace: "webook",
Subsystem: "db",
Name: "query_duration_seconds",
Help: "数据库查询耗时分布",
Buckets: []float64{0.01, 0.05, 0.1, 0.5, 1, 5},
})
func init() { prometheus.MustRegister(queryHist) }
// 业务里记录一次查询耗时(单位:秒)
func ObserveQuery(durSeconds float64) {
queryHist.Observe(durSeconds) // 例如 Observe(0.12) 表示一次 120ms 的查询
}
典型场景:响应时间分布(按区间)、错误码分布、按业务类型统计。
Bucket 设置原则:
{0.01, 0.05, 0.1, 0.5, 1, 5}。桶分得越细、覆盖越广,P99 算得越准,但时间序列也越多(折中即可)。小收口:Histogram 把"每次观测值"分进桶里,存的是"每个桶的计数"。好处是服务端能跨实例把这些桶加起来再算分位。那 Summary 凭什么和它并列?看下一节。
3.4.4 Summary 分位数(客户端直接算)
给个直觉:Summary 像"体检中心直接给你出报告"——它在你这台机器上就把 P50 / P90 / P99 算好,你拿到的是现成的分位数,不用自己再算。代价是报告是"这台机器自己算的",没法和其他机器的报告合并。
最小可运行示例:
// 文件路径: internal/metrics/http_summary.go
package metrics
import "github.com/prometheus/client_golang/prometheus"
var summary = prometheus.NewSummary(prometheus.SummaryOpts{
Namespace: "webook",
Subsystem: "http",
Name: "request_duration_seconds",
Help: "HTTP 请求耗时",
Objectives: map[float64]float64{
0.5: 0.01, // P50,误差 1%
0.9: 0.005, // P90,误差 0.5%
0.99: 0.001, // P99,误差 0.1%
0.999: 0.0001, // P999,误差 0.01%
},
})
func init() { prometheus.MustRegister(summary) }
// 业务里记录一次 128ms 的请求
func ObserveRequest() {
summary.Observe(0.128)
}
典型场景:单实例下需要精确 P99 / P999 时。
小收口:Summary 直接出分位,Histogram 出桶让你自己算。下一节是面试高频——它俩到底怎么选。
3.4.5 Summary vs Histogram:面试高频考点
这是面试官最爱问的区别,务必讲清。
核心差异一句话:Summary 不能跨实例聚合,Histogram 可以。
为什么?因为 Summary 在每个实例本地独立把分位数算死了(P99 是多少直接出结果),三台机器各自算出自己的 P99,你没法把这三个 P99 正确合并成一个"全局 P99"。而 Histogram 存的是分桶计数,可以用 histogram_quantile 在 Prometheus 服务端把所有实例的桶汇总后再算分位,结果才对。
flowchart LR
A[实例1 算 P99] -->|无法合并| X[全局 P99 错]
B[实例2 算 P99] -->|无法合并| X
C[实例3 算 P99] -->|无法合并| X
H1[实例1 桶计数] -->|汇总| Y[服务端 histogram_quantile]
H2[实例2 桶计数] -->|汇总| Y
H3[实例3 桶计数] -->|汇总| Y结论:多实例部署(生产环境基本都是)优先用 Histogram。只有单实例、或需要极精确分位数(不接受分桶误差)时才用 Summary。
再补一个实用小知识:用 Summary / Histogram 统计响应时间时,客户端会同时输出两个伴生指标——_sum(所有观测值之和)和 _count(观测次数)。它们能直接组合出两个常用数字:
# 平均响应时间 = 总耗时 / 总次数
rate(webook_http_request_duration_seconds_sum[5m]) / rate(webook_http_request_duration_seconds_count[5m])
# QPS = 次数的每秒增长率
rate(webook_http_request_duration_seconds_count[5m])
Histogram 还会多带一族 _bucket 指标(每个桶一条序列),这是一套通用约定,在 Grafana 里配面板时能直接看到。
常见坑:
histogram_quantile 在 Prometheus 服务端把所有实例的桶汇总后再算分位,结果才对。所以多实例场景优先 Histogram。小收口:四种指标类型到此讲完。一个重要细节——指标的命名。
3.5 namespace / subsystem / name 命名设计
先抛问题:你看到 shop_order_created_total、webook_http_request_duration_seconds 这种长名字,三段到底是什么意思?自己定义指标时怎么起名才规范?
给个直觉:指标的完整名字 = namespace_subsystem_name,像"公司_部门_具体指标",三层递进定位,一眼知道它属于谁、算什么。
| 组织方式 | namespace | subsystem | name |
|---|---|---|---|
| 按部门 | 部门名 | 子系统 | 数据名 |
| 按小组 | 小组名 | 系统/模块 | 数据名 |
约定俗成的几条:
Namespace:大业务 / 部门,如shop、webook。Subsystem:子系统 / 模块,如order、http、db、redis。Name:具体指标,Counter 习惯加_total后缀(如created_total),耗时类加_seconds后缀(如request_duration_seconds)。
例子:shop_order_created_total、webook_http_request_duration_seconds。这套命名让 namespace + subsystem + name 能快速定位到具体业务,也方便在 Grafana 里按前缀批量筛选。
小收口:命名是"表面功夫",真正决定指标好不好用的是 label 设计——特别是"不要踩基数爆炸的坑",下面接着讲 P99 这个关键概念,然后进实战埋点。
3.6 补课胶囊:什么叫分位数 P99(为什么平均值会骗人)
一句话:P99 = 把 100 次请求的耗时从小到大排,第 99 个的耗时;意思是"99% 的请求都比它快"。
类比:平均值像"全班平均分 80 分"——看起来不错,但可能一半人不及格、另一半满分拉高了。P99 像"倒数第二名的成绩"——它告诉你"最慢的那批用户实际体验有多差"。
最小示例:10 次请求耗时(毫秒)
[10, 11, 12, 12, 13, 13, 14, 15, 200, 500]:
- 平均值 = (10+11+…+500)/10 = 80ms(看起来很健康)
- P99 = 排序后第 99% 位置 ≈ 500ms(实际有用户等了半秒)
先抛问题:为什么前面一直强调"看 P99 别看平均值"?
给个直觉:平均值会把"极端慢"和"普遍快"混在一起抹平。一个接口平均 20ms 很漂亮,但如果 P99 是 2s,说明有 1% 的用户每次都要等 2 秒——这 1% 可能正好是 VIP 或大请求。
为什么平均值会骗人(工程视角):
常见坑:
小收口:理解了 P99,你就懂了为什么 Histogram 要分桶、为什么 Grafana 面板要画 P99 线。现在进入实战——给 Gin 接口埋点。
3.7 Gin 中间件:HTTP 指标埋点
先抛问题:每个接口的响应时间、每个时刻的活跃请求数,不可能在每个 Handler 里手写统计——太侵入、太累。怎么办?
给个直觉:Gin middleware 埋点就像在餐厅每个出入口装计数器——客人进门 +1、出门 -1,你不用改厨房(业务 Handler)一行代码,就能实时知道"现在店里有几个人"。这正是"无侵入埋点“的精髓。
3.7.1 统计响应时间(Histogram)
// 文件路径: internal/middleware/metrics.go
package middleware
import (
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus"
)
// httpDuration 是一个带 label 的 Histogram:按 路由模板/方法/状态码 分维度
// 多实例部署下必须用 Histogram(分桶可跨实例聚合),P99 由 PromQL histogram_quantile 算出
var httpDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Namespace: "webook",
Subsystem: "http",
Name: "request_duration_seconds",
Buckets: prometheus.DefBuckets, // 默认分桶;P99 在查询侧用 histogram_quantile(0.99, ...) 计算
}, []string{"pattern", "method", "status"})
func init() { prometheus.MustRegister(httpDuration) }
func Prometheus() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now() // 步骤 1:记录请求开始时间
c.Next() // 步骤 2:放行,让后续 middleware 和 Handler 先执行
duration := time.Since(start).Seconds()
// c.FullPath() 拿到路由模板,如 /users/:id,避免按 id 分裂指标
httpDuration.With(prometheus.Labels{
"pattern": c.FullPath(),
"method": c.Request.Method,
"status": strconv.Itoa(c.Writer.Status()),
}).Observe(duration)
}
}
注册方式(在 main 里):r.Use(middleware.Prometheus())。
逐行讲解:
| 行 | 代码 | 为什么这么写 |
|---|---|---|
NewHistogramVec | 带 label 的 Histogram | 按 pattern/method/status 分维度;分桶存储,P99 在查询侧用 histogram_quantile 跨实例聚合算出 |
start := time.Now() | 请求前计时 | c.Next() 之前记下起点 |
c.Next() | 放行 | 让后续 Handler 先跑,回来才统计完整耗时 |
c.FullPath() | 路由模板 | 返回 /users/:id 而非 /users/123,否则每个 id 一个序列,爆炸 |
.Observe(duration) | 记录耗时 | 自动累加到对应 label 组合的分位数 |
3.7.2 统计当前活跃请求数(Gauge)
// 文件路径: internal/middleware/active.go
package middleware
import (
"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus"
)
var activeReq = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: "webook",
Subsystem: "http",
Name: "active_requests",
})
func init() { prometheus.MustRegister(activeReq) }
func ActiveRequests() gin.HandlerFunc {
return func(c *gin.Context) {
activeReq.Inc() // 请求进来 +1
defer activeReq.Dec() // 请求结束 -1(defer 保证一定执行)
c.Next()
}
}
一张图看埋点数据流:
flowchart LR
Req[请求进入] --> MW[Prometheus middleware]
MW -->|Inc 活跃请求数| H[Handler 业务]
H -->|Defer Dec| MW
MW -->|Observe 耗时| P[(Prometheus)]这张图怎么读:顺着一次请求的生命周期走:请求进入后先到 Prometheus middleware,它把活跃请求数 Gauge Inc 加 1,再放行给 Handler 执行业务;请求处理完经 Defer Dec 回到 middleware 减 1(defer 保证即使 panic 也一定执行),middleware 随即把这次请求的耗时 Observe 上报给 Prometheus。中间件只在出入口做这两件事——进出各计数一次、结束时记一次耗时——业务 Handler 一行代码不用改。
3.7.3 响应时间怎么读
- 绝对值:P99 是不是慢?平均值是不是慢?
- 增长率:发版前后 P99 是否变慢?平均数是否上升?
- 相对值:平均值 vs P99 差距大 → 存在长尾请求,部分用户体验差。
小收口:Gin 出入口埋点搞定。但数据库查询耗时还没统计——它不在 HTTP 出入口里,得从 GORM 下手。
3.8 GORM 监控
先抛问题:慢 SQL 是线上性能的头号杀手,但 Gin 中间件只能看到"整个请求耗时”,看不到"其中花在 DB 上多久"。怎么单独监控数据库?
3.8.1 官方 Prometheus 插件
GORM 自带一个 Prometheus 插件,能自动采集连接池指标:
// 文件路径: internal/repository/init.go(节选)
package repository
import "gorm.io/plugin/prometheus"
func InitDB() {
db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{})
db.Use(prometheus.New(prometheus.Config{
DBName: "webook",
StartServer: false, // 不另起 server,复用应用的 :8081
PushAddr: "", // 不推送,让 Prometheus 来拉
}))
}
插件自动采集的指标(重点关注):
gorm_dbstats_idle:空闲连接数gorm_dbstats_in_use:使用中的连接数gorm_dbstats_wait_count:等待连接的请求数gorm_dbstats_wait_duration:等待连接的总时长gorm_dbstats_max_open_connections:最大连接数gorm_dbstats_max_idle_closed:因超过最大空闲连接数被关闭的连接数gorm_dbstats_max_idletime_closed:因超过最大空闲时间被关闭的连接数
怎么解读:
wait_count / wait_duration 涨,说明请求在"等连接",直接调大 MaxOpenConns;看到 idle 长期很高,说明池子开太大浪费资源,调小 MaxIdleConns。监控不是装了好看,是用来指导调参的。3.8.2 用 Callback 统计查询耗时(官方插件不统计 SQL 耗时)
官方插件不统计每条 SQL 的耗时分布,得自己用 Callback 注册。GORM 的 Callback 机制允许你在每次 CRUD 的"前/后"插一段逻辑:
// 文件路径: internal/repository/metrics_callback.go
package repository
import (
"time"
"github.com/prometheus/client_golang/prometheus"
"gorm.io/gorm"
)
type queryMetrics struct {
duration *prometheus.HistogramVec
}
func (q *queryMetrics) Register(db *gorm.DB) {
// 在所有 CRUD 之前插入 before
db.Callback().Create().Before("*").Register("metrics_before", q.before)
db.Callback().Query().Before("*").Register("metrics_before", q.before)
db.Callback().Update().Before("*").Register("metrics_before", q.before)
db.Callback().Delete().Before("*").Register("metrics_before", q.before)
// 在所有 CRUD 之后插入 after
db.Callback().Create().After("*").Register("metrics_after", q.after)
db.Callback().Query().After("*").Register("metrics_after", q.after)
db.Callback().Update().After("*").Register("metrics_after", q.after)
db.Callback().Delete().After("*").Register("metrics_after", q.after)
}
func (q *queryMetrics) before(tx *gorm.DB) {
start := time.Now()
// 通过 InstanceSet 把 start 暂存,after 时取回
tx.InstanceSet("metrics_start", start)
}
func (q *queryMetrics) after(tx *gorm.DB) {
val, ok := tx.InstanceGet("metrics_start")
if !ok {
return
}
start := val.(time.Time)
duration := time.Since(start).Seconds()
// 按表名统计耗时分布
q.duration.WithLabelValues(tx.Statement.Table).Observe(duration)
}
关键说明(对照 Gin middleware 理解最快):
Before("*")/After("*")里的*表示"排在所有其他回调之前 / 之后"。GORM 的 Hook 只能挂在少数固定生命周期点,Callback 是更底层的通用入口。before和after是两个独立函数,没法像 Gin middleware 那样用c.Next()把前后写在一个闭包里共享局部变量。所以 start 时间要靠InstanceSet/InstanceGet从 before 传到 after。
常见坑:
InstanceSet。after 里 InstanceGet 拿不到值只能默默 return,这条查询的耗时就悄悄丢了——监控数据莫名缺失还不报错,很难排查。InstanceSet / InstanceGet 要成对写。小收口:HTTP 出入口 + 数据库查询都埋上了。但还有两类容易出问题的地方——错误码和缓存。先看错误码。
3.9 错误码设计与统一监控
先抛问题:系统里错误千千万,哪些值得告警?如果每个错误都告警,你会被告警淹没。怎么用"错误码设计"来化繁为简?
3.9.1 分段式错误码
错误码用"分段"表达含义,一眼能看出错误类型和归属模块:
第一位:类型(2 成功 / 4 客户端错误 / 5 服务端错误)
中间两位:模块号(公司内部统一分配)
后三位:具体错误编号
例如:
2000000= 用户模块成功5001001= 用户模块 - DB 查询失败4002003= 文章模块 - 参数错误
监控重点:只关注 5 开头的错误(服务端错误)。4 开头是用户自己的问题(参数错、没登录),不该惊动运维;5 开头才是你系统内部炸了。
两条权衡要知道:
- 错误码不必一上来就求全:只有"需要特殊关注"的错误才值得给具体编号,其余笼统用"系统错误"“用户输入错误"表达。开发早期就知道要盯的错误先定义;其他的等需要关注了再加。
- 一个系统内部最好统一分配(比如按模块各占一段),否则两个团队可能定义出同一个码却含义不同。本质是"规范严苛性"与"施行成本"的折中。
3.9.2 在统一响应里埋点错误码
与其反序列化响应体再统计(性能差),不如在统一封装的响应函数里埋点:
// 文件路径: internal/web/response.go
package web
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus"
)
var errCodeCounter = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: "webook",
Subsystem: "biz",
Name: "error_total",
}, []string{"code"})
func init() { prometheus.MustRegister(errCodeCounter) }
func WrapResp[T any](c *gin.Context, code int, msg string, data T) {
// 业务返回前统一埋点:按错误码计数
errCodeCounter.WithLabelValues(strconv.Itoa(code)).Inc()
c.JSON(http.StatusOK, Result{
Code: code, Msg: msg, Data: data,
})
}
小收口:错误码监控让你"只盯服务端错误”。接下来缓存——Redis 命中率怎么算?
3.10 Redis 监控:用 Hook 算缓存命中率
先抛问题:缓存命中率直接决定系统性能。命中率低说明缓存形同虚设、全打到 DB。怎么统计 Redis 命令和命中情况?
给个直觉:Redis 客户端(go-redis)支持"钩子(Hook)"——每次命令执行前后都会经过它,像给 Redis 装了个"监控探头",不改业务代码就能统计。
// 文件路径: internal/cache/redis_hook.go
package cache
import (
"context"
"errors"
"github.com/prometheus/client_golang/prometheus"
"github.com/redis/go-redis/v9"
)
var redisCmd = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: "webook",
Subsystem: "redis",
Name: "commands_total",
}, []string{"cmd", "hit"})
func init() { prometheus.MustRegister(redisCmd) }
// metricsHook 实现 go-redis v9 的 redis.Hook 接口:
// 四个方法缺一不可,不需要埋点的直接透传 next,只拦单命令 ProcessHook
type metricsHook struct{}
func (h metricsHook) DialHook(next redis.DialHook) redis.DialHook { return next }
func (h metricsHook) ProcessPipelineHook(next redis.ProcessPipelineHook) redis.ProcessPipelineHook {
return next
}
func (h metricsHook) ProcessHook(next redis.ProcessHook) redis.ProcessHook {
return func(ctx context.Context, cmd redis.Cmder) error {
err := next(ctx, cmd) // 真正执行命令
hit := "yes"
if errors.Is(err, redis.Nil) {
hit = "no" // 缓存未命中(GET 一个不存在的 key 会返回 redis.Nil)
}
redisCmd.WithLabelValues(cmd.Name(), hit).Inc()
return err
}
}
func RedisHook() redis.Hook {
return metricsHook{}
}
// 注册:rdb.AddHook(RedisHook())
hit=no 占比就是缓存未命中率,反过来就是命中率。在 PromQL 里算命中率:
# 命中率 = 命中数 / 总命令数
sum(webook_redis_commands_total{hit="yes"}) / sum(webook_redis_commands_total)
小收口:HTTP、DB、错误码、Redis 四类埋点齐全,全是"无侵入"的(中间件 / Callback / Hook / 装饰器)。现在进入全书最难也最迷人的部分——链路追踪。
3.11 链路追踪核心概念(补课胶囊)
一句话:链路追踪 = 把"一次请求横跨多个服务/函数"的整个过程,画成一棵带时间的树,让你看见它卡在哪一枝。
类比:一次用户请求像一次"快递配送"——从下单、分拣、运输到派送,经过好多环节。Trace 就是这整趟配送的运单,Span 是其中每一段(如"上海分拣中心停留 2 小时"),TraceID 是运单号(全程不变),SpanID 是每一段的编号。
最小示例:下面这棵树就是一条 Trace——
TraceID=abc123(整条链路唯一)
├─ Span#s1 HTTP /login 总耗时 2.1s
│ ├─ Span#s2 GORM 查用户表 耗时 2.0s ← 慢在这!
│ └─ Span#s3 发送短信 耗时 80ms
先抛问题:为什么微服务没有链路追踪就像瞎子?
给个直觉:在单体里,一次请求就在一个进程里,看日志栈就能拼出过程。但在微服务里,一次请求可能穿过 5 个服务、10 次 RPC——你只看到"服务 A 慢了",却不知道是 A 调 B 慢、还是 B 调 DB 慢。链路追踪给每次请求发一个全局 TraceID,把散落在各服务的片段按父子关系串成一条链,你就能直接看到"卡在哪一跳"。
四个核心概念:
| 概念 | 一句话 | 类比 |
|---|---|---|
| Trace | 一次请求从进到出的完整链路 | 一趟快递配送的全程 |
| Span | 链路里"一段操作"(一次函数 / 一次 RPC / 一条 SQL) | 配送中的某一程(如空运段) |
| TraceID | 整条链路唯一 ID,所有 Span 共享 | 运单号 |
| SpanID | 本段 Span 的唯一 ID,父子靠它关联 | 每一段的段编号 |
小收口:Trace / Span / TraceID / SpanID 是链路追踪的"词汇表"。下一节用 OpenTelemetry 这套标准协议把它们落地。
3.12 OpenTelemetry 链路追踪
先抛问题:链路追踪工具有 Jaeger、Zipkin 好多种,我写代码要绑死某一家吗?今天用 Zipkin,明天换 Jaeger 难道要重写?
给个直觉:OpenTelemetry(简称 OTel / OTLP)是 CNCF 主导的云原生可观测性标准协议——它像"充电界的 USB-C":你写代码只依赖 OTel 这套接口,后端换谁都行,不用改业务代码。
它的目标:
- 与供应商无关:同一套 API,后端可换 Jaeger、Zipkin、Datadog 等
- 统一覆盖 Trace / Metrics / Logs
- 不绑定具体实现
核心抽象(后面代码会逐个用到):
| 概念 | 作用 |
|---|---|
| Tracer | 创建 Span 的工厂 |
| Span | 一次操作的一段,有父子关系 |
| SpanContext | Span 的标识,跨进程传递 |
| Propagator | 把 SpanContext 注入/提取 HTTP header 等载体 |
| TracerProvider | Tracer 的全局管理器 |
| Exporter | 把数据上报到后端(Jaeger/Zipkin/OTLP) |
InitTracer 里的 Exporter。所以新项目起步就直接用 OTel API,是最省未来的选择。3.12.1 初始化 TracerProvider(用 Zipkin 作后端)
// 文件路径: internal/trace/init.go
package trace
import (
"context"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/zipkin"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.4.0"
)
func InitTracer(ctx context.Context, endpoint string) (func(), error) {
// 1. 创建 Exporter(这里用 Zipkin)
exp, err := zipkin.New(endpoint) // endpoint 形如 http://localhost:9411/api/v2/spans
if err != nil {
return nil, err
}
// 2. 创建 Resource(描述"这是哪个服务")
res, _ := resource.New(ctx, resource.WithAttributes(
semconv.ServiceNameKey.String("webook"), // 服务名
))
// 3. 创建 TracerProvider,用批处理方式上报
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exp), // 批量上报,减少网络开销
sdktrace.WithResource(res),
)
// 4. 全局注册 TracerProvider
otel.SetTracerProvider(tp)
// 5. 设置 Propagator:让链路信息能跨服务传递(关键,别漏)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
))
// 6. 返回清理函数,进程退出前必须调用
return func() {
_ = tp.Shutdown(ctx) // 必须 Shutdown,否则可能丢数据
}, nil
}
常见坑:
Shutdown 会丢数据。上面用 WithBatcher 批量上报,Span 是先攒在内存里再发的。如果进程退出前不调用 tp.Shutdown(ctx),那些还没发出去的 Span 就丢了,链路里出现缺口。所以 InitTracer 返回的清理函数一定要在 main 退出前 defer 调用。3.12.2 手动打 Span
// 文件路径: internal/service/example.go
package service
import (
"context"
"go.opentelemetry.io/otel"
)
func doSomething(ctx context.Context) error {
// 步骤 1:从 ctx 里找父 Span,没有就新建根 Span
ctx, span := otel.Tracer("webook").Start(ctx, "doSomething")
defer span.End() // 原则:谁创建谁 End
// ... 业务逻辑
return nil
}
关键点:
- 调用
Start时如果传入的 ctx 里有 Span 信息,会自动作为父 Span。 - 每个 Span 必须调用
End,约定俗成用defer span.End()。
span.End() 链路就不完整。Span 靠 End 来"结束计时并上报",如果只 Start 不 End,这条 Span 会一直挂着不上报,调用链里出现空缺、耗时算不出来。所以约定俗成用 defer span.End(),确保函数退出时一定结束。一张图理解 Span 的父子树和跨进程传递:
flowchart TD
R[HTTP 请求根 Span] --> DB[GORM 查询子 Span]
R --> S[发送短信子 Span]
DB --> Q[SQL 执行]
R -. ctx 携带 SpanContext 跨进程 .-> X[下游服务 Span]这张图怎么读:根 Span(HTTP 请求)下挂两个并行的子 Span——GORM 查询与发送短信,GORM 查询内部再套一层"SQL 执行",父子关系靠 Start 时传入的 ctx 自动建立;最下方那条虚线是跨进程场景:ctx 携带 SpanContext 传到下游服务后,对方生成的 Span 与本地根 Span 共享同一个 TraceID,在 Zipkin 里能拼成一棵完整的时间树。排查时看哪段横条最长,请求就慢在哪一跳。
小收口:手动打 Span 你会了。但为什么 Span 能"跨函数、跨进程"自动串起来?秘密在 context.Context,下一节讲。
3.13 context.Context 在链路追踪中的作用(补课胶囊)
一句话:
context.Context是 Go 里"贯穿一次请求生命周期"的随身背包——既能装值(如 traceID、用户 ID),又能发取消/超时信号。类比:context 像快递员随身带的"运单夹"——每经过一个环节(函数/服务),运单(SpanContext)都夹在里面,下一环从夹子里取出运单,就知道"我是哪一趟配送的哪一段"。
最小示例:
// 1. 传值(类似其他语言的 thread local)
ctx = context.WithValue(ctx, "uid", 123)
uid, _ := ctx.Value("uid").(int)
// 2. 超时 / 取消控制
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel() // 所有派生的子 ctx 会一起被取消
先抛问题:进程内这么多函数层层调用,Span 的父子关系是怎么"自动"传下去的?跨服务调用时又怎么传?
给个直觉:context.Context 在 Go 里承担两大职责——传值和控制(超时/取消)。链路追踪靠的是"传值":每次 Start(ctx, ...) 时,OTel 把当前 Span 的 SpanContext 塞进 ctx;下一个函数从同一个 ctx 里取出,就能接着当父 Span 建子 Span。所以你只要保证 ctx 在调用链里一路传下去,链路就自动串起来了。
Context 接口四个核心方法:
| 方法 | 用途 | 使用频率 |
|---|---|---|
Deadline() | 返回过期时间 | 不常用 |
Done() | 返回取消信号 channel | 常用 |
Err() | 返回取消原因(Canceled / DeadlineExceeded) | 常用 |
Value(key) | 取上下文值 | 常用 |
特点:
- 父子关系:父取消/超时,所有子都被取消(控制从上至下)。
- 查找 key:子先找自己,没有去祖先找(查找从下至上)。
context.Context 是 Go 并发工程的"主动脉"。它同时承担"传值(用户信息、traceID)“和"控制(超时、取消)“两件事,而且父子 context 形成树——父取消,所有子孙一起取消。这就是为什么超时控制能在整条调用链生效:最外层设一个 3 秒超时,里面无论调几次下游、起多少 goroutine,到点全部中断,不会出现"上游都返回了下游还在傻跑"的资源泄漏。使用规范:
- 作为函数的第一个参数(
ctx context.Context)。 - 公共方法都加 ctx(util / helper 除外)。
- 不要用作结构体字段(除非结构体本身表达上下文)。
小收口:context 是链路的"传送带"。但"跨服务"时 ctx 在两个进程间传不了——这时要靠 Propagator 把 SpanContext 塞进 HTTP header。下一节先讲 Gin/GORM 怎么无侵入接入,再讲跨服务。
3.14 Gin + GORM 无侵入接入 OTel
先抛问题:手动给每个函数打 Span 太累。Gin 的每次 HTTP 请求、GORM 的每次查询,能不能自动建 Span?
给个直觉:社区已经把"给 Gin 请求建根 Span"“给 GORM 查询建子 Span"封装好了,你只要 Use 一下插件,链路就自动出来了。
Gin middleware(社区已封装好):
// 文件路径: cmd/server/main.go(节选)
import "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
// 在 r.Use(middleware.Prometheus()) 旁边加一行
r.Use(otelgin.Middleware("webook"))
GORM 接入:
// 文件路径: internal/repository/init.go(节选)
import "gorm.io/plugin/opentelemetry/tracing"
db.Use(tracing.NewPlugin())
每个 HTTP 请求会自动创建一个根 Span,GORM 操作作为子 Span,调用链一目了然。
业务关键点手动打点(插件覆盖不到的环节,自己补):
// 文件路径: internal/service/sms.go
package service
import (
"context"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
)
type otelSmsService struct {
svc sms.Service
}
func (o *otelSmsService) Send(ctx context.Context, tplId string, args []string, numbers ...string) error {
ctx, span := otel.Tracer("webook").Start(ctx, "sms_send")
defer span.End()
// 给 Span 加属性,方便在 Zipkin 里筛选
span.SetAttributes(attribute.String("tpl_id", tplId))
err := o.svc.Send(ctx, tplId, args, numbers...)
if err != nil {
span.RecordError(err) // 记录错误
span.SetStatus(codes.Error, err.Error()) // 标红状态
}
return err
}
经验法则:程序出口、关键步骤、易错步骤都打 Span。缓存访问性能敏感,一般不打 Span,但也可以打。
Span 解读(在 Zipkin 里看):
- 横条长度 = 执行时间
- 横条之间的空隙 = 父 Span 自身代码执行时间(没有子 Span 覆盖)
- 空隙多/长 = 需要补充打点
小收口:Gin/GORM 接入让你的链路"主干自动、枝节手动”。但链路到了另一个服务怎么不断?这就是跨服务传播。
3.15 跨服务传播:trace 是怎么跨进程的
先抛问题:进程内靠 ctx 传 SpanContext 已经会了,那跨服务调用(服务 A 调服务 B)呢?ctx 在两个进程间传不了啊。
给个直觉:跨服务靠 Propagator(传播器),它的工作只有两个动词:
- 注入(Inject):HTTP 客户端发请求前,把当前的 SpanContext 序列化写进请求 header。
- 提取(Extract):HTTP 服务端收到请求后,从 header 里还原出 SpanContext,作为自己根 Span 的"父亲"。
业界标准是 W3C Trace Context 协议,载体是一个叫 traceparent 的请求头,长这样:
traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01
四段含义:版本号 - TraceID(整条链路唯一,所有服务共享同一个)- SpanID(本段 Span 的 ID)- 采样标记。
flowchart LR
A[服务A 处理请求
产生根 Span] -->|Propagator 注入
traceparent 写进 header| B[HTTP 请求]
B -->|Propagator 提取
从 header 还原出父 Span| C[服务B 生成子 Span]
C -->|traceID 相同 父子相连| Z[Zipkin 里拼出一条完整链路]这就是为什么 3.12.1 初始化里那两行
otel.SetTextMapPropagator(...)不能省:没有 Propagator,每个服务的 Span 都是孤立的根 Span,链路在跨服务那一刻就断了。跨语言、跨协议(HTTP / gRPC / 消息队列)都是同一个思路——只要收发双方约定从同一种载体里注入 / 提取即可。
常见坑:
SetTextMapPropagator;② 客户端发请求用的是不是被 otel 包装过的 transport(裸 http.Get 不会自动注入 header);③ 中间经过的网关或代理有没有把 traceparent 头吃掉。三步查完,绝大多数断链都能定位。小收口:链路追踪全讲完——从概念、初始化、手动打点、ctx 传播,到跨服务。最后把数据画出来、并让它在出事时叫你:Grafana。
3.16 Grafana 仪表盘与告警
先抛问题:指标在 Prometheus、链路在 Zipkin,散着看不直观。怎么把它们画成一屏能看的"驾驶舱"?怎么在系统快挂时主动通知你?
3.16.1 接入数据源与创建 Dashboard
Grafana 支持多种数据源,本课程接入:
- Prometheus:用于指标
- Zipkin / Jaeger:用于链路
Dashboard 不是"把所有指标堆一屏",而是"按排查思路组织":最上面是"有没有问题"(错误率、P99 红绿)、中间是"瓶颈在哪"(连接池等待、缓存命中率、第三方耗时)、底下是"细节钻取"(链路追踪入口)。
3.16.2 配置告警 Contact Point
Contact Point = “告警怎么发给你”。课程用企业微信机器人:
- 在企业微信群创建机器人,拿到 webhook URL
- Grafana → Alerting → Contact points → 新建 → 选企业微信 → 粘贴 webhook
- 点 Test 验证群里能收到消息
3.16.3 设置告警规则
一张图看清一条告警是怎么从指标变成企业微信消息的:
flowchart LR
M[指标 active_requests] -->|超阈值且持续 N 分钟| R[告警规则触发]
R --> C[Contact Point
企业微信机器人]
C --> W[群里收到告警]例如「活跃请求数 > 阈值且持续 N 分钟」:
- 选指标
webook_http_active_requests - 设置阈值(图表上的红线)
- 设置持续时间(防止瞬时抖动误报)
- 绑定 Contact Point
3.16.4 业务告警清单
- 慢查询告警:GORM Prometheus 监控 + 阈值(如 max > 100ms)。慢查询理论上不该出现,一出现就要查。
- 慢请求告警:HTTP / RPC 响应时间,按接口单独配置。
- 异常请求告警:非 2xx 响应码数量、error 出现次数超阈值。
- 系统状态告警:CPU 使用率持续高、内存使用率持续高、goroutine 数超阈值持续一段时间。
- 业务告警:例如短信发送频率错误短时间内超阈值。
小收口:三支柱全部落地——指标(Prometheus + Grafana)、链路(OTel + Zipkin)、日志(你早就在写了)。下一节把这些串成一次完整的动手主线。
四、跟着写:分步实现
本章主线目标:给你的 Gin 服务装上"眼睛和耳朵"。下面 7 步,每步结束项目都能编译、能跑、能验证。
前置:假设你已有第 02-06 章的 Gin + GORM + Redis 项目(能跑起来、有
/users/:id这类接口)。本章在其基础上加监控。
步骤 1 · 引入依赖并暴露 /metrics
目标:让 Prometheus 能拉到你的指标。
操作:在 go.mod 同目录执行:
go get github.com/prometheus/client_golang/prometheus
go get github.com/prometheus/client_golang/prometheus/promhttp
在 main.go 里加独立端口暴露(见 3.3.3 节代码)。
验证:
go run ./cmd/server
curl http://localhost:8081/metrics | head
预期输出:看到 go_goroutines、go_memstats_* 等 Go runtime 指标,说明暴露成功。
步骤 2 · 加 Gin 中间件埋点
目标:统计每个接口的响应时间 P99 和活跃请求数。
操作:把 3.7.1、3.7.2 节的 middleware.Prometheus() 和 middleware.ActiveRequests() 注册到 Engine:
r.Use(middleware.ActiveRequests())
r.Use(middleware.Prometheus())
验证:访问几个接口后,再 curl http://localhost:8081/metrics | grep request_duration,预期输出:看到 webook_http_request_duration_seconds(带 pattern/method/status 标签)和 webook_http_active_requests。
步骤 3 · 接 GORM 监控
目标:看到数据库连接池指标和每条 SQL 耗时。
操作:
db.Use(prometheus.New(prometheus.Config{DBName: "webook"}))
// 再注册 3.8.2 的 queryMetrics Callback
queryMetrics{duration: dbHist}.Register(db)
验证:curl .../metrics | grep gorm_dbstats,预期输出:看到 gorm_dbstats_in_use、gorm_dbstats_idle 等连接池指标。
步骤 4 · 加 Redis Hook 算命中率
目标:统计 Redis 命令数与命中情况。
操作:rdb.AddHook(cache.RedisHook())(见 3.10 节)。
验证:触发一次缓存查询后,curl .../metrics | grep webook_redis_commands_total,预期输出:看到带 cmd / hit 标签的计数。
步骤 5 · 启动 Prometheus 并接上
目标:Prometheus 开始拉你的指标。
操作:准备好 3.3.2 节的 docker-compose.yaml + prometheus.yml,docker compose up -d prometheus。
验证:打开 http://localhost:9090,输入 webook_http_active_requests 查询,预期输出:能画出一条曲线(值随你访问接口变化)。
步骤 6 · 接入 OpenTelemetry 链路
目标:在 Zipkin 里看到调用链。
操作:
go get go.opentelemetry.io/otel \
go.opentelemetry.io/otel/exporters/zipkin \
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin \
gorm.io/plugin/opentelemetry/tracing
在 main 里 trace.InitTracer(ctx, "http://localhost:9411/api/v2/spans"),并 defer cleanup();给 Engine 加 r.Use(otelgin.Middleware("webook")),给 DB 加 db.Use(tracing.NewPlugin())。
验证:先 docker run -p 9411:9411 openzipkin/zipkin,访问接口后打开 http://localhost:9411,预期输出:能看到一条从 /xxx 根 Span 到 GORM 子 Span 的链路,带耗时横条。
步骤 7 · 在 Grafana 配一条告警
目标:活跃请求数超阈值时,企业微信群收到消息。
操作:接 Prometheus 数据源 → 建 Dashboard 画 webook_http_active_requests → Alerting 里设"超阈值持续 N 分钟"+ 企业微信 Contact Point。
验证:故意压测接口(如 ab / hey),预期输出:企业微信群收到告警卡片。
本章完成后的项目结构(监控相关)
cmd/server/main.go # 暴露 /metrics + 启动 OTel + 注册中间件
internal/middleware/ # Prometheus() / ActiveRequests() Gin 埋点
internal/metrics/ # Counter / Gauge / Histogram 定义
internal/repository/ # GORM Callback 统计 SQL 耗时
internal/cache/ # Redis Hook 算命中率
internal/trace/ # OTel TracerProvider 初始化
docker-compose.yaml # Prometheus + Zipkin
prometheus.yml # scrape 配置
验证清单(可勾选)
-
:8081/metrics能访问,含go_goroutines、webook_http_*、gorm_dbstats_*、webook_redis_* - 访问接口后,
webook_http_request_duration_seconds出现 P99 分位 - Prometheus
9090界面能查到上述指标并画图 - Zipkin
9411能看到一次请求的根 Span + GORM 子 Span - Grafana 配出 Dashboard,且压测时企业微信收到告警
五、工程实践要点
来自原文「工程实践要点」+ 经验补充,生产环境务必遵守:
scrape_interval不宜过短:高并发应用太频繁采集会拖累业务,15s~30s 比较合理。- 业务指标独立端口暴露:避免
/metrics接口被业务请求挤占(用独立:8081+ 独立 goroutine)。 - label 基数要控制:
user_id、order_id这种千万级取值绝对不能做 label,否则 Prometheus 内存爆炸(见下方常见坑)。 - 路由模板做 label,不要用具体路径:用
c.FullPath()拿/users/:id,不要用/users/123,否则指标会被分裂成无数条。 - Histogram vs Summary:多实例场景优先 Histogram(可聚合),单实例或需精确 P99 用 Summary。
- OpenTelemetry Provider 必须 Shutdown:Batcher 会缓存数据,不 Shutdown 会丢数据(进程退出前
defer cleanup())。 - Span 必须 End:忘记 End 会导致链路不完整,建议
defer span.End()。 - 告警要有持续时间:瞬时抖动不要告警,否则告警风暴会让人麻木(狼来了效应)。
- 缓存访问慎用 Tracing:缓存本身要求极高性能,Tracing 开销累积起来不小,按需打开。
user_id 当 label,假设有 1000 万用户,Prometheus 就要为这个指标维护 1000 万个时间序列,内存直接爆炸,server 可能 OOM 挂掉。label 只放"取值种类有限"的维度(如 status、method、pattern)。需要按用户维度查询时,应该走日志或专门的 OLAP 库,而不是指标。- 指标不是越多越好:每条指标都是常驻内存 + 抓取开销。上线一个新指标前问自己三个问题——出问题时我会看它吗?有对应的告警规则吗?它和已有指标重复吗?三个都答不上来就别加。生产上常见的反模式是"把数据库每张表的行数都导成 Gauge",表一多就是灾难。
- 告警要分级:P0(服务不可用)打电话、P1(P99 超阈值)企业微信、P2(磁盘 80%)日报。所有告警一个渠道等于没有告警——狼来了效应的根源就是不分级。
- 给"依赖"也留指标:MySQL 连接池(
gorm_dbstats)、Redis 池、Kafka lag。很多"P99 突变"的根因在依赖方,自己代码一行没改也会被拖慢,提前留好这些指标能省下大量"自证清白"的时间。
duration Histogram、total Counter、错误码 label 就应该同时就位。等到出问题再补埋点,等于车已经撞了才装行车记录仪。六、自测题与动手练习
自测题
看着题目能答出来,这一章才算真懂(答案都在第七节的面试问答里):
- 可观测性和监控的区别是什么?为什么"接了监控平台"不等于"系统可观测"?
- 三支柱各自回答什么问题?缺了链路追踪,排查会卡在哪一步?
- Prometheus 为什么选拉模型而不是推模型?拉模型对故障排查有什么天然优势?
- Counter / Gauge / Histogram / Summary 各适合什么场景?为什么 Counter 不能拿来做"当前活跃请求数"?
- 多实例场景下,P99 为什么必须用 Histogram 而不是 Summary?
- 为什么平均值会骗人?P99 和 P50 一起看有什么好处?
- Gin 埋点时为什么必须用
c.FullPath()做路由 label,而不能用具体路径?高基数 label 会带来什么灾难? - TraceID 是怎么跨服务传播的?和 context.Context 是什么关系?
动手练习
练习 1 · 给第 02 章的 webook 加全套监控(60 分钟)
在你第 02 章写的注册登录服务上,完成三件事并截图/记录输出:
- 接入
promhttp,在:8081暴露/metrics - 写一个 Gin 中间件,用 Histogram 统计每个路由模板的响应时间(label 用
method+pattern) - 写一个 Counter,统计每个路由的请求总数和错误数(label 加一个
code)
完成标准:curl localhost:8081/metrics 能看到这三个指标;用 hey 或 ab 压 30 秒后,rate() 和 histogram_quantile() 两条 PromQL 都能查出合理数值。
练习 2 · 用 OTel 追一条链路(45 分钟)
给上面的服务接入 OpenTelemetry + Zipkin:
- 初始化 TracerProvider(用 Zipkin 作 exporter,
defer shutdown别忘) - Gin 中间件里启动根 Span;GORM 回调里加子 Span(记录表名)
- 浏览器访问一次
/users/:id
完成标准:Zipkin UI 里能看到一条完整链路:root span(HTTP GET /users/1)→ child span(gorm.query users),耗时分布正确。
练习 3 · 复现一次"日志当指标"的坑(15 分钟,纯思考 + 走查)
检查你写过的代码(或本章示例),找出至少一处"用日志在承担指标职责"的地方(比如打印每次请求耗时),把它改成指标埋点。写 100 字说明:这个数据为什么该走 Metrics 而不是日志。
七、模拟面试问答
这一节模拟真实面试场景,12 题覆盖本章全部考点。每题按问题 → 一句话结论 → 展开 → 面试官想听的关键词 → 常见失分点五段组织。
一句话结论:可观测性是"仅凭系统对外暴露的行为数据,就能推断出内部发生了什么"的能力;监控只是"盯着你预先定义好的那几个红灯"。
展开:监控是可观测性的子集。监控要求你提前知道要盯什么(机油灯、水温灯),没装的灯坏了你就不知道;可观测性要求出了没预料到的问题也能从数据里反推病因,像医生用 CT + 验血 + 听诊做诊断。工程上落地为三支柱:日志(发生了什么)、指标(整体表现如何)、链路(请求怎么走的)。
面试官想听的关键词:三支柱、预先定义 vs 未知故障、从症状反推病因、日志/指标/链路各答一问。
常见失分点:把两者说成同一个东西;或者只会背"日志指标链路"却说不清三者各自回答什么问题、怎么配合定位一次线上故障。
一句话结论:拉模型让监控目标的"生死"由 Prometheus 自己验证——拉不到就是挂了,天然自带健康检查,还把采集频率、目标清单的控制权收在监控端。
展开:推模型下,应用主动推数据,如果某个应用两个月没推了,你分不清是"它挂了"还是"它本来就不该推";拉模型下 Prometheus 拉不到目标就立刻知道目标失联。此外拉模型天然适配服务发现(K8s 里跟着 Endpoint 变更刷新目标列表)、应用侧只需暴露一个 /metrics 接口(谁都能 curl 调试),还避免了推模式下"客户端疯狂推数据打爆监控端"的问题。推模型适合短生命周期任务(如 BatchJob 结束就退出,没机会被拉),这时用 Push Gateway 补位。
面试官想听的关键词:健康检查内建、服务发现适配、采集频率控制权、Push Gateway 补短任务场景、/metrics 可直接 curl。
常见失分点:只答"拉模型更简单"说不出具体优势;不知道 Push Gateway 这个例外场景,被追问"定时任务怎么办"就卡住。
一句话结论:Counter 只增不减记累计量、Gauge 可升可降记瞬时值、Histogram 分桶统计分布、Summary 客户端预计算分位数——选型只问一句"这个数据要不要按时间聚合、要不要跨实例聚合"。
展开:Counter(请求总数、错误总数)只能加不能减,重启归零是正常语义,配合 rate() 算 QPS;Gauge(当前活跃请求数、goroutine 数、内存占用)是"此刻的读数",可以上下波动;Histogram(响应时间分布)把观测值落进预定义的桶(如 <0.1s、<1s),服务端用 histogram_quantile() 算分位数——桶数据是可加的,所以多实例聚合没问题;Summary 在客户端就预计算好 P50/P90/P99,查询快,但分位数无法跨实例再聚合(P99 的 P99 没有意义)。
面试官想听的关键词:只增不减、瞬时值、分桶可聚合、客户端预计算、rate() 配 Counter、histogram_quantile()。
常见失分点:拿 Counter 去存"当前在线人数"(应该用 Gauge,Counter 减不了);多实例场景选 Summary 算全局 P99(技术上就错了);说不出 Histogram 和 Summary 的本质区别是"聚合发生在哪一侧"。
一句话结论:平均值会被大量正常请求稀释掉少数极慢请求——1000 个 10ms 加 10 个 10s,平均才 208ms,看起来"还行";P99 是"把所有请求按耗时排序后第 99% 位置的值",它专门盯最慢的那批。
展开:接口的平均响应时间 208ms 可能意味着 1% 的用户在等 10 秒——这 1% 恰恰是最有价值的用户(数据最多、操作最重的老用户往往请求最慢)。所以 SLA 通常写 P99 < 500ms 而不是均值。工程上 P99 用 Histogram 落桶后 histogram_quantile(0.99, ...) 查询。看指标时 P50(中位数)和 P99 要一起看:P50 反映大多数人体感,P99 反映长尾;两者差距大说明系统"平时快、偶尔抽风",往往指向锁竞争、GC 停顿、慢 SQL 这类间歇性问题。
面试官想听的关键词:长尾请求、稀释效应、SLA 用分位数、P50/P99 搭配看、间歇性问题的信号。
常见失分点:把 P99 解释成"99% 的请求都这么慢"(说反了,是 99% 的请求快于这个值);只知道看均值和最大值,说不出分位数在 SLA 里的地位。
c.FullPath() 而不是请求的具体路径?高基数 label 的危害是什么?一句话结论:label 用 /users/:id(有限个模板)而不是 /users/123(无限个路径),因为 Prometheus 会为每一个 label 组合单独维护一条时间序列——高基数 label 会让序列数爆炸,把 server 内存打爆。
展开:Prometheus 的数据模型是"指标名 + label 集 = 一条时间序列"。如果用具体路径做 label,10 万个用户 ID 就是 10 万条序列;如果用 user_id 做 label,千万用户就是千万条序列,server 直接 OOM。正确做法:路由用 c.FullPath() 拿模板(/users/:id),label 只放取值有限的维度(method、code、pattern)。需要按用户维度分析时,走日志或 OLAP 系统(ClickHouse、Loki),不要走指标。
面试官想听的关键词:时间序列 = 指标名 + label 组合、基数爆炸、模板化路径、指标管聚合日志管明细、OOM。
常见失分点:知道不能用 user_id 却答不出"为什么"(时间序列模型);给出"那就给指标加个采样"这种跑偏方案——基数问题靠采样解决不了序列数膨胀。
一句话结论:三段式把指标组织成"系统/子系统/指标名"的层级(如 webook_http_request_duration_seconds),保证"看名字就知道谁在报、报的是什么",跨团队查询才不用靠猜。
展开:namespace 用产品/服务名(webook),subsystem 用模块(http、rpc、dao、cache),name 用具体指标(request_duration_seconds)。两条硬规则:一是单位写进名字(_seconds、_bytes、_total),PromQL 里做除法时一眼对上量纲;二是全公司统一前缀规范,否则 A 团队叫 http_request_duration、B 团队叫 latency_http,联合看板就没法配。Gin 生态里 promhttp 的 Go runtime 指标(go_goroutines 等)是现成参考。
面试官想听的关键词:层级命名、单位后缀、量纲对齐、命名规范先行、跨团队看板。
常见失分点:只答"命名要规范"这个空话,给不出"单位写进名字"这条具体规则;没意识到命名混乱的代价是在联合查询时才爆发的。
一句话结论:在 go-redis 的 Hook(每次命令前后必经的钩子)里数 HIT/MISS 两个 Counter,HIT / (HIT + MISS) 就是命中率——不改任何业务代码就能拿到缓存体系的核心健康指标。
展开:go-redis 的 AddHook 会在每条命令执行前后被调用,在 BeforeProcess 里记下命令、在 AfterProcess 里根据 redis.Nil 判定未命中。命中率是缓存体系最重要的信号:命中率从 95% 跌到 80%,意味着穿透到 DB 的流量翻了 4 倍,往往预示着键设计变更、序列化 bug 或缓存容量不足。它是"投入 20 行代码、回报全天候守护"的典型埋点。进阶:把命中率做成告警规则(连续 5 分钟 < 80% 触发),配合第 06 章的缓存三大问题形成闭环。
面试官想听的关键词:Hook 钩子、redis.Nil 判 MISS、HIT/(HIT+MISS)、命中率下跌 = DB 流量放大、告警规则。
常见失分点:说要在每个业务调用点手动埋点(说明不知道 Hook 机制);只统计 MISS 不统计 HIT(没有分母就算不出比率)。
一句话结论:一次请求的完整轨迹叫一个 Trace(全局唯一 TraceID);它内部每一段工作(HTTP 处理、一次 SQL、一次 Redis 调用)是一个 Span(有全局唯一 SpanID + 指向父 Span 的 parentID),Span 之间的父子关系连成树,就是这条请求的链路。
展开:请求进入 Gin 中间件时创建根 Span(生成 TraceID + SpanID);接下来调 GORM 查库时,从 context.Context 里取出当前 Span,创建子 Span(新 SpanID,parentID 指向根),再把带新 Span 的 ctx 往下传;跨服务调用时,TraceID 和当前 SpanID 通过 HTTP header(traceparent)带给下游,下游从 header 里解出来继续当父节点。这样 Zipkin 里就能看到一棵树:HTTP 2.0s → gorm.query 1.8s → redis.get 5ms,一眼定位慢在哪一跳。
面试官想听的关键词:Trace=树、Span=节点、TraceID 全局唯一贯穿、parentID 连父子、W3C traceparent header 跨进程、context 传递。
常见失分点:说不清 Trace 和 Span 的包含关系;漏掉"跨服务传播靠 HTTP header"这一环,被追问"那 ctx 怎么过网络"就卡住。
一句话结论:初始化四件套——全局 TracerProvider、选好 Exporter(如 Zipkin)、注册 Propagator(跨进程传播)、Shutdown;漏了 Shutdown 会在进程退出时丢掉缓冲区里还没上报的最后一批 Span。
展开:otel.SetTracerProvider(provider) 之后所有 otel.Tracer("...") 才有统一出口;Exporter 决定数据去哪(Zipkin/Jaeger/OTLP);otel.SetTextMapPropagator(propagation.TraceContext{}) 负责把 TraceID 塞进/取出 HTTP header,不注册它跨服务链路就断了;BatchSpanProcessor 是异步攒批上报的,进程退出前不 defer shutdown(ctx),缓冲区里的 Span 就永远上不去了——这是"本地调试明明有、上到 Zipkin 就少一截"的经典原因。另外每个 Span 必须 defer span.End(),忘了 End 的 Span 不会出现在链路里。
面试官想听的关键词:TracerProvider、Exporter、Propagator、BatchSpanProcessor 攒批、Shutdown 丢数据、span.End()。
常见失分点:只写初始化不写 Shutdown(最常见实战 bug);不知道 Propagator 这个角色,以为 TraceID 会"自动"跨进程。
一句话结论:先看指标缩小范围(是所有接口还是个别接口、是否伴随错误率/QPS 变化、goroutine 数),再用链路定位慢在哪一跳(DB?下游服务?锁?),最后用日志确认具体错误——三支柱各干各的活,顺序不能乱。
展开:第一步看指标:P99 是全局涨还是只涨了某个 pattern?同时段 QPS 有没有翻倍(流量问题)、错误率有没有涨(故障问题)、go_goroutines 有没有堆积(阻塞问题)、CPU/内存有没有变化(资源问题)。第二步看链路:抽几条慢请求的 Trace,看时间耗在哪一段——gorm 子 Span 1.8s 说明慢 SQL;下游 RPC Span 慢说明依赖方问题;没有子 Span 慢但整体慢,怀疑锁竞争或 GC。第三步看日志:拉这个时间窗的 ERROR 日志,确认具体报错(连接池打满?超时?)。反过来"先翻日志"在海量日志里是大海捞针,指标→链路→日志是成本最低的顺序。
面试官想听的关键词:先指标后链路最后日志、全局 vs 单接口、QPS/错误率/goroutine 联动看、Trace 定位慢 Span、大海捞针。
常见失分点:一上来就说"查日志"(顺序反了,暴露没有实战排查经验);只盯一个 P99 指标孤立地看,不知道联动 QPS、错误率、goroutine 数一起判断。
一句话结论:因为 Summary 的分位数是各实例在客户端各自预计算的,全局 P99 ≠ 各实例 P99 的平均或求和——数学上就没有"合并分位数"的运算;解决就是改用 Histogram,让网关/查询端把桶数据聚合后再算全局分位数。
展开:实例 A 的 P99 是 100ms、实例 B 的 P99 是 900ms,全局 P99 既不是 100 也不是 900,更不是均值 500——它取决于两个实例各自的流量占比和完整分布,而这个信息在 Summary 的输出里(只有三个分位数值)已经丢了。Histogram 输出的是每个桶的计数(le="0.1" 有多少条、le="1" 有多少条),桶计数可以跨实例直接相加,相加后重新算分位数就是真正的全局 P99。这就是"要聚合选 Histogram、单实例看精确值选 Summary"这条选型法则的由来。
面试官想听的关键词:客户端预计算 vs 服务端聚合、分位数不可合并、桶计数可加、histogram_quantile、聚合单元在哪一侧。
常见失分点:答"取平均"(数学错误,分位数没有线性性);知道要换 Histogram 但说不清"为什么桶能加而分位数不能加"。
一句话结论:不能加——label 取值数量决定时间序列数量,用户 ID/订单号这种高基数(high cardinality)维度会把序列数推到百万千万级,Prometheus 内存直接 OOM;按用户/订单维度查数据的需求应该走日志(或 OLAP)。
展开:回应要给替代方案而不是只说"不行":一是讲清代价——1000 万用户就是 1000 万条序列,Prometheus 每条序列都有常驻内存成本,还会拖垮查询;二是给方案——用户维度的明细查询走结构化日志(按 TraceID/user_id 建索引的 Loki 或 ClickHouse),指标只保留有限维度的聚合视图(code、method、pattern);三是给折中——如果确实要看"个别用户的异常",可以只对抽样或出错的请求记录明细,正常请求不记。把"指标管聚合、日志管明细"这条原则讲给领导,比硬顶回去有效。
面试官想听的关键词:高基数、序列数 = label 组合数、内存 OOM、指标管聚合日志管明细、ClickHouse/Loki、抽样。
常见失分点:只说"加 label 会慢"(不准确,核心是内存爆炸而非查询变慢);给不出替代方案,显得只会说"不行"——面试官想看的是你能否用对方听得懂的方式讲清技术权衡。
八、本章小结
可勾选清单(面试前 5 分钟扫一遍)
- 可观测性 vs 监控:监控盯"预定义的红灯",可观测性从"未知症状"反推病因;三支柱落地。
- 三支柱分工:指标答"整体如何"(Prometheus)、链路答"卡在哪一跳"(OTel/Zipkin)、日志答"具体什么错"(zap→ELK,第 18 章展开)。
- 拉模型:Prometheus 主动拉
/metrics,拉不到 = 目标失联(健康检查内建);短生命周期任务用 Push Gateway 补位。 - 四指标选型:Counter 只增记累计(配
rate())、Gauge 记瞬时、Histogram 分桶可跨实例聚合、Summary 客户端预计算分位数不可聚合。 - P99:99% 的请求快于该值;盯长尾不被均值稀释;P50+P99 搭配看"间歇性抽风";SLA 用分位数不用均值。
- 命名三段式:
webook_http_request_duration_seconds——系统/子系统/指标,单位写进名字,全公司统一。 - Gin 埋点:路由 label 用
c.FullPath()拿模板;label 只放有限维度;高基数 label = 序列爆炸 = OOM。 - GORM 监控:官方 Prometheus 插件 + Callback 统计慢 SQL,输出到指标和链路两个支柱。
- 错误码:分段式(服务-模块-错误),在 Wrap 时统一带上
codelabel 埋点。 - Redis Hook:
AddHook里数 HIT/MISS,HIT/(HIT+MISS)即命中率——性价比最高的埋点之一。 - OTel 四件套:TracerProvider + Exporter + Propagator + Shutdown(漏了丢最后一批数据);Span 必须
defer span.End()。 - 跨服务传播:TraceID/SpanID 走 W3C
traceparentheader,进程内靠context.Context层层传。 - 排查顺序:指标缩小范围 → 链路定位慢 Span → 日志确认具体错——反着来就是大海捞针。
下一章我们将进入"榜单模型与分布式任务调度"——热榜是内容社区的核心运营功能:什么算"热点"(热度算法)、怎么实时排出 TopN(ZSet + 小顶堆)、多实例下定时任务怎么不重复执行(分布式锁)。你会看到本章的可观测性直接派上用场:榜单计算任务的执行耗时、成功率,全都要埋点监控。