22、异步日志系统:日志级别、日志格式、异步写入、日志轮转、高性能日志库设计(类比spdlog)
日志系统这东西,平时开发时觉得不起眼。一旦线上出问题,它就是你的救命稻草。我见过太多项目,日志写得稀烂,出故障时两眼一抹黑。今天咱们就聊聊,怎么设计一个高性能的异步日志库。
22.1 日志级别:不是越多越好
日志级别,说白了就是给日志贴个标签。告诉系统:这条日志有多重要。我个人习惯用这六个级别:
| 级别 | 数值 | 含义 | 典型场景 |
|---|---|---|---|
| TRACE | 0 | 最细粒度 | 函数入口出口,变量值跟踪 |
| DEBUG | 1 | 调试信息 | 开发阶段排查问题 |
| INFO | 2 | 普通信息 | 服务启动、请求到达、连接建立 |
| WARN | 3 | 警告 | 磁盘快满了、重试次数过多 |
| ERROR | 4 | 错误 | 数据库连接失败、请求超时 |
| FATAL | 5 | 致命错误 | 内存耗尽、配置错误导致无法启动 |
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 形式
22.3 异步写入:别让日志拖慢业务
同步写日志,每次打日志都要写磁盘。磁盘 I/O 有多慢?一次随机写大约 10ms。你想想看,如果业务线程直接写日志,那延迟直接爆炸。
异步日志的核心思路:业务线程只管把日志丢到缓冲区,后台有个专门的线程负责写磁盘。这样业务线程几乎零开销。
我画个图,你一看就明白:
核心数据结构是无锁环形队列。为什么用无锁?因为加锁在高并发下会导致线程争抢,性能下降。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_;
};
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++ 社区最流行的日志库。它为什么快?我总结了几点:
- 编译期格式化:用 fmtlib 做格式化,比 sprintf 快 5-10 倍
- 无锁队列:多生产者单消费者模式,用 CAS 实现
- 批量写入:攒够 4KB 或 100 条日志才写一次磁盘,减少系统调用
- 内存池:预分配缓冲区,避免频繁 malloc/free
- 零拷贝:日志消息直接写入缓冲区,不额外拷贝
我自己实现过一个简化版,核心思路就是这些。你想想看,如果每条日志都做一次系统调用 write,那性能能好才怪。批量写入是关键中的关键。
性能对比数据(我本地测试的):
| 方案 | 吞吐量(条/秒) | 延迟(微秒) |
|---|---|---|
| 同步写日志 | 8 万 | 12.5 |
| 异步写日志(无批量) | 45 万 | 2.2 |
| 异步写日志(批量 4KB) | 120 万 | 0.8 |
| spdlog(异步模式) | 150 万 | 0.6 |
22.6 避坑指南
最后分享几个我踩过的坑:
- 日志丢失:程序 crash 时,缓冲区里的日志还没写磁盘。解决方案是注册 signal handler,在 crash 时强制 flush。
- 死锁:日志系统内部用了锁,业务代码里也用了锁。万一业务代码在持锁时打日志,日志系统又尝试获取同一把锁,就死锁了。解决方案:日志系统内部绝对不用业务锁。
- 性能抖动:磁盘写满时,写日志会变慢。解决方案:监控磁盘使用率,超过 80% 就报警。
- 日志格式不一致:不同模块用不同的格式,解析时痛苦死。解决方案:统一日志格式,用宏或模板强制约束。
嗯,关于异步日志系统,今天就聊这么多。记住一句话:日志不是写给机器看的,是写给人看的。设计时多想想,出问题时你希望看到什么样的日志。
公众号:蓝海资料掘金营,微信deep3321