← 返回题目列表

日志有哪些最佳实践?MDC 是什么?为什么要用异步日志?

高频 中等 第 2 / 23 题 更新于 2026/08/03
日志MDC异步日志日志脱敏

简化版

日志的最佳实践围绕「写得对、查得到、不拖慢、不泄密」:① 用参数化占位符log.info("user {} login", id) 而非字符串拼接,性能好、日志级别不满足时不拼接);② 用 MDC 做链路追踪(把 traceId 放进 MDC,一个请求的所有日志自动带上同一个 traceId,能串起整条调用链);③ 用异步日志(写日志走独立线程/队列,不阻塞业务线程,提升吞吐);④ 日志脱敏(手机号、身份证、密码等敏感信息打码,防泄露);⑤ 合理分级(ERROR 真错误、WARN 警告、INFO 关键流程、DEBUG 调试细节);⑥ 别吞异常(catch 里要打日志带堆栈,别空 catch)。一句话:占位符防拼接、MDC 串链路、异步不阻塞、脱敏防泄露、分级要合理、异常带堆栈

详细版

核心最佳实践

实践做法原因
参数化占位符log.info("id={}", id)避免字符串拼接、级别不满足时不计算
MDC 链路追踪把 traceId 放 MDC一个请求的所有日志带同一 traceId,能串联
异步日志AsyncAppender写日志不阻塞业务线程,提升吞吐
日志脱敏敏感信息打码防手机号/身份证/密码等泄露
合理分级ERROR/WARN/INFO/DEBUG 各司其职便于过滤、控制日志量
异常带堆栈log.error("msg", e)catch 里要记录异常,别吞

