插桩与埋点
自动/手动 instrumentation。
插桩与埋点
这一课你将能做到
- 区分 自动插桩 与 手动埋点 的职责边界,说清判题链路上哪些观测点必须靠框架、哪些必须自己写
- 为
POST /api/judge/submissions 设计最少必要的手动埋点(Counter / Timer / Span),并给出标签白名单
- 评估插桩对 性能 与 标签基数 的影响,能讲清「指标爆炸」是怎么把自己放倒的
为什么要学
可观测性不会从空气里长出来:要么框架和 agent 替你注入,要么你在业务代码里显式声明。插桩(Instrumentation) 就是把 Metrics / Logs / Traces 信号「种」进系统的过程。种少了,故障时两眼一抹黑;种多了、标签乱了,时序库和账单先把你放倒——典型翻车:有人给每个 REST 路径的每个查询参数组合建指标,一周后 Prometheus 内存爆了,监控先死于自己的观测。
判题链路横跨 浏览器 → Nginx → Spring Boot → Kafka → 判题 worker → MySQL/Redis,任何一个环节没有信号,排障就断在哪一环。面试里「自动插桩和手动埋点怎么分工」「你们怎么控制指标基数」都是高频题,本课给答案。
先建立图像
| 方式 | 来源 | 覆盖什么 | 注意 |
|---|
| 自动插桩 | OTel Java Agent、Micrometer 的 MVC/JDBC 插桩 | HTTP 入口、JDBC、HTTP client、JVM | 零侵入打底,标签仍要治理 |
| 手动埋点 | 业务代码里的 registry.counter()、@Timed、自定义 Span | 业务结果:提交量、判题通过/拒绝、队列投递 | 只埋业务决策点,控制基数 |
一次判题提交的信号来源:
浏览器 → Nginx → Spring Boot → Kafka → worker → MySQL/Redis
│ │ │ │ │
│ │ │ │ └─ 自动:JDBC Span + 慢 SQL 日志
│ │ │ └─ 手动:judge_results_total{result="accepted"}
│ │ └─ 自动:Kafka producer Span + 手动:队列投递 Counter
│ └─ 自动:http_server_requests Timer + MVC Span
└─ 手动:access log(含 traceId)
原则:自动打底(框架已知边界),手动补「业务结果与决策」(框架不知道的部分)
跟着做
步骤 1:给判题链路列一张「谁负责」清单
在记事本填表,每个观测点标注 自动/手动:
1. 接口 QPS、4xx/5xx、延迟 → 自动(Micrometer MVC 插桩)
2. JDBC 调用、连接池占用 → 自动(DataSource 插桩)
3. Kafka 投递成功/失败 → 自动(producer 插桩)+ 手动业务标签
4. 判题结果分布(AC/WA/TLE/RE) → 手动:框架不知道什么叫「判题通过」
5. 提交语言分布 → 手动 Counter,标签 language(枚举可控)
6. traceId 全链传播 → 自动(agent),异步线程池需验证
应得到的核心结论:超过八成观测点应该自动完成,手动只补框架不知道的业务语义。
步骤 2:写一个低基数的判题结果 Counter
worker 判完题后记录结果:
@Service
public class JudgeResultRecorder {
private final MeterRegistry registry;
public JudgeResultRecorder(MeterRegistry registry) {
this.registry = registry;
}
public void record(String language, String result) {
// 标签只用有界枚举:language 3~5 种,result 是 AC/WA/TLE/RE/MLE 之一
registry.counter("judge_results_total",
"language", language,
"result", result)
.increment();
}
}
应得到:能解释为什么 language、result 可以做标签,而 userId、submissionId 绝对不行——前者取值个位数,后者百万级,每个组合都是一条新时序。
步骤 3:给关键业务步骤加手动 Span
「创建提交记录」这种跨库+发消息的决策点值得一个业务 Span:
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.context.Scope;
public SubmissionDto submit(SubmitRequest req) {
Span span = tracer.spanBuilder("judge.createSubmission")
.setAttribute("judge.language", req.getLanguage())
.setAttribute("judge.lessonId", req.getLessonId())
.startSpan();
try (Scope scope = span.makeCurrent()) {
SubmissionDto dto = doSubmit(req); // 写库 + 投递 Kafka
span.setAttribute("judge.submissionId", dto.getId()); // Span 属性无基数问题
return dto;
} catch (Exception e) {
span.recordException(e);
throw e;
} finally {
span.end();
}
}
注意对照:Span 的 attribute 可以挂高基数值(submissionId),指标标签不行。这是两种信号的关键差异——Trace 是单次事件的详情,指标是聚合统计。
步骤 4:验证异步链路的 context 传播
worker 从 Kafka 消费消息处理判题,属于跨进程异步。发一条提交,然后在日志里检查:
# producer 端和 consumer 端应出现同一个 traceId
grep "judge" app.log | grep "traceId=4bf92f3577b34da6a3ce929d0e0e4736"
如果 consumer 端 traceId 变了或为空,说明 Kafka header 传播没生效——OTel agent 对主流 Kafka client 默认支持,自研封装层需检查是否在发消息时丢了 header。
步骤 5:做一次「指标爆炸」预演
假设有人提议:「给每个 lessonId 建一个提交量指标,方便看每课的提交趋势」。
拒绝:lessonId 会随内容增长,每个 ID 一条时序,指标无限膨胀。
替代:Counter 只带 language 等枚举标签;
「每课提交趋势」用日志字段 lessonId + 离线聚合,或业务库 GROUP BY 出报表。
讲清楚要点
- 框架已知 HTTP/DB/Kafka 边界,所以这些优先自动插桩——手写重复且容易与框架口径不一致。
- 框架不知道「判题通过」是什么,所以业务结果、领域状态变更必须手动埋——这是手动埋点存在的唯一理由。
- 每个标签组合都是一条新时序,所以埋点设计首先是基数设计——先定标签白名单,再写代码。
- 插桩本身消耗 CPU 和内存分配,所以热路径避免同步远程调用、避免挂巨型 attribute——SDK 默认批量异步导出,理解并信任这个默认。
- Span 属性无基数约束、指标标签有,所以高基数详情进 Trace 和日志,聚合统计进指标——三条信号各司其职。
- 异步和线程池是 context 丢失重灾区,所以埋点验收必须包含「跨边界 traceId 连续」这一项。
- 降级、熔断触发也是关键事件,所以可靠性动作要有指标或结构化日志——否则故障时你分不清「系统自己降级了」还是「系统坏了」。
你可能会懵的地方
-
MVC 自动插桩已有 HTTP 耗时,还要 @Timed 吗?
自动计的是入口总耗时;@Timed 可以给业务方法一个语义化的名字和标签。如果完全重复且没有新语义,不加——双份指标等于双倍基数。
-
OTel Java Agent 会不会明显拖慢服务?
常规负载下开销通常在个位数百分比,但必须用预发压测实测对比。可以按模块关闭用不到的 instrumentation(如某些老库的插桩),进一步降低开销。
-
「最少必要」怎么判断?
反着问:没有这条信号,故障时能不能定位?SLO/告警是否需要它?如果答案是「只是好奇」,砍掉。观测成本是持续的存储和查询成本。
-
日志算不算插桩?
广义上算——日志也是一种遥测信号的生产。实践里 Metrics/Logs/Traces 三条线一起规划:指标管聚合,日志管详情,Trace 管因果。
-
agent 自动插桩和手写 Micrometer 会双计吗?
会,如果不管。约定分工:agent/SDK 管 traces,Micrometer 管 metrics,日志库管 logs;同一信号只允许一个生产者,避免一条请求两条 Span 或两份计数。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 给每个内部私有方法加 Timer | 指标爆炸、噪音淹没关键信号 | 框架自动打底,手动只埋业务决策点 |
| 指标标签用 userId / submissionId | 时序数量随业务量爆炸 | 低基数枚举标签;详情进日志和 Span 属性 |
| 手工再造一套 HTTP 耗时指标 | 与框架口径不一致、双份存储 | 沿用 Micrometer/OTel 语义约定,补业务标签 |
| 只埋成功路径 | 失败、超时、降级完全不可见 | 失败与降级同样要 Counter/事件 |
| 异步任务不处理 context | 链路在 Kafka/线程池处断成两截 | agent 支持的组件直接用,自研封装显式传 context |
卡点急救
- 手动 Counter 在
/actuator/prometheus 里搜不到:Micrometer 是惰性注册——Counter 第一次 increment() 后才出现。先发一次真实请求再查;另外 Micrometer 导出名会自动加 _total 后缀,别搜错。
- 加了 OTel agent 后启动报错或行为异常:先
-Dotel.instrumentation.xxx.enabled=false 逐个排除问题模块,再查 agent 版本与 Spring Boot 大版本的兼容矩阵。
- Kafka consumer 端 traceId 全新:检查是否绕过了原生 producer(如自己拼字节数组发送),或消费端用
new Thread 裸跑丢了 context——改用 agent 识别的 KafkaTemplate/Listener,或显式 Context.wrap()。
面试常问
-
自动插桩和手动埋点怎么分工?
背这个版本:自动插桩靠 agent 或框架集成,覆盖框架已知边界的通用观测点——HTTP 入口出口、JDBC、消息队列收发,零侵入、口径统一,应该作为底座。手动埋点解决框架不知道的业务语义:判题结果分布、转化漏斗、领域状态变更,用业务指标和自定义 Span 表达。分工原则一句话:自动打底保证覆盖,手动补义保证业务可解释;同时同一信号只能有一个生产者,避免双计。
-
怎么防止指标基数爆炸?
基数 = 各标签取值数的乘积,每个组合一条时序。治理靠三招:标签白名单制度,取值必须是有界枚举;uri 用路由模板不用原始 URL;高基数字段(用户 ID、订单号、异常文本)只进日志字段和 Trace attribute,永远不进指标标签。上线前用 /actuator/metrics 或 TSDB 的 cardinality 统计做体检。
-
插桩对性能有影响吗?怎么控制?
有,来自三部分:内存分配(每次观测创建对象)、CPU(聚合计算)、导出 IO。控制手段:用 SDK 默认的批量异步导出;关闭用不到的自动插桩模块;手动埋点只放在业务决策点而非每个函数;预发压测对比开关 agent 的吞吐和延迟,拿数据说话。
自测三问
- 判题链路上举出三个「必须自动、不必手动」的观测点,以及两个「必须手动」的观测点。
- 设计一个判题结果 Counter,写出它的标签并证明每个标签都是有界枚举。
- 为什么 Span attribute 可以挂 submissionId,而指标标签不行?
小结一句话
自动打底、手动补义、标签白名单——插桩是为排障和 SLI 服务,不是刷指标数量。
延伸