qinghuan
← 工程实践 / 全部笔记

单元 51 / 工程实践

P0 · 必会主线L1 · 基础

日志与调试:让一次失败请求留下足够而克制的证据

让日志能串起一条请求的关键步骤、结果与异常,同时控制字段和敏感信息,并正确清理线程上下文。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能为一次失败请求串起入口、依赖和结果,并控制日志中的敏感信息。

学习理由、准备要求与知识点

为什么学它

没有请求上下文的日志很难定位偶发问题,日志质量也影响是否泄露凭据和个人数据。

先读这些内容

知识点清单

  • 日志级别
  • 参数化日志
  • 异常堆栈
  • 请求标识
  • MDC 生命周期
  • 脱敏
  • 断点与条件断点
  • 日志时间线

运行前先确认Java 17 与 SLF4J 2.x 概念;完整算法使用 ThreadLocal 模型和真实本地单线程池,未运行具体日志后端或分布式追踪集成。ThreadLocal 源码核验 Microsoft 17.0.20.1,上游 jdk-17.0.20-ga。

01先弄懂它是什么

一次订单失败,日志应能回答:是哪次请求、哪笔订单、在哪一步、结果怎样。requestId 把同一请求的记录连起来,业务编号指向具体对象,事件名说明阶段,异常堆栈帮助找到调用位置。并非字段越多越好;整份请求正文既可能淹没重点,也可能把敏感信息带进日志。

SLF4J 提供统一日志接口,真正输出由日志提供者完成。本篇使用 SLF4J 2.x 常见的占位符写法,避免手工拼接字符串,但传入参数里的昂贵计算仍可能提前执行。记录异常时,要用相应重载或适当末尾参数传入 Throwable,才能保留堆栈;只写 e.getMessage 往往看不到真正失败的位置。

MDC 用来给后续日志附上一组信息,例如 requestId。常见日志实现按线程保存这组信息,但线程池会让同一线程接着处理别人的请求,一个请求也可能换线程继续。因此不能用线程名代替请求编号。进入处理时设置好信息,结束时恢复原值或清理;把工作交给另一个线程时,则明确复制需要的少量字段,再由工作线程安装,不能假设线程池会自动带过去。

文中这几个词,先认一认

关联标识
在同一次操作的多条记录里保持一致的编号,方便把分散日志连成过程。
上下文作用域
某组日志字段从何时开始生效、到何时恢复或清除,避免串到后续请求。
诊断假设
对故障原因提出可验证的解释,并说明用什么输入和现象能支持或推翻它。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

同步调用可以在入口安装 requestId,并在 finally 中清理。若框架已安装上层上下文,内层不应简单 clear 整张表,否则会删除不属于自己的字段;应恢复进入前的快照,或仅管理自己拥有的键。嵌套操作结束后恢复外层值,和完全新请求结束后清空,是两个不同的情况。

提交异步任务时,就把需要的日志字段复制下来,而不是等任务开始执行才去读取调用方的新状态。工作线程开始前先保存自己原有的信息,再装上这份副本;任务结束后,无论成功失败,都在 finally 中恢复原来的信息。这样不会把这次请求的编号留给下一个任务。ThreadLocal 只是让每条线程各自保存一份值,不负责帮你跨线程传递,也不会在任务结束时自动清理。

日志级别应该表达事件意义。可预期的输入拒绝未必是系统错误,真正不可完成的操作则需要足够上下文与异常链。高频事件可以聚合为指标,必要日志采用采样或限流;但涉及必须完整保留的审计记录,应单独设计可靠存储和保留策略,不能把普通异步日志队列当成绝不丢失的账本。

调试从可重复的输入和具体假设开始。使用条件断点检查某个订单首次进入错误状态的地点,观察变量与调用栈,再用修复后的测试重放。断点暂停会改变超时和线程交错,因此调试器里不再复现不能证明竞态消失。Java 17 的 jcmd 等工具可补充线程与运行时证据,使用前先确认目标进程和命令的影响范围。

正在加载流程图…

