printf 让你看见程序在跑,日志让你还原它为什么出错——从随手打印,到专业的黑匣子
核心方法论:排除不可能
「当你排除了所有不可能的情况,剩下的,不管多么难以置信,那一定就是真相。日志就是侦探的第一手证据——printf 能让你看见程序在跑,却看不见它为什么出了错。这一章,我们从『随手打印』走向『专业记录』。」
先回答一个最朴素的问题:什么是日志(log)?说人话,日志就是程序运行过程中留下的记录——什么时间连上了服务器、收到了多少数据、哪里超时重试了,一一写下来。它像飞机的黑匣子:平时没人看,一旦出事,它是还原现场的第一手证据。几乎每个 C++ 新手的第一行"调试代码"都是 printf,因为打印是最直觉的观察方式。但当你真正排查一个跑了很久的程序时,printf 会暴露三个致命伤。
第一,没有级别。几十行输出混在一起,你分不清哪条是"正常通知"、哪条是"这里要出事了"。出问题时只能从头到尾一行行人肉扫描。第二,没有时间戳。无法判断事件发生的先后顺序,也就无法重建时间线——而"先发生了什么"往往是定位 bug 的关键。第三,生产环境关不掉。上线之后你想关掉调试输出,唯一的办法是注释代码、重新编译、重新部署,而生产环境恰恰最怕这种动静。这三点合起来,就是福尔摩斯说的"不可能三角":你不可能靠 printf 同时做到分级、带时间、可开关。
再补一刀:多线程下 printf 也靠不住。虽然单次 printf 调用本身是线程安全的,但你的"一条日志"往往由多次调用拼成(先打印前缀、再打印数值、再打印换行),多个线程的输出会互相穿插,一条完整的日志被撕成碎片。所以结论很清晰:printf 适合"临时看一眼",不适合"长期记录"。日志库(logging library)就是来接管这件事的——它把"记录"标准化:自动带时间戳、按级别过滤、随时开关、写文件还能轮转(rotation,即日志文件满了自动滚动换新文件,后文细讲)。
// 用 printf 排查:信息挤成一团,没有时间、没有级别、没有归属
printf("connect ok fd=%d\n", fd);
printf("timeout, retrying...\n");
printf("recv %zu bytes\n", n);
printf("user=%s\n", uid.c_str()); // 上线后想关掉这一条?注释、重编译、重启
// 同样的事交给日志库(spdlog 写法,7.5 节细讲)
spdlog::info("connect ok fd={}", fd);
spdlog::warn("timeout, retrying...");
spdlog::info("recv {} bytes", n);
spdlog::debug("user={}", uid); // 生产环境把级别调到 info,这行自动消失,不用改代码
福尔摩斯办案的第一原则是"先看现场"。日志就是你程序的案发现场:时间戳告诉你是几点出的事,级别告诉你要优先看哪几行,可开关让你在不惊动生产的情况下随时加装监控。把这三点当成日志库的"体检标准",后面选库、配库都有谱。
日志级别(log level)说人话,就是给每条日志标上的"重要程度",像新闻的紧急程度分级:天气预报是例行播报,台风红色预警就是头等大事。有了级别,程序运行时就可以按阈值过滤——低于阈值的日志干脆不写,既不打扰运行,也不浪费磁盘。这正是 7.1 里 printf 做不到的事。
spdlog 定义了六档级别,从低到高依次是 trace / debug / info / warn / error / critical:trace 最琐碎(每个函数进出的细节),debug 是调试期才关心的信息,info 是正常业务事件(服务启动、连接建立),warn 表示可疑但不致命(重试了一次、磁盘快满了),error 表示出错了但程序还活着,critical 是致命错误(内存耗尽、配置损坏,程序马上要完)。这套命名是社区约定俗成的,换个日志库(比如 easyloggingpp 用 TRACE/DEBUG/INFO/WARNING/ERROR/FATAL,外加一个 VERBOSE 详细日志档位)只是名字略有不同,概念完全一样。
级别过滤的规则一句话:设置级别为 X,则 X 及比 X 更严重的才输出,比 X 轻的一律不写。说人话,级别像筛子,阈值是筛孔大小——孔开在 info,trace 和 debug 就被筛掉了。开发期把阈值放低(debug 甚至 trace)看细节,生产期把阈值抬到 info,日志量立刻降一个数量级,而"重要的事"一条不少。
| 档位(低→高) | spdlog | easyloggingpp | 什么时候用 |
|---|---|---|---|
| 0 | trace | TRACE | 函数进出、变量值等鸡毛蒜皮 |
| 1 | debug | DEBUG | 调试期才需要的细节 |
| 2 | info | INFO | 正常的业务事件 |
| 3 | warn | WARNING | 可疑但不致命 |
| 4 | error | ERROR | 出错但程序还能继续 |
| 5 | critical | FATAL | 致命错误,程序将终止 |
// spdlog:全局级别 + 各级别示例
spdlog::set_level(spdlog::level::debug); // 全局阈值:debug 及以上输出
spdlog::trace("enter loop, i={}", i); // 比 debug 轻,被过滤
spdlog::debug("buffer size = {}", n); // 输出
spdlog::info("server started on port 8080"); // 输出
spdlog::error("recv failed: {}", err); // 输出
// 把阈值抬到 info:前两条自动消失,后三条保留
// easyloggingpp:用 LOG(级别) 宏,级别名字略有不同
LOG(INFO) << "server started";
LOG(DEBUG) << "cache hit, key=" << key;
LOG(WARNING) << "disk usage over 90%";
LOG(ERROR) << "cannot open config file";
LOG(FATAL) << "out of memory, abort";
级别不是装饰品:把一切都写成 info,等于没有级别。排障时你要靠级别快速过滤,级别写准了,福尔摩斯才能"排除不可能"——直接跳过噪音,锁定 error 以上的行。
先认识本章的第一位选手:easyloggingpp(GitHub 上叫 amrayn/easyloggingpp,作者 Majid Q.)。作者在实战笔记里记录过它的真实用法(素材笔记《日志库LOG的使用》)——笔记里把它俗称 easyloggingcpp,官方文件名其实是 easylogging++.h 和 easylogging++.cc。它最大的特点是轻:整个库就这两个文件,不用安装、不依赖任何第三方库,直接把两个文件放进自己的项目一起编译就行,素材笔记原话是"直接把 easyloggingcpp.h 和 easyloggingcpp.cpp 放到自己的项目中进行编译就好了"。这种"扔进项目就能用"的形态,对不想引入复杂构建的项目非常友好。
用它的关键一步,是在 main 所在的文件里、最后一个 #include 之后写一行 INITIALIZE_EASYLOGGINGPP。宏(macro)说人话就是"预处理阶段展开的一小段代码";这个宏会展开成一堆全局变量的定义,所以整个程序只能出现一次——放两处就会编译报"重复定义"错误,官方文档的原话是"should be used once and once-only"(只能且必须使用一次)。写日志则用 LOG(INFO) << "..." 这种流式写法,和 std::cout 的手感几乎一样,后面接数字、字符串、变量都行。
编译也很直接:把两个文件一起交给编译器(官方 README 的编译命令):g++ main.cc easylogging++.cc -o prog -std=c++11。注意 easyloggingpp 要求 C++11 及以上。跑起来后,每条日志会自动带上时间和级别——这个"开箱即用"的体验,正是它当年在嵌入式和小项目里流行的原因。
// main.cc —— easyloggingpp 最小示例(素材笔记的真实用法)
#include "easylogging++.h"
INITIALIZE_EASYLOGGINGPP // 只能出现一次,放在 main 所在文件的最后一个 include 之后
int main(int argc, char* argv[]) {
LOG(INFO) << "My first info log using default logger";
LOG(DEBUG) << "connecting to server...";
return 0;
}
# 官方 README 的编译命令:两个文件一起编,-std=c++11 起步
g++ main.cc easylogging++.cc -o prog -std=c++11
# 运行输出(示意):自动带上日期时间与级别
# 2020-09-11 10:23:45,678 INFO [default] My first info log using default logger
# 2020-09-11 10:23:45,678 DEBUG [default] connecting to server...
选库如同选线索:先排除不可能。easyloggingpp 从 2012 年维护到 2018 年,之后项目已归档(archived)、官方不再更新,作者在 README 里明确建议新项目考虑 spdlog 等仍在维护的库;好在它是 MIT 许可,你可以自由 fork 继续用。所以它的定位是:存量项目好用、拿来即走;但新项目选型,先排除它。这就是为什么本章的主角是后面的 spdlog。
素材笔记专门提醒过:多线程中使用 easyloggingpp,必须设置几个重要的点,不然就会抛出异常、导致程序中断。为什么?因为日志库内部要写共享的缓冲区或文件,多个线程同时写,就像多个人抢同一支笔往同一张纸上写字——字会糊、顺序会乱,最坏情况是崩溃。解决"多人抢一支笔"的办法叫锁(lock),加了锁的代码才是线程安全(thread-safe)的,说人话就是"多个线程同时调用也不会出错"。
easyloggingpp 的设计是:默认不加锁,把性能留给单线程场景;要用多线程,就得显式打开线程安全模式,开关是一个宏:ELPP_THREAD_SAFE。素材笔记给了两种开法:编译时在编译参数里加 -DELPP_THREAD_SAFE(CMake 工程则在 CMakeLists.txt 里写 SET(CMAKE_CXX_FLAGS "-DELPP_THREAD_SAFE"),CMake 版本需要在 3.0 以上);源码时在用到日志的文件里、#include 之前先 #define ELPP_THREAD_SAFE。两种方式效果相同,选一种即可,但注意宏必须在 include 之前定义,且所有用到 LOG 的翻译单元要保持一致,否则行为不可预期。
这个"默认不线程安全、要你手动开"的设计,恰恰是后来日志库改进的靶子。spdlog 把线程安全直接做进了名字里:后缀 _mt(multi-threaded)表示多线程安全版,_st(single-threaded)表示单线程高速版——创建 logger 时选好后缀就完事,不用记宏、不用担心漏配。这就是设计上的"排除不可能":把容易踩的坑,在 API 层面直接消灭掉,而不是留给使用者小心翼翼。
// 方式一:在使用文件的 include 之前定义宏(素材笔记原文)
#define ELPP_THREAD_SAFE // 必须在 include 之前!
#include "easylogging++.h"
void worker(int id) {
LOG(INFO) << "worker " << id << " done"; // 多线程下安全
}
# 方式二:CMakeLists.txt 里设置编译参数(素材笔记原文,CMake 3.0+)
cmake_minimum_required(VERSION 3.0)
project(my_app CXX)
SET(CMAKE_CXX_FLAGS "-DELPP_THREAD_SAFE")
add_executable(my_app main.cc easylogging++.cc)
宏的位置是命门:ELPP_THREAD_SAFE 写在 #include "easylogging++.h" 之后等于没写——头文件已经按"单线程模式"完成了编译。排查这种问题时,福尔摩斯的方法就是按"宏是否先于 include 生效"逐一排除。
现在登场本章主角:spdlog(github.com/gabime/spdlog),README 上自称 "Fast C++ logging library"(快速的 C++ 日志库),C++11 编译器即可使用。说人话,它把"记录日志"这件事做成了专业选手:快(官方基准测试里单线程每秒能写数百万条)、格式化漂亮(基于 fmt 库,用 {} 占位符代替 printf 的 %d,类型安全、编译器会帮你检查参数)、输出目标多样(终端、文件、轮转文件、每日文件、syslog 等)。它的热度在 GitHub 上常年位居 C++ 库前列,是现在新项目的首选日志方案。
安装方式非常灵活:header-only(仅头文件)模式下,把仓库的 include/spdlog 目录拷进你的工程就能用,一个外部依赖都没有;官方 README 也推荐编译版(git clone + CMake 构建),编译速度更快。走包管理器的话,可以 vcpkg install spdlog(第 5 章的主角又出场了)、sudo apt install libspdlog-dev(Debian/Ubuntu)或 brew install spdlog(macOS)。
spdlog 里最重要的概念是 sink。sink 说人话就是日志的"去向"或"出口"——一条日志最终要流向哪里:终端?文件?轮转文件?syslog?一个 logger(日志器,说人话就是"负责写日志的那个对象")可以同时挂多个 sink,比如"终端只显示 warn 以上,文件全量记录"。创建 logger 的工厂函数(factory function,说人话就是"专门负责造 logger 的函数")后缀 _mt 表示线程安全版,日常直接用 _mt 系列即可。常用工厂函数对照如下:
| 工厂函数 | 输出目标 | 典型用途 |
|---|---|---|
spdlog::stdout_color_mt("name") | 终端(彩色) | 开发期看输出 |
spdlog::stderr_color_mt("name") | 标准错误(彩色) | 错误单独走 stderr |
spdlog::basic_logger_mt("name", "path") | 单个文件 | 最简单的文件日志 |
spdlog::rotating_logger_mt("name", "path", max_size, max_files) | 轮转文件 | 控制单个文件大小 |
spdlog::daily_logger_mt("name", "path", h, m) | 每日文件 | 按天归档 |
# 安装方式三选一
# 方式一:vcpkg(第 5 章的主角)
./vcpkg/vcpkg install spdlog
# 方式二:Linux 发行版包
sudo apt install libspdlog-dev
# 方式三:header-only,把仓库的 include/spdlog 目录拷进工程
git clone https://github.com/gabime/spdlog
cp -r spdlog/include/spdlog ./third_party/
// 全局 logger 快速上手(官方 README 示例)
#include <spdlog/spdlog.h>
int main() {
spdlog::info("Welcome to spdlog!");
spdlog::error("Some error message with arg: {}", 1);
spdlog::warn("Easy padding in numbers like {:08d}", 12);
spdlog::critical("hex: {0:x}, oct: {0:o}", 42);
spdlog::set_level(spdlog::level::debug); // 全局级别调到 debug
spdlog::debug("This message should be displayed..");
// 自定义输出格式:%n=logger 名 %L=级别 %t=线程号 %v=消息本体
spdlog::set_pattern("[%H:%M:%S %z] [%n] [%^---%L---%$] [thread %t] %v");
return 0;
}
注意上面示例里的 pattern(输出格式模板):%v 是消息本体,%L 是级别,%n 是 logger 名字,%t 是线程号,%^ 和 %$ 是颜色开关(两者之间的内容上色),%H:%M:%S 是时间、%z 是时区。这套 % 开头的占位符就是 spdlog 的"格式化语法",想要什么样的日志行,拼一个 pattern 字符串即可——排障时把线程号打出来,多线程问题立刻能缩小范围。
先上最常用的文件日志。全局函数 spdlog::info 只写终端,要写文件得自己建 logger:spdlog::basic_logger_mt("basic_logger", "logs/basic-log.txt")——第一个参数是 logger 的名字(之后可以用 spdlog::get("basic_logger") 在任何地方取回它),第二个参数是文件路径。注意一个常见坑:文件所在目录必须已经存在,否则创建 logger 时会抛出 spdlog::spdlog_ex 异常(说人话就是"spdlog 自己定义的错误类型"),所以官方示例总是用 try/catch 包住创建过程。
生产环境更常用的是轮转日志(rotating log)。轮转说人话:日志文件写到上限后,把当前文件"滚动"成历史文件、重新开一个新文件继续写,最多保留 N 个历史文件——就像循环录像带,旧画面自动覆盖,磁盘永远不会被撑爆。对应函数 spdlog::rotating_logger_mt("rot_logger", "logs/rotating.txt", max_size, max_files),例如单文件上限 5MB、最多保留 3 个历史文件(官方示例的参数)。此外还有 daily_logger_mt:每天凌晨固定时刻自动开一个新文件,适合按天归档的场景。
当日志量很大、或者不想让业务线程卡在写盘上时,用异步日志(async logging)。异步说人话:调用方把日志丢进一个内存队列就立刻返回,后台专门有一个线程从队列里取出来写盘——"前台只管交,后台负责写"。用之前要先 spdlog::init_thread_pool(队列大小, 后台线程数) 建线程池(官方默认建议 8192 条队列 + 1 个后台线程),再创建 logger 时套上 spdlog::async_factory。队列满时有两种策略:block(调用方阻塞等待队列空出位置)和 overrun(丢弃最旧的日志、优先保证新日志),取舍是"保完整"还是"保实时"。另外别忘冲刷(flush):写文件是有缓冲的,程序异常退出可能丢日志,用 spdlog::flush_every(std::chrono::seconds(3)) 让所有 logger 每 3 秒自动把缓冲落盘一次。
// 基础文件日志 + 轮转文件日志(官方 README 示例)
#include <spdlog/spdlog.h>
#include <spdlog/sinks/basic_file_sink.h>
#include <spdlog/sinks/rotating_file_sink.h>
void file_example() {
try {
auto basic = spdlog::basic_logger_mt("basic_logger", "logs/basic-log.txt");
basic->info("hello from basic file logger");
auto max_size = 1048576 * 5; // 单文件 5MB
auto max_files = 3; // 保留 3 个历史文件
auto rot = spdlog::rotating_logger_mt("rot_logger", "logs/rotating.txt", max_size, max_files);
rot->warn("disk usage over 90%");
} catch (const spdlog::spdlog_ex &ex) {
printf("log init failed: %s\n", ex.what()); // 目录不存在等错误会走到这里
}
}
// 异步日志(官方 README 示例)
#include <spdlog/async.h>
#include <spdlog/sinks/basic_file_sink.h>
#include <chrono>
void async_example() {
// 先建线程池:队列 8192 条,1 个后台写盘线程
spdlog::init_thread_pool(8192, 1);
auto async_file = spdlog::basic_logger_mt<spdlog::async_factory>(
"async_file_logger", "logs/async_log.txt");
async_file->info("logged by background thread");
// 每 3 秒自动冲刷所有 logger,防止异常退出丢日志
spdlog::flush_every(std::chrono::seconds(3));
}
// 多 sink:一个 logger,终端与文件各设各的级别(官方 README 示例)
#include <spdlog/spdlog.h>
#include <spdlog/sinks/stdout_color_sinks.h>
#include <spdlog/sinks/basic_file_sink.h>
void multi_sink_example() {
auto console_sink = std::make_shared<spdlog::sinks::stdout_color_sink_mt>();
console_sink->set_level(spdlog::level::warn); // 终端只看 warn 及以上
auto file_sink = std::make_shared<spdlog::sinks::basic_file_sink_mt>("logs/multisink.txt", true);
file_sink->set_level(spdlog::level::trace); // 文件全量记录
spdlog::logger logger("multi_sink", {console_sink, file_sink});
logger.warn("this appears in console and file");
logger.info("this appears only in file");
}
官方基准测试(Intel i7-4770、Ubuntu 64 位)给出的参考数字:单线程 basic_st 约 578 万条/秒,10 线程抢同一个 basic_mt logger 约 166 万条/秒,异步模式约 268 万条/秒(队列溢出策略 overrun 时)。结论:绝大多数业务用同步的 _mt 就绰绰有余,只有日志量巨大、或写盘延迟会拖垮业务线程时,才值得引入异步。
把福尔摩斯的"排除不可能"落到日志排障上,就是四步走:① 定时间窗——靠时间戳把问题锁定到某几分钟;② 按级别筛——先看 error/critical,再看 warn,噪音瞬间消失;③ 锁线程与模块——pattern 里带上线程号 %t 和 logger 名 %n,多线程问题立刻缩小到具体线程;④ 还原现场——配合环形缓冲把出错前的蛛丝马迹倒出来。每一步都在排除一批"不可能",剩下的就是真相。
生产环境的标配配置如下:级别开到 info(省磁盘、省 CPU),文件用轮转(10MB × 5 个,防止日志撑爆磁盘),pattern 带上完整时间(含毫秒 %e)和线程号,再用 flush_every 定期落盘防丢失。还有一招很实用:spdlog::cfg::load_env_levels() 支持用环境变量 SPDLOG_LEVEL 在不重新编译的情况下动态调级别,比如 SPDLOG_LEVEL=info,mylogger=trace 表示全局 info、只有 mylogger 降到 trace——上线后想临时放大某个模块的日志,一条环境变量命令搞定,这正是 7.1 说的"生产环境可开关"。
最后介绍一个排障利器:backtrace(环形缓冲)。环形缓冲(ring buffer)说人话就是一个"只留最近 N 条"的定长内存队列,新的进来、最旧的被挤掉。玩法是:平时让 debug 消息只写进环形缓冲、不落盘(零开销),一旦出错,调用 spdlog::dump_backtrace() 把最近 32 条一口气倒出来——平时不打扰,出事有证据,堪称"福尔摩斯的袖珍笔记本"。
// 生产环境初始化:轮转 + 级别 + 格式 + 定时冲刷
void setup_production_logging() {
auto max_size = 1048576 * 10; // 10MB 轮转
auto rot = spdlog::rotating_logger_mt("app", "logs/app.log", max_size, 5);
rot->set_level(spdlog::level::info); // 生产只留 info 及以上
rot->set_pattern("[%Y-%m-%d %H:%M:%S.%e] [%l] [%t] %v"); // 时间+级别+线程
spdlog::set_default_logger(rot); // 全局 spdlog::info 都走这个文件
spdlog::flush_every(std::chrono::seconds(3));
}
// 环形缓冲 + 环境变量调级别(官方 README 示例)
#include <spdlog/spdlog.h>
#include <spdlog/cfg/env.h>
int main() {
spdlog::cfg::load_env_levels(); // 支持 SPDLOG_LEVEL 环境变量,免重编译调级别
spdlog::enable_backtrace(32); // 只攒最近 32 条 debug 消息,不落盘
for (int i = 0; i < 100; ++i) {
spdlog::debug("backtrace message {}", i); // 此刻并不写盘
}
// ……程序某处出错:
spdlog::dump_backtrace(); // 把最近 32 条一口气写出来,还原现场
return 0;
}
至此,"从 printf 到 spdlog"这条路你已经走完了:printf 是"临时看一眼",日志库是"长期记录与排障";easyloggingpp 教会我们"两个文件搞定、多线程要开 ELPP_THREAD_SAFE"的轻量哲学,spdlog 则用 _mt 后缀、sink、轮转、异步把日志做成了工程级能力。下一章我们就要用第 5 章的 vcpkg 装 pistache 写 REST 服务——到时候记得先按这一章的方法把日志配好,因为服务上线之后,日志就是你唯一的眼睛。
用一句话分别解释:日志、日志级别、sink。再按从低到高排出 spdlog 的六档级别。
黑匣子 / 重要程度 / 日志的去向;六档顺序记口诀:trace 最细、critical 最狠。
日志:程序运行过程留下的记录,像黑匣子,出事时是还原现场的第一手证据;日志级别:给每条日志标上的重要程度,用于按阈值过滤;sink:日志的输出去向(终端、文件、轮转文件等)。spdlog 六档由低到高:trace < debug < info < warn < error < critical。
补全下面的程序:让它能打印一条 INFO 日志和一条 ERROR 日志,并写出对应的编译命令(两个文件一起编译,C++11)。
// main.cc
#include "easylogging++.h"
? // 这一行该写什么?
int main(int argc, char* argv[]) {
? // 打印 INFO 日志
? // 打印 ERROR 日志
return 0;
}
还记得 7.3 那个"只能出现一次"的宏吗?日志用 LOG(级别) << 内容 的流式写法。
初始化行写 INITIALIZE_EASYLOGGINGPP(放在 main 所在文件最后一个 include 之后,只能出现一次);日志行写 LOG(INFO) << "some info"; 和 LOG(ERROR) << "some error";。编译命令:g++ main.cc easylogging++.cc -o prog -std=c++11。注意:如果程序里用了多线程,还要在 include 之前 #define ELPP_THREAD_SAFE(或编译时加 -DELPP_THREAD_SAFE)。
以下四个场景分别该用哪个 spdlog 工厂函数?并写出一段代码:创建名为 "app" 的文件 logger,写入 logs/app.log,级别设为 warn,再打一条 warn 日志。(a) 开发期想在终端看彩色输出;(b) 要求单文件不超过 5MB、最多留 3 个历史文件;(c) 每天凌晨 2:30 自动开新文件;(d) 日志量大,不想让业务线程卡在写盘。
对照 7.5 的表格;写文件级别用 set_level(spdlog::level::warn);建 logger 的调用要 try/catch spdlog::spdlog_ex。
(a) spdlog::stdout_color_mt("console");(b) spdlog::rotating_logger_mt("name", "logs/x.log", 1048576 * 5, 3);(c) spdlog::daily_logger_mt("name", "logs/daily.txt", 2, 30);(d) 先 spdlog::init_thread_pool(8192, 1),再 spdlog::basic_logger_mt<spdlog::async_factory>("name", "logs/async.txt")。代码:auto app = spdlog::rotating_logger_mt("app", "logs/app.log", 1048576 * 5, 3); app->set_level(spdlog::level::warn); app->warn("disk almost full");(建 logger 用 try/catch 包住,捕获 spdlog::spdlog_ex)。
你的服务有 10 个工作线程,每线程每秒打几十条日志,日志量中等。要求:日志绝不能崩程序、不能互相穿插、生产环境只留 warn 以上。请回答:(a) 用 easyloggingpp 时最少要做哪件事才不会崩?为什么默认会崩?(b) 用 spdlog 时该选 basic_logger_mt 还是 basic_logger_st?后缀分别代表什么?(c) 什么情况下才值得换异步日志?队列满时 block 和 overrun 策略各有什么代价?
(a) 想 7.4 的"抢笔"比喻;(b) mt = multi-threaded,st = single-threaded;(c) 想 7.6 的官方基准数字,再想想"丢日志"和"卡线程"哪个更不能接受。
(a) 必须开启线程安全模式:所有用到 LOG 的翻译单元在 include 之前 #define ELPP_THREAD_SAFE,或编译参数加 -DELPP_THREAD_SAFE(CMake 里 SET(CMAKE_CXX_FLAGS "-DELPP_THREAD_SAFE"),CMake 3.0+)。因为 easyloggingpp 默认不加锁,多线程同时写共享缓冲会数据错乱甚至抛异常中断程序。(b) 选 basic_logger_mt:_mt 是多线程安全版(内部有锁,10 线程约 166 万条/秒),_st 是单线程高速版(约 578 万条/秒),多线程用 _st 是危险的。(c) 当日志量大到写盘阻塞业务线程、或业务对延迟敏感时才值得异步(官方基准异步 overrun 约 268 万条/秒)。队列满时 block 会阻塞调用方等待队列腾位置——保完整但可能卡业务;overrun 丢弃最旧日志——保实时但可能丢记录。中等日志量选同步 _mt + 轮转 + flush_every 即可。