ByFomo

架构实战|可观测性三件套:把链路追踪、日志、指标拧成一根绳(连载第 7 篇)

2026/02/04
1
0

> 连载背景:我们在做一套高并发交易平台(下单/支付/库存/账本/履约)。前几篇把 Outbox+消费者幂等、秒杀库存预扣、限流公平性、分库分表&全局 ID、Redis 坑位地图都铺了。

>

> 本篇切到“系统上线后最贵的能力”——可观测性。你要的不只是图表热闹,而是:**出事时能在 10 分钟内把锅从“全体服务”缩到“某个 span / 某条 SQL / 某个租户的一类请求”。**

# 0. 这一篇要解决什么:让系统从‘会跑’升级成‘会说话’

交易系统的故障分两类:

- **死得很干脆**:服务挂了,报警响成打铁,你一看就知道哪台机器要埋。

- **死得很文艺**:看起来都正常,但就是慢;成功率掉一点点,P95 飞到天上,业务同学在群里问你:“是不是你们代码又抽风?”

第二类才是最折磨人的。它不会立刻把你打醒,但会把你拖进“猜测地狱”:

- “我觉得是 Redis 热 Key。”

- “我觉得是 MySQL 慢查询。”

- “我觉得是 JVM 抖动。”

可观测性的目的,就是把“我觉得”变成“我知道”。

# 1. 夜里 02:17 的告警:它不是挂了,是‘瞎了’

本周三凌晨,值班群里一个告警把大家从梦里拎起来:

- API:`POST /trade/checkout`

- P95:从 120ms 飙到 3.2s

- 成功率:99.95% 掉到 98.7%

- 但 CPU 不高、GC 正常、Redis 也没炸、MySQL QPS 甚至还挺稳定

同事在群里说了句经典台词:

> “看起来都正常,但就是慢。”

翻译成人话:**我们没有一套把业务链路串起来的证据体系。系统没挂,但我们瞎了。**

# 2. 三件套的分工:Metrics 快速发现,Tracing 精准定位,Logging 给出证据

很多团队把“可观测性”理解成“多装几个系统”:

- ELK 也上了

- Prometheus 也上了

- Jaeger 也上了

但出事时还是靠“群聊算命”。

原因往往不是工具不够,而是**三件套没形成闭环**。

- **Metrics(指标)**:告诉你“发生了什么、规模多大、趋势如何”。它适合做告警、做容量、做 SLO。

- **Tracing(链路追踪)**:告诉你“在哪儿慢、哪儿错”。它适合做定位:哪个服务、哪个依赖、哪个 SQL。

- **Logging(日志)**:告诉你“为什么”。它适合做证据:参数、分支、异常栈、幂等冲突、补偿结果。

一句话总结:

> 指标是雷达,链路是导航,日志是行车记录仪。

# 3. 先定 SLO:你要守的是用户体验,不是机器体温

别一上来就“CPU 80% 告警”。CPU 80% 不一定是事故;CPU 20% 也可能是事故(比如线程都卡在 IO,机器很凉,用户很热)。

我们给交易平台定义三条 SLO(示例,按你们业务再裁剪):

- **可用性 SLO**:`/trade/checkout` 月度成功率 ≥ 99.9%

- **延迟 SLO**:`/trade/checkout` P95 ≤ 300ms,P99 ≤ 800ms

- **一致性 SLO**:支付成功后 2 分钟内账本落账成功率 ≥ 99.99%(异步链路)

## 3.1 错误预算(Error Budget):让架构讨论有个公约数

有了 SLO,就有错误预算:

- 99.9% 可用性 → 每月允许约 43.2 分钟不可用

错误预算不是“给事故开脱”,而是“给决策定价”:

- 预算花光:暂停大改/收紧发布/优先稳定性

- 预算充足:可以更激进地迭代(但别浪)

# 4. Tracing:让一次请求有‘身份证号’,并且能跨 HTTP、MQ、DB

在第 2 篇(Outbox + 幂等)里我们反复强调“状态机”;但落到线上你会遇到更现实的问题:

> “这笔订单到底走到了哪一步?是卡在库存?卡在支付?还是卡在消费端?”

Trace 就是“全链路流水号”。

## 4.1 统一 traceId:入口生成/透传/落日志

原则:**任何进入系统的请求,都要有一个 traceId,并贯穿所有日志与消息。**

