Java 日志开发实践整理
目录
为什么团队最好先约定一套日志写法#
日志不是“顺手打印一行字符串”,而是故障排查、审计追踪和可观测性的一部分。没有相对一致的写法时,最常见的问题不是“没有日志”,而是日志很多却无法检索、无法关联、无法长期维护。
典型表现包括:
message风格混乱,同类事件写法完全不同- 关键上下文字段缺失,只能靠全文检索猜测问题链路
- 敏感信息进入日志,带来合规和安全风险
- 生产问题无法按
traceId、requestId、订单号或用户 ID 快速定位
本文不讨论“如何随手打一行日志”,而是尝试把日志整理成一套团队更容易落地的开发约定:哪些值得优先做,哪些应尽量避免,哪些是我比较推荐的实践;同时给出 Spring Boot 项目的落地示例和支撑链接。
日志开发的基本原则#
日志约定先解决的是可用性,而不是“看起来有日志”。
我更推荐的原则是:message 只描述发生了什么,不承担完整上下文;关键上下文最好通过结构化字段输出;默认假设日志会进入集中平台并长期保留,因此内容最好可检索、可审计、可脱敏。
不建议把日志当成临时调试输出。不要依赖随手拼接字符串来补充上下文,也不要把敏感信息、不可检索的大段文本或纯噪声写进生产日志。
推荐的做法是让 message 与结构化字段组合起来回答三个问题:发生了什么、影响了什么、后续如何定位。这样日志才能服务排障、审计和关联分析。
错误示例:把所有信息拼进 message,无法按字段过滤,也无法聚合统计:
// 上下文全靠字符串拼接,检索只能依赖全文匹配
log.info("Order " + orderId + " status changed from " + oldStatus + " to " + newStatus + " for user " + userId);
正确示例:message 稳定、简洁,上下文放到结构化字段,可按任意维度检索:
log.atInfo()
.setMessage("order status changed")
.addKeyValue("orderId", orderId)
.addKeyValue("fromStatus", oldStatus)
.addKeyValue("toStatus", newStatus)
.addKeyValue("userId", userId)
.log();
日志框架选型与使用建议#
团队在日志框架选型时通常会优先考虑解耦和可维护性。我更倾向于在应用代码中使用 SLF4J 作为日志门面,而不是直接绑定具体实现(如 Logback、Log4j2)。这样做的好处是未来切换底层实现时只需更换依赖,无需修改业务代码。
<!-- 推荐:只依赖 SLF4J API -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<!-- 运行时绑定具体实现(Spring Boot 默认 Logback) -->
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
</dependency>
评估具体实现时应关注:
- 性能:异步追加器能力、吞吐量、内存占用
- 灵活性:是否支持结构化输出、自定义 encoder、过滤器链
- 生态:是否有成熟的 appender 支持集中式日志平台(如 OpenSearch、Kafka、GELF)
Spring Boot 项目默认使用 Logback,3.4+ 版本已内建 ECS 结构化日志支持;如果团队有 Log4j2 迁移需求,只需调整依赖桥接即可,业务代码无需改动。
选型建议:除非微基准测试明确显示 Log4j2 在你的热路径中有显著优势,否则我通常会先沿用 SLF4J 门面 + Logback。至少在 Spring Boot 项目里,这条路径和框架集成得更顺,也少一些桥接配置成本。
更值得记录什么,尽量别记录什么#
推荐优先记录的内容包括:
- 请求进入关键边界时的入口日志,例如进入 HTTP 接口、消息消费、定时任务或批处理入口
- 外部调用的失败、超时、重试和降级通常都应该记录;关键外部调用的完成日志也应保留,开始日志只在排查延迟或超时链路时按需开启
- 关键状态变化,例如订单从
PENDING进入PAID,或任务从RUNNING进入FAILED - 影响业务结果分支的决定,例如命中风控拒绝、库存不足终止下单、触发降级返回兜底结果
- 需要人工介入的错误和异常
// 入口日志:记录消息消费起始
@KafkaListener(topics = "order-events")
public void onMessage(OrderEvent event) {
log.atInfo()
.setMessage("order event received")
.addKeyValue("orderId", event.getOrderId())
.addKeyValue("eventType", event.getType())
.log();
process(event);
}
// 关键状态变化
log.atInfo()
.setMessage("order status changed")
.addKeyValue("orderId", orderId)
.addKeyValue("fromStatus", "PENDING")
.addKeyValue("toStatus", "PAID")
.log();
// 影响业务结果的决策分支
if (stockCheckResult.isInsufficient()) {
log.atWarn()
.setMessage("order rejected: insufficient stock")
.addKeyValue("orderId", orderId)
.addKeyValue("skuId", skuId)
.addKeyValue("requested", quantity)
.addKeyValue("available", stockCheckResult.getAvailable())
.log();
return OrderResult.rejected("INSUFFICIENT_STOCK");
}
// 外部调用失败
log.atError()
.setMessage("payment gateway call failed")
.addKeyValue("orderId", orderId)
.addKeyValue("externalService", "payment-gateway")
.addKeyValue("statusCode", response.getStatus())
.setCause(e)
.log();
一般不建议记录的内容包括:
- 密码、Token、Key、完整身份证号和银行卡号
- 完整原始请求体和响应体,尤其是包含个人信息或凭证的内容
- 高频循环中的成功日志
- 没有业务含义的 method entry / exit 日志
// 禁止:密码、Token 进入日志
log.info("User login: username={}, password={}", username, password); // 不建议这样写
log.info("Request headers: {}", request.getHeaders()); // 可能含 Authorization
log.debug("Response body: {}", objectMapper.writeValueAsString(responseBody)); // 可能含个人信息
// 禁止:高频循环里的成功日志(每秒数千次)
for (Item item : items) {
process(item);
log.info("item processed: {}", item.getId()); // 噪声,应去除或降为 DEBUG + 采样
}
// 禁止:无业务含义的 entry / exit
log.debug("Entering calculateDiscount()");
log.debug("Exiting calculateDiscount()");
以上边界可参考 OWASP Logging Cheat Sheet 的建议,核心目标是保证日志既可用又不泄露不该出现的数据。
日志与指标的边界#
日志不是唯一的数据源。开发者需要区分哪些内容应该记入日志,哪些应该作为指标(Metrics)上报。混淆两者会导致日志管道过载、存储成本上升,且查询效率下降。
应该作为指标而非日志的内容:
- 负载均衡器健康检查成功(周期性、无异常信息)
- 循环计数器(每秒处理多少条消息、完成多少个任务)
- 定时任务的周期性执行成功
- 纯数值型状态快照(当前线程数、连接池使用率)
// 禁止:健康检查成功写入日志(每秒一次,纯噪声)
@GetMapping("/health")
public ResponseEntity<String> health() {
log.info("Health check passed"); // 应改为指标
return ResponseEntity.ok("UP");
}
// 禁止:循环计数器写入日志
for (Order order : orders) {
process(order);
log.info("processed order #{}", order.getId()); // 1000 次/分钟,应改为 Counter 指标
}
应该作为指标的正确做法:
// 使用 Micrometer 计数器代替循环日志
private final Counter ordersProcessedCounter;
public OrderService(MeterRegistry registry) {
this.ordersProcessedCounter = Counter.builder("orders.processed")
.tag("type", "standard")
.register(registry);
}
public void processBatch(List<Order> orders) {
for (Order order : orders) {
process(order);
ordersProcessedCounter.increment(); // 指标,不产生日志
}
// 批处理完成时再写一条日志
log.atInfo()
.setMessage("batch order processing completed")
.addKeyValue("totalCount", orders.size())
.log();
}
判断原则:
| 特征 | 日志(Log) | 指标(Metric) |
|---|---|---|
| 数据模型 | 离散事件 | 聚合数值(Counter、Gauge、Histogram) |
| 查询场景 | “发生了什么、为什么” | “有多频繁、趋势如何” |
| 存储方式 | 全文检索、结构化字段 | 时序数据库 |
| 典型用途 | 根因分析、审计追踪 | 仪表盘、告警阈值 |
| 是否含上下文 | 是,包含业务实体和因果关系 | 否,仅数值和时间戳 |
一句话总结:需要回答"为什么"的内容记日志;需要回答"有多少"的内容记指标。
message 编写约定#
核心规则很简单:message 只描述动作,具体上下文放到结构化字段里。值班工程师看 message 应该快速知道发生了什么;需要检索、聚合和关联的内容则放到结构化字段中。
错误示例:
下面的问题不是“参数化日志不能用”,而是把关键上下文直接写进了 message 文本:
log.info("User authenticated successfully for userId={}, ip={}, ua={}", userId, ip, userAgent);
正确示例:
log.atInfo()
.setMessage("authentication succeeded")
.addKeyValue("userId", userId)
.addKeyValue("ip", ip)
.addKeyValue("userAgent", userAgent)
.log();
推荐约定#
- 不要用字符串拼接拼出日志内容
- 允许使用参数化日志或 fluent API;当需要输出结构化字段时,优先使用 fluent API
message保持稳定、短句、可复用- 同类事件使用一致的表述,避免同义不同写
性能提醒:Fluent API(
atInfo().addKeyValue().log())在多数业务关键路径(INFO 及以上)中通常是更稳妥的选择,代码可读性和结构化能力都比较好。但在极致热路径(每秒数万次以上)中,链式调用产生的临时对象会增加 GC 压力,此时DEBUG/TRACE级别可回退到参数化日志 +isDebugEnabled()守卫。
团队约定#
- 本文约定
message统一使用英文短句,避免中英混写 message保持简洁,不写长句和解释性背景- 统一动词模式,例如
authentication succeeded、validation succeeded、authentication failed - 同一类事件在全项目内使用相同模板,方便检索和聚合
结构化字段约定#
结构化日志的目标不是”把日志改成 JSON”,而是让同一类事件在检索、聚合和关联分析时保持稳定。message 负责描述事件本身,字段负责承载可过滤、可统计、可关联的上下文。
推荐优先使用这些字段:orderId、durationMs、externalService、statusCode、requestId。链路追踪字段 trace.id、span.id 由 Micrometer Tracing 按 ECS 规范自动注入,使用点分隔格式;业务自定义字段统一使用 camelCase(如 orderId、durationMs),不要用 addKeyValue("traceId", ...) 手动覆盖 Micrometer 已自动写入的 trace.id。userId 这类稳定业务标识只在合规允许且确实有助于排障时记录,并按政策做脱敏、哈希或掩码处理。字段名要保持长期稳定,同一语义在全项目内只用一种写法,避免后续平台聚合和仪表盘规则失效。
字段命名一致性示例:
// 禁止:同一语义用了三种写法,导致检索和仪表盘规则分裂
log.atInfo().addKeyValue("order_id", orderId)... // 下划线写法
log.atInfo().addKeyValue("OrderId", orderId)... // 大驼峰写法
log.atInfo().addKeyValue("orderId", orderId)... // 小驼峰写法(团队约定统一用这种)
// 正确:全项目只用一种写法
log.atInfo()
.setMessage("payment initiated")
.addKeyValue("orderId", orderId) // 稳定字段名
.addKeyValue("externalService", "alipay")
.addKeyValue("durationMs", elapsed)
.addKeyValue("statusCode", 200)
.log();
userId 脱敏示例:
// 合规场景下记录 userId,按政策做掩码
String maskedUserId = userId.substring(0, 3) + "****" + userId.substring(userId.length() - 2);
log.atInfo()
.setMessage("user login succeeded")
.addKeyValue("userId", maskedUserId)
.addKeyValue("ip", clientIp)
.log();
使用 fluent API 的 addKeyValue() 时,字段会被加入当前日志事件;但它是否最终以怎样的字段类型、映射和检索行为进入 OpenSearch / Elasticsearch,取决于索引模板和平台侧配置,不是 addKeyValue() 本身决定的。换句话说,代码负责发出字段,平台负责定义 schema,而且当前激活的 encoder/layout 也必须支持这些字段输出,例如结构化 logging 输出或 Logback 的 %kvp,否则它们可能不会按预期出现在日志里。
TraceId、RequestId 与上下文传播约定#
日志最好带有可关联的上下文,否则故障排查很容易退化成靠猜。团队层面建议同时保留链路级 traceId / spanId 和请求级 requestId:前者用于跨服务串联,后者用于单次请求的稳定定位。
在 Spring Boot 体系里,traceId / spanId 优先交给 Micrometer Tracing 管理,requestId 和 tenantId 这类需要跨服务传播的标识,我更倾向于使用 OTel Baggage——它通过 W3C baggage header 自动随 trace 传播到下游,并可配置自动注入 MDC,从根本上解决跨线程和跨服务两个问题。Baggage 配置与跨线程传播的三种路径(虚拟线程、传统线程池、Reactor/CompletableFuture)详见 Spring Boot 3.5 Tracing 实践记录·第六节:上下文传播边界。
如果场景仅限于单服务内部(不需要跨服务传播),MDC 方案仍然可用,通过 Filter 在请求入口写入并在退出时清理:
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RequestIdFilter extends OncePerRequestFilter {
private static final String REQUEST_ID_HEADER = "X-Request-Id";
private static final String MDC_KEY = "requestId";
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String requestId = Optional.ofNullable(request.getHeader(REQUEST_ID_HEADER))
.filter(s -> !s.isBlank())
.orElse(UUID.randomUUID().toString());
MDC.put(MDC_KEY, requestId);
response.setHeader(REQUEST_ID_HEADER, requestId);
try {
chain.doFilter(request, response);
} finally {
MDC.remove(MDC_KEY); // 确保线程归还线程池时 MDC 已清理
}
}
}
注意:MDC 不跨线程传播——切换线程、线程池或异步执行器时,MDC 中的字段会丢失。跨线程场景请参考上述 Tracing 文章的传播方案。
日志级别使用约定#
日志级别的边界要清晰:ERROR 表示已经发生的业务或系统失败;WARN 表示发生了异常但系统仍可继续,或需要后续跟进的降级和不一致;INFO 记录关键业务事件和状态变化;DEBUG 用于开发和联调时的诊断细节;TRACE 只保留最细粒度、最噪声化的跟踪信息。级别表达的是事件严重性,不等同于告警策略,是否触发告警要由监控规则单独决定。
各级别使用示例:
// ERROR:业务流程失败,需要人工介入
log.atError()
.setMessage("order payment failed")
.addKeyValue("orderId", orderId)
.addKeyValue("externalService", "payment-gateway")
.setCause(e)
.log();
// WARN:命中降级,系统仍可继续,但需要后续跟进
log.atWarn()
.setMessage("inventory service degraded: using cached result")
.addKeyValue("skuId", skuId)
.addKeyValue("cacheAge", cacheAgeMs)
.log();
// INFO:关键业务事件,正常流程
log.atInfo()
.setMessage("order created")
.addKeyValue("orderId", orderId)
.addKeyValue("userId", maskedUserId)
.addKeyValue("totalAmount", amount)
.log();
// DEBUG:开发诊断细节,生产默认关闭
log.atDebug()
.setMessage("discount rule evaluated")
.addKeyValue("ruleId", rule.getId())
.addKeyValue("matched", matched)
.addKeyValue("discountRate", rate)
.log();
常见误用示例:
// 禁止:用 ERROR 记录业务校验失败(不需要人工介入)
log.error("Invalid request: missing orderId"); // 应该用 WARN 或 INFO
// 禁止:用 INFO 记录调试细节(噪声过高)
log.info("Checking discount rule #{} for user {}", ruleId, userId); // 应该用 DEBUG
// 禁止:用 WARN 记录已造成业务中断的失败
log.warn("Failed to deduct balance, order cannot proceed"); // 已影响业务,应该用 ERROR
日志消息的受众意识#
编写日志消息时应考虑谁会阅读这些日志,并根据受众调整内容:
- 开发者:需要代码级技术细节,包括类名、方法名、行号、参数值和异常堆栈
- DevOps/SRE:需要诊断和架构上下文,如服务依赖、延迟指标、降级状态、资源使用率
- 支持团队/最终用户:需要清晰、可操作的指导,说明发生了什么以及下一步该做什么,避免技术术语
// 面向开发者:包含技术细节
log.atError()
.setMessage("database connection pool exhausted")
.addKeyValue("poolSize", pool.getMaxConnections())
.addKeyValue("activeConnections", pool.getActiveConnections())
.addKeyValue("waitQueueSize", pool.getWaitQueueSize())
.setCause(e)
.log();
// 面向 DevOps:包含系统状态和依赖
log.atWarn()
.setMessage("upstream service timeout: using fallback response")
.addKeyValue("service", "recommendation-service")
.addKeyValue("timeoutMs", timeout)
.addKeyValue("fallbackUsed", true)
.log();
// 面向支持团队:业务友好,不含技术细节
log.atInfo()
.setMessage("order processing delayed: payment verification in progress")
.addKeyValue("orderId", orderId)
.addKeyValue("estimatedCompletionMinutes", 5)
.log();
Spring Boot 的 log groups 适合把一组相关包或组件绑成一个调试单元,便于一次性调整级别,而不是逐个 logger 修改:
logging:
group:
payment: com.example.payment,com.example.gateway
level:
payment: DEBUG # 一次性调整整个支付模块
运行时调整日志级别时,优先使用 Actuator 的 /actuator/loggers;它支持在线调整,但是否可审计、可回滚,还取决于额外的认证、审计留痕和运维管控:
# 在线将 payment 包调到 DEBUG,无需重启
curl -X POST http://localhost:8080/actuator/loggers/com.example.payment \
-H “Content-Type: application/json” \
-d '{“configuredLevel”: “DEBUG”}'
不建议把”改 Kubernetes 环境变量后自动生效”当成运行时调级手段;环境变量通常只在进程启动时读取,实际生效仍需要重启 Pod。要做在线调整,应走应用暴露的日志管理接口。
异常日志约定#
异常日志的第一原则是把异常对象本身传进去,这样日志系统才能保留完整堆栈和根因链路。只打印 e.getMessage() 会丢掉调用路径,排障价值会明显下降,尤其是在多层包装异常或重试失败场景里。
错误示例:丢失堆栈,排障无法定位根因:
// 禁止:只打 message,堆栈丢失
log.error("Payment failed: {}", e.getMessage());
// 禁止:把上下文硬塞进异常字符串
log.error("Payment failed for order={}, service={}: {}", orderId, service, e.getMessage());
正确示例:异常对象传入,上下文放结构化字段:
// 正确:setCause(e) 保留完整堆栈,addKeyValue 提供可检索上下文
log.atError()
.setMessage("payment gateway call failed")
.addKeyValue("orderId", orderId)
.addKeyValue("externalService", "payment-gateway")
.addKeyValue("statusCode", response.getStatus())
.setCause(e)
.log();
多层包装异常示例:保留根因链路:
try {
paymentGateway.charge(request);
} catch (GatewayTimeoutException e) {
// 抛出业务异常时,把原始异常作为 cause 保留
throw new PaymentFailedException("payment timed out", e);
}
// 在最外层统一记录,cause chain 完整
log.atError()
.setMessage("order payment failed")
.addKeyValue("orderId", orderId)
.setCause(e) // PaymentFailedException,cause 指向 GatewayTimeoutException
.log();
需要补充业务上下文时,不要把上下文硬塞进异常字符串里。更稳妥的做法是保留异常原文,同时通过结构化字段记录 orderId、externalService、statusCode 这类可检索信息。SLF4J 的 fluent logging API 和键值对写法可以直接支持这种风格,见 SLF4J Manual。
简化成一句话:异常负责根因,结构化字段负责上下文。
记录故障前的上下文叙事,而非仅记录终端错误#
排障时,值班工程师需要的不仅是"最终那行 ERROR",而是导致失败的前置事件链。只记录终端错误的日志就像只看事故现场不看监控录像——知道发生了什么,但不知道为什么发生。
错误示例:只记录最终失败,缺少前置上下文:
// 只在最终失败时记录,排障时不知道为什么会走到这里
log.atError()
.setMessage("order creation failed")
.addKeyValue("orderId", orderId)
.setCause(e)
.log();
正确示例:在关键决策点和边界事件记录上下文,形成可回溯的叙事链:
// 1. 请求进入时的入口日志
log.atInfo()
.setMessage("order creation request received")
.addKeyValue("orderId", orderId)
.addKeyValue("userId", maskedUserId)
.addKeyValue("itemCount", request.getItems().size())
.log();
// 2. 关键校验结果
if (!inventoryCheck.isAvailable()) {
log.atWarn()
.setMessage("order creation blocked: insufficient inventory")
.addKeyValue("orderId", orderId)
.addKeyValue("requestedSku", inventoryCheck.getSkuId())
.addKeyValue("availableQty", inventoryCheck.getAvailable())
.log();
throw new InsufficientInventoryException(inventoryCheck.getSkuId());
}
// 3. 外部调用的关键结果
log.atInfo()
.setMessage("payment authorization obtained")
.addKeyValue("orderId", orderId)
.addKeyValue("authId", paymentAuth.getAuthorizationId())
.addKeyValue("authorizedAmount", paymentAuth.getAmount())
.log();
// 4. 最终失败(包含完整上下文)
log.atError()
.setMessage("order creation failed: inventory reservation timeout")
.addKeyValue("orderId", orderId)
.addKeyValue("step", "inventory-reservation")
.addKeyValue("elapsedMs", elapsed)
.setCause(e)
.log();
排查时,通过 orderId 串联起来的日志序列清晰展示了:
[10:00:01] INFO order creation request received orderId=ORD-123, itemCount=3
[10:00:01] INFO inventory check passed orderId=ORD-123, sku=SKU-456, available=10
[10:00:02] INFO payment authorization obtained orderId=ORD-123, authId=AUTH-789, amount=199.00
[10:00:05] ERROR order creation failed: inventory reservation timeout orderId=ORD-123, elapsedMs=3000
关键原则:
- 入口日志、关键校验、外部调用结果、状态变化这四类事件构成叙事链的主干
- 每条链路用
orderId、requestId或traceId串联 - 不在每个方法调用都打日志,而是在改变流程走向的决策点记录
- 正常流程可以精简,但异常发生前的最后 3-5 条相关日志必须能够还原完整上下文
性能与异步日志的边界#
异步日志的价值是把写盘、网络发送和格式化成本从业务线程中剥离出来,但代价是引入队列、额外内存占用,以及在压力过大时的丢弃风险。它适合吞吐优先的生产场景,不适合用来掩盖本来就过慢的日志设计。
如果使用 Logback AsyncAppender,需要关注几个边界:queueSize 决定缓冲能力和内存占用,discardingThreshold 决定拥塞时是否主动丢弃低优先级日志,队列满时会出现背压或丢日志的取舍,includeCallerData 则会显著增加调用栈采集成本,只有真正需要类名、行号时才开启。
Logback AsyncAppender 配置示例:
<configuration>
<!-- 同步文件 Appender -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/app.log</file>
<encoder>
<pattern>%d{ISO8601} %-5level [%X{traceId},%X{requestId}] %logger{36} - %message %kvp%n</pattern>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/app.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
</appender>
<!-- 异步包装:把 FILE 包进队列,业务线程不等写盘 -->
<appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender">
<appender-ref ref="FILE"/>
<queueSize>512</queueSize> <!-- 队列满则触发背压,不宜设太大 -->
<discardingThreshold>0</discardingThreshold> <!-- 0 表示队列 80% 满时不主动丢 TRACE/DEBUG -->
<includeCallerData>false</includeCallerData> <!-- 采集类名/行号代价高,按需开启 -->
<neverBlock>false</neverBlock> <!-- false:队列满时阻塞业务线程;true:直接丢弃 -->
</appender>
<root level="INFO">
<appender-ref ref="ASYNC_FILE"/>
</root>
</configuration>
discardingThreshold默认值是queueSize / 5。当队列剩余容量低于此阈值时,自动丢弃 TRACE、DEBUG、INFO 级别的日志(WARN和ERROR不受影响)。设为0表示不主动丢弃任何日志,但队列满时更容易触发背压或阻塞。生产环境应根据实际吞吐量和内存预算调整。
原则上,异步日志优先保留少量、明确、可承受的输出路径,避免把过多重型处理塞进日志线程。若业务本身依赖每条日志都必须落盘或必须实时可见,就不要指望异步层能无损解决性能问题。
性能优化关键实践#
1. 限制消息冗余度
高频或过长的日志会增加存储成本并降低检索性能。应避免:
- 每秒数千次的循环中记录成功事件
- 在
message中塞入大段 JSON 或二进制数据 - 重复记录相同的诊断信息
// 禁止:高频循环中的成功日志
for (Order order : orders) { // 假设每秒处理数千个
process(order);
log.info("order processed: {}", order.getId()); // 噪声过高
}
// 推荐:只在异常或批处理完成时记录
log.atInfo()
.setMessage("batch order processing completed")
.addKeyValue("totalCount", orders.size())
.addKeyValue("successCount", successCount)
.addKeyValue("failedCount", failedCount)
.log();
2. 原生 JSON 输出
生产环境应直接输出 JSON 格式日志,避免日志采集器(如 Filebeat、Fluentd)再用正则解析文本。Spring Boot 3.4+ 已内建 ECS JSON 支持:
logging:
structured:
format:
file: ecs # 直接输出 JSON,无需采集器解析
JSON 输出的好处包括:
- 多行堆栈自动合并,无需额外配置
- 字段直接可检索、可聚合、可建仪表盘
- 减少日志采集器的 CPU 和内存开销
3. 使用 Marker 或条件守卫避免高成本操作
某些操作(如敏感数据脱敏、复杂正则匹配、大对象序列化)成本很高,不应在每次日志时都执行。推荐使用 Marker 或级别守卫:
// 错误示例:即使日志级别关闭,toString() 和序列化仍会执行
log.debug("Request body: {}", objectMapper.writeValueAsString(request));
// 正确示例 1:使用 isDebugEnabled() 守卫
if (log.isDebugEnabled()) {
log.debug("Request body: {}", objectMapper.writeValueAsString(request));
}
// 正确示例 2:使用 Supplier 延迟求值(SLF4J 2.0+)
log.atDebug()
.setMessage(() -> "Request body: " + serializeExpensive(request))
.log();
// 正确示例 3:使用 Marker 控制特定日志的开关
Marker sensitiveData = MarkerFactory.getMarker("SENSITIVE");
log.atInfo().addMarker(sensitiveData)
.setMessage("user PII accessed")
.addKeyValue("userId", maskedUserId)
.log();
// 在 logback-spring.xml 中可单独控制此 Marker
// <logger name="com.example" level="INFO">
// <markerFilter marker="SENSITIVE" onMatch="DENY" onMismatch="NEUTRAL"/>
// </logger>
4. 日志采样策略
生产环境不应追求"所有日志全部保留"。采样的核心目标是:100% 记录错误,防止排障时缺少关键信息;对成功或重复的日志进行抽样,避免管道过载。
必须 100% 保留的内容:
- 所有
ERROR及以上级别的日志 - 关键业务状态变化(如订单从
PAID进入SHIPPED) - 需要人工介入的异常和安全事件
可以采样的内容:
INFO级别的成功请求日志- 高频但需要保留少量样本用于验证的日志
- 重复出现的相同模式日志
Spring Boot 中实现采样推荐用 TurboFilter,它在日志事件创建前就执行,开销低于 Appender 级别的 Filter。通过 MDC 标记实现动态采样:
按请求特征动态采样:
// 特定用户或环境的日志全量保留
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class DynamicSamplingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String userId = request.getHeader("X-User-Id");
boolean isDebugUser = "debug-user-123".equals(userId);
if (isDebugUser) {
// 特定用户全量保留,允许输出 DEBUG
MDC.put("logLevel", "DEBUG");
}
try {
chain.doFilter(request, response);
} finally {
MDC.remove("logLevel");
}
}
}
在 logback-spring.xml 中使用 DynamicMDCFilter(TurboFilter,在配置级别声明):
<configuration>
<!-- TurboFilter 在日志事件创建前执行,开销极低 -->
<turboFilter class="ch.qos.logback.classic.turbo.DynamicMDCFilter">
<defaultThreshold>INFO</defaultThreshold>
<!-- MDC 中 logLevel=DEBUG 的请求允许输出 DEBUG -->
<mdcValueThresholdPairs>
<mdcValueThresholdPair>
<value>DEBUG</value>
<threshold>DEBUG</threshold>
</mdcValueThresholdPair>
</mdcValueThresholdPairs>
</turboFilter>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{ISO8601} %-5level [%X{traceId},%X{requestId}] %logger{36} - %message %kvp%n</pattern>
</encoder>
</appender>
<root level="TRACE">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>
注意:DynamicMDCFilter 的根 level 需要设为最细级别(如 TRACE),因为实际的级别控制由 TurboFilter 的 defaultThreshold 和 MDC 配对决定。
采样的关键原则:
- 级联故障时避免日志管道崩溃:采样成功日志,但错误全记
- 采样率应可调且无需重启:走 Actuator
/actuator/loggers或动态配置 - 采样后的日志仍能代表系统行为:基于请求特征采样优于固定截取
容器化日志实践#
在容器化环境(Kubernetes、Docker Compose 等)中,日志的处理方式与传统虚拟机或物理机有本质区别。核心原则是:应用只输出到标准输出(stdout)和标准错误(stderr),不写入本地文件。
为什么容器不应写本地文件#
- 容器文件系统是临时的,Pod 重启或重新调度后日志丢失
- 写入容器内文件会占用容器层存储,影响性能和镜像大小
- 容器编排平台(如 Kubernetes)的日志采集器默认从 stdout/stderr 采集
容器环境推荐配置#
Spring Boot 3.4+ 优先使用 YAML 原生配置(见"Spring Boot 项目的推荐落地方式"章节),只需一行 console: ecs 即可。当需要精细控制 Appender 行为(如 stderr 分流、自定义 Filter、多输出目标)时才需要 logback-spring.xml。
Kubernetes 的日志采集架构会自动接管:
┌─────────────────────────────────────┐
│ Pod (Spring Boot App) │
│ ┌───────────────────────────────┐ │
│ │ stdout → ECS JSON logs │ │
│ │ stderr → Error logs │ │
│ └───────────────────────────────┘ │
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Node: Fluentd / Filebeat DaemonSet │
│ 采集 stdout/stderr → 发送到 │
│ OpenSearch / Elasticsearch │
└─────────────────────────────────────┘
如果使用 Helm 部署,确保日志格式与采集器期望的格式一致。例如:
- 采集器期望 JSON:设置
console: ecs - 采集器期望纯文本:不设置
console,保留 Logback 默认彩色输出
注意:容器环境的日志路由通常由平台团队配置,应用开发者的责任是确保输出格式正确、结构化、包含必要上下文。
异常日志的 stderr 路由#
部分日志平台会把 ERROR 级别路由到 stderr,其他级别走 stdout,便于在采集管道中做不同处理。Spring Boot 3.4+ 原生 YAML 配置默认只走 stdout,需要自行分流时必须用 logback-spring.xml:
<!-- logback-spring.xml -->
<!-- stdout:INFO、WARN -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<target>System.out</target>
<encoder class="co.elastic.logging.logback.EcsEncoder"/>
</appender>
<!-- stderr:只接收 ERROR 及以上 -->
<appender name="ERROR_CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<target>System.err</target>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>ERROR</level>
</filter>
<encoder class="co.elastic.logging.logback.EcsEncoder"/>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="ERROR_CONSOLE"/>
</root>
注意:这种配置会让 ERROR 同时出现在 stdout 和 stderr(因为 CONSOLE 没有拒绝 ERROR)。要实现严格的"二选一",需要在 CONSOLE 的 ThresholdFilter 前加一个自定义 Filter 拒绝 ERROR 级别。大多数场景下,ERROR 同时出现在两个输出流不影响实际使用——采集平台通常去重或只订阅其中一个流。
日志反模式与常见错误#
反模式(Anti-patterns)是团队在日志实践中反复出现的错误做法。识别并避免这些模式比"知道该怎么做"更重要。
1. 静默吞掉异常#
// 禁止:catch 后什么都不做
try {
paymentService.charge(request);
} catch (PaymentException e) {
// 异常被静默吞掉,排障无从下手
}
// 禁止:只打印到标准输出,不进入日志系统
try {
paymentService.charge(request);
} catch (PaymentException e) {
e.printStackTrace(); // 不进入集中式日志平台
}
2. 记录后立即重新抛出,未增加诊断价值#
// 禁止:记录的内容和抛出的异常完全一样,没有增加上下文
try {
paymentService.charge(request);
} catch (PaymentException e) {
log.error("Payment failed: {}", e.getMessage()); // 无新增信息
throw e;
}
// 正确:增加业务上下文后重新抛出,或在顶层统一记录
try {
paymentService.charge(request);
} catch (PaymentException e) {
log.atError()
.setMessage("payment processing failed")
.addKeyValue("orderId", orderId)
.addKeyValue("paymentMethod", request.getMethod())
.setCause(e)
.log();
throw new OrderProcessingException("Failed to process payment for order " + orderId, e);
}
3. 模糊的日志消息#
// 禁止:无法知道哪个服务、哪个请求、什么状态码
log.error("Communication error");
// 禁止:信息量不足,无法定位问题
log.warn("Something went wrong");
// 正确:明确动作、目标、结果
log.atError()
.setMessage("inventory service call failed")
.addKeyValue("service", "inventory-service")
.addKeyValue("statusCode", 503)
.addKeyValue("retryCount", 3)
.addKeyValue("orderId", orderId)
.setCause(e)
.log();
4. 极端冗余或信息匮乏#
两个极端都不可取:
- 过度日志:每秒数万条、重复、无差别输出,导致"信息疲劳"
- 日志饥饿:关键路径无日志,异常无堆栈,排障靠猜
// 禁止:过度日志(每个方法 entry/exit + 每个变量变化)
log.debug("Entering processOrder()");
log.debug("orderId = {}", orderId);
log.debug("Calling validateOrder()");
log.debug("Exiting validateOrder(), result = {}", valid);
log.debug("Calling calculatePrice()");
// ... 数十行类似日志
// 禁止:日志饥饿(整个关键流程只有一行,无异常堆栈)
log.info("Order processed"); // 成功失败?耗时?哪些步骤?无从得知
// 正确:关键边界 + 结构化上下文
log.atInfo()
.setMessage("order processing completed")
.addKeyValue("orderId", orderId)
.addKeyValue("durationMs", elapsed)
.addKeyValue("stepsCompleted", List.of("validation", "pricing", "inventory-reserve", "payment"))
.log();
5. 在容器中写入临时存储#
// 禁止:容器内写文件,Pod 重启后丢失
File logFile = new File("/var/log/app/debug.log");
Files.writeString(logFile.toPath(), logLine); // 容器文件系统不是持久化存储
// 正确:输出到 stdout/stderr,由平台采集
log.info("Debug information: {}", details); // 配置 ConsoleAppender 即可
Spring Boot 项目的推荐落地方式#
Spring Boot 3.4+ 已内建 structured logging 支持(ECS、GELF、Logstash 等格式),优先使用 YAML 原生配置,无需额外依赖 co.elastic.logging 或编写 logback-spring.xml。参考 Spring Boot Logging 和 Structured logging in Spring Boot 3.4 了解其引入背景。
如果你在使用 Spring Boot 3.4+,推荐阅读配套文章 Spring Boot 3.4+ 结构化日志实践:用 ECS + Fluent API 告别 logback-spring.xml,其中包含从零配置 ECS 输出、Fluent API 与 OpenSearch KQL 查询联动、以及无需 XML 的精细化定制方案。
容器环境推荐 console: ecs 输出 ECS JSON 到 stdout;本地开发不设置 console,保留 Logback 默认彩色文本;仅在合规要求需要日志存档时才额外启用 file: ecs。YAML 完整配置、Fluent API 写法与 OpenSearch KQL 查询联动,见配套文章 Spring Boot 3.4+ 结构化日志实践:用 ECS + Fluent API 告别 logback-spring.xml。
Logback 与 Spring 环境属性集成#
日志配置不应硬编码。Spring Boot 提供了 <springProperty> 扩展,让 logback-spring.xml 直接读取 application.yml 中的属性值,避免在两份配置文件中重复维护相同的信息。
<!-- logback-spring.xml -->
<configuration>
<!-- 从 application.yml 读取 myapp.log-service.host,默认 localhost -->
<springProperty scope="context" name="logHost"
source="myapp.log-service.host"
defaultValue="localhost"/>
<!-- 从 application.yml 读取 myapp.log-service.port,默认 5044 -->
<springProperty scope="context" name="logPort"
source="myapp.log-service.port"
defaultValue="5044"/>
<appender name="LOGSTASH" class="net.logstash.logback.appender.LogstashTcpSocketAppender">
<destination>${logHost}:${logPort}</destination>
<encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
</appender>
<root level="INFO">
<appender-ref ref="LOGSTASH"/>
</root>
</configuration>
对应的 application.yml:
myapp:
log-service:
host: log-collector.internal
port: 5044
关键约束:
<springProperty>的source属性必须使用 kebab-case,支持 Spring Boot 的宽松绑定- 只能用于
<springProperty scope="context">声明上下文属性,不能直接引用到 pattern 表达式中 - 注意:
<springProperty>和<springProfile>与<configuration scan="true">不兼容,需要二选一 - 日志系统在
ApplicationContext之前初始化,所以@Configuration类上的@PropertySource无法影响日志配置
Profile-specific 日志配置#
Spring Boot 支持在 logback-spring.xml 中通过 <springProfile> 标签为不同环境设置不同的日志策略,无需维护多套配置文件:
<configuration>
<!-- 开发环境:彩色控制台,DEBUG 级别 -->
<springProfile name="dev">
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%cyan(%d{HH:mm:ss.SSS}) %magenta(%thread) %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="DEBUG">
<appender-ref ref="CONSOLE"/>
</root>
</springProfile>
<!-- 生产环境:JSON ECS 输出,INFO 级别 -->
<springProfile name="prod">
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="co.elastic.logging.logback.EcsEncoder"/>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</springProfile>
<!-- 多环境组合:staging 或非 production 环境都生效 -->
<springProfile name="staging | !production">
<logger name="com.example.payment" level="DEBUG"/>
</springProfile>
</configuration>
支持的布尔表达式:name="dev"、name="dev \| test"、name="staging & !production"。
--debug 与 --trace 的真实行为#
启动参数 --debug 和 --trace(或 application.yml 中的 debug=true / trace=true)不会将全局日志级别设为 DEBUG/TRACE。它们只开启 Spring Boot 核心包、Tomcat、Hibernate 等关键组件的 DEBUG 级别,全局 root 级别仍保持默认值(通常是 INFO)。
如果需要在开发环境全局调到 DEBUG,应显式设置:
# application-dev.yml
logging:
level:
root: DEBUG
或启动参数:--logging.level.root=DEBUG。
Code Review 检查清单#
- 是否记录了关键边界事件
- 是否泄漏敏感信息
- 是否使用参数化日志或 fluent API
- 是否补充了必要的结构化字段(
orderId、maskedUserId、externalService、durationMs、statusCode) - 是否错误使用了日志级别
- 是否在异常日志中丢失堆栈
- 是否在异步场景中遗漏上下文传播
- 在 K8s 环境中是否仅输出 stdout/stderr(无不必要的文件 Appender)
- 异常是否在日志记录后正确重抛或交由统一异常处理(配合熔断器使用)
参考资料#
- Spring Boot Logging
- Spring Boot Actuator Loggers
- Spring Boot Tracing
- Structured logging in Spring Boot 3.4
- SLF4J Manual
- Logback Manual: Appenders
- OWASP Logging Cheat Sheet
- OpenSearch Mappings
- Elastic Common Schema (ECS)
日志集中管理与监控#
日志的价值不在于"写入磁盘",而在于能否被集中管理、关联分析和告警。没有集中式日志平台时,再好的日志规范也只能停留在"知道怎么查,但查不到"的状态。
集中式日志架构#
典型的集中式日志架构包括以下层次:
┌──────────────────────────────────────────────────────────┐
│ 应用层 (Spring Boot) │
│ - 结构化日志输出 (ECS JSON) │
│ - TraceId/RequestId 自动注入 │
│ - 异常堆栈完整记录 │
└──────────────────────┬───────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ 采集层 (Filebeat / Fluentd / Fluent Bit) │
│ - 从 stdout/stderr 或文件采集 │
│ - 解析 JSON(如未原生输出) │
│ - 添加元数据:Pod 名、Namespace、Node IP │
└──────────────────────┬───────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ 缓冲层 (Kafka / Redis) [可选] │
│ - 削峰填谷,避免采集器直连存储 │
│ - 支持多消费者(搜索、告警、分析) │
└──────────────────────┬───────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ 存储层 (OpenSearch / Elasticsearch / Loki) │
│ - 索引映射定义字段类型 │
│ - 生命周期管理(ILM):热/温/冷/删除 │
│ - 全文检索 + KQL 查询 │
└──────────────────────┬───────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ 展示层 (Grafana / Kibana / OpenSearch Dashboards) │
│ - 仪表盘:错误率、延迟分布、流量趋势 │
│ - 告警规则:基于日志指标触发通知 │
│ - 关联分析:与 Metrics、Traces 联动 │
└──────────────────────────────────────────────────────────┘
JVM 与 GC 日志#
除了应用日志,JVM 级别的日志也应纳入集中管理,尤其是性能调优和 OOM 排查时:
# JVM 启动参数推荐
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/heapdump.hprof
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=50m
-Xlog:jni+resolve=off
Spring Boot 容器环境推荐把 GC 日志也输出到 stdout:
-Xlog:gc*:stdout:time,uptime,level,tags
GC 日志可由 GCLogViewer、GCEasy 或集中平台的专用解析器处理,用于:
- 识别内存泄漏和频繁 Full GC
- 调整堆大小和 GC 算法
- 关联应用延迟尖刺与 GC Stop-The-World
日志指标与告警#
日志不仅是"用来搜索的",也可以作为告警的数据源。常见基于日志的告警场景:
- 单位时间内
ERROR级别日志超过阈值 - 特定关键词(如
OutOfMemoryError、Connection refused)出现 - 关键业务事件(如
order payment failed)频率异常 - 外部调用失败率持续升高
告警规则应在日志平台侧配置,而不是由应用代码直接触发。这样的好处是:
- 告警逻辑与业务代码解耦
- 支持跨服务、跨实例的聚合告警
- 告警规则可独立于应用发布周期调整
日志生命周期管理#
日志数据应设置合理的保留策略:
- 热存储(7-30 天):SSD,支持快速检索和即时排障
- 温存储(30-90 天):HDD/对象存储,检索较慢但成本更低
- 冷存储(90 天-数年):归档存储(如 S3 Glacier),满足合规审计要求
- 删除:超过保留期的日志自动清理,控制存储成本
OpenSearch/Elasticsearch 的 ILM(Index Lifecycle Management)可自动管理这一流程,无需手动干预。