> 连载背景:我们在做一套高并发交易平台(下单/支付/库存/账本/履约)。前几篇把 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 次数
——