SLF4J 与 Logback 日志
学习目标
- 能用 SLF4J 门面完成参数化日志,并在异常分支保留完整
Throwable堆栈。 - 能用 MDC 放入请求标识,配置 Logback 输出和滚动策略,并在异步边界处理上下文传播。
- 能区分业务日志、诊断日志与操作审计,知道脱敏、采样和 AOP 记录的安全边界。
核心知识点
专业术语
- SLF4J(Simple Logging Facade for Java):日志门面 API;业务代码依赖
Logger,运行时再绑定 Logback 等实现。 - 参数化日志(parameterized logging):用
{}参数标记和参数传值,让门面在对应级别开启时再格式化字符串。 - Throwable:异常对象;把它作为日志调用的最后一个参数,SLF4J/Logback 才能输出堆栈而不是只有
getMessage()。 - MDC(Mapped Diagnostic Context):与当前线程关联的键值上下文,常放
traceId、租户或任务标识。 - Appender 与滚动策略(rolling policy):Appender 决定日志写到控制台、文件或其他目的地,滚动策略决定按时间/大小归档和清理。
- AOP(Aspect-Oriented Programming):在方法边界织入横切行为;操作日志要明确切点、字段白名单和失败语义。
白话解释与边界
SLF4J 只规定调用方式,Logback 负责真正的输出;应用代码应依赖 org.slf4j.Logger 和 LoggerFactory,不要把业务逻辑绑死在 Logback 的实现类上。info、warn 和 error 表示事件严重程度,不等于“所有异常都必须 error”;可恢复的业务拒绝通常应带稳定事件名和参数。
参数化日志应写成 log.info("order={} state={}", safeOrderId, state),不要先用 + 拼接完整字符串。异常日志保留 Throwable,例如 log.error("job={} failed", safeJobId, ex);只输出 ex.getMessage() 会丢失调用栈和异常类型。日志内容还要避开密码、令牌、完整身份证号、上传内容和未经裁剪的请求体。
MDC 通常是线程局部上下文;进入请求或任务时 put,在 finally 中 remove,否则线程池复用会把上一个请求的 traceId 泄露给下一个请求。异步执行、消息消费和虚拟线程切换不应假设 MDC 自动传播,要用明确的任务装饰器或参数传递。Logback 滚动既要限制单文件大小,也要限制保留天数和总磁盘量。
操作日志比普通调试日志更接近审计记录:应只写谁、何时、做了什么、结果和关联 ID,不直接序列化整个参数对象。AOP 适合统一记录稳定的方法边界,但自调用、异步方法、异常被吞掉和代理未生效都会造成漏记;关键审计仍应在业务成功提交后显式记录。
依赖与版本基线
本文按 Java 17、Spring Boot 4.1.0 与 Spring Framework 7 的项目基线组织示例;日志依赖由 Boot BOM 统一管理,示例版本范围是 org.slf4j:slf4j-api:2.0.x 与 ch.qos.logback:logback-classic:1.5.x。Logback 1.5.x 应和 SLF4J 2.0.x provider 配套,不要把 1.7.x provider 混进来。SLF4J 1.7 时代主要依赖静态 binder,SLF4J 2.0 改用 ServiceLoader 找 provider,但 Logger、参数化 {} 和 MDC 的常用调用保持兼容;升级时要一起检查绑定、桥接包和启动告警。
日志关联 ID 是可观测元数据而不是用户输入回显:traceId、jobId 只允许短的 [A-Za-z0-9._:-] 字符串并拒绝 CR/LF,长度上限统一为 64 个字符。日志白名单中 password、secret、token 均不记录,完整请求体或上传内容也禁止进入日志;必要时只记录脱敏后的摘要。
import java.util.regex.Pattern;
private static final Pattern CONTEXT_ID = Pattern.compile("[A-Za-z0-9._:-]{1,64}");
// 初始状态:CONTEXT_ID = Pattern.compile("[A-Za-z0-9._:-]{1,64}")。
// 作用:private static final Pattern CONTEXT_ID = Pattern.compile("[A-Za-z0-9._:-]{1,64}");;长度为 1-64,且只匹配安全上下文字符。
static String safeContextId(String raw) {
if (raw == null || raw.length() > 64 || raw.indexOf('\r') >= 0 || raw.indexOf('\n') >= 0
|| !CONTEXT_ID.matcher(raw).matches()) return "invalid";
// 初始状态:|| !CONTEXT_ID.matcher(raw).matches()) return "invalid"。
// 作用:|| !CONTEXT_ID.matcher(raw).matches()) return "invalid";;raw 不匹配时返回 "invalid"。
return raw;
}
// 结果:safeContextId("trace-01") 返回 "trace-01",传入换行符或超过 64 个字符时返回 "invalid"常用用法
LoggerFactory:获取门面 Logger
用途:用于按类获取 SLF4J Logger,让业务代码只依赖门面并保留统一级别和字段约定。
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
final class ImportService {
private static final Logger log = LoggerFactory.getLogger(ImportService.class);
// 作用:private static final Logger log = LoggerFactory.getLogger(ImportService.class);;获取门面 Logger。
void run(String jobId) {
String safeJobId = safeContextId(jobId);
// 初始状态:safeJobId = safeContextId(jobId)。
// 作用:String safeJobId = safeContextId(jobId);;获取门面 Logger。
log.info("job={} started", safeJobId);
// 作用:log.info("job={} started", safeJobId);;获取门面 Logger。
System.out.println("logged=" + safeJobId);
// 输出:logged=job-1
}
}
new ImportService().run("job-1");
// 初始状态:new ImportService().run("job-1")。
// 作用:用于按类获取 SLF4J `Logger`,让业务代码只依赖门面并保留统一级别和字段约定。同一个类只保留一个静态 logger 即可;日志级别由配置决定,不能把 System.out 当作生产日志通道。输出的事件名和字段名应稳定,便于检索和统计。
这里直接 new ImportService().run("job-1") 只展示方法输出 logged=job-1 和 logger 的普通调用;若日志字段、脱敏或审计由 Spring AOP 代理补充,手工 new 不会触发这些切面,必须调用容器中的代理 Bean。
Logger.info:记录参数化事件
用途:用于在 info 级别开启时才用占位符格式化业务事件。
import org.slf4j.Logger;
void process(Logger log, String taskId) {
log.info("task={} state={}", taskId, "running");
// 初始状态:task = {} state={}", taskId, "running")。
// 作用:log.info("task={} state={}", taskId, "running");;记录参数化事件。
System.out.println("logged=" + taskId);
// 输出:logged=task-7
}
// 作用:用于在 info 级别开启时才用占位符格式化业务事件。参数不应包含令牌、密码或未脱敏的个人数据。
Logger.error:保留异常堆栈
用途:用于记录失败分支并把 Throwable 作为最后参数保留调用栈。
try {
throw new IllegalStateException("temporary failure");
// 作用:throw new IllegalStateException("temporary failure");;保留异常堆栈。
// 初始状态:throw new IllegalStateException("temporary failure")。
} catch (RuntimeException ex) {
log.error("task={} failed", "task-7", ex);
// 初始状态:task = {} failed", "task-7", ex)。
// 作用:log.error("task={} failed", "task-7", ex);;保留异常堆栈。
System.out.println("handled=" + ex.getClass().getSimpleName());
// 输出:handled=IllegalStateException
}
// 说明:log.error("order {} failed", orderId, ex) 用 orderId 填充 {},并把末尾 ex 作为 Throwable 输出完整堆栈;写成 ex.getMessage() 会丢失调用链。只记录 ex.getMessage() 会丢失调用栈;异常应位于占位符参数之后。
Logback 滚动文件:限制日志占用
用途:用于把应用日志写入滚动文件,并同时按时间和大小限制单文件、保留周期与磁盘占用。
String appender = "RollingFileAppender";
// 作用:String appender = "RollingFileAppender";;限制日志占用。
// 初始状态:appender = "RollingFileAppender"。
String policy = "SizeAndTimeBasedRollingPolicy";
// 初始状态:policy = "SizeAndTimeBasedRollingPolicy"。
String pattern = "%d %-5level [%X{traceId}] %logger - %msg%n";
// 初始状态:pattern = "%d %-5level [%X{traceId}] %logger - %msg%n"。
System.out.println(appender + "/" + policy + ":" + pattern);
// 输出:RollingFileAppender/SizeAndTimeBasedRollingPolicy:%d %-5level [%X{traceId}] %logger - %msg%n
// 说明:appender=RollingFileAppender 配合 policy=SizeAndTimeBasedRollingPolicy;pattern 中的 %X{traceId} 从 MDC 读取请求标识。真实文件名、单卷大小和保留周期应在 logback-spring.xml 中配置。Logback XML 中通常把 RollingFileAppender 配合 SizeAndTimeBasedRollingPolicy,设置 fileNamePattern、maxFileSize、maxHistory 和 totalSizeCap。开发环境可以同时使用控制台 Appender;生产环境要确认归档目录权限、时区、压缩和清理策略,不能只设置单文件上限而不设置总量上限。
MDC:为请求附加 traceId
用途:用于让同一请求的日志带上 traceId,并在复用线程返回池前清理上下文。
import org.slf4j.MDC;
void handle(String traceId) {
MDC.put("traceId", safeContextId(traceId));
// 作用:MDC.put("traceId", safeContextId(traceId));;为请求附加 traceId。
// 初始状态:MDC.put("traceId", safeContextId(traceId))。
try {
System.out.println("trace=" + MDC.get("traceId"));
// 输出:trace=req-7
} finally {
MDC.remove("traceId");
// 作用:MDC.remove("traceId");;为请求附加 traceId。
}
}
// 作用:用于让同一请求的日志带上 `traceId`,并在复用线程返回池前清理上下文。Logback pattern 中用 %X{traceId} 读取 MDC;没有 finally 清理时,线程池中的后续请求可能继承旧值。跨线程执行应显式复制允许的键并在目标线程结束后清理,不能把 MDC 当作可靠的业务参数或授权依据。safeContextId 的返回值才允许进入 MDC 或日志模板,不能先记录原始 traceId/jobId 再“事后脱敏”。
不常用但需要知道
脱敏:日志字段白名单与遮蔽
用途:用于在写日志前遮蔽令牌、邮箱和长文本,避免调试便利变成敏感信息泄露。
String maskToken(String token) {
if (token == null || token.length() < 8) return "***";
return token.substring(0, 2) + "***" + token.substring(token.length() - 2);
// 初始状态:return token.substring(0, 2) + "***" + token.substring(token.length() - 2)。
// 作用:return token.substring(0, 2) + "***" + token.substring(token.length() - 2);;日志字段白名单与遮蔽。
}
System.out.println(maskToken("token-123456"));
// 输出:to***56
// 作用:用于在写日志前遮蔽令牌、邮箱和长文本,避免调试便利变成敏感信息泄露。脱敏应按字段语义而不是按全局 String 类型替换;日志白名单优先于黑名单,尤其要避免把整个请求对象通过 toString() 写出。脱敏函数也要覆盖空值、短值和异常路径,不能因为日志级别较低就输出原始机密。
AOP 操作日志:只记录业务边界
用途:用于在稳定的服务方法边界记录操作者、操作名和结果,避免把每个 getter 或内部循环都变成噪声。
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
@Aspect
class OperationLogAspect {
@Pointcut("within(app.service..*) && execution(* *(..))")
void serviceBoundary() {}
@Around("serviceBoundary()")
Object logOperation(ProceedingJoinPoint joinPoint) throws Throwable {
Object result = joinPoint.proceed();
// 初始状态:result = joinPoint.proceed()。
// 作用:Object result = joinPoint.proceed();;只记录业务边界。
System.out.println("audit=success:" + joinPoint.getSignature().getName());
// 输出:audit=success:update
return result;
}
}
// 说明:@Around 匹配标注 @OperationLog 的业务方法,proceed() 前后记录操作名和耗时,异常路径保留 Throwable;目标对象直接 new 或自调用不会经过该 Spring AOP 通知。切点只表示候选边界,代理必须真正创建且调用要经过代理;同类自调用、private 方法和某些异步切换可能绕过切面。真实审计需要区分业务提交成功、业务拒绝和异常失败,并只提取字段白名单;不要在切面中替代授权或吞掉异常。
采样/异常堆栈:控制噪声但保留诊断
用途:用于对高频成功事件采样,同时对异常保留堆栈,避免日志洪水掩盖真正的故障。
import org.slf4j.Logger;
void record(Logger log, boolean sampled, Throwable failure) {
if (sampled) log.debug("heartbeat sampled");
if (failure != null) log.error("worker failed", failure);
System.out.println("diagnostic=" + (failure != null ? "stack" : "sample"));
// 输出:diagnostic=stack
}
// 作用:用于对高频成功事件采样,同时对异常保留堆栈,避免日志洪水掩盖真正的故障。采样规则应按事件类型和关联 ID 可配置,并保留计数指标;不要采样掉支付、权限变更等必须审计的事件。异常堆栈应写入受控日志并设定保留期限,向客户端返回稳定错误码而不是把堆栈直接回显。
常用调用标题补齐
Logger.debug:记录可按需开启的调试细节
Logger.debug 适合诊断路径和中间状态;使用 {} 参数化,避免级别关闭时仍构造字符串。
// 作用:通过 Logger.debug 记录可按需开启的调试细节。
final class CacheReader {
private static final Logger log =
LoggerFactory.getLogger(CacheReader.class);
// 作用:LoggerFactory.getLogger(CacheReader.class);;记录可按需开启的调试细节,返回读取结果。
String read(String key) {
log.debug("reading cache key={}", key);
// 初始状态:key = {}", key)。
// 作用:log.debug("reading cache key={}", key);;记录可按需开启的调试细节。
String value = "hit";
// 初始状态:value = "hit"。
log.debug("cache result key={} present={}", key, value != null);
// 初始状态:key = {} present={}", key, value != null)。
// 作用:log.debug("cache result key={} present={}", key, value != null);;记录可按需开启的调试细节。
return value;
}
}
// 结果:日志事件按级别和字段约定记录,敏感信息不会以原值输出。输出(DEBUG 开启):reading cache key=user:42,随后记录 present=true。
Logger.warn:记录可恢复的异常状态
Logger.warn 表示当前请求可继续但需关注;不要把每次正常分支或敏感数据记为警告。
// 作用:通过 Logger.warn 记录可恢复的异常状态。
final class RemoteLookup {
private static final Logger log =
LoggerFactory.getLogger(RemoteLookup.class);
// 作用:LoggerFactory.getLogger(RemoteLookup.class);;记录可恢复的异常状态。
String lookup(String id) {
try {
return callRemote(id);
} catch (TimeoutException ex) {
log.warn("remote timeout id={}; using fallback", id, ex);
// 初始状态:id = {}; using fallback", id, ex)。
// 作用:log.warn("remote timeout id={}; using fallback", id, ex);;记录可恢复的异常状态。
return "fallback";
}
}
}
// 结果:`callRemote(id)` 超时时记录 `remote timeout id={}` 与异常堆栈,并向调用方返回 `fallback`。输出:超时时保留异常堆栈并返回 fallback。
继续阅读
- List 基础:整理结构化日志字段和批量输出时查 List 的遍历与快照边界。
- Map 基础:组织 MDC、字段白名单和事件属性时查 Map 的键值语义。
- String 文本处理:处理日志模板、字段裁剪和脱敏文本时查字符串边界。
简单案例
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
final class JobRunner {
private static final Logger log = LoggerFactory.getLogger(JobRunner.class);
// 作用:private static final Logger log = LoggerFactory.getLogger(JobRunner.class);;记录可恢复的异常状态。
static String run(String traceId, String jobId) {
String safeJobId = safeContextId(jobId);
// 初始状态:safeJobId = safeContextId(jobId)。
// 作用:String safeJobId = safeContextId(jobId);;记录可恢复的异常状态。
MDC.put("traceId", safeContextId(traceId));
// 作用:MDC.put("traceId", safeContextId(traceId));;记录可恢复的异常状态。
try {
log.info("job={} state={}", safeJobId, "started");
// 作用:log.info("job={} state={}", safeJobId, "started");;记录可恢复的异常状态。
if (safeJobId.equals("invalid")) throw new IllegalArgumentException("job id required");
// 初始状态:if (safeJobId.equals("invalid")) throw new IllegalArgumentException("job id required")。
log.info("job={} state={}", safeJobId, "done");
// 作用:log.info("job={} state={}", safeJobId, "done");;记录可恢复的异常状态。
return "success";
} catch (RuntimeException ex) {
log.error("job={} state={}", safeJobId, "failed", ex);
// 作用:log.error("job={} state={}", safeJobId, "failed", ex);;记录可恢复的异常状态。
return "failure";
} finally {
MDC.remove("traceId");
// 作用:MDC.remove("traceId");;记录可恢复的异常状态。
}
}
}
System.out.println(JobRunner.run("req-7", "job-1"));
// 输出:success
System.out.println(JobRunner.run("req-8", ""));
// 输出:failure案例中成功路径和失败路径都有稳定返回值,异常仍由日志保留完整堆栈;MDC 在两个调用结束后都会清理。框架片段需容器和日志绑定运行,具体输出格式由 Logback 配置决定;不要把这里的 System.out 当作生产日志配置。
易混点
- SLF4J 是调用门面,Logback 是实现;业务代码不应直接依赖 Logback 的内部类来记录普通事件。
- 参数化
{}与字符串拼接的区别是延迟格式化和结构稳定性;异常对象要作为最后参数保留堆栈。 - MDC 是线程上下文,不是跨线程可靠传参;线程池、异步回调和消息消费必须显式传播并清理。
- 日志滚动解决文件增长,脱敏解决内容泄露;设置
maxFileSize不能替代敏感字段白名单。 - AOP 切面可统一观察方法边界,但代理失效、自调用和事务未提交都可能使审计记录与真实结果不一致。
- 采样只适合非关键高频事件;权限、资金和配置变更等审计事件不能静默丢弃。
课后小问
为什么
log.error("failed", ex)比只输出ex.getMessage()更适合排查? 答案:前者保留异常类型和完整调用堆栈,后者通常只有一行文本。 解析:堆栈能定位调用链和根因;客户端响应仍应使用稳定错误码,不能把堆栈回显给用户。为什么 MDC 必须在
finally中清理? 答案:线程池会复用线程,不清理会把旧请求的traceId带到新请求。 解析:MDC 的生命周期应覆盖一次请求或任务,而不是覆盖线程池线程的整个生命周期;跨线程还要显式传播。AOP 操作日志能否替代授权检查? 答案:不能,切面记录“发生了什么”,授权策略决定“是否允许发生”。 解析:代理可能失效且日志可能异步落盘;授权必须在可靠的业务边界执行,审计则记录授权后的结果和失败原因。
本节小结
- SLF4J 提供稳定门面,参数化日志和最后位置的
Throwable保留可检索的诊断信息。 - MDC 适合短生命周期关联 ID,必须在任务结束时清理并为异步边界设计传播策略。
- Logback 用 Appender 和滚动策略控制输出目标、归档周期与磁盘上限。
- 脱敏、白名单、采样和异常堆栈是不同责任,不能用降低日志级别代替安全策略。
- AOP 只负责横切观察,关键操作审计仍要和业务成功、失败及授权边界对齐。
快速回顾
- 会用
LoggerFactory获取 logger,并用{}写参数化info/error。 - 会在
finally清理 MDC,并知道%X{traceId}如何进入 Logback pattern。 - 能配置
RollingFileAppender、SizeAndTimeBasedRollingPolicy的文件边界。 - 能识别 AOP 自调用、异步传播、异常堆栈和敏感字段脱敏的边界。
阿源的知识库