C++ 项目里打日志,是一件看起来简单、铺开就乱的事情。早期方案无非是printf加一个文件重定向,或者自己封装一个fprintf轮子,再往后可能换到 log4cxx、glog 这些老牌库。但要么跨平台麻烦,要么编译依赖重,要么接口不顺手。很多项目做到中途,日志系统反而成了最不想动、又最需要维护的部分。如果你也在 C++ 项目里折腾过日志,多半能理解这种处境。
spdlog 是目前 C++ 社区里最值得优先考虑的日志库之一。它的核心优势不是“功能多”这么简单,而是把日志记录这件事的工程成本压得很低:头部模式(header-only)可以直接塞进现有项目,编译快,依赖简单,默认就带异步日志、滚动文件、格式化、按天切分这些生产环境必需的机制。而且它的 API 非常直观,基本上一行代码就能开始打日志。
这篇文章我会从实际使用的角度讲清楚 spdlog 的引入、配置、常见用法和容易踩坑的地方,重点覆盖四个方面:第一,它的核心概念和设计思路;第二,如何下载、集成到一个 C++ 工程里;第三,从基础日志到异步日志、滚动文件日志、MFC 项目集成这些完整示例;第四,生产环境使用时的排查方法和最佳实践。如果你正准备给 C++ 项目引入日志库,或者已经在用 spdlog 但想进一步用好它,这篇文章可以直接参考操作。
1. spdlog 真正解决了什么问题
先说一个很典型的开发场景。项目早期,你可能用printf配合fprintf(stderr, ...)看输出,程序跑起来就算完事。等模块多了、项目进入联调阶段,问题开始出现:多线程打印内容交错;不同模块的输出混在一起,无法按级别过滤;线上环境不能看终端,只能写文件,但文件无法自动切分,跑一天就几个 GB;定位问题的时候,日志里没有时间、线程号、源码位置,根本没法追踪调用链。
这些问题的本质是:打日志本身不难,难的是日志系统的“可用性”。一个可用的日志系统至少要解决四个问题:
- 线程安全。多线程环境不能出现内容错乱。
- 格式化能力。要能方便地输出变量、容器、自定义类型。
- 输出路由。内存、终端、文件、远程日志可以灵活切换。
- 性能。日志写入不能拖垮业务逻辑。
spdlog 正是围绕这些问题设计的。它内部使用锁和异步队列来保证线程安全,支持格式化输出语法,通过 sink(输出目标)机制把“日志内容”和“输出到哪里”解耦,异步模式下日志写入和业务线程分离,调用方几乎无感知。更关键的是,它的默认配置已经覆盖了大多数项目的基本需求,真正做到了“开箱即用”。
从技术成本角度看,spdlog 替代的不只是printf加文件流,而是整个自研日志模块的开发量。它默认依赖只有一个 fmt 库(实际上 1.x 版本已经内置 fmt 支持),可以以纯头文件方式使用,也可以编译成静态库/动态库接入。这种设计让它在 CMake、vcpkg、conan、MFC、Qt 项目里都能顺利落地。
2. spdlog 的核心概念与设计原理
要真正用好 spdlog,需要先理解它的三个核心概念:logger(日志器)、sink(输出目标)、pattern(输出格式)。
2.1 Logger:日志记录的门面
Logger 是你在代码里直接操作的对象。它负责三件事:接收日志内容、判断当前日志级别是否需要输出、把日志内容交给一个或多个 sink。
spdlog 提供了两种使用方式:
- 全局静态函数,例如
spdlog::info("...")。底层使用一个默认的 logger 实例,适合快速使用和轻量项目。 - 命名 logger,例如
spdlog::get("my_logger")。可以创建多个 logger,按模块或业务维度区分,写不同的日志文件、使用不同的级别配置。
第二种方式更适合中大型项目,因为不同模块可以独立控制日志输出,避免全部日志混在一个文件里。
2.2 Sink:输出目标
Sink 是 spdlog 里最值得理解的设计。它把“记录日志”和“输出日志”彻底解耦。常用的 sink 有:
| Sink | 用途 |
|---|---|
| stdout_sink / stderr_sink | 输出到控制台 |
| stdout_color_sink | 带颜色的控制台输出 |
| basic_file_sink | 输出到单个文件 |
| rotating_file_sink | 按文件大小滚动切分 |
| daily_file_sink | 按日期切分 |
| msvc_sink | 输出到 Visual Studio 输出窗口 |
| syslog_sink / tcp_sink | 系统日志或网络传输 |
一个 logger 可以同时挂多个 sink。例如开发阶段,控制台看日志、文件留记录;生产环境只留文件,并且按大小滚动。
2.3 Pattern:日志格式模板
Pattern 决定了每条日志长什么样。spdlog 使用类似 C 风格占位符的格式串,常用的占位符包括:
| 占位符 | 含义 |
|---|---|
%Y-%m-%d %H:%M:%S | 时间 |
%e | 毫秒 |
%l | 日志级别 |
%t | 线程 ID |
%s | 源码文件名 |
%# | 源码行号 |
%v | 日志正文 |
例如"[%Y-%m-%d %H:%M:%S.%e] [%l] [thread %t] [%s:%#] %v"可以输出成下面这种格式:
[2025-01-08 14:23:55.123] [info] [thread 3021] [main.cpp:15] application started这种格式在排查问题时非常有用,因为可以快速定位日志来自哪个文件哪一行,以及是哪个线程打出来的。
2.4 异步机制为什么不卡业务线程
spdlog 的异步模式值得单独提。异步 logger 内部维护一个线程池和一个消息队列。业务线程调用logger->info(...)时,只是把日志内容格式化并塞进队列,真正的文件写入由后台线程完成。这样日志写入不会阻塞业务逻辑,在高频日志场景下性能提升明显。
这里需要区分一个容易误解的点:异步不是“默认开启”的。直接使用spdlog::info时是同步模式,日志内容在调用线程内完成格式化并写入 sink。只有使用spdlog::create_async或者spdlog::async_logger相关接口时,才会走线程池队列。
理解了这个设计,后续在配置异步日志时就不会犯“以为设了队列就是异步”的错误。
3. spdlog 下载与环境准备
spdlog 的官方仓库在 GitHub 的gabime/spdlog。下载方式可以按项目和团队习惯任选一种。
3.1 直接下载源码
从 GitHub Releases 页面下载最新源码包,解压后把include目录加入项目的头文件搜索路径即可。这是最快的接入方式,适合小型项目或者临时验证。
官方仓库链接:
https://github.com/gabime/spdlog3.2 使用 CMake FetchContent
在实际工程中,更推荐使用 CMake 的 FetchContent 或者 Git 子模块的方式来管理。
使用 Git 子模块:
git submodule add https://github.com/gabime/spdlog.git third_party/spdlog在 CMakeLists.txt 里添加子目录:
add_subdirectory(third_party/spdlog) target_link_libraries(your_target PRIVATE spdlog::spdlog)或者用 FetchContent 自动下载:
include(FetchContent) FetchContent_Declare( spdlog GIT_REPOSITORY https://github.com/gabime/spdlog.git GIT_TAG v1.x # 以实际版本标签为准 ) FetchContent_MakeAvailable(spdlog) target_link_libraries(your_target PRIVATE spdlog::spdlog)3.3 使用 vcpkg 或 conan
如果你的项目已经使用了 vcpkg 或 conan,可以直接安装:
vcpkg install spdlogconan install spdlog/1.x这里有一个容易踩的坑:vcpkg 默认安装的是编译好的静态库/动态库形式,而 spdlog 本身还支持 header-only 模式。两种模式的宏定义不同,混用会出现链接错误,下面第 8 节会详细说。
3.4 环境要求
spdlog 对 C++ 标准的要求是 C++11 以上,但这个“以上”很宽松。实际项目建议至少使用 C++11,如果条件允许,直接用 C++17 会让fmt格式化能力更完整。它支持 Windows、Linux、macOS,也支持 MinGW、MSVC、GCC、Clang 等主流编译器。不依赖额外的大型第三方库,这是它能快速接入各类项目的重要原因。
4. 第一个 spdlog 示例:快速跑通
先用最小代码体验一下 spdlog 的基本使用。假设你已经把include目录配置好了。
创建一个main.cpp:
// 文件路径:src/main.cpp #include "spdlog/spdlog.h" int main() { // 使用全局默认 logger spdlog::info("Welcome to spdlog!"); spdlog::warn("This is a warning, value = {}", 42); spdlog::error("An error occurred, code = {}", -1); // 设置默认 logger 的输出格式 spdlog::set_pattern("[%Y-%m-%d %H:%M:%S.%e] [%l] [%t] %v"); // 设置最低日志级别,默认是 info spdlog::set_level(spdlog::level::debug); spdlog::debug("Debug-level message"); return 0; }编译运行。如果你使用 Linux 或 macOS:
g++ src/main.cpp -I/path/to/spdlog/include -std=c++17 -pthread -o demo ./demo预期输出类似:
[2025-01-08 14:23:55.123] [info] Welcome to spdlog! [2025-01-08 14:23:55.124] [warning] This is a warning, value = 42 [2025-01-08 14:23:55.124] [error] An error occurred, code = -1 [2025-01-08 14:23:55.125] [debug] Debug-level message这段代码虽然简单,但已经展示了 spdlog 的几个核心能力:{}占位符格式化、全局默认 logger、级别控制、格式设置。{}是 spdlog 基于 fmt 的格式化语法,比printf的%d、%s安全,编译器会检查参数类型,支持自定义类型格式化,这也是很多 C++ 开发者从printf迁徙过来的主要原因之一。
5. 核心功能详解:sink、级别、格式化与日志刷新
第一段代码跑通后,再看 spdlog 的常用功能配置,这些才是实际项目中真正需要的。
5.1 创建文件日志
开发阶段控制台日志够用,但生产环境必须落盘。创建一个简单的文件日志:
// 文件路径:src/file_logger_demo.cpp #include "spdlog/spdlog.h" #include "spdlog/sinks/basic_file_sink.h" int main() { // 创建名为 "file_logger" 的日志器,写入日志文件 auto logger = spdlog::basic_logger_mt("file_logger", "logs/demo.log"); logger->info("This is a file log, user_id = {}", 10001); logger->warn("Disk space is low, remaining = {} MB", 256); // 程序退出前刷新并关闭日志 spdlog::shutdown(); return 0; }这里值得解释的是basic_logger_mt。mt表示 multi-threads,即线程安全的日志器,适用于多线程程序。spdlog 还提供了basic_logger_st,其中的st表示 single-thread,单线程场景下性能更好,但多线程使用不安全。默认情况下,文件日志器不会自动刷新到磁盘,需要手动设置刷新策略。
5.2 日志级别与刷新策略
spdlog 的日志级别从低到高分别是:
| 级别 | 说明 |
|---|---|
| trace | 最详细的调试信息 |
| debug | 调试信息 |
| info | 普通信息 |
| warn | 警告 |
| err / error | 错误 |
| critical | 严重错误 |
设置级别有两个方向:一个是 logger 的级别,决定哪些日志会被输出;另一个是 flush 级别,决定哪些级别的日志会立即刷新到磁盘。
// 只有高于等于 warn 的日志才会输出 logger->set_level(spdlog::level::warn); // 当出现 warn 及以上级别日志时,立即刷新到磁盘,防止程序崩溃丢日志 logger->flush_on(spdlog::level::warn); // 每 3 秒自动刷新一次 spdlog::flush_every(std::chrono::seconds(3));这些配置在生产环境中很关键。如果不设置刷新策略,程序异常退出时,最后的日志可能还停留在缓冲区里,丢失排障信息。但如果每条日志都强制刷新,性能又会明显下降。正确做法是:正常级别按时间或大小批量刷新,错误级别即时刷新。
5.3 Pattern 格式化完整示例
一个更完整的格式化配置:
// 文件路径:src/pattern_demo.cpp #include "spdlog/spdlog.h" #include "spdlog/sinks/basic_file_sink.h" int main() { auto logger = spdlog::basic_logger_mt("pattern_logger", "logs/pattern.log"); // 输出文件名、行号、线程号,方便定位问题 logger->set_pattern("[%Y-%m-%d %H:%M:%S.%e] [%l] [thread %t] [%s:%#] %v"); logger->info("order created, order_id = {}", 20250108001); spdlog::shutdown(); return 0; }输出结果会包含源码文件名和行号。注意,%s和%#依赖编译器预处理宏__FILE__和__LINE__,debug 和 release 下都会生效,不会影响性能。
许多项目最后还会定义一个全局宏,统一管理 logger 引用和格式,比如:
// 文件路径:src/log_util.h #pragma once #include "spdlog/spdlog.h" #include "spdlog/sinks/basic_file_sink.h" #define LOG_INFO(...) SPDLOG_LOGGER_INFO(spdlog::get("app"), __VA_ARGS__) #define LOG_WARN(...) SPDLOG_LOGGER_WARN(spdlog::get("app"), __VA_ARGS__) #define LOG_ERROR(...) SPDLOG_LOGGER_ERROR(spdlog::get("app"), __VA_ARGS__)这种宏定义方式,一方面不用每次写spdlog::get("app"),另一方面也保留了对默认 logger 或命名 logger 的灵活性。
5.4 多个 Sink 组合
实际项目中经常需要“控制台 + 文件”同时输出。spdlog 支持把一个 logger 挂多个 sink:
// 文件路径:src/multi_sink_demo.cpp #include "spdlog/spdlog.h" #include "spdlog/sinks/stdout_color_sinks.h" #include "spdlog/sinks/rotating_file_sink.h" int main() { auto console_sink = std::make_shared<spdlog::sinks::stdout_color_sink_mt>(); console_sink->set_level(spdlog::level::debug); auto file_sink = std::make_shared<spdlog::sinks::rotating_file_sink_mt>( "logs/multi.log", 1024 * 1024 * 5, 3 ); file_sink->set_level(spdlog::level::info); auto logger = std::make_shared<spdlog::logger>("multi_logger", spdlog::sinks_init_list{console_sink, file_sink}); logger->set_level(spdlog::level::debug); logger->info("console and file logger work together"); spdlog::register_logger(logger); spdlog::shutdown(); return 0; }这里rotating_file_sink_mt的参数含义是:文件路径、单文件最大大小(5 MB)、保留文件数量(3 个)。当文件超过 5 MB 时,自动滚动成multi.1.log、multi.2.log,最多保留 3 个滚动文件。这个机制在生产环境中非常重要,避免单个日志文件无限增长撑爆磁盘。
6. 异步日志与滚动文件实战
生产环境里,日志写入频率可能很高,尤其是网络服务、游戏服务器这类程序。如果同步写文件,业务线程会被文件 I/O 卡住。spdlog 的异步模式就是为了解决这个问题。
6.1 异步日志基础使用
异步日志需要先初始化线程池,然后创建异步 logger。
// 文件路径:src/async_demo.cpp #include "spdlog/spdlog.h" #include "spdlog/async.h" #include "spdlog/sinks/rotating_file_sink.h" int main() { // 初始化线程池:队列大小 8192,后台线程数 1 spdlog::init_thread_pool(8192, 1); auto logger = spdlog::create_async<spdlog::sinks::rotating_file_sink_mt>( "async_logger", "logs/async.log", 1024 * 1024 * 10, // 单文件 10 MB 5 // 保留 5 个滚动文件 ); logger->set_pattern("[%Y-%m-%d %H:%M:%S.%e] [%l] [thread %t] %v"); for (int i = 0; i < 1000; ++i) { logger->info("async log message, index = {}", i); } spdlog::shutdown(); return 0; }init_thread_pool(8192, 1)中的 8192 是消息队列容量,1 是后台写入线程数。当业务线程产生日志的速度超过后台写入速度时,日志消息会堆积在队列里。队列满时,spdlog 有两种处理策略,默认是丢弃新日志;另一种是阻塞调用线程直到队列有空间。
6.2 异步溢出策略
如果队列满,默认行为是阻塞还是丢弃?spdlog 默认使用async_overflow_policy::block,也就是队列满时会阻塞调用线程,保证日志不丢失。
可以通过下面的方式设置为丢弃新消息:
#include "spdlog/async.h" #include "spdlog/sinks/stdout_color_sinks.h" // 使用丢弃策略创建异步 logger auto logger = spdlog::create_async<spdlog::sinks::stdout_color_sink_mt>( "async_drop_logger", spdlog::async_overflow_policy::overrun_oldest );overrun_oldest表示队列满时覆盖最早的日志,适合“只要最新日志,不想要旧日志”的监控场景。而如果业务日志非常重要,建议保持默认的block策略,虽然会带来一定阻塞,但日志不会丢。
6.3 按天切分日志
有些业务日志更适合按日期归档,比如每天一个文件,保留最近 30 天。spdlog 提供了daily_file_sink:
// 文件路径:src/daily_demo.cpp #include "spdlog/spdlog.h" #include "spdlog/sinks/daily_file_sink.h" int main() { // 每天 0 点整切分,分钟和小时决定切分时刻,保留 30 个历史文件 auto logger = spdlog::daily_logger_mt("daily_logger", "logs/daily.log", 0, 0, false, 30); logger->info("today log content"); spdlog::shutdown(); return 0; }daily_logger_mt的参数分别是:logger 名称、日志文件路径、切分时间(小时、分钟)、是否在启动时截断旧文件、最大文件数量。日志文件会以daily_2025-01-08.log这样的形式命名。
这里提醒一点:daily_file_sink只能按自然时间切分,不能按文件大小切分。如果你需要“既按时间又按大小”,有daily_file_sink和rotating_file_sink结合的自定义方案,也可以在代码中组合多个 sink,或者使用spdlog::sinks::daily_file_sink的自定义文件命名规则。
7. MFC / Windows 项目集成技巧
搜索“mfc使用 spdlog例子代码”的人很多,因为 MFC 项目接入第三方库时,坑往往不在库本身,而在项目配置和字符处理上。这里单独讲一下 MFC 集成 spdlog 的完整思路。
7.1 在 MFC 项目中添加 spdlog
如果你用 Visual Studio 开发 MFC 项目,最简单的方式是使用 vcpkg:
vcpkg install spdlog:x86-windows然后在 VS 的“项目属性 -> C/C++ -> 常规 -> 附加包含目录”中添加 vcpkg 对应的 include 路径,或者直接使用 vcpkg 的集成命令:
vcpkg integrate install如果不使用 vcpkg,也可以直接把 spdlog 源码的include目录复制到项目里,然后配置好头文件路径。
在 MFC 项目中,一个常见的需求是让日志同时输出到文件和控制台窗口。对 MFC 这种 GUI 程序来说,控制台窗口不是默认存在的,可以在InitInstance里分配一个控制台,或者直接只写文件,调试阶段再用OutputDebugString在 VS 输出窗口查日志。
7.2 在 MFC 应用启动时初始化日志
下面这段代码演示了在 MFC 的CMyApp::InitInstance中初始化一个文件日志,并设置常用的格式:
// 文件路径:MyApp.cpp #include "spdlog/spdlog.h" #include "spdlog/sinks/rotating_file_sink.h" #include "spdlog/sinks/msvc_sink.h" BOOL CMyApp::InitInstance() { // 创建文件 sink,按大小滚动 auto file_sink = std::make_shared<spdlog::sinks::rotating_file_sink_mt>( "logs/app.log", 1024 * 1024 * 5, 3 ); // 创建 VS 输出窗口 sink,方便调试 auto msvc_sink = std::make_shared<spdlog::sinks::msvc_sink_mt>(); auto logger = std::make_shared<spdlog::logger>( "mfc_app", spdlog::sinks_init_list{file_sink, msvc_sink} ); logger->set_level(spdlog::level::debug); logger->set_pattern("[%Y-%m-%d %H:%M:%S.%e] [%l] [%s:%#] %v"); logger->flush_on(spdlog::level::err); spdlog::register_logger(logger); spdlog::set_default_logger(logger); logger->info("MFC application started"); // ... 其他初始化 ... return TRUE; }使用msvc_sink是个很实用的技巧。调试时日志会输出到 Visual Studio 的“输出”窗口,点开日志能看到源码文件和行号,排查问题比看弹窗提示高效得多。
7.3 MFC 中 CString 与 spdlog 的格式化
MFC 里到处是CString。直接向 spdlog 传入CString会失败,因为 spdlog 的格式化器不认识CString类型。处理方式是在传参前转换成std::string或std::wstring。
// 文件路径:StringConvert.h #pragma once #include <atlstr.h> #include <string> // CString 转 UTF-8 std::string inline std::string CStringToUtf8(const CString& str) { if (str.IsEmpty()) { return std::string(); } int len = ::WideCharToMultiByte( CP_UTF8, 0, str.GetString(), str.GetLength(), nullptr, 0, nullptr, nullptr ); std::string result(len, 0); ::WideCharToMultiByte( CP_UTF8, 0, str.GetString(), str.GetLength(), &result[0], len, nullptr, nullptr ); return result; }使用的时候:
CString userName = L"张三"; logger->info("user login, name = {}", CStringToUtf8(userName));这里要注意编码问题。MFC 项目默认可能是 Unicode 字符集,CString底层是宽字符。CStringToUtf8转成 UTF-8 后,写入日志文件时可读性最好。如果不做转换直接传CString,不但编译不过,还会因为字符编码问题产生乱码。
7.4 MFC 项目中的运行时库匹配
MFC 项目在 Windows 上有一个隐蔽的坑,就是运行时库设置。spdlog 在 Windows 上写文件使用标准 C++ 文件流,理论上运行时库要和张模式匹配。如果项目用的/MT(静态运行时),而 spdlog 编译时使用了/MD(动态运行时),链接会报 LNK2038 或 LNK2005 之类的错误。
解决思路是保持整个项目运行时库一致。通常把 MFC 项目和 spdlog 的“代码生成 -> 运行库”设成相同选项,或者干脆直接用 header-only 模式让编译器按项目设置来编译 spdlog 的模板代码。这也是 spdlog 在 Windows 上比较推荐 header-only 模式的原因之一。
8. spdlog 常见问题与排查方法
日志库的问题通常不像业务代码那样直接,更多出现在编译期、链接期和运行时环境上。下面按实际频率整理几个高频问题。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 链接错误 LNK2019 / LNK2005:spdlog 相关符号未解析或重复定义 | 编译模式与链接模式不一致(header-only 与 compiled 混用) | 查看编译命令中是否定义了 SPDLOG_COMPILED_LIB | 保持统一模式;header-only 不定义SPDLOG_COMPILED_LIB,compiled 模式则必须统一定义 |
找不到spdlog/fmt/bundled相关头文件 | fmt 库冲突或 spdlog 版本内置 fmt 未启用 | 查看 include 路径和项目已有 fmt 版本 | 使用 spdlog 内置 fmt,避免外部 fmt;或使用SPDLOG_FMT_EXTERNAL并确保外部 fmt 版本匹配 |
| 中文日志乱码 | 文件编码与写入字节不一致,CString 未转换 | 检查日志文件编码和传入的字符串类型 | 统一使用 UTF-8,或让文件 sink 使用宽字符日志 |
| 异步 logger 初始化时崩溃或抛异常 | 调用create_async前未初始化线程池 | 检查是否调用了spdlog::init_thread_pool或spdlog::create_async的时机 | 在创建 async logger 之前初始化线程池,全局初始化只做一次 |
| 日志文件不按预期滚动 | rotating sink 参数理解偏差或时间切分混用 | 查看滚动日志生成的文件名和时间 | rotating 按大小滚动,daily 按日期滚动,确认使用的是哪个 sink |
| 输出窗口没有 msvc_sink 日志 | MFC 项目没有附加 msvc_sink,或 VS 输出窗口未打开 | 检查 logger 构造时是否加入msvc_sink | 把msvc_sink_mt加入 sinks_init_list |
| 性能下降明显 | 每条日志都flush,或同步模式下日志量过大 | 分析日志调用频率和 flush 策略 | 减少非关键路径日志频率,开启异步模式,设置flush_on为错误级别 +flush_every批量刷新 |
除了表格里的问题,还有一个容易忽略的点:spdlog 的spdlog::shutdown()应该在程序即将退出时调用,它会刷新所有 logger 并释放资源。如果程序通过exit()或者 Windows 的TerminateProcess直接终止,日志可能来不及落盘,最后一段日志丢失。
另外,在 Windows 上从 GitHub 下载 spdlog 源码可能会遇到网络问题。可以把源码包放到公司内部 Git 仓库,或者使用 vcpkg 缓存,避免每次配置环境都重新下载。
9. 工程最佳实践与注意事项
项目里的日志系统一旦跑起来,改动成本会越来越高。因此一开始就把规范定好,能节省大量后期维护时间。
9.1 明确使用命名 Logger
不要在整个项目里到处使用默认 logger。正确做法是按模块或服务创建命名 logger,例如订单模块用order_logger,用户模块用user_logger。这样每个模块可以独立设置级别、输出文件和滚动策略。模块之间日志互不干扰,排查问题时只需看对应模块的日志文件。
9.2 级别设置要区分 Debug 和 Release
开发阶段为了调试方便,可以把级别设为debug甚至trace。但生产环境如果也开debug,日志量会非常恐怖,影响性能,也会让关键错误信息淹没在大量非关键日志里。
建议:
- Debug 构建:
spdlog::set_level(spdlog::level::debug)。 - Release 构建:
spdlog::set_level(spdlog::level::info)或warn。 - 特殊时期:通过配置中心或环境变量动态调整级别,而不是改代码重新编译。
9.3 合理设计刷新策略
日志的刷新策略要分场景:
- 错误和严重级别日志:
flush_on(spdlog::level::err),哪怕牺牲一点性能,也要保证错误日志落盘。 - 普通日志:使用
flush_every(std::chrono::seconds(3))批量刷新,降低 I/O 压力。 - 程序结束:调用
spdlog::shutdown()。
9.4 不要把敏感信息写入日志
这是非常容易被忽视的一点。日志中不要记录密码、token、身份证号、银行卡号等敏感信息。如果业务确实需要记录用户标识,可以使用脱敏后的 ID,而不是完整信息。一旦日志文件泄露,敏感数据会直接造成安全事故。spdlog 本身没有脱敏能力,脱敏逻辑要在业务层完成。
9.5 生产环境默认使用异步和滚动文件
生产环境最稳妥的组合是:rotating_file_sink或daily_file_sink加异步模式。日志文件要设置大小上限和保留数量,避免磁盘被日志写满。对长时间运行的服务来说,无限增长的日志文件是最大的隐患之一。
9.6 高版本 C++ 带来的额外收益
如果项目允许,尽量使用 C++17 及以上。spdlog 的格式化底层依赖 fmt,更高的 C++ 标准意味着更好的编译优化和更完整的格式化支持。同时,新版本的 spdlog 对概念、自定义类型格式化支持更友好,使用体验会明显提升。
9.7 团队协作时统一封装
在团队项目里,建议在 spdlog 之上再做一层薄薄的封装,比如提供统一的LOG_INFO、LOG_ERROR宏,或者日志管理类。这样未来如果要迁移日志系统,不需要改动每个业务文件,只需要改封装层。但封装要薄,不要过度设计,避免把 spdlog 的高性能特性包没了。
10. 总结与后续学习方向
spdlog 吸引人的地方,不是它有多么花哨的功能,而是它把 C++ 日志系统里那些“不得不做”的基础工作全部做完了:跨平台、线程安全、格式化、文件滚动、异步队列、多样化的输出目标。引入 spdlog 之后,团队可以把精力从“怎么把日志写进文件”转移到“日志内容是否有价值”上。
读完这篇文章,你可以从三个方面继续深入:
第一,跑通一个最小 spdlog 示例,确认集成路径无误后,再把文件日志和异步日志接入自己的项目。
第二,根据项目规模选择具体用法。小型工具直接使用全局静态函数实现几行日志,大型服务优先按模块创建命名 logger,并使用旋转文件加异步模式。
第三,针对特殊场景,比如自定义 sink、多进程日志文件锁、远程日志传输、结构化日志输出,可以去看 spdlog 的源码和官方示例。spdlog 的源码规模并不大,注释也比较清晰,读源码是理解日志系统设计的好方式。
日志系统的价值,往往要到问题发生时才会体现。如果那行救命的日志恰好被你写进了代码,调试时间可能从几小时缩短到几分钟。而 spdlog 能保证的,就是当那行日志被写出来时,它能够稳定、快速、按你期望的格式出现在正确的地方。
建议收藏备用。下次给 C++ 项目接入日志时,直接按这篇文章的路径操作,能少踩不少坑。