线程池复用不应让前一请求的字段泄漏到下一任务;异常路径也必须恢复。
查看流程图源码(Mermaid)
flowchart TD
 A["提交线程捕获字段快照"] --> B["任务进入工作线程"]
 B --> C["保存工作线程原上下文"]
 C --> D["安装快照并执行业务"]
 D --> E["finally 恢复原上下文"]
 E --> F["线程可安全服务下一任务"]

按顺序看,每一步做了什么

  1. 定义请求标识和必要业务字段。
  2. 明确上下文所有者及嵌套规则。
  3. 跨线程前捕获不可变快照。
  4. 执行前安装,finally 恢复。
  5. 用同一失败输入核对结果与关联日志。

03对照实现代码

ThreadLocal.remove:清理当前线程中的条目 (新标签页)↗

Microsoft OpenJDK 17.0.20.1;java.base/java/lang/ThreadLocal.java · java.lang.ThreadLocal#remove()

以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。

核心机制代码 · java
public final class SourceModel {
  private static final ThreadLocal<String> REQUEST=new ThreadLocal<>();
  static void run(String request,Runnable body) {
    String previous=REQUEST.get(); // 保存外层值,允许同步嵌套。
    REQUEST.set(request);
    try {body.run();}
    finally {
      if(previous==null) REQUEST.remove(); // 当前线程没有外层状态时删除条目。
      else REQUEST.set(previous); // 嵌套退出恢复外层,而非一概清空。
    }
  }
}

本机 ThreadLocal.remove 获取当前线程对应的 ThreadLocalMap,存在时从中移除当前 ThreadLocal 的条目。这是当前线程局部操作,不会清理其他线程的映射。调用位置错误,例如在提交线程清理而工作线程仍残留字段,就无法解决线程池中的上下文串线。

ThreadLocal 对象通常是共享的访问入口,值却附着于各线程。线程池长期保留工作线程,任务结束本身不等于线程结束,所以任务逻辑必须显式处理条目的生命周期。垃圾回收内部使用弱引用等机制,也不能被当成业务层保证及时清理上下文的方案。

教学代码是原创字符串作用域模型,不是 SLF4J MDC 或具体日志后端的原样实现。它展示 finally 与外层恢复,省略键值映射、异步传输和日志格式。SLF4J 的实际 MDC 行为还取决于提供者,生产项目需核对所用版本和适配器契约。

InheritableThreadLocal 可能在创建新线程时复制状态,但线程池线程常在请求之前已经建好,所以不能靠它自动传递每个请求。应在提交任务时捕获信息,在真正运行任务的线程中设置,并在这个线程里恢复。完整算法用包装器把这三个位置明确写出来。

代码先调用谁,再调用谁

  1. ThreadLocal.remove
  2. 获取 Thread.currentThread 的映射
  3. 移除本 ThreadLocal 条目
  4. 任务 finally 完成恢复
  5. 后续任务重新建立自己的上下文

04动手写一个例子

带嵌套恢复的请求上下文传播

将提交时的字符串字段快照传播到任务执行线程,正常或异常退出后恢复该线程旧状态;通过单工作线程复用验证没有请求串线。完整程序只依赖 Java17。

输入 1
外层 A,内层 B 结束
输出 1
恢复 A
输入 2
请求 A 在线程池异常退出,下一任务无上下文
输出 2
下一任务看到空表

上下文不能直接共享可变 Map,否则提交后修改原表会影响尚未执行的任务。先使用 Map.copyOf 固定键值快照,它同时拒绝空键和值,减小含糊状态。每个作用域保存 previous,再设置 snapshot,执行后根据 previous 恢复或 remove。

wrap 在调用时捕获当前上下文,返回一个稍后调用 run 的 Runnable。安装发生在工作线程,而捕获发生在提交线程,这两个动作分离后才能明确传播时刻。任务自己的嵌套作用域依然按相同规则保存与恢复,异常不会跳过 finally。

测试使用只有一个工作线程的 ExecutorService,让第二个任务必然复用第一个任务的线程,从而检测残留。另行检查嵌套恢复、异常恢复和原始 Map 修改不影响快照,避免只测正常结果。测试结束关闭线程池并等待退出,防止测试进程因为资源未释放而挂起。

