第 08 章(节选) 核心结论与深度思考题

来自第 08 章「第 08 章 排错方法论:从异常栈到根因(一套能复用的 SOP)」。 返回:第 08 章索引

08.9 本章核心结论

  1. “猜”低效不是因为你不聪明,是因为反馈周期。 Go 改一次 3 秒,Java 重启一次 3 分钟 —— 差 60 倍,所以 Java 必须换成”先假设,后验证”。而且重启会清空缓存、连接池、静态状态和 JIT profile,它会伪造出”问题消失了”的假象。

  2. 排错的元方法是三条:先定位后修复、二分法最小化复现、相信证据不相信直觉。 验证顺序按成本排(30 秒能出结果的先做),不是按可能性排。判据:你能不能用一句话说清”第几行的什么东西,在什么条件下,变成了什么状态”。

  3. 异常栈的读法是:先看最后一个 Caused by,从上往下找第一行你自己的包名,... N more 不是截断而是省略重复栈帧,Suppressed 常被忽略但往往才是真凶。 更要命的是 -XX:-OmitStackTraceInFastThrow —— 日志里出现没有栈的 NPE 时,先怀疑它,而不是怀疑日志框架。这个参数建议生产默认就带上。

  4. 日志的质量决定排查的上限。 异步日志必须关掉 discardingThreshold(否则会在最忙的时候丢掉你最需要的 INFO),绝不用 %L/%M(靠抛异常取行号,性能杀手),用参数化日志(级别关闭时不执行 toString()),MDC 塞 traceId(分布式排查的地基)。异常栈回答”在哪出错”,日志参数回答”是什么数据触发的”,缺一不可。

  5. Arthas 是本章最值钱的工具,它把 Java 的反馈周期压回秒级。 dashboard 扫一眼排除一半可能,thread -b 一秒找到持锁线程,sc -d 的 code-source 三秒解决依赖冲突,jad 十秒确认线上版本,watch/trace 让你不用加日志重启就能看到线上数据。纪律:用完 stop,不是 quit;-x 从 2 开始;tt 用完 --delete-all。

  6. Arthas 能做到而 Go 做不到,根源是”字节码 + Instrumentation”vs”机器码 + 无插桩接口”。 这是”运行时灵活性”这笔交易的收益侧 —— Java 把决策推迟到运行时,代价是 NoSuchMethodError 和启动慢,回报是运行时可以被观察和干预。Go 编译成本地码,干净高效,但也永久失去了运行时插桩能力,只能靠事先埋点补偿。

  7. 分布式排查的标准流程是 Metrics 发现问题 → Tracing 定位范围 → Logging 看细节 → 单机工具下钻。 TraceId 在线程池里会丢,因为 MDC 是 ThreadLocal;用 InheritableThreadLocal 会更糟(池化场景下值永不更新,导致数据错乱 + 泄漏)。正解是 TaskDecorator / TTL / OTel Context。Go 的 context.Context 在这个问题上明显更优:显式、不会错乱、不会泄漏。

  8. 三个案例的共同根因不是”代码写错了”,而是”代码的假设和它所处的环境不再匹配”。 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 自己泄漏你的堆)
忘记 resetquit 只断开客户端,增强还在你以为结束了,实际上一直在拖慢服务
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% 的日志能被找到、且包含足够信息”:

  1. 能被找到 → 需要 traceId(MDC)、需要检索能力(ES)、需要合理的保留期
  2. 包含足够信息 → 需要业务主键 + 关键参数 + 耗时(08.3 那张好/坏日志对照表)
  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 章的主题)。

不能复现的问题,八成是因为那段代码从来没有被测试覆盖过,所以没人知道它在边界条件下会怎样。

给老代码加测试的正确顺序(不是按覆盖率,是按价值):

  1. 先测出过问题的那条路径(它已经证明自己会坏)
  2. 再测边界条件(null、空集合、超大数据量、并发)
  3. 最后才是补常规路径的覆盖率

手段三: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 万行造数据 / 用影子库
并发度1500 QPS压测
依赖版本SNAPSHOT固定版本锁版本
配置application-local.ymlapplication-prod.ymldiff 两个配置文件
时区/编码本地容器容器化本地环境

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 这些”上下文传递”的机制,恰好是单元测试里最容易被忽略、也最容易出问题的一类代码。

排错让你知道问题在哪,测试让你有能力修好它。