参数化占位符(为什么用 {}

// ✗ 差:字符串拼接——无论日志级别是否输出,都会拼接(浪费)
log.debug("user " + user.getName() + " age " + user.getAge());
// ✓ 好:占位符——只有级别满足要输出时才拼接
log.debug("user {} age {}", user.getName(), user.getAge());

MDC(Mapped Diagnostic Context)链路追踪

// 请求入口(如 Filter):把 traceId 放进 MDC
MDC.put("traceId", UUID.randomUUID().toString());
try {
    // 处理请求——这期间所有 log 都会自动带上 traceId
    log.info("处理订单");  // 输出:[traceId=abc123] 处理订单
} finally {
    MDC.clear();   // ★ 必须清理,否则线程复用会串味
}
// 日志格式配置里加 %X{traceId} 就能打印 MDC 里的 traceId

异步日志(AsyncAppender)

<!-- logback:把同步 Appender 包成异步 -->
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
    <appender-ref ref="FILE"/>   <!-- 实际写文件的 appender -->
    <queueSize>512</queueSize>   <!-- 缓冲队列大小 -->
</appender>
<!-- 业务线程只把日志放进队列就返回,独立线程从队列取出写文件 -->

⚠️ MDC 用完必须 clear()(或 remove)——MDC 底层是 ThreadLocal,日志线程/请求线程会被线程池复用。如果一个请求处理完不清理 MDC,下一个请求复用这个线程时会读到上一个请求残留的 traceId(串味),导致链路追踪错乱。所以标准做法是在请求入口 MDC.put、在 finallyMDC.clear()(Spring Cloud Sleuth/Micronaut Tracing 等框架会自动管理,手写时要注意)。

完整版教学

一、参数化占位符:为什么不用字符串拼接

日志的第一个基本功是用 {} 占位符而非字符串拼接,这不只是风格问题,有实际的性能考量:

// 字符串拼接的问题:
log.debug("user " + user.getName() + " orders " + orders.size());
// 即使当前日志级别是 INFO(debug 不输出),
// 这行的字符串拼接(user.getName()、orders.size()、+ 连接)也会先执行!
// → 白白做了拼接计算,浪费 CPU(高频日志下明显)

// 占位符的优势:
log.debug("user {} orders {}", user.getName(), orders.size());
// 日志框架先判断"debug 级别要不要输出":
//   不输出 → 直接返回,不做任何拼接(参数原样传着但不 toString)
//   输出   → 才用参数替换 {} 生成最终字符串

核心原因:占位符实现了「延迟计算」——只有确定要输出这条日志时,才做字符串拼接。而字符串拼接(+)是「立即计算」——无论级别是否满足,拼接都先发生了。在高频调用的代码里(如循环里的 debug 日志),大量「不会输出但仍执行拼接」的字符串操作会浪费 CPU。所以用 {} 占位符是性能 + 可读性的双重最佳实践。理解「占位符延迟计算、级别不满足不拼接」,就理解了为什么这是日志的第一条铁律。

二、MDC:链路追踪的关键

在微服务/多线程环境下,一个请求的日志会散落在各处,怎么把「同一个请求的所有日志」串起来?靠 MDC(Mapped Diagnostic Context,映射诊断上下文)

问题:高并发下,日志文件里混着成千上万个请求的日志,怎么找出"某一个请求"的完整日志?
  没有标识 → 无法区分哪些日志属于同一个请求 → 排查困难

MDC 的解法:给每个请求分配一个唯一的 traceId,放进 MDC
  请求入口:MDC.put("traceId", 唯一ID)
  → 这个请求处理期间的所有 log,自动在日志里带上这个 traceId
  → 日志格式配 %X{traceId},每行日志都带 [traceId=xxx]
  → 用 traceId 一搜,就能捞出这个请求的所有日志(串起整条链路)

MDC 的价值是「给日志打上「请求身份」标识,实现链路追踪」——底层是 ThreadLocal(存当前线程的诊断信息),所以「当前请求线程」处理期间的所有日志都能带上同一个 traceId。这是排查问题的利器:线上出问题,拿到 traceId 一搜,整个请求的调用链日志全出来了。分布式链路追踪(Sleuth/SkyWalking)就是基于这个思想(traceId 还要跨服务传递)。理解「MDC 用 ThreadLocal 存 traceId、串联一个请求的所有日志」,就掌握了日志链路追踪的核心。但要记住 MDC 用完必须清理(ThreadLocal + 线程池复用,不清会串味,下节详述)。

三、异步日志:不阻塞业务线程

日志写文件/网络是 IO 操作,同步写日志会阻塞业务线程——高并发下这个开销不可忽视:

同步日志的问题:
  业务线程执行 log.info() → 要等日志真的写到磁盘/网络才返回
  → 磁盘 IO 慢时,业务线程被日志 IO 拖住
  → 高并发 + 大量日志 → 日志 IO 成为业务的瓶颈

异步日志的解法(AsyncAppender):
  业务线程 log.info() → 只把日志"放进一个队列"就立即返回(不等写)
  → 独立的日志线程从队列取出、慢慢写到磁盘
  → 业务线程不被日志 IO 阻塞,吞吐提升

异步日志的核心是「用队列解耦『产生日志』和『写日志』」——业务线程只负责「把日志扔进队列」(快),独立的日志线程负责「从队列取出写文件」(慢的 IO 不影响业务)。这和「生产者-消费者」是同一思想。代价和注意点:① 队列满了怎么办(配置丢弃策略或阻塞,别让队列无限涨导致 OOM);② 应用崩溃时队列里未写的日志可能丢失(异步的固有风险,关键日志要权衡);③ 日志顺序(异步下多线程的日志顺序可能和实际执行顺序略有出入)。所以异步日志是「用一点可靠性/顺序性换吞吐」的权衡——高吞吐场景值得,但要配好队列和丢弃策略。理解「异步日志用队列不阻塞业务线程、但有丢日志风险」,就知道怎么权衡使用。

四、日志脱敏:防敏感信息泄露

日志里经常无意中打印了敏感信息(手机号、身份证、密码、银行卡),这是重大的安全和合规风险:

风险场景:
  log.info("user register: {}", user);  // user.toString() 打印了手机号、密码明文
  → 日志文件里明文存了敏感信息
  → 日志被泄露/被运维看到 → 用户隐私泄露、违反合规(GDPR/个人信息保护法)

脱敏(打码)的做法:
  手机号:138****8888(中间打码)
  身份证:110***********1234
  密码/密钥:完全不打,或 ******
  银行卡:只留后 4 位

实现方式:
  - 自定义 toString/日志转换器,对敏感字段打码
  - Logback/Log4j2 的自定义 Converter/Pattern 做统一脱敏
  - 用注解 + AOP 标记敏感字段自动脱敏

日志脱敏的核心原则是「敏感信息不落日志明文」——手机号、身份证等打码,密码/密钥根本不打。这不只是最佳实践,更是合规要求(个人信息保护法、GDPR 都要求保护个人敏感信息)。实现上要「统一处理」——别指望每个开发者手动脱敏(会漏),而是用自定义日志转换器、脱敏工具类、或 AOP + 注解统一处理敏感字段。理解「日志脱敏防泄露、是安全和合规要求、要统一处理」,就知道这是不能忽视的红线——线上事故里「日志泄露敏感信息」是常见的一类。

五、日志分级:合理使用级别

日志级别(TRACE < DEBUG < INFO < WARN < ERROR)不是随便用的,合理分级才能让日志「查得到、不刷屏」:

各级别的正确用法:
  ERROR:真正的错误——影响功能、需要人介入处理(如支付失败、DB 连不上)
    → ERROR 应该配告警,出现就要有人看
  WARN:警告——不影响当前功能但要注意(如重试成功、降级、参数不合法但已兜底)
  INFO:关键业务流程——记录重要节点(如订单创建、支付成功、用户登录)
    → 生产环境默认级别,要能还原关键流程但不刷屏
  DEBUG:调试细节——开发/排查时用(如方法入参、中间变量)
    → 生产默认不开,需要时临时开
  TRACE:最细的追踪——极少用

分级的关键原则:① ERROR 要「真的是错误」(滥用 ERROR 会让告警失去意义——狼来了);② INFO 记关键流程但别刷屏(生产默认 INFO,要能还原业务流程,但别每一步都打);③ DEBUG 生产默认关(DEBUG 日志量大,生产开着会拖慢+占磁盘,排查时临时开)。合理分级的价值是「用级别控制日志量和重要性」——生产用 INFO 看关键流程、出问题临时开 DEBUG 看细节、ERROR 配告警及时响应。理解「级别各司其职、ERROR 别滥用、生产 INFO 为主」,就知道怎么让日志既「查得到」又「不刷屏」。

六、其他实践:异常处理与规范

还有几条重要的日志规范:

① 异常要带堆栈,别吞:
   ✗ catch (Exception e) { }                    // 空 catch,吞异常,出问题查不到
   ✗ log.error("出错了: " + e.getMessage());     // 只打 message,没堆栈,定位难
   ✓ log.error("处理订单失败, orderId={}", id, e); // 带上下文 + 完整异常堆栈

② 别打无意义的日志:
   避免"进入方法""方法结束"这类噪音日志(刷屏、无信息量)
   日志要有"信息量"——出问题时能帮你还原现场

③ 日志要有上下文:
   log.error("失败") ← 没上下文,不知道什么失败、哪个订单
   log.error("扣款失败, userId={}, amount={}", uid, amt, e) ← 有上下文,好排查

④ 控制日志量:
   高频代码里别打大量日志(尤其循环里)→ 刷爆磁盘、拖慢性能

其中最重要的是「异常要带堆栈别吞」——catch 块里要么处理、要么用 log.error("msg", e) 记录完整异常(把异常对象作为最后一个参数传给日志方法,才会打印堆栈),绝不能空 catch 吞异常(出了问题完全查不到)。也别只打 e.getMessage()(丢了堆栈,定位困难)。其次是日志要有上下文和信息量——打日志要带关键参数(orderId、userId),出问题时能还原现场,而不是一句「失败了」。理解「异常带堆栈、日志有上下文、别打噪音、控制日志量」,就掌握了日志的实用规范。

记忆钩子:「日志最佳实践:① 占位符 {} 防拼接(延迟计算,级别不满足不拼)② MDC 放 traceId 串链路(ThreadLocal,用完必须 clear 防串味)③ 异步日志 AsyncAppender 不阻塞业务(队列解耦,但有丢日志风险)④ 脱敏防泄露(手机号身份证打码,合规要求)⑤ 合理分级(ERROR 真错误配告警、INFO 关键流程、DEBUG 生产默认关)⑥ 异常带堆栈别空 catch、日志有上下文」

七、常见误区与追问

  • 误区:日志用字符串拼接还是占位符无所谓。 有区别——占位符延迟计算(级别不满足不拼接),字符串拼接立即执行(浪费),高频日志下性能差异明显,应始终用 {}
  • 误区:MDC 用完不清理也没事。 大问题——MDC 底层是 ThreadLocal,线程池复用线程时会读到上个请求残留的 traceId(串味),链路追踪错乱,必须在 finally 里 clear。
  • 误区:异步日志没有代价。 有——队列满要处理(丢弃或阻塞,别 OOM)、应用崩溃时队列里未写的日志可能丢失、多线程日志顺序可能略乱;是「用可靠性换吞吐」的权衡。
  • 误区:catch 里打 e.getMessage() 就够了。 不够——只打 message 丢了堆栈、定位困难;应 log.error("msg", e) 把异常对象作为参数传入,才会打印完整堆栈。
  • 追问:MDC 是什么,怎么实现链路追踪? Mapped Diagnostic Context,底层是 ThreadLocal;请求入口 put 一个 traceId,处理期间所有日志自动带上(日志格式配 %X{traceId}),用 traceId 就能捞出一个请求的所有日志串联链路。
  • 追问:为什么要用异步日志?有什么风险? 同步写日志阻塞业务线程(IO 慢),异步用队列解耦——业务线程只入队立即返回、独立线程写文件,提升吞吐;风险是崩溃时队列未写日志丢失、队列满要处理、顺序可能略乱。
  • 追问:日志脱敏为什么重要,怎么做? 防手机号/身份证/密码等敏感信息泄露(安全 + 合规要求);做法是统一处理——自定义日志转换器/脱敏工具/AOP+注解对敏感字段打码,别依赖开发者手动(会漏)。

八、加强记忆

日志最佳实践围绕「写得对、查得到、不拖慢、不泄密」,六条要点:① 参数化占位符log.info("id={}", id) 而非字符串拼接——占位符延迟计算,日志级别不满足时不拼接、省 CPU);② MDC 链路追踪(把 traceId 放进 MDC,一个请求的所有日志自动带同一 traceId、能串联整条链路;底层是 ThreadLocal用完必须 clear() 否则线程池复用会串味);③ 异步日志(AsyncAppender 用队列解耦产生和写入,业务线程只入队不等 IO、提升吞吐,但有崩溃丢日志/队列满/顺序乱的风险);④ 日志脱敏(手机号/身份证打码、密码不打,是安全和合规要求,要统一处理别靠手动);⑤ 合理分级(ERROR 真错误配告警、WARN 警告、INFO 关键流程、DEBUG 生产默认关,用级别控制日志量和重要性);⑥ 异常带堆栈log.error("msg", e) 把异常对象传入才打堆栈,绝不空 catch 吞异常,日志要带上下文参数有信息量)。一句话「占位符防拼接、MDC 串链路要 clear、异步不阻塞有丢日志险、脱敏防泄露、分级要合理、异常带堆栈别吞」。