如果你还没上 OpenTelemetry,也可以先用 MDC 做过渡(示例代码,重点是理念):

```java

@Component

public class TraceIdFilter implements Filter {

private static final String HEADER_TRACE_ID = "X-Trace-Id";

@Override

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)

throws IOException, ServletException {

HttpServletRequest req = (HttpServletRequest) request;

String traceId = req.getHeader(HEADER_TRACE_ID);

if (traceId == null || traceId.isBlank()) {

traceId = UUID.randomUUID().toString().replace("-", "");

}

MDC.put("traceId", traceId);

try {

chain.doFilter(request, response);

} finally {

MDC.remove("traceId");

}

}

}

```

上线 OTel 后,建议做到:

- HTTP:自动提取/生成上下文

- MQ:生产消息时注入上下文,消费时提取上下文

- DB:自动生成 span(JDBC/ORM)

- Logs:桥接 traceId/spanId 到 MDC,让每行日志都可关联

## 4.2 采样别抠:交易链路要‘有证据’

很多团队采样 0.1%,理由是“成本”。

我见过更贵的成本:**事故 1 小时定位不出来,损失以百万计。**

建议(经验值,按成本调整):

- 核心交易接口:常态 1%~5%

- 告警期间:动态提升采样(30% 甚至 100%)

## 4.3 Trace 里要带业务维度:不然你只能“按时间刷”

交易平台里这些维度很关键:

- `tenantId`(租户)

- `bizOrderId`(业务订单号)

- `payOrderId`(支付单号)

- `scene`(秒杀/普通/补单)

- `channel`(App/H5/小程序/第三方)

做法:把它们作为 span attribute(或 baggage)写进去。

这样你才能在 Trace 平台上回答:

- “为什么只有某个租户慢?”

- “为什么只有秒杀场景慢?”

- “为什么只有某个渠道慢?”

# 5. Logging:把‘猜’变成‘证据’,但别把日志当垃圾场

日志不是写给你自己爽的,是写给“凌晨的你”看的。

## 5.1 结构化日志:别让 grep 当你的查询引擎

建议日志以 JSON 输出(或至少 key=value),并统一字段:

- `traceId` / `spanId`

- `bizOrderId` / `tenantId`

- `resultCode`

- `costMs`

- `downstream`

Logback(简化)示例:

```xml

<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] traceId=%X{traceId} %logger - %msg%n</pattern>

```

## 5.2 交易链路建议记录三类日志

1) **入口日志(INFO)**:请求进入,参数脱敏(别把身份证/银行卡/手机号全量写进去)

2) **关键业务状态变更(INFO)**:

- 订单从 CREATED → CONFIRMED

- 预扣库存成功/失败

- Outbox 写入/投递/消费结果

3) **异常证据(WARN/ERROR)**:

- 下游超时(含 timeout、重试次数)

- 幂等冲突(幂等 key、历史状态)

注意:

- 别每一步都打 INFO,否则你只是在制造“日志洪水”。

- 关键状态要“稀疏但关键”。

# 6. Metrics:用数字告诉你系统是不是在‘窒息’

指标是最先发现问题的地方。

## 6.1 技术指标:RED(Rate/Errors/Duration)

技术层面建议统一 RED:

- **Rate**:QPS

- **Errors**:错误率

- **Duration**:延迟(P50/P95/P99)

## 6.2 业务指标:把‘账本正确性’也变成数字

交易平台别只盯接口:

- 下单成功/失败

- 支付成功数

- Outbox 堆积量(待投递条数)

- 消费端幂等冲突次数

- 库存预扣成功率

Micrometer 示例:

```java

@Component

public class TradeMetrics {

private final Counter checkout;

public TradeMetrics(MeterRegistry registry) {

checkout = Counter.builder("trade_checkout_total")

.tag("result", "unknown")

.register(registry);

}

public void inc(String result) {

Counter.builder("trade_checkout_total")

.tag("result", result)

.register(Metrics.globalRegistry)

.increment();

}

}

```

## 6.3 线程池/连接池指标:比 CPU 更早暴露“慢性窒息”

很多 P95 上升的第一现场,其实是:

- 线程池队列越来越长

- 连接池 pending 从 0 变成几十/上百

建议纳入:

- Tomcat/Netty:active、queue、reject

- HikariCP:active/idle/pending

- MQ consumer:lag、rebalance 次数

——