日志体系¶
面试追问地图¶
| 主问题 | 必讲关键点 | 下一层追问 |
|---|---|---|
| 门面与实现 | API 与实现解耦、provider、库只依赖门面 | SLF4J 2.x 如何发现实现 |
| 桥接与冲突 | 旧 API 桥接、单一出口、依赖排除 | 多个 provider 如何排查 |
| 日志级别 | ERROR/WARN/INFO/DEBUG、可行动性 | 为什么不能所有异常都 ERROR |
| 参数化日志 | 延迟格式化、Supplier、异常参数 | 对象 toString 的隐藏成本 |
| 异步日志 | 业务线程与 IO 解耦、队列、丢弃与阻塞 | 宕机丢日志、队列打满怎么办 |
| MDC | ThreadLocal、traceId、跨线程传播与清理 | 线程池脏数据、链路追踪关系 |
| 结构化日志 | JSON、稳定字段、检索与聚合 | 高基数字段、字段演进 |
| 生产治理 | 滚动、保留、采样、脱敏、防注入 | 磁盘打满、敏感信息泄露 |
日志的目标是形成可检索的运行证据,而不是把每个局部变量都打印出来。指标和 Trace 的分工见系统设计的可观测性章节。
一、门面、实现与桥接¶
SLF4J 日志门面解决什么问题?¶
频次 ★★★★ · 难度 🟢 · 高频:阿里/美团
SLF4J 提供统一 API,Logback、Log4j2 等提供输出、滚动、格式化等实现。业务代码和公共库面向门面编程,部署应用决定最终 provider,从而避免每换一个日志实现就改全部调用代码。
SLF4J 2.x 通过 ServiceLoader 查找 provider;1.7 及更早版本使用 StaticLoggerBinder。应用通常只保留一个 provider:没有 provider 会退化为 NOP,多 provider 则选择结果可能不符合预期并产生告警。
常见追问:公共库应该依赖什么?→ 只依赖 slf4j-api,不要把具体实现作为传递依赖强塞给使用方;最终应用负责选择实现。
日志桥接为什么可能形成死循环?¶
频次 ★★★★ · 难度 🟡
桥接包把旧日志 API 的调用转发到统一门面,例如把 JUL、JCL 或 Log4j 1.x 转到 SLF4J。适配器则可能反向把 SLF4J 转给某实现。如果同时引入两个方向,会形成调用环:
治理原则是“多个入口、一个出口”:允许不同 API 都桥接到 SLF4J,但只保留一个最终 provider,并排除反向适配器。排查顺序:
- 查看启动期 SLF4J 告警。
- 用 Maven/Gradle 依赖树找所有 provider 和 bridge。
- 明确目标实现,逐个排除多余传递依赖。
- 运行时确认实际
ILoggerFactory类型。
二、日志内容与性能¶
如何选择日志级别?¶
频次 ★★★★ · 难度 🟢
| 级别 | 适用含义 | 例子 |
|---|---|---|
| ERROR | 当前请求/任务失败,需要处置或告警 | 订单状态写入最终失败 |
| WARN | 出现异常信号但系统已降级或恢复 | 主缓存失败后回源成功 |
| INFO | 低频、关键业务状态变化 | 发布版本、任务开始结束 |
| DEBUG/TRACE | 定位问题的细节,生产默认关闭 | 路由决策、协议字段 |
同一异常只在有处置责任的边界记录一次。DAO 打 ERROR 后抛出,Service 再打 ERROR,Controller 又打一次,会让一次故障变成三条告警。底层要么补充上下文后抛出,要么由统一边界记录完整异常栈。
参数化日志为什么优于字符串拼接?¶
// 不推荐:即使 DEBUG 关闭,也先完成拼接和 toString
log.debug("order=" + order);
// 推荐:级别关闭时不做消息格式化
log.debug("orderId={}, status={}", order.getId(), order.getStatus());
参数化只避免格式化,不保证参数求值免费;log.debug("result={}", expensiveCall()) 仍会先执行方法。昂贵计算应先判断级别,或使用 SLF4J 2.x 的 Supplier。记录异常时把 Throwable 作为最后一个独立参数,不能只打印 e.getMessage() 丢掉堆栈。
异步日志如何提高吞吐又会带来什么风险?¶
频次 ★★★★ · 难度 🟡 · 高频:字节/美团
异步日志让业务线程只负责把事件写入内存队列,后台线程批量格式化和落盘,减少磁盘 IO 阻塞。但它把压力转移到了队列:
- 队列满时阻塞,会把延迟重新传回业务线程。
- 队列满时丢弃,排障证据不完整。
- 进程崩溃时,尚未刷出的事件可能丢失。
- 可变参数对象若延迟格式化,后台看到的状态可能已变化。
生产策略应按级别区分:DEBUG/INFO 可采样或丢弃,ERROR 通常同步兜底或使用更高优先级通道;同时监控队列深度、丢弃数和刷盘耗时。异步日志不是“永远更快”,低流量服务反而增加线程切换和排障复杂度。
三、上下文与结构化日志¶
MDC 如何串起一次请求?¶
频次 ★★★★ · 难度 🟡
MDC 是当前线程关联的键值上下文,常放 traceId、requestId、tenantId。入口写入、日志 pattern 或 JSON encoder 自动输出、请求结束在 finally 清理。
MDC 通常基于 ThreadLocal,线程池和异步回调不会天然传播。应在提交任务时复制上下文、执行前恢复、执行后恢复旧值或清空。只复制不清理会造成租户和 traceId 串请求,原理与并发编程的 ThreadLocal 脏数据相同。
Trace 负责跨服务的 span 关系,MDC 负责让普通业务日志带上 traceId。二者结合后,先在 Trace 找慢 Span,再按 traceId 检索业务细节。
结构化日志应该记录哪些字段?¶
结构化日志把字段作为独立键值输出,而不是让采集端用正则拆文本。建议稳定保留:
- 时间、级别、服务名、环境、实例和版本。
- traceId、spanId、requestId。
- 事件名、结果、耗时、错误码。
- 经过脱敏的业务主键,如 orderId。
字段名要稳定,数值保持数值类型。不要把完整 URL、堆栈或用户输入当标签建立高基数索引;它们适合正文或非聚合字段,否则索引成本会急剧上升。
四、生产治理¶
生产日志如何做滚动、保留和脱敏?¶
频次 ★★★★ · 难度 🟡
- 滚动:按日期并设置单文件大小上限,避免一个文件无限增长。
- 保留:同时限制天数和总容量;磁盘水位告警必须早于写满。
- 采样:对高频成功日志按规则采样,但错误、审计和计费证据不能随意采样。
- 脱敏:密码、Token、Cookie、私钥绝不记录;手机号、证件号按规则掩码。
- 防注入:对换行等控制字符编码,避免用户输入伪造下一行日志。
日志采集失败不能拖垮核心交易。应用侧应有本地缓冲和明确的背压策略,采集端不可用时优先保障业务,但审计类系统需要单独评估“日志不可丢”的合规要求。
线上日志突然暴增怎么排查?¶
- 按服务、实例、级别和 logger 聚合,定位增长源。
- 检查是否发生重试风暴、循环异常或动态调高级别。
- 先限流/采样或回调级别保护磁盘,再修根因。
- 核对滚动策略和采集延迟,区分“产生过多”和“发送不走”。
不能直接删除当前活跃日志文件:进程可能仍持有文件描述符,目录看似释放但空间不会归还。应先滚动或重载配置,再处理旧文件,Linux 文件句柄排查见Linux与工程化。