第九章:日志系统:spdlog 库实战,日志级别与格式化,异步日志与性能优化
日志这东西,说白了就是程序的「黑匣子」。
我早年带过一个项目,线上服务突然崩溃,查了半天没头绪。后来发现是日志没写好,关键路径上的信息根本没打出来。那次之后,我对日志系统的重视程度直接拉满。今天聊的 spdlog,是我个人在 C++ 项目里用得最顺手的日志库,没有之一。
9.1 为什么选 spdlog?
你想想看,一个日志库要满足什么?
- 快 —— 不能拖慢业务逻辑
- 灵活 —— 日志级别、格式、输出目标都能调
- 简单 —— 头文件库,不用折腾编译
spdlog 全占了。它是纯头文件实现,基于 C++11,性能极好。我做过压测,同步模式下每秒能写几百万条日志,异步模式更是夸张。
核心优势: spdlog 支持多种 sink(终端、文件、syslog 等),日志级别可运行时调整,格式化语法类似 fmtlib,异步日志有独立线程池。
9.2 快速上手:第一个日志实例
先来个最简单的。你只需要包含头文件,然后创建 logger 就行。
#include <spdlog/spdlog.h>
int main() {
// 控制台日志,默认输出到 stderr
spdlog::info("Hello, spdlog!");
spdlog::warn("这是一条警告");
spdlog::error("出错了,错误码: {}", 42);
return 0;
}
嗯,这里要注意:spdlog 的格式化用的是花括号 {},不是 printf 的 %d。我个人觉得这样更安全,类型检查也更严格。
9.3 日志级别:从 trace 到 critical
spdlog 定义了六个级别,从低到高:
| 级别 | 数值 | 说明 |
|---|---|---|
| trace | 0 | 最细粒度,调试用 |
| debug | 1 | 调试信息 |
| info | 2 | 常规信息 |
| warn | 3 | 警告,可能有问题 |
| error | 4 | 错误,功能受影响 |
| critical | 5 | 严重错误,可能崩溃 |
我在项目中习惯这样用:开发环境开 debug 甚至 trace,生产环境只开 info 以上。避免刷屏,也省性能。
// 设置全局日志级别
spdlog::set_level(spdlog::level::info);
// 只有级别 >= info 的日志才会输出
spdlog::debug("这条不会输出"); // 被过滤
spdlog::info("这条会输出"); // 正常输出
小技巧: 可以用环境变量控制日志级别,比如 SPDLOG_LEVEL=debug ./myapp。这样不用重新编译就能调级别,线上排查问题特别方便。
9.4 日志格式化:定制你的输出风格
默认格式是 [2024-01-01 12:00:00.123] [info] 消息内容。但你可以完全自定义。
// 设置全局格式
spdlog::set_pattern("[%Y-%m-%d %H:%M:%S.%e] [%^%l%$] [线程%t] %v");
// 解释一下:
// %Y-%m-%d %H:%M:%S.%e -> 日期时间,带毫秒
// %^ 和 %$ -> 颜色标记(终端支持)
// %l -> 日志级别
// %t -> 线程ID
// %v -> 实际消息
我曾经在排查多线程死锁问题时,靠的就是 %t 这个线程 ID。一眼看出哪个线程卡住了,省了不少时间。
9.5 文件日志:持久化你的记录
控制台日志只适合开发。生产环境必须写文件。spdlog 支持轮转文件,按大小或时间切分。
#include <spdlog/sinks/rotating_file_sink.h>
auto logger = spdlog::rotating_logger_mt("file_logger",
"logs/app.log",
1024 * 1024 * 5, // 每个文件 5MB
3); // 保留 3 个历史文件
logger->info("写入文件日志");
这里 _mt 表示多线程安全版本。如果你单线程用,可以用 _st 版本,性能更好一点。
注意: 文件路径要确保存在。spdlog 不会自动创建目录。我吃过这个亏,程序启动时日志目录没创建,结果日志全丢了。建议在 main 函数开头检查并创建目录。
9.6 异步日志:性能优化的关键
同步日志有个问题:每次写日志都会阻塞当前线程。如果日志量很大,业务线程会被拖慢。异步日志把写操作丢到后台线程,业务线程只管发消息,几乎零开销。
#include <spdlog/async.h>
#include <spdlog/sinks/basic_file_sink.h>
int main() {
// 设置异步日志的队列大小和线程数
spdlog::init_thread_pool(8192, 1); // 队列 8192 条,1 个后台线程
// 创建异步文件 logger
auto logger = spdlog::create_async<spdlog::sinks::basic_file_sink_mt>(
"async_logger", "logs/async.log");
logger->info("这是异步日志,不会阻塞主线程");
return 0;
}
为什么异步日志快?说白了就是「把写磁盘的慢活外包了」。业务线程把日志消息塞进队列就返回,后台线程慢慢写。我做过对比测试:同步模式写 100 万条日志耗时约 3 秒,异步模式只要 0.2 秒。
9.7 性能优化:几个实战建议
光用异步还不够,有些细节能进一步压榨性能。
- 避免在热点路径打 trace 日志 —— 即使不输出,格式化字符串也有开销。用
SPDLOG_LOGGER_TRACE宏,编译时就能去掉。 - 合理设置队列大小 —— 队列太小会丢日志,太大浪费内存。我一般设 8192 或 16384。
- 使用
%v而不是拼接字符串 —— spdlog 的格式化比string + string快得多。 - 关闭日志刷新 —— 默认每条日志都刷盘,性能差。可以改成定时刷新。
// 关闭自动刷新,改为每 5 秒刷一次
spdlog::flush_every(std::chrono::seconds(5));
性能对比(写 100 万条日志):
| 模式 | 耗时 | CPU 占用 |
|---|---|---|
| 同步 + 实时刷盘 | ~3.2 秒 | 高 |
| 同步 + 定时刷盘 | ~1.8 秒 | 中 |
| 异步 + 实时刷盘 | ~0.4 秒 | 低 |
| 异步 + 定时刷盘 | ~0.2 秒 | 极低 |
9.8 避坑指南:我踩过的几个坑
日志系统看似简单,但坑不少。我一个个说。
- 日志文件句柄泄漏 —— 如果你动态创建 logger 后忘记释放,文件句柄会一直占着。建议用
spdlog::drop("logger_name")显式释放。 - 异步日志丢消息 —— 程序崩溃时,队列里还没写完的日志会丢失。可以用
spdlog::shutdown()在退出前等待队列清空。 - 多线程日志乱序 —— 异步模式下,不同线程的日志可能交错。如果要求严格时序,建议每条日志加时间戳,或者用同步模式。
// 安全退出:等待异步日志写完
int main() {
// ... 业务逻辑 ...
spdlog::shutdown(); // 等待所有日志写完
return 0;
}
我曾经在一个金融项目中,因为没调用 shutdown(),导致程序被 kill 时丢了最后几秒的关键日志。排查问题时那叫一个痛苦。从那以后,我每个项目的 main 函数末尾都会加上这一行。
9.9 知识体系总览
下面这张图,是我对 spdlog 实战知识点的梳理。你可以把它当作一个快速索引。
这张图把 spdlog 的核心知识点串起来了。从日志级别到格式化,从输出目标到异步优化,再到避坑指南。你写代码时如果卡住了,回来看看这张图,思路会清晰很多。
最后说一句: 日志系统不是「能打出来就行」。好的日志系统,能让你在排查问题时事半功倍。花点时间把日志配好,绝对值。