22、异步日志系统:日志级别、日志格式、异步写入、日志轮转、高性能日志库设计(类比spdlog)

日志系统这东西,平时开发时觉得不起眼。一旦线上出问题,它就是你的救命稻草。我见过太多项目,日志写得稀烂,出故障时两眼一抹黑。今天咱们就聊聊,怎么设计一个高性能的异步日志库。

22.1 日志级别:不是越多越好

日志级别,说白了就是给日志贴个标签。告诉系统:这条日志有多重要。我个人习惯用这六个级别:

级别 数值 含义 典型场景
TRACE 0 最细粒度 函数入口出口,变量值跟踪
DEBUG 1 调试信息 开发阶段排查问题
INFO 2 普通信息 服务启动、请求到达、连接建立
WARN 3 警告 磁盘快满了、重试次数过多
ERROR 4 错误 数据库连接失败、请求超时
FATAL 5 致命错误 内存耗尽、配置错误导致无法启动
我的经验:线上环境一般开到 INFO 级别就够了。DEBUG 日志量太大,会拖垮性能。我曾经在一个高并发服务上开了 DEBUG,结果日志线程把 CPU 吃掉了 30%。嗯,从那以后我学乖了。

22.2 日志格式:结构化的力量

日志格式,我推荐用结构化格式。说白了就是每条日志都包含固定的字段。这样后续用 ELK 或 Splunk 分析时,能直接按字段过滤。

一个典型的日志格式长这样:

[2025-01-15 14:30:22.123] [INFO] [server.cpp:42] [thread_id=12345] [request_id=abc-123] 用户登录成功, user_id=10086

字段拆解:

  • 时间戳:精确到毫秒,最好用 ISO 8601 格式
  • 日志级别:方便 grep 过滤
  • 源文件位置:文件名+行号,定位问题全靠它
  • 线程 ID:多线程环境下,知道是哪个线程打的日志
  • 请求 ID:分布式追踪的关键,把一次请求的所有日志串起来
  • 消息体:具体内容,建议用 key=value 形式
注意:千万别在日志里拼 JSON 字符串。你以为方便解析,实际上浪费了大量 CPU 在字符串拼接上。用 key=value 格式,解析时用正则或 split 就够了。

22.3 异步写入:别让日志拖慢业务

同步写日志,每次打日志都要写磁盘。磁盘 I/O 有多慢?一次随机写大约 10ms。你想想看,如果业务线程直接写日志,那延迟直接爆炸。

异步日志的核心思路:业务线程只管把日志丢到缓冲区,后台有个专门的线程负责写磁盘。这样业务线程几乎零开销。

我画个图,你一看就明白:

业务线程 1 业务线程 2 业务线程 3 无锁环形队列 (多生产者单消费者) 缓冲区 1 缓冲区 2 缓冲区 3 ... 后台写线程 (唯一消费者) 磁盘文件

核心数据结构是无锁环形队列。为什么用无锁?因为加锁在高并发下会导致线程争抢,性能下降。CAS 操作比锁快一个数量级。

代码骨架大概这样:

class AsyncLogger {
public:
    void log(const std::string& msg) {
        // 尝试写入当前缓冲区
        if (!current_buffer_->push(msg)) {
            // 缓冲区满了,切换缓冲区
            switch_buffer();
            current_buffer_->push(msg);
        }
        // 唤醒后台线程
        cv_.notify_one();
    }

private:
    void flush_thread() {
        while (running_) {
            std::unique_lock<std::mutex> lock(mutex_);
            cv_.wait(lock, [this]{
                return !current_buffer_->empty() || !running_;
            });
            // 交换缓冲区,避免写的时候阻塞业务线程
            BufferPtr ready_buffer = std::move(current_buffer_);
            current_buffer_ = std::make_shared<Buffer>();
            lock.unlock();
            // 写磁盘
            ready_buffer->flush_to_file(file_);
        }
    }

    std::shared_ptr<Buffer> current_buffer_;
    std::mutex mutex_;
    std::condition_variable cv_;
    FILE* file_;
};
关键点:双缓冲机制。业务线程写一个缓冲区,后台线程写另一个。写完后交换。这样业务线程永远不会等磁盘 I/O。

22.4 日志轮转:别让日志撑爆磁盘

日志轮转,说白了就是日志文件不能无限增长。我见过一个项目,日志文件涨到 200GB,把磁盘撑爆了,整个服务挂了。惨痛教训。

常见的轮转策略有两种:

  • 按大小轮转:每个文件 100MB,写满了就新建一个。保留最近 10 个文件,旧的自动删除。
  • 按时间轮转:每小时或每天新建一个文件。保留最近 7 天。

我推荐两者结合:按大小轮转为主,按时间轮转为辅。比如每个文件 100MB,同时每天凌晨 0 点强制切分一次。

轮转时的原子操作很重要:

void rotate() {
    // 1. 关闭当前文件
    fclose(current_file_);
    // 2. 重命名当前文件,加上时间戳
    rename("server.log", "server_20250115_143022.log");
    // 3. 打开新文件
    current_file_ = fopen("server.log", "a");
    // 4. 检查文件数量,删除最旧的
    clean_old_files(max_files_);
}
坑:重命名操作在多线程下要加锁。否则可能出现两个线程同时重命名,导致文件丢失。我曾经踩过这个坑,排查了一整天才找到原因。

22.5 高性能日志库设计:向 spdlog 学习

spdlog 是目前 C++ 社区最流行的日志库。它为什么快?我总结了几点:

  1. 编译期格式化:用 fmtlib 做格式化,比 sprintf 快 5-10 倍
  2. 无锁队列:多生产者单消费者模式,用 CAS 实现
  3. 批量写入:攒够 4KB 或 100 条日志才写一次磁盘,减少系统调用
  4. 内存池:预分配缓冲区,避免频繁 malloc/free
  5. 零拷贝:日志消息直接写入缓冲区,不额外拷贝

我自己实现过一个简化版,核心思路就是这些。你想想看,如果每条日志都做一次系统调用 write,那性能能好才怪。批量写入是关键中的关键。

性能对比数据(我本地测试的):

方案 吞吐量(条/秒) 延迟(微秒)
同步写日志 8 万 12.5
异步写日志(无批量) 45 万 2.2
异步写日志(批量 4KB) 120 万 0.8
spdlog(异步模式) 150 万 0.6
我的建议:如果项目对性能要求极高,直接用 spdlog。如果只是想学习原理,自己实现一个简化版也很有价值。我当年就是自己手写了一个,才真正理解了异步日志的精髓。

22.6 避坑指南

最后分享几个我踩过的坑:

  • 日志丢失:程序 crash 时,缓冲区里的日志还没写磁盘。解决方案是注册 signal handler,在 crash 时强制 flush。
  • 死锁:日志系统内部用了锁,业务代码里也用了锁。万一业务代码在持锁时打日志,日志系统又尝试获取同一把锁,就死锁了。解决方案:日志系统内部绝对不用业务锁。
  • 性能抖动:磁盘写满时,写日志会变慢。解决方案:监控磁盘使用率,超过 80% 就报警。
  • 日志格式不一致:不同模块用不同的格式,解析时痛苦死。解决方案:统一日志格式,用宏或模板强制约束。

嗯,关于异步日志系统,今天就聊这么多。记住一句话:日志不是写给机器看的,是写给人看的。设计时多想想,出问题时你希望看到什么样的日志。


公众号:蓝海资料掘金营,微信deep3321