为什么这样写是对的任一作用域退出后,当前线程的上下文等于进入前状态;捕获快照不随来源 Map 后续修改而变化。

时间 每次作用域安装或捕获 O(k),k 为字段数;具体不可变映射复用可能减少复制。额外空间 深度 d、每层 k 字段时上界 O(dk),另含排队任务捕获的快照。

主要成本来自 Map.copyOf 遍历字段;ThreadLocal 访问在常见实现下预期常数,实际任务执行成本另计。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 45 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.*;
import java.util.concurrent.*;
public class Main {
  static final ThreadLocal<Map<String,String>> LOCAL=new ThreadLocal<>();
  static Map<String,String> current() {Map<String,String> m=LOCAL.get(); return m==null?Map.of():m;}
  static void run(Map<String,String> input,Runnable body) {
    Map<String,String> snapshot=Map.copyOf(input),previous=LOCAL.get(); // 固定快照并保存当前线程旧值。
    LOCAL.set(snapshot);
    try {body.run();}
    finally {
      if(previous==null) LOCAL.remove(); else LOCAL.set(previous); // 异常与正常路径都恢复。
    }
  }
  static Runnable wrap(Runnable body) {
    Map<String,String> captured=Map.copyOf(current()); // 在提交方捕获,稍后在执行方安装。
    return ()->run(captured,body);
  }
  static void eq(Object a,Object b) {if(!Objects.equals(a,b)) throw new AssertionError(a+" != "+b);}
  public static void main(String[] args) throws Exception {
    eq(current(),Map.of());
    run(Map.of("id","A"),()->{
      eq(current().get("id"),"A");
      run(Map.of("id","B"),()->eq(current().get("id"),"B"));
      eq(current().get("id"),"A"); // 内层不能清掉外层字段。
    }); eq(current(),Map.of());
    try {run(Map.of("id","bad"),()->{throw new IllegalStateException();});}
    catch(IllegalStateException expected) {} eq(current(),Map.of());
    Map<String,String> mutable=new HashMap<>(Map.of("id","stable"));
    run(mutable,()->{mutable.put("id","changed"); eq(current().get("id"),"stable");});
    Runnable[] captured=new Runnable[1];
    run(Map.of("id","async"),()->captured[0]=wrap(()->eq(current().get("id"),"async")));
    ExecutorService pool=Executors.newSingleThreadExecutor();
    try {
      pool.submit(captured[0]).get();
      eq(pool.submit(Main::current).get(),Map.of()); // 同一工作线程下一任务不得残留请求字段。
      Runnable[] broken=new Runnable[1];
      run(Map.of("id","error"),()->broken[0]=wrap(()->{throw new IllegalArgumentException();}));
      try {pool.submit(broken[0]).get(); throw new AssertionError();}
      catch(ExecutionException expected) {eq(expected.getCause().getClass(),IllegalArgumentException.class);}
      eq(pool.submit(Main::current).get(),Map.of());
    } finally {pool.shutdown(); if(!pool.awaitTermination(5,TimeUnit.SECONDS)) throw new AssertionError("pool");}
    eq(current(),Map.of()); // 工作线程的传播没有修改提交线程。
    System.out.println("PASS 7");
  }
}

这些特殊情况也要试一试

  • 无上下文时返回空表。
  • 嵌套退出恢复外层。
  • 任务异常仍清理。
  • 原始 Map 后续修改不改变快照。
进阶选读 · 分布式追踪上下文

场景一次调用跨服务、线程、重试和消息队列。

做法采用框架支持的追踪协议,区分请求标识、traceId 与 spanId,控制传播字段和信任边界,并在异步和批处理场景明确定义父子关系。

复杂度传播字段通常固定,但 span 数与调用量相关,需要采样和存储预算。

好处与代价完整链路更易诊断,仍受采样、时钟差异和丢失事件影响,不保证每次请求都有全量轨迹。

05项目里怎么用

日志应围绕排障问题设计稳定字段,而不是把整个对象 toString 输出。凭据、令牌、完整个人信息和连接秘密应在进入日志前过滤;外部可控文本还需处理换行与长度,避免破坏一条事件一条记录的结构。requestId 也要限制格式和长度,不应无条件相信客户端传入的任意字符串。

通常在能决定业务结果并补充操作信息的地方记录一次异常,再继续抛出或转成明确响应。每层重复打印同一堆栈会淹没重点,完全不记录又无法解释失败。指标用于发现错误率变化,日志查看具体案例,追踪连接跨服务的先后,三者各有作用。

日志输出也消耗 CPU、内存、磁盘与网络。异步输出降低调用线程等待,但队列满、进程退出或磁盘故障时的策略必须明确。重要审计数据应拥有独立可靠性设计。调试和性能分析应限定环境与时间窗口,尤其是暂停线程、生成堆转储等操作,其影响和数据内容不能被当作普通无成本打印。

容易踩的坑

  • 线程池任务结束后不清理 MDC。
  • 内层直接 clear 删除框架拥有的字段。
  • 只打印异常消息而丢掉堆栈。
  • 把线程名等同于跨线程请求标识。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
同步参数化日志调用路径直观输出等待可能影响请求低到中等日志量
异步日志降低直接等待队列与退出策略更复杂高吞吐且允许明确丢弃策略
追踪与结构化事件跨步骤关联较清楚采样、字段和存储需治理多服务故障定位

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01ThreadLocal 为什么不会自动传播任务上下文?

展开参考答案与推导

它提供当前线程的局部存储,任务调度可能换到已有工作线程。提交时捕获和执行时安装需要显式机制,不能依据同一个调用链就假设值自动存在。

02finally 为什么恢复旧值而非总是 clear?

展开参考答案与推导

嵌套调用或框架可能已经建立外层上下文。恢复旧值能保留上层所有权,而新入口没有旧值时再删除,避免破坏不属于本层的字段。

03参数化日志是否阻止昂贵表达式求值?

展开参考答案与推导

通常不能保证。Java 会先计算方法参数,若参数调用昂贵函数,日志级别关闭也可能已支付成本;可按 API 使用级别检查或惰性参数机制。

换个情况,还会处理吗

01不同用户日志出现相同 requestId 如何定位?

展开参考答案与推导

检查线程池复用后的清理和异常路径,再检查捕获时刻是否共享可变 Map。用单线程池连续执行两个不同请求,可以稳定暴露残留而不依赖随机调度。

02断点下竞态消失说明修复了吗?

展开参考答案与推导

不能。暂停改变了调度和超时,可能只隐藏原交错。需要重放并发场景、检查同步关系和共享状态,调试器观察只提供一种执行轨迹。

03每层都记录同一异常如何改进?

展开参考答案与推导

选择能决定最终操作结果且具备业务上下文的边界记录,内层补充原因后传播。保留必要堆栈与关联标识,减少重复打印造成的信号稀释。

写代码试一试

01嵌套作用域退出后是什么?

输入 / 条件
A 内运行 B,B 完成
输出 / 预期
A

提示 保存进入前状态。

展开参考答案与推导

进入 B 时保存 A,finally 恢复 A;最外层结束才删除。若 B 直接清空整表,会破坏外层后续日志。

02如何证明异常任务没有污染下一个任务?

输入 / 条件
单线程池任务一抛异常,任务二读上下文
输出 / 预期
空表

提示 强制复用同一线程。

展开参考答案与推导

先等待任务一完成并检查其异常,再提交任务二读取上下文。单线程池使两任务共享工作线程,能够直接检验 finally 清理效果。

03如何防止提交后改字段影响异步任务?

输入 / 条件
捕获 id=A,原 Map 随后改 B
输出 / 预期
任务仍看到 A

提示 在捕获点复制。

展开参考答案与推导

使用不可修改快照保存字段,而不是保留外部可变 Map 引用。安装应发生在工作线程,快照应在定义好的提交边界创建。

读完之后,独立完成

课后作业:自己做一遍

本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能为一次失败请求串起入口、依赖和结果,并控制日志中的敏感信息。

本篇重点加练

请求关联与脱敏必须在输入不可信时仍可解释,日志完整不等于可以无界记录原始数据。

  • 请求标识
  • 脱敏

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    订单服务为每条输入打印完整 JSON,热点循环还拼接大型字符串。制定日志级别和参数化记录规则,说明信息价值及计算成本。

    提交内容
    日志字段与级别规范
    怎样算完成
    • 区分事件级别和日志量
    • 考虑未启用级别时的表达式代价

    练到的知识点日志级别 · 参数化日志

  2. 作业 02代码推演

    一次请求被网关、控制器和服务层连续捕获同一异常。为异常堆栈和请求标识制定记录责任,画出调查人员关联这些记录的过程。

    提交内容
    日志样例格式与责任图
    怎样算完成
    • 避免脱离原因的重复错误
    • 保留原始异常关联信息

    练到的知识点异常堆栈 · 请求标识

  3. 作业 03代码推演

    外部客户端重复使用同一 requestId,网关又生成内部关联 ID。设计多次请求的关联规则,避免把不同请求拼成同一次执行。

    提交内容
    标识来源与关联表
    怎样算完成
    • 区分外部声明和内部身份
    • 涵盖合法重试

    练到的知识点请求标识

综合应用

  1. 作业 04设计评审

    日志上下文含用户 ID、手机号和请求令牌,异步任务晚于请求结束执行。审查字段必要性、脱敏规则及 MDC 的生命周期。

    提交内容
    字段分级与时序图
    怎样算完成
    • 不用真实个人信息举例
    • 覆盖匿名、异常和线程复用

    练到的知识点MDC 生命周期 · 脱敏

  2. 作业 05排错实验

    在本地应用中制造一个特定订单金额下的校验错误,使用条件断点和日志时间线分别调查,比较两种证据的干扰与遗漏。

    提交内容
    操作记录与调查报告
    怎样算完成
    • 条件断点范围明确
    • 记录调试是否改变时序

    练到的知识点断点与条件断点 · 日志时间线

  3. 作业 06编程实践

    实现本地订单失败演示,包含同步校验、异步回调与一个受控依赖异常;输出脱敏的结构化日志,并写程序检查关联字段完整性。

    提交内容
    代码、脱敏日志与检查报告
    怎样算完成
    • 不记录密码或完整令牌
    • 所有异常路径有可串联记录

    练到的知识点日志级别 · 参数化日志 · 异常堆栈 · 请求标识 · MDC 生命周期 · 脱敏 · 日志时间线

  4. 作业 07设计评审

    用户昵称包含换行、分隔符和类似日志前缀的文本。评审结构化日志、导出文本与人工检索中的转义及脱敏职责。

    提交内容
    日志数据契约
    怎样算完成
    • 使用虚构字符串
    • 不把脱敏等同于防止结构混淆

    练到的知识点脱敏

  5. 作业 08编程实践

    写本地日志输入测试器,生成重复关联 ID、特殊字符和嵌套敏感字段,核验输出能否保持独立事件边界与字段最小化。

    提交内容
    程序、输出及核查报告
    怎样算完成
    • 禁止使用真实令牌
    • 错误样本也不能泄漏完整敏感值

    练到的知识点请求标识 · 脱敏

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    一次故障出现十万条重复日志并挤满磁盘。设计日志预算、聚合和采样后的调查能力验收,说明哪些关键事件应独立保存。

    提交内容
    容量估算与验收表
    怎样算完成
    • 采样丢失信息有明确边界
    • 不以减少日志掩盖错误计数

    练到的知识点日志级别 · 异常堆栈 · 请求标识 · 脱敏 · 日志时间线

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 跨域迁移

    将排障日志转为自动喂给 AI 分析的输入,日志中的用户文本可能包含指令。重新定义日志内容与分析系统控制指令的信任边界。

    提交内容
    数据流与攻击面评审
    怎样算完成
    • 不实际攻击第三方模型或系统
    • 关联事实与不可信文本清楚分开

    练到的知识点请求标识 · 脱敏

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