cswork / 系统设计

第 52 章 / 共 57 章 · 约 10 分钟

LLD 案例:日志框架

本页目录
  1. 需求澄清
  2. 核心类
  3. 用到的设计模式
  4. 核心代码
  5. 异步输出
  6. 扩展讨论
  7. 章节测验

日志框架是一道“看起来人人都用过”的题,考的是你有没有想过它内部的取舍:级别怎么继承,一条日志怎么分发到多个目的地,写日志会不会拖慢业务线程,日志关掉时还有没有开销。

需求澄清

功能需求

  1. 四个级别:DEBUG < INFO < WARN < ERROR;低于阈值的日志直接丢弃。
  2. 记录器按名字分层,比如 app.db.pool。没有单独设置级别的记录器,沿用最近的上级的级别;根记录器默认 INFO。
  3. 一条日志可以输出到多个目的地(Appender):控制台、按大小滚动的文件等,每个目的地可以有自己的格式。
  4. 格式可配置:时间、级别、记录器名、线程名、消息。

非功能需求

  • 线程安全:多个线程同时写日志,单条日志不会交错。
  • 低开销:日志级别关闭时几乎没有成本;写文件不能拖慢业务线程。
  • 进程正常退出时,已经产生的日志不能丢。

核心类

类职责
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,修改后其他线程立即可见。

章节测验

3 道选择题
  1. log.debug(() -> "cache " + dump()) 比 log.debug("cache " + dump()) 好在哪里?
  2. 记录器 app.db.pool 没有设置级别,app.db 设为 WARN,根记录器是 INFO。app.db.pool 的 INFO 日志会输出吗?
  3. 异步 Appender 的队列满了。为什么 INFO 日志默认丢弃,而不是让调用线程等待?

章节讨论

正在加载讨论…