第 08 章(节选) 核心结论与深度思考题
来自第 08 章「第 08 章 排错方法论:从异常栈到根因(一套能复用的 SOP)」。 返回:第 08 章索引
08.9 本章核心结论
-
“猜”低效不是因为你不聪明,是因为反馈周期。 Go 改一次 3 秒,Java 重启一次 3 分钟 —— 差 60 倍,所以 Java 必须换成”先假设,后验证”。而且重启会清空缓存、连接池、静态状态和 JIT profile,它会伪造出”问题消失了”的假象。
-
排错的元方法是三条:先定位后修复、二分法最小化复现、相信证据不相信直觉。 验证顺序按成本排(30 秒能出结果的先做),不是按可能性排。判据:你能不能用一句话说清”第几行的什么东西,在什么条件下,变成了什么状态”。
-
异常栈的读法是:先看最后一个
Caused by,从上往下找第一行你自己的包名,... N more不是截断而是省略重复栈帧,Suppressed常被忽略但往往才是真凶。 更要命的是-XX:-OmitStackTraceInFastThrow—— 日志里出现没有栈的 NPE 时,先怀疑它,而不是怀疑日志框架。这个参数建议生产默认就带上。 -
日志的质量决定排查的上限。 异步日志必须关掉
discardingThreshold(否则会在最忙的时候丢掉你最需要的 INFO),绝不用%L/%M(靠抛异常取行号,性能杀手),用参数化日志(级别关闭时不执行toString()),MDC 塞 traceId(分布式排查的地基)。异常栈回答”在哪出错”,日志参数回答”是什么数据触发的”,缺一不可。 -
Arthas 是本章最值钱的工具,它把 Java 的反馈周期压回秒级。
dashboard扫一眼排除一半可能,thread -b一秒找到持锁线程,sc -d的code-source三秒解决依赖冲突,jad十秒确认线上版本,watch/trace让你不用加日志重启就能看到线上数据。纪律:用完stop,不是quit;-x从 2 开始;tt用完--delete-all。 -
Arthas 能做到而 Go 做不到,根源是”字节码 + Instrumentation”vs”机器码 + 无插桩接口”。 这是”运行时灵活性”这笔交易的收益侧 —— Java 把决策推迟到运行时,代价是
NoSuchMethodError和启动慢,回报是运行时可以被观察和干预。Go 编译成本地码,干净高效,但也永久失去了运行时插桩能力,只能靠事先埋点补偿。 -
分布式排查的标准流程是 Metrics 发现问题 → Tracing 定位范围 → Logging 看细节 → 单机工具下钻。 TraceId 在线程池里会丢,因为 MDC 是 ThreadLocal;用
InheritableThreadLocal会更糟(池化场景下值永不更新,导致数据错乱 + 泄漏)。正解是TaskDecorator/ TTL / OTel Context。Go 的context.Context在这个问题上明显更优:显式、不会错乱、不会泄漏。 -
三个案例的共同根因不是”代码写错了”,而是”代码的假设和它所处的环境不再匹配”。
synchronized里查库在本地数据量小时没问题;静态 Map 缓存在订单量小时没问题;List装 50 万条在一年前数据量小时也没问题。所以修复前必须理解”为什么在当下这个条件下会出问题”,否则你只是在挪动问题。
08.10 深度思考题
题 1:生产环境能不能用 Arthas?风险在哪?怎么把风险控制住?
参考答案
直接答案:能用,而且应该用 —— 不用它的风险(长时间定位不了故障)大于用它的风险。但必须有一套规范,不能随手就上。
收益侧先算清楚:一个线上 P1 故障,多停 10 分钟的损失 vs Arthas 带来的几百毫秒性能抖动。大多数业务场景下,答案毫无悬念。拒绝在生产用 Arthas,本质上是把”工具的风险”看得比”故障的持续时间”更重 —— 这个优先级通常是错的。
风险清单(对应到具体机制):
| 风险 | 机制 | 后果 |
|---|---|---|
| 字节码增强的持续开销 | 每个埋点在每次调用时执行 | 吞吐下降、RT 变长 |
-x 4 展开复杂对象 | 反射递归遍历 + 字符串拼接,在业务线程上同步执行 | 单次调用卡住数百毫秒 |
高频方法上开 trace/watch | 每秒几千次埋点 + 输出 | CPU 飙升、磁盘写满、服务端 OOM |
tt 持有对象引用 | Map<Integer, TimeFragment> 持有参数对象 | 内存泄漏(Arthas 自己泄漏你的堆) |
忘记 reset | quit 只断开客户端,增强还在 | 你以为结束了,实际上一直在拖慢服务 |
redefine/retransform | 热替换字节码 | 最高危:替换失败可能导致类定义损坏 |
jad/sc 大量输出 | 终端刷屏 | 客户端卡死(影响有限) |
生产使用规范(可直接抄进运维手册):
# 1. 事前:高危命令先在预发验证,且默认禁掉最危险的一类
options unsafe false # 禁掉 redefine / retransform(改字节码的命令)
# 2. 事中:严格控制观测范围和深度
watch com.example.OrderService pay '{params, returnObj, #cost}' '#cost > 500' -n 20 -x 2
# ^^^^^^^^^ ^^^^^^ ^^^ ^^^^
# 条件过滤 限 20 次 深度 2
# 绝不裸跑:watch com.example.OrderService pay -x 4
# 3. 只对一台机器操作,且先摘掉这台机器的流量
# 不要批量 attach 整个集群
# 4. 避开高峰期做 trace / profiler / heapdump
# 5. 事后:清理 + 关闭
tt --delete-all # 如果用过 tt
reset # 撤销所有字节码增强
stop # ★ 彻底关闭 Arthas 服务端(会自动 reset)
# 确认:jps 里 arthas-core 相关进程消失,或者 dashboard 连不上一条额外的建议:把 Arthas 的使用做成”有记录的变更”。
在很多团队里, Arthas 命令是通过跳板机手动敲的,敲完就忘了。三个月后服务变慢,没人想到三个月前有人挂了一个 watch 没 reset。
所以:记录下你在哪台机器上、什么时间、执行了什么命令、有没有 reset。这跟”变更要有记录”是同一个道理 —— 线上环境的任何变更都应该可追溯。
更深一层:这是一个”可观测性工具自身的风险”的通用问题,不只在 Arthas 上。
想想看:tcpdump 抓包会不会把磁盘写满?jmap -dump 会不会 STW 八秒?动态开 DEBUG 会不会因为日志量暴涨把服务拖垮?所有观测行为都会改变被观测的系统(这是从量子力学借来的一个比喻,但在分布式系统里同样成立)。
区别在于可观测性的代价是否是可控的、可预期的、可撤销的:
| 工具 | 开销是否可控 | 是否可撤销 |
|---|---|---|
jstat -gcutil | 是(固定,极低) | 是(Ctrl+C 就停) |
| JFR 常开录制 | 是(< 1%,有上限配置) | 是(JFR.stop) |
Arthas watch | 取决于 QPS 和 -x | 是(但要记得 reset) |
| 动态开 DEBUG | 不可控(取决于日志量) | 是(但要记得关) |
jmap -dump | 否(取决于堆大小) | 否(已经 STW 了) |
排在表格上半部分的工具,可以常态化使用;排在下半部分的,必须当成”变更”来审批。
题 2:一个 OOM 的堆转储有 8G,你的笔记本只有 16G 内存,怎么分析?
参考答案
直接答案:四条路,按推荐顺序 —— ① 先在服务器上用 jmap -histo 看个大概(很多时候就够了);② 用 MAT 的命令行脚本 ParseHeapDump.sh 在服务器上生成报告;③ 加大 MAT 堆 + 用 64 位 + SSD;④ 用 -gz 压缩 + 只 dump 存活对象减小文件。别用 jhat。
先说一条反直觉的结论:你很可能根本不需要分析那 8G。
在真正 dump 之前,jmap -histo 给出的信息往往已经足够定罪:
jmap -histo 12345 | head -20 # 不加 :live,不触发 Full GC如果输出里前几名是 com.example.xxx.OrderDTO 这种你自己包的类名,你已经知道是哪个类在泄漏了,接下来直接去代码里搜这个类的引用即可。08.7 的案例二就是这么破的 —— dump 只是用来确认”谁持有它”的最后一步。
方案一:在服务器上用 MAT 的命令行工具(推荐,最省事)。
MAT 除了图形界面,还提供命令行解析脚本,它不需要把整个堆加载进内存:
# MAT 解压后的目录里
./ParseHeapDump.sh /data/dump/heap.hprof org.eclipse.mat.api:suspects \
org.eclipse.mat.api:overview \
org.eclipse.mat.api:top_components产出是一组 HTML 报告(xxx_Leak_Suspects.zip 等),只有几 MB,下载到本地用浏览器打开即可。报告里包含:泄漏嫌疑点、Dominator Tree 的 top N、按包/类加载器的统计。
服务器的内存通常比笔记本大得多(生产机 64G~256G 很常见),把分析放在数据所在的地方,而不是把数据搬到分析工具那儿。这条原则对任何大数据量的分析都成立。
如果服务器上连 JDK/MAT 都装不了(容器场景),可以把 dump 文件挂到一个临时的、内存足够大的 Pod 上分析。
方案二:压缩 + 只 dump 存活对象(从源头减小文件)。
# -gz=1:JDK 11+ 支持压缩,8G 堆通常能压到 2~3G
jcmd 12345 GC.heap_dump -gz=1 /data/dump/heap.hprof
# 默认行为(不带 -all)就是"只 dump 存活对象",但代价是会先触发一次 Full GC
# 如果不想触发 Full GC(比如要在业务高峰期保留完整现场):
jcmd 12345 GC.heap_dump -all=true -gz=1 /data/dump/heap.hprof
# -all=true:包含不可达对象,不触发 Full GC,但文件会更大权衡:
| 选项 | Full GC | 文件大小 | 适用场景 |
|---|---|---|---|
默认(-all=false) | 会 | 小(只存活对象) | 查内存泄漏(你要的就是”为什么这些对象还活着”) |
-all=true | 不会 | 大 | 查”对象是从哪分配出来的”(需要看短命对象) |
查泄漏用默认的就够了 —— 不可达对象本来就不是泄漏,它们只是还没被回收。
方案三:给 MAT 加大堆(本地分析的必要条件)。
# 编辑 MAT 安装目录下的 MemoryAnalyzer.ini
-vmargs
-Xmx12g # 16G 内存的机器给 12G(留 4G 给系统和其他应用)
-XX:+UseG1GC
-Djava.awt.headless=true经验公式:MAT 需要的堆约等于 dump 文件大小的 1.2~1.5 倍(它要构建索引、支配树等结构)。所以 8G 的 dump 需要 10~12G 的堆 —— 在 16G 的笔记本上是勉强可行的,前提是你关掉其他所有应用、有 SSD(MAT 会在磁盘上建大量临时索引文件)。
如果 dump 真的压不下来、服务器也用不了,还有一个折中:用 MAT 打开后立刻导出”按包统计”和”Dominator Tree top 100”,然后关掉 MAT。这两份数据通常足够定位,而且导出过程比完整分析省内存。
为什么不推荐 jhat?
jhat -J-Xmx12g heap.hprof # JDK 自带的堆分析工具,JDK 9 已被移除jhat 会把整个堆读进内存然后启动一个 HTTP 服务让你查。问题是:它没有索引优化,8G 的堆会让它跑几个小时,然后大概率 OOM。 JDK 9 已经把它删了,官方推荐用 MAT 或 VisualVM。同样不推荐 VisualVM 分析超大 dump(它的堆分析能力比 MAT 弱不少)。
方案四(预防):让 OOM 的 dump 从一开始就更容易分析。
# 常开自动 dump
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump/
# 关键:限制堆大小,让 dump 本身可控
-Xmx4g # 而不是让 JVM 用默认(物理内存的 1/4),那样 128G 的服务器会 dump 出 30G 的文件
# 更好的做法:容器化 + 单实例堆不要太大
# 8 个实例 × 4G 堆,比 1 个实例 × 32G 堆好得多:
# - 单个 dump 文件小,好分析
# - 单个实例 OOM 影响范围小
# - Full GC 停顿短更深一层:这道题暴露的是”可观测性数据的规模悖论”。
诊断数据最大的时候,恰恰是你最需要它、但也最难处理它的时候。 堆越大越容易 OOM,OOM 时的 dump 就越大,大 dump 越难分析 —— 这是一个正反馈的恶性循环。
打破这个循环的思路有两类:
- 降低单次数据的粒度:
jmap -histo(只有几十行)比 dump(8GB)信息少,但足以定罪 80% 的泄漏场景。先用便宜的工具 - 分散而非集中:很多小实例比一个大实例好诊断。这跟”微服务拆分”的动机之一是一致的 —— 可诊断性也是架构决策的一个维度,不只是性能和可用性
这一点在 Go 里完全不同:pprof -heap 默认只采样的(每 512KB 分配采一次),所以 heap profile 文件通常只有几 MB,不管你的堆有多大。Go 用”采样”换来了诊断数据规模的可控性,代价是拿不到”某个对象是被谁持有的”这种精确引用链。 Java 的 dump 是全量的,信息完整,代价就是这个文件本身会成为问题。
又是同一笔交易:全量 vs 采样,精确 vs 可控。
题 3:日志打太多导致磁盘满了,打太少导致排查不了,怎么平衡?
参考答案
直接答案:不要试图找一个”刚刚好的量”,那个量不存在。正确做法是让日志量变成动态的** —— 平时少打(省成本),出事时多打(保证据),而不是永远打一个固定的量。**
核心手段有五个:分级、采样、动态调级、保留策略、关键路径必打。
手段一:分级(按级别差异化处理)。
<!-- ERROR/WARN 单独一个文件,保留久一点,因为它们量小但价值高 -->
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>/data/logs/app-error.log</file>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>WARN</level> <!-- 只收 WARN 及以上 -->
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>/data/logs/app-error.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>200MB</maxFileSize>
<maxHistory>30</maxHistory> <!-- ★ 保留 30 天 -->
<totalSizeCap>5GB</totalSizeCap> <!-- ★ 总量上限,双重保险 -->
</rollingPolicy>
</appender>
<!-- INFO 和以下单独一个文件,保留短一些 -->
<appender name="INFO_FILE" ...>
<maxHistory>3</maxHistory> <!-- 只保留 3 天 -->
<totalSizeCap>20GB</totalSizeCap>
</appender>关键点是 totalSizeCap:它保证日志无论什么情况都不会吃光磁盘。没有这个配置,你的日志策略就是”祈祷不会出事”。
再加一层系统级保险 —— logrotate 或者容器里的 sidecar,以及磁盘使用率的告警(80% 告警,90% 紧急)。
手段二:采样(高频路径)。
// 对高频日志按比率采样,保留统计意义但砍掉量
private static final AtomicLong COUNTER = new AtomicLong();
private void logPaymentStart(Long orderId) {
// 每 100 条打 1 条,但错误永远打
if (COUNTER.incrementAndGet() % 100 == 0) {
log.info("payment.start orderId={}", orderId);
}
}更好的做法是用日志框架自带的采样能力(Logback 没有内置,Log4j 2 有 BurstFilter):
<!-- Log4j 2:速率限制,每秒最多 100 条,超出的丢弃(可配置超速率后的行为) -->
<BurstFilter level="INFO" rate="100" maxBurst="200"/>采样的原则:错误和异常绝不采样(它们的价值密度太高),只对成功路径的 INFO 采样。
手段三(这是本题的核心答案):动态调日志级别。
生产默认 INFO(或 WARN),出事时临时开 DEBUG,复现完调回去,全程不重启:
# 方式一:Arthas
logger --name com.example.order --level DEBUG
# ... 复现问题 ...
logger --name com.example.order --level INFO # ★ 记得调回来
# 方式二:Spring Boot Actuator(需要开启端点并鉴权)
curl -X POST http://localhost:8080/actuator/loggers/com.example.order \
-H "Content-Type: application/json" \
-d '{"configuredLevel":"DEBUG"}'# application.yml:只暴露 loggers 端点,且必须鉴权
management:
endpoints:
web:
exposure:
include: health,info,loggers,metrics
endpoint:
loggers:
enabled: true这个能力把”日志量”从一个静态的设计决策,变成了一个动态的运维动作。 你不再需要在”省磁盘”和”留证据”之间做一次性取舍 —— 你平时省着,需要的时候按需打开。
代价和注意事项:
- 开 DEBUG 的瞬间日志量可能暴涨 10 倍,先确认磁盘有余量
- 调完必须调回去(建议设个手机闹钟,或者做成”带 TTL 的调级”脚本)
- Actuator 端点暴露了修改能力,必须鉴权,否则等于给了攻击者一个”让磁盘写满”的按钮
手四:保留策略(不同介质不同时长)。
| 介质 | 保留时长 | 理由 |
|---|---|---|
| 本地磁盘(热) | 3~7 天 | 排查最近的问题,要快 |
| 对象存储(冷,如 S3/OSS) | 30~90 天 | 合规 + 追溯历史问题,便宜 |
| 日志平台(ES/Loki/ClickHouse) | 7~14 天热 + 更长的冷存 | 全文检索能力是排查的核心 |
注意冷热分层的成本差异巨大:ES 里存 1TB 日志的成本,可能是对象存储里的几十倍。所以不是所有日志都值得进 ES —— 只把 ERROR + 采样后的 INFO 送进去,全量原始日志压缩后丢对象存储。
手段五:关键路径必打(不可协商的底线)。
有些日志无论成本多高都必须打,因为它们的缺失会让排查从”10 分钟”变成”不可能”:
// 必须打的关键节点(每一条都带业务主键 + 关键参数)
log.info("order.created orderId={} userId={} amount={} skuCount={}", ...);
log.info("payment.result orderId={} channel={} success={} code={} cost={}ms", ...);
log.warn("inventory.short orderId={} sku={} required={} actual={}", ...);
log.error("order.failed orderId={} userId={} reason={}", ..., e);怎么定义”关键路径”? 一个判据:如果这个操作失败会导致用户投诉或资金损失,它的每个状态变更都要有日志。 下单、支付、退款、发货、状态机流转 —— 这些属于”钱和货”的路径,日志成本相对于业务价值可以忽略。
而”进入方法""查询完成""开始处理”这类,永远不需要。
更深一层:这道题的本质是”信息的不对称价值”。
我在前面异步日志那道题里点过一次,这里再展开:日志的价值分布是极端长尾的。
- 99.9% 的日志:永远不会被任何人读。它们的价值是零,纯成本
- 0.1% 的日志:在某个凌晨三点被打开,价值是”少损失几十万”
所以优化目标不是”让日志更好”,而是”让那 0.1% 的日志能被找到、且包含足够信息”:
- 能被找到 → 需要 traceId(MDC)、需要检索能力(ES)、需要合理的保留期
- 包含足够信息 → 需要业务主键 + 关键参数 + 耗时(08.3 那张好/坏日志对照表)
- 不被淹没 → 需要准确的级别(别什么都打 ERROR)、需要采样(砍掉无价值的重复)
从这个角度看,很多”日志规范”的问题是它们优化错了目标 —— 它们关心”日志格式是否统一""日志量是否达标”,而这些跟”凌晨三点能否定位问题”关系不大。
Go 的对照:zap 的 SamplingConfig 是标准库级别的能力,开箱即用:
cfg := zap.NewProductionConfig()
cfg.Sampling = &zap.SamplingConfig{
Initial: 100, // 同一条日志(相同 level + message)每秒前 100 条都打
Thereafter: 10, // 之后的每秒只打 10 条
}注意它的采样单位是”同一条日志”(按 level + message 去重),这意味着:高频重复的成功日志被采样掉,而罕见的错误日志因为是”第一条”所以 100% 保留。 这个设计比 Java 的”全局采样”聪明 —— 它天然地”按价值采样”而不是”按比例采样”。
Java 想要这个能力得自己写(Logback 没有内置)。这是个值得抄过来的设计。
题 4:为什么”能复现”是排错的分水岭?Java 项目里怎么提高可复现性?
参考答案
直接答案:因为”能复现”意味着你获得了做实验的能力 —— 而排错本质上就是一系列实验。
为什么复现这么关键?三个理由:
1. 复现让你能做受控实验(单变量原则)。
不能复现时,你的所有判断都是基于一次观察的相关性推断:“我看到日志里有 A,然后系统挂了,所以 A 导致了挂”。这可能是因果,也可能纯粹是巧合(它们在时间上相邻而已)。
能复现时,你可以:改一个变量 → 再跑一次 → 看结果。两次观察的差异,比一次观察的内容信息量大得多。
2. 复现让你能验证修复(而不只是”看起来好了”)。
这是最实际的一条。不能复现时,你的”修复”永远无法被证实 —— 你只能祈祷别再出现。而 08.1 讲过,重启能伪造”问题消失”的假象。
能复现时,你有一条清晰的验收标准:修复前必现,修复后不再现。
3. 复现把”偶发问题”降级为”确定性问题”,从而解锁二分法。
不能复现的问题无法用二分法(你删掉一半代码,问题没出现 —— 是修好了,还是这次本来就碰巧不出?你无法区分)。能复现就可以。
Java 项目里提高可复现性的六个手段:
手段一:让本地能起完整依赖(Testcontainers)。
这是投入产出比最高的一件事。Java 服务依赖 MySQL、Redis、Kafka、Elasticsearch —— 如果本地起不来这些,你就永远不敢改代码,因为改完没法验证。
@Testcontainers
class OrderServiceIT {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("order")
.withInitScript("schema.sql");
@Container
static GenericContainer<?> redis =
new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
@DynamicPropertySource
static void props(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.redis.host", redis::getHost);
}
@Test
void shouldDeductInventory() { /* 真实的集成测试 */ }
}这一步的价值超出测试本身:它意味着任何一个新人(包括三个月后的你)都能在 10 分钟内把整套环境跑起来,然后自己复现问题。
手段二:给老代码补测试(第 09 章的主题)。
不能复现的问题,八成是因为那段代码从来没有被测试覆盖过,所以没人知道它在边界条件下会怎样。
给老代码加测试的正确顺序(不是按覆盖率,是按价值):
- 先测出过问题的那条路径(它已经证明自己会坏)
- 再测边界条件(null、空集合、超大数据量、并发)
- 最后才是补常规路径的覆盖率
手段三:Arthas 的 tt 回放 —— “半个复现”。
tt -t com.example.OrderService pay -n 50 # 记录真实调用
tt -i 1004 -p # 用当时的参数重放严格说这不是真复现(它只重放了这一层的入参,不重放外部依赖的状态),但在”拿不到真实输入”的场景下,它是最接近复现的东西。尤其是配合 tt -i 1004 -w 'params[0].xxx' 把参数导出来,写成一个单元测试的输入 —— 这就是一个真正的复现用例了。
手段四:流量录制回放(jvm-sandbox-repeater / jvm-sandbox)。
这是阿里开源的一套方案,原理是在 JVM 层录制线上真实流量(入参 + 下游调用的返回值),然后在测试环境回放:
线上:记录 OrderService.pay(params) 的入参
+ 记录它调用 paymentClient.pay() 的返回值(mock 数据)
+ 记录它查 DB 的结果
↓ 存起来
测试环境:用同样的入参跑,把下游调用替换成录制的返回值
↓
→ 完整复现当时的执行环境,包括外部依赖的行为
它解决了 tt 解决不了的问题:外部依赖的状态。这是生产级复现能力的天花板,代价是接入和运维成本较高。
手段五:把”环境差异”显式化。
很多”本地不复现、线上必现”的问题,根因是环境差异。显式列出这些差异,能省掉大量瞎猜:
| 差异维度 | 本地 | 生产 | 怎么消除 |
|---|---|---|---|
| JVM 参数 | 默认 | -Xmx4g -XX:+UseG1GC ... | 本地用同样的参数 |
| 数据量 | 100 行 | 200 万行 | 造数据 / 用影子库 |
| 并发度 | 1 | 500 QPS | 压测 |
| 依赖版本 | SNAPSHOT | 固定版本 | 锁版本 |
| 配置 | application-local.yml | application-prod.yml | diff 两个配置文件 |
| 时区/编码 | 本地 | 容器 | 容器化本地环境 |
diff 一下本地和生产的配置 —— 这一步 5 分钟,经常直接命中。
手段六(最根本):把不确定性从代码里拿掉。
回顾 08.7 三个案例,它们的根因代码都有一个共同点:行为依赖于外部状态,而这个依赖是隐式的。
// 隐式依赖:锁的持有时间取决于 SQL 有多慢
public synchronized void deduct(String sku, int n) { statsMapper.selectDailyStats(...); }
// 隐式依赖:内存占用取决于订单量
static Map<Long, OrderDTO> CACHE = new ConcurrentHashMap<>();
// 隐式依赖:内存占用取决于表有多少行
List<OrderDTO> all = orderMapper.selectAll();提高”可复现性”的最有效手段,其实是让代码的行为不依赖于不可控的外部状态:锁里不做 IO、缓存有容量上限、大结果集流式处理。这些改动表面上是”性能和稳定性的优化”,实际上它们同时让系统变得可预测,而可预测 = 可复现 = 可排错。
更深一层:可复现性不是运气,是设计出来的。
这句话值得单独强调。很多人把”能不能复现”当成运气问题 —— “这个 bug 邪门,本地就是不复现”。但真相是:可复现性是系统的一个属性,就像性能、可用性一样,它需要被设计进去。
一个系统的可复现性由这些因素决定:
- 外部依赖能否被本地完整拉起(Testcontainers)
- 外部依赖的行为能否被记录并重放(流量录制)
- 代码的行为是否依赖于隐式的外部状态(并发、时间、数据量、随机数)
- 是否有足够的观测数据让你”看到”当时发生了什么(日志、tracing)
Go 项目在这件事上通常比 Java 项目做得好,原因很实在:
# Go 项目拉起全套依赖
docker compose up -d # 一个文件搞定
go test ./... # 跑起来很快
# Java 项目
docker compose up -d
mvn test # 慢,且 Spring 上下文启动一次几十秒Go 的编译快 + 启动快 + 依赖少(标准库覆盖了 HTTP/JSON/SQL),让”本地跑起来”这件事的成本极低,所以 Go 项目天然更容易做到”人人能复现”。Java 的 Spring Boot 项目启动一次 40 秒,跑一次集成测试要等上下文加载 —— 这个摩擦成本会真实地降低团队写集成测试的意愿。
所以 Java 提高可复现性最关键的一步,可能不是技术,而是把”跑一次的成本”降下来:用 Testcontainers 的 Reusable 模式(容器复用,不用每次启停)、用 @DirtiesContext 控制 Spring 上下文的重建、把慢的集成测试和快的单元测试分开跑。
题 5(开放题,无标准答案):对比 Go 和 Java 的可观测性生态,你觉得各自最该向对方学什么?
先自己列一张表,把两边的”有”和”没有”写下来,再看下面的几个方向。
几个可以顺着想的轴:
- 事后诊断 vs 事先埋点:Java 能在出事后插桩(Arthas),Go 只能在写代码时埋好。谁更优?还是说这根本不是”优劣”而是”取舍”?
- 数据规模的可控性:Go 的 pprof 文件永远只有几 MB(采样),Java 的堆 dump 可能有 30G(全量)。在”云时代按量付费”的背景下,哪个更现实?
- 标准库的角色:
net/http/pprof、runtime/trace、log/slog都是标准库,零接入成本。Java 的 JFR/JMX 也是 JDK 自带的 —— 那为什么大家还是觉得 Java 的可观测性”要装一堆东西”?- 可观测性数据的”语义”:Go 的 pprof 能精确到 allocations space 的分配点;Java 的 MAT 能给出完整的 GC Root 引用链;eBPF 能给出系统视角但拿不到业务语义。这三者能互相替代吗?
- 一个反方向的问题:Java 生态有没有可能因为”工具太强”而放松了对代码本身可观测性的要求?(提示:想想那些”反正有 Arthas,日志打不打无所谓”的项目,出事时的下场。)
想清楚你的答案,写下来。等读完第 21 章(《上线与运维》)再回来看这一题 —— 那一章会讲可观测性在 CI/CD 和生产环境里怎么落地,你可能会想改答案。
下一章预告
你现在有一套完整的 SOP:能读栈、能看日志、能用 Arthas 在线上直接看数据、能靠 JFR 抓偶发问题、还能顺着 traceId 把八台机器上的日志串起来。
但你面对的是一个没有测试、没人敢改的老服务。你定位到了根因,写好了修复 —— 然后呢?你怎么证明这个修复是对的?怎么保证它不会在三个月后以另一种形式复发?你不敢改,因为你没有安全网。
第 09 章《单元测试与可测试性》,我们解决”敢改别人的代码”这件事。具体讲:JUnit 5 的核心机制(@ParameterizedTest、动态测试、扩展模型),Mockito 的正确用法和它的边界(哪些东西不该 mock),Testcontainers 怎么让集成测试真正跑起来,以及最关键的 —— 怎么给一段没有测试、依赖满天飞、静态调用遍地的前任代码,一点点把可测试性撬开。
那一章会直接用到本章的两个东西:tt 抓出来的真实参数是最好的测试用例输入;TaskDecorator、MDC 这些”上下文传递”的机制,恰好是单元测试里最容易被忽略、也最容易出问题的一类代码。
排错让你知道问题在哪,测试让你有能力修好它。