第 52 章 / 共 57 章 · 约 10 分钟
LLD 案例:日志框架
日志框架是一道“看起来人人都用过”的题,考的是你有没有想过它内部的取舍:级别怎么继承,一条日志怎么分发到多个目的地,写日志会不会拖慢业务线程,日志关掉时还有没有开销。
需求澄清
功能需求
- 四个级别:DEBUG < INFO < WARN < ERROR;低于阈值的日志直接丢弃。
- 记录器按名字分层,比如
app.db.pool。没有单独设置级别的记录器,沿用最近的上级的级别;根记录器默认 INFO。 - 一条日志可以输出到多个目的地(Appender):控制台、按大小滚动的文件等,每个目的地可以有自己的格式。
- 格式可配置:时间、级别、记录器名、线程名、消息。
非功能需求
- 线程安全:多个线程同时写日志,单条日志不会交错。
- 低开销:日志级别关闭时几乎没有成本;写文件不能拖慢业务线程。
- 进程正常退出时,已经产生的日志不能丢。
核心类
| 类 | 职责 |
|---|---|
LogManager | 按名字创建和缓存记录器,维护父子关系 |
Logger | 级别判断,构造日志记录,交给自己和上级的 Appender |
LogRecord | 一条日志:时间、级别、记录器名、线程、消息 |
Appender | 输出目的地:控制台、滚动文件、异步包装 |
Formatter | 把日志记录格式化成文本或 JSON |
图表可横向滚动,展开后可缩放查看。
查看图表源码
classDiagram
accTitle: 日志框架类图:记录器层级、日志记录、输出目的地与格式
class LogManager {
-Map loggers
-Logger root
+get(String name)$ Logger
}
class Logger {
-String name
-Logger parent
-Level level
-List appenders
+effectiveLevel() Level
+log(Level at, Supplier message)
}
class LogRecord {
-Instant time
-Level level
-String logger
-String thread
-String message
}
class Appender {
<<interface>>
+append(LogRecord r)
}
class Formatter {
<<interface>>
+format(LogRecord r) String
}
class AsyncAppender {
-BlockingQueue queue
-Appender target
}
LogManager "1" *-- "many" Logger
Logger --> Logger : parent
Logger "1" o-- "many" Appender
Appender <|.. ConsoleAppender
Appender <|.. RollingFileAppender
Appender <|.. AsyncAppender
AsyncAppender --> Appender : wraps
ConsoleAppender --> Formatter
RollingFileAppender --> Formatter
Logger ..> LogRecord : creates
用到的设计模式
- 责任链:日志从当前记录器开始,沿着父记录器一路往上,每一级的 Appender 都会收到。这样只要在根记录器上配一个控制台输出,所有记录器的日志都能打印出来。
- 策略模式:
Formatter决定输出格式,文本和 JSON 可以互换。 - 装饰器模式:
AsyncAppender包住另一个 Appender,加上“异步”这个能力,被包的那个完全不知道。 - 注册表:
LogManager保证同名记录器只有一个实例。
核心代码
enum Level { DEBUG, INFO, WARN, ERROR }
record LogRecord(Instant time, Level level, String logger, String thread, String message) {}
interface Appender { void append(LogRecord r); }
final class Logger {
private final String name;
private final Logger parent; // null for the root
private volatile Level level; // null: inherit
private final List<Appender> appenders = new CopyOnWriteArrayList<>();
Logger(String name, Logger parent) { this.name = name; this.parent = parent; }
void setLevel(Level level) { this.level = level; }
void addAppender(Appender a) { appenders.add(a); }
Level effectiveLevel() {
for (Logger l = this; l != null; l = l.parent)
if (l.level != null) return l.level;
return Level.INFO;
}
void log(Level at, Supplier<String> message) {
if (at.compareTo(effectiveLevel()) < 0) return; // off: the message is never built
LogRecord r = new LogRecord(Instant.now(), at, name, Thread.currentThread().getName(), message.get());
for (Logger l = this; l != null; l = l.parent) // "app.db" also reaches "app" and the root
for (Appender a : l.appenders) a.append(r);
}
void debug(Supplier<String> message) { log(Level.DEBUG, message); }
void info(String message) { log(Level.INFO, () -> message); }
void error(String message) { log(Level.ERROR, () -> message); }
}
final class LogManager {
private static final Logger ROOT = new Logger("", null);
private static final Map<String, Logger> LOGGERS = new ConcurrentHashMap<>();
static Logger get(String name) {
if (name.isEmpty()) return ROOT;
Logger known = LOGGERS.get(name);
if (known != null) return known;
Logger parent = get(name.contains(".") ? name.substring(0, name.lastIndexOf('.')) : "");
return LOGGERS.computeIfAbsent(name, n -> new Logger(n, parent)); // never recurse inside
}
}
两个细节值得在面试里主动说出来:
- 传入
Supplier,而不是拼好的字符串。log.debug(() -> "cache " + dump())在 DEBUG 关闭时,dump()根本不会执行。如果参数是字符串,拼接和dump()在调用前就已经执行完了,级别判断再快也省不下这部分开销。 LogManager.get先建好父记录器,再computeIfAbsent。如果在computeIfAbsent的回调里递归调用get,就是在同一个ConcurrentHashMap的更新过程中再去修改它,Java 9 以后可能直接抛出IllegalStateException: Recursive update。
异步输出
写文件是慢操作,可能因为磁盘繁忙卡上几十毫秒。异步 Appender 把日志放进一个有界队列,由后台线程写入:
final class AsyncAppender implements Appender, AutoCloseable {
private final BlockingQueue<LogRecord> queue = new ArrayBlockingQueue<>(10_000);
private final AtomicLong dropped = new AtomicLong();
private final Appender target;
private final Thread writer;
AsyncAppender(Appender target) {
this.target = target;
this.writer = new Thread(this::drain, "log-writer");
writer.setDaemon(true);
writer.start();
}
public void append(LogRecord r) {
if (r.level() == Level.ERROR) {
try { queue.put(r); } // errors wait for room
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
} else if (!queue.offer(r)) {
dropped.incrementAndGet(); // full: drop, never stall the caller
}
}
private void drain() {
try {
while (true) target.append(queue.take());
} catch (InterruptedException e) {
List<LogRecord> rest = new ArrayList<>();
queue.drainTo(rest); // flush what is left on close
rest.forEach(target::append);
}
}
public void close() throws InterruptedException {
writer.interrupt();
writer.join(2_000);
}
}
队列满了怎么办,是这道题最主要的取舍:
| 策略 | 好处 | 代价 |
|---|---|---|
| 丢弃 | 业务线程永远不会被日志卡住 | 高峰期会丢日志,要记录丢了多少 |
| 阻塞 | 一条都不丢 | 磁盘一慢,所有业务线程跟着变慢 |
| 按级别区分 | ERROR 阻塞,其他丢弃 | 实现稍复杂,但最符合实际需要 |
代码里用的是第三种。队列必须有界:无界队列在写入跟不上时会一直增长,最后把内存耗尽。关闭时中断后台线程,把队列里剩下的日志写完。
扩展讨论
- 滚动文件:文件超过 100 MB 就重命名为
app.log.1并新建文件,最多保留 N 个;按日期滚动同理。 - 结构化日志:消息之外带上键值对(请求 ID、用户 ID),用 JSON 格式输出,方便日志系统检索。
- 上下文传递:用线程本地变量保存请求 ID,Formatter 自动带上;跨线程池时要手动传递。
- 运行时调整级别:线上排查问题时把某个包临时调到 DEBUG,
level字段是volatile,修改后其他线程立即可见。