spdlog实战指南:C++日志库从接入到异步写盘全解析
2026/8/31 9:48:01 网站建设 项目流程

这次我们来看一个在 C++ 日志领域几乎绕不开的开源项目:gabime / spdlog。它解决的痛点在服务端、桌面客户端、嵌入式 Linux 里都很常见——程序跑着跑着出了问题,却没有一套“不乱、不丢、不卡线程”的日志系统。spdlog 的特点是 API 简洁、接入成本低,默认 header-only 使用,也支持编译成静态库或动态库。它在单线程和多线程场景下都有稳定的文件写入能力,可以按大小轮转、按天生成、异步批量写入,还能把日志同时输出到文件、控制台、调试窗口等多个目标。

这篇文章不会只贴几个“你好世界”示例就结束。我会把 spdlog 的下载方式、CMake 接入、编译成库、基础用法、异步写日志、MFC 项目集成、资源占用观察和常见坑完整过一遍,末尾给一套可以直接抄进生产项目的建议。无论你是刚开始选型 C++ 日志库,还是已经用了 spdlog 但遇到中文乱码、日志丢失、退出崩溃这些问题,都可以在文章里找到对应处理思路。

1. spdlog 核心能力速览

先给一张速览表,方便你快速判断这个库适不适合当前项目。

能力项说明
项目类型C++ 日志库,开源项目,作者为 gabime
使用方式默认 header-only,也可以编译为静态库/动态库
操作系统Windows / Linux / macOS 等常见平台,跨平台
编译要求需要现代 C++ 编译环境,建议使用 C++17 及以上工具链
核心功能同步日志、异步日志、按大小轮转、按天/小时生成文件、多 sink 组合、自定义 pattern
格式化依赖内置 fmt 格式化引擎,支持{}占位符写法
线程安全提供_mt多线程安全版本和_st单线程版本 sink
异步写盘支持线程池 + 队列,批量写入日志文件
是否支持 API本身不提供 HTTP API,但可以输出文件或自定义 sink 对接采集系统
适合场景服务端程序、后台任务、MFC/Win32 桌面应用、嵌入式 Linux 工具

需要说明一点:表格里的“适合场景”是我从社区使用情况和 spdlog 功能范围总结的判断,不是官方承诺。具体能不能满足你的项目,还是要以小规模测试为准。

2. spdlog 适用场景与使用边界

2.1 适用场景

spdlog 适合这几类项目:

  • 长期运行的守护进程或服务。这类程序需要按天或按大小切割日志,避免单文件无限增长,同时要记录线程号、时间戳、源码位置。
  • MFC/Win32 桌面程序。客户端在用户机器上出问题时,往往没有调试器,只能靠日志文件排查。spdlog 可以同时输出到文件与 VS 调试窗口,适合这种“线上难定位”的场景。
  • 日志量较大的后台任务。比如批量处理队列、网络请求转发程序,每条日志的开销必须足够低,否则日志成为性能瓶颈。
  • 多线程并发环境。spdlog 的_mtsink 和异步模式本身就是为多线程写入设计的。

2.2 不适合的场景

  • C++98 老项目。spdlog 依赖现代 C++ 特性,老编译器很难直接编译通过。如果项目无法升级工具链,建议继续使用原有日志方案。
  • 只是临时打印几行调试信息。轻量脚本或一次性工具,直接用printf/std::cout可能更省事,不需要引入第三方库。
  • 需要报表式日志分析。spdlog 只解决“日志生成和写盘”,不负责日志检索、图表、告警。这部分需要配合 ELK、Loki 等采集分析系统。

2.3 使用边界与合规提醒

日志是把双刃剑。写入过于详细的信息会带来隐私和安全隐患。实际项目中要注意:

  • 不要明文输出密码、Token、银行卡号、身份证号等敏感信息。
  • 涉及用户手机号、邮箱等个人信息时,生产环境建议脱敏后再写入。
  • 人脸、声音、文件内容等素材涉及授权问题的场景,日志中也不要随意记录原文或路径。
  • 如果日志会长期保留,要考虑磁盘占用和数据合规要求。

这一点和用哪个日志库无关,但每次提日志系统都应该强调。

3. spdlog 下载与本地部署环境准备

3.1 操作系统与编译器

Windows 下建议使用 Visual Studio 2019/2022,MSVC 编译;Linux 下可以用 g++ 或 clang。spdlog 本身的代码不算复杂,但依赖较新的标准库和编译器特性。如果项目还在用比较老的编译器,建议先到 GitHub Releases 页面查看当前版本 tag 对 C++ 标准的要求。

3.2 获取 spdlog 源码

获取方式主要有以下几种:

方式一:GitHub 拉取源码

git clone https://github.com/gabime/spdlog.git

如果不需要最新提交,直接 checkout 到某个固定 tag 更稳:

cd spdlog git checkout v1.x.y

具体 tag 名称以 GitHub Releases 实际为准,建议用你验证过的版本,不要每次跟随最新 main 分支。

方式二:vcpkg

vcpkg install spdlog

安装后 Visual Studio 项目中可通过 vcpkg 的 CMake toolchain 找到spdlog::spdlog

方式三:Conan

conan install spdlog/1.x.y@

版本号需要替换成实际发布的版本,并和项目的 Conan remote 匹配。

方式四:Ubuntu/Debian 系统包管理器

sudo apt install libspdlog-dev

使用 apt 的好处是安装简单,坏处是系统仓库里的版本通常偏旧,具体看发行版。如果项目计划长期维护,建议用 vcpkg、Conan 或源码固定 tag。

3.3 磁盘与依赖

spdlog 源码体积不大,拉取后只占很小一部分空间。它内部会使用 fmt 库来做格式化。默认情况下 spdlog 自带 fmt,不需要你单独安装;如果你希望使用外部 fmt,需要确认版本一致并定义SPDLOG_FMT_EXTERNAL

4. spdlog 引入与编译方式

spdlog 最舒服的一点是:你可以完全不编译它,直接包含头文件使用。这在小项目和快速原型阶段很省事。生产项目如果希望减少编译时间,也可以把它编译成静态库。

4.1 Header-Only 直接引入

把 spdlog 源码中的include目录加入项目包含路径,然后在代码里:

#include "spdlog/spdlog.h" int main() { spdlog::info("Hello, spdlog!"); return 0; }

Windows 下的 Visual Studio 操作路径是:项目属性 -> C/C++ -> 常规 -> 附加包含目录,添加spdlog/include

这里有一个关键点:spdlog 默认是 header-only 模式,所以不需要额外定义宏。如果你的项目把 spdlog 编译成了库,就需要在所有包含头文件的源文件里统一定义SPDLOG_COMPILED_LIB,否则会出现符号不一致的问题。

4.2 CMake FetchContent 引入

现代 CMake 项目推荐用 FetchContent 自动拉取 spdlog:

include(FetchContent) FetchContent_Declare( spdlog GIT_REPOSITORY https://github.com/gabime/spdlog.git GIT_TAG v1.x.y ) FetchContent_MakeAvailable(spdlog) target_link_libraries(your_target PRIVATE spdlog::spdlog)

注意把GIT_TAG替换成实际使用的版本号。固定 tag 非常重要,如果直接拉主分支,后续上游更新可能改变行为。

4.3 CMake 编译安装 spdlog 静态库

如果项目里已经有预编译依赖的习惯,可以直接编译安装 spdlog:

cmake -S spdlog -B build -DSPDLOG_BUILD_EXAMPLE=OFF -DSPDLOG_BUILD_TESTS=OFF cmake --build build --config Release cmake --install build

这种方式会生成静态库或动态库,具体还要看 CMake 配置项。生成后用find_package(spdlog REQUIRED)或直接链接到目标库。

4.4 Header-Only 和编译库怎么选

  • 项目不大、不想引入复杂构建流程:用 header-only。
  • 项目很大、有大量源文件都包含 spdlog:建议编译成静态库,减少重复模板实例化带来的编译时间。
  • 多模块/插件系统:用动态库更统一,但要注意运行时库类型。

5. spdlog 功能测试与效果验证

5.1 控制台日志与日志级别

先写一个最小程序,测试最基本的控制台输出:

#include "spdlog/spdlog.h" int main() { spdlog::trace("trace message"); spdlog::debug("debug message"); spdlog::info("Hello {}!", "spdlog"); spdlog::warn("disk space is low: {} GB", 3.2f); spdlog::error("failed to connect: {}", 10060); return 0; }

默认日志级别是info,所以tracedebug在控制台看不到。如果你希望看到更详细的调试信息,需要显式设置级别:

spdlog::set_level(spdlog::level::debug);

这一步是很多新手第一次跑示例时踩的坑:不是没输出,而是级别被过滤了。

5.2 文件日志与按大小轮转

把日志写到文件里是生产环境的基本需求。basic_file_sink适合单文件简单记录,rotating_file_sink适合控制文件大小:

#include "spdlog/spdlog.h" #include "spdlog/sinks/basic_file_sink.h" #include "spdlog/sinks/rotating_file_sink.h" auto file_logger = spdlog::basic_logger_mt("file_logger", "logs/run.log"); file_logger->info("write to basic file"); auto rotating_logger = spdlog::rotating_logger_mt( "rot_logger", "logs/app.log", 1024 * 1024 * 5, // 单个文件 5MB 3 // 保留 3 个历史文件 ); rotating_logger->info("write to rotating file");

判断标准:运行后logs/run.log存在,并且里面有对应日志。继续写入超过 5MB 后,logs目录会出现app.1.logapp.2.log等文件,旧日志被自动滚动。

5.3 按天生成日志文件

服务器程序通常希望每一天一个日志文件:

#include "spdlog/sinks/daily_file_sink.h" auto daily_logger = spdlog::daily_logger_mt("daily_logger", "logs/daily.log", 0, 0); daily_logger->info("daily log entry");

daily_logger_mt的后两个参数是每天轮转的小时和分钟,0, 0表示每天 0 点切割。

5.4 自定义日志格式 pattern

spdlog 的 pattern 语法很适合输出结构化日志。常见的 pattern 如下:

spdlog::set_pattern("[%Y-%m-%d %H:%M:%S.%e] [%^%l%$] [thread %t] [%s:%#] %v"); spdlog::info("request finished, cost={} ms", 12);

这里几个常用占位符:

  • %Y-%m-%d %H:%M:%S.%e:日期、时间、毫秒。
  • %l:日志级别。
  • %^%$:给级别着色。
  • %t:线程 ID。
  • %s:源码文件名。
  • %#:源码行号。
  • %v:日志正文。

生产环境建议保留线程 ID、时间戳和文件名行号,这样排查多线程问题会方便很多。

5.5 功能验证小结

测试时可以按这个顺序走:

  1. 控制台能否输出 info/warn/error。
  2. 调整set_level(debug)后 trace/debug 能否输出。
  3. 文件日志是否生成,路径是否正确。
  4. 轮转文件是否按时、按大小切割。
  5. 自定义 pattern 是否符合预期。

每个步骤都能通过,说明 spdlog 的基本链路已经跑通。

6. MFC 项目集成示例

热词里提到“mfc 使用 spdlog 例子代码”,说明很多桌面客户端项目对这块有需求。MFC 集成 spdlog 有几个特殊点:字符集、运行时库、调试窗口、应用退出时的资源释放。

6.1 MFC 项目里的关键注意点

  • 字符集:建议统一使用 UTF-8 模式。如果项目是 Unicode 字符集,写入CString时需要先转成 UTF-8 再写日志,避免中文乱码。
  • 运行时库:[!] 如果 spdlog 编译成静态库,要和 MFC 项目的 Runtime Library 设置一致,避免LNK2038不匹配错误。
  • 调试窗口:spdlog 提供msvc_sink,可以把日志输出到 Visual Studio 的调试输出窗口。这对 MFC 调试很有用。
  • 生命周期:不要在全局对象析构时依赖 Logger。最好在InitInstance里初始化,在ExitInstance里显式spdlog::shutdown()

6.2 MFC 程序初始化日志

#include "spdlog/spdlog.h" #include "spdlog/sinks/msvc_sink.h" #include "spdlog/sinks/daily_file_sink.h" // CMyApp::InitInstance 中初始化日志 BOOL CMyApp::InitInstance() { try { auto msvcSink = std::make_shared<spdlog::sinks::msvc_sink_mt>(); auto fileSink = std::make_shared<spdlog::sinks::daily_file_sink_mt>( "D:/logs/my_mfc_app.log", 0, 0); std::vector<spdlog::sink_ptr> sinks; sinks.push_back(msvcSink); sinks.push_back(fileSink); auto logger = std::make_shared<spdlog::logger>("mfc_app", sinks.begin(), sinks.end()); spdlog::set_default_logger(logger); spdlog::set_level(spdlog::level::debug); spdlog::flush_on(spdlog::level::debug); spdlog::debug("MFC app initialized"); } catch (const spdlog::spdlog_ex& e) { // 日志初始化失败不应影响程序启动 ::MessageBox(nullptr, CString(e.what()), L"Log Init Error", MB_ICONWARNING); } return TRUE; } int CMyApp::ExitInstance() { spdlog::shutdown(); return CWinApp::ExitInstance(); }

这里用了msvc_sink_mtdaily_file_sink_mt,两个 sink 同时生效:VS 调试窗口能看到实时日志,文件系统里保留按天切割的日志文件。flush_on设置为 debug 级别,意味着每次写 debug 及以上日志都会刷新到磁盘,方便调试,但性能会比定期 flush 差。调试阶段可以接受,发布前建议改回warn级别或依赖定时 flush。

6.3 CString 转换为 UTF-8 再写入

MFC 默认的CString在 Unicode 构建下是宽字符。spdlog 推荐输出 UTF-8 字符串,所以需要做一次转换:

#include <windows.h> #include <string> std::string ToUtf8(const CString& str) { if (str.IsEmpty()) { return std::string(); } int len = ::WideCharToMultiByte( CP_UTF8, 0, str.GetString(), -1, nullptr, 0, nullptr, nullptr); std::string result(len, '\0'); ::WideCharToMultiByte( CP_UTF8, 0, str.GetString(), -1, &result[0], len, nullptr, nullptr); // WideCharToMultiByte 会在末尾写入 \0,去掉它 if (!result.empty() && result.back() == '\0') { result.pop_back(); } return result; } void CMyDialog::OnSave() { spdlog::info("Save button clicked, path={}", ToUtf8(m_strSavePath)); }

在 MFC 对话框中写日志时,统一走这个转换函数,能避免日志文件里出现一堆??或乱码。

6.4 MFC 日志模块的目录划分建议

  • 日志目录放在D:/logs/或用户 AppData 目录下,不要放在程序安装目录。安装目录往往没有写权限。
  • 日志文件名加入进程号或模块名,防止多个实例互相覆盖。
  • 如果客户端有“一键导出日志”功能,直接把日志目录打包即可。

7. spdlog 异步日志与批量写入

7.1 为什么需要异步日志

同步写日志时,业务线程调用logger->info(...)会直接向文件写入。如果磁盘较慢,或者单条日志格式化时间较长,业务线程会被拖慢。

异步模式下,业务线程只把日志消息投递到队列,然后立即返回。后台线程池从队列取出消息,批量写入文件。这样高频日志场景下业务线程的耗时更稳定。

7.2 使用异步 logger

创建异步 logger 最常见的方式是spdlog::create_async

#include "spdlog/spdlog.h" #include "spdlog/async.h" #include "spdlog/sinks/basic_file_sink.h" int main() { // 显式初始化线程池,队列大小 8192,线程数 1 spdlog::init_thread_pool(8192, 1); auto async_logger = spdlog::create_async<spdlog::sinks::basic_file_sink_mt>( "async_logger", "logs/async.log"); async_logger->info("async log message"); async_logger->info("async log {} {}", 1, 2); async_logger->warn("system is running low on resources"); // 退出前必须 shutdown,等待队列中的日志写完 spdlog::shutdown(); return 0; }

不显式调用init_thread_pool时,spdlog 通常会创建一个默认线程池,但队列大小、线程数就不可控了。建议在高频写入场景显式配置。

7.3 队列大小与满时策略

异步模式下有两个问题需要提前考虑:

  • 队列太小:日志产生速度超过写入速度时,队列会满。
  • 满时策略:默认是阻塞等待,还是丢弃新日志,取决于配置和版本。

如果日志量波动较大,建议把队列调大,并采用阻塞策略,避免重要日志被丢弃。但队列也不是越大越好,队列越大,内存占用越高,异常退出时未落盘日志也越多。

7.4 flush 刷新策略

异步日志不是实时写盘,而是攒一批再写。这就会出现一个风险:进程被kill -9或直接断电时,队列里尚未落盘的日志会丢失。

可以定期 flush:

spdlog::flush_every(std::chrono::seconds(3));

也可以在关键级别触发 flush:

spdlog::flush_on(spdlog::level::warn);

这样出现 warn 及以上级别时,立即把当前缓冲刷入磁盘。正常退出时调用spdlog::shutdown(),会等待队列中的日志处理完毕并关闭 sink。

8. 资源占用与性能观察

8.1 怎么观察日志模块的资源占用

spdlog 是纯用户态库,资源占用主要体现在:

  • CPU:格式化字符串、文件写入、颜色控制都会消耗 CPU。
  • 内存:异步队列里每一条日志消息都是一段缓冲,队列越大内存越多。
  • 磁盘 IO:同步模式每次写入都可能触发系统调用;异步模式把多次写入合并,整体 IO 次数减少。
  • 线程数:异步线程池里的线程数量,以及每个 sink 自身的刷新线程。

Windows 下可以通过任务管理器或 Process Explorer 观察线程数和内存;Linux 下用tophtoppidstat观察。要更精确地追踪 CPU 和磁盘,可以用性能剖析工具。

8.2 影响性能的主要因素

  • 单条日志长度:日志越长,复制和格式化开销越大。
  • 日志级别过滤set_level设为 info 后,debug 消息不会进入输出,但格式化的参数表达式仍然会被执行。
  • 是否异步:异步模式提交日志的延迟通常更低,但整体写盘延迟取决于后台线程。
  • 文件系统类型:机械硬盘、SSD、网络盘的写入速度差异很大。
  • pattern 复杂度:输出文件名和行号、线程 ID 会带来额外开销,但一般可以接受。

8.3 一套简单的同步/异步对比测试

可以用下面的程序粗略对比同步和异步日志在“提交耗时”上的差异:

#include <chrono> #include "spdlog/spdlog.h" #include "spdlog/async.h" #include "spdlog/sinks/basic_file_sink.h" int main() { const int total = 100000; // 先初始化异步线程池 spdlog::init_thread_pool(8192, 1); auto sync_logger = spdlog::basic_logger_mt("sync_logger", "logs/sync.log"); auto async_logger = spdlog::create_async<spdlog::sinks::basic_file_sink_mt>( "async_logger", "logs/async.log"); auto start = std::chrono::steady_clock::now(); for (int i = 0; i < total; i++) { sync_logger->info("sync message {}", i); } auto end = std::chrono::steady_clock::now(); auto sync_ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count(); start = std::chrono::steady_clock::now(); for (int i = 0; i < total; i++) { async_logger->info("async message {}", i); } end = std::chrono::steady_clock::now(); auto async_ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count(); spdlog::shutdown(); spdlog::info("sync: {} ms", sync_ms); spdlog::info("async: {} ms", async_ms); return 0; }

需要特别注意:这个对比统计的是“调用info返回的耗时”。异步模式下,业务线程只是把消息丢进队列,真正写盘发生在后台线程,所以异步耗时少不代表磁盘写入已经完成。要验证最终落盘状态,必须看spdlog::shutdown()是否正常返回,以及日志文件内容是否完整。

9. spdlog 常见问题与排查方法

问题现象可能原因排查方式解决方案
编译时找不到spdlog.hinclude 路径未配置检查附加包含目录把 spdlog/include 加入项目
编译报 fmt 相关错误内置 fmt 与外部 fmt 冲突查看编译日志中的宏定义统一使用内置 fmt,或定义SPDLOG_FMT_EXTERNAL并对齐外部 fmt 版本
LNK2005 或重复符号错误header-only 与编译库混用检查是否定义了SPDLOG_COMPILED_LIB统一用 header-only,或统一用编译库
Windows 控制台中文乱码源码编码与控制台编码不一致查看文件日志是否正常源码用 UTF-8 编码,或对宽字符做转码
日志没有实时写入文件flush 策略未设置检查文件内容最后更新时间使用flush_onflush_every
多线程写入偶发崩溃使用了单线程_stsink检查 sink 类型命名改用_mtsink,或使用异步 logger
异步日志丢失队列过小/满时策略不满足需求查看队列大小配置调大队列,或选择阻塞策略
日志文件无限增长未配置轮转查看文件大小变化使用rotating_file_sinkdaily_file_sink
MFC 退出时崩溃logger 生命周期先于系统退出检查全局对象析构顺序ExitInstance中显式spdlog::shutdown()
日志目录没有写权限目标目录不可写检查文件权限将日志目录改到用户目录或 AppData 目录

写日志是低频错误排查和系统观察的重要手段,一旦日志模块本身出问题,会让问题更难定位。遇到上述情况时,优先看控制台错误输出和编译日志,再回到上面这张表找对应解法。

10. spdlog 最佳实践与使用建议

10.1 区分开发与生产日志级别

不同阶段跑不同级别的日志,避免生产环境被 debug 刷爆磁盘:

#ifdef _DEBUG spdlog::set_level(spdlog::level::debug); #else spdlog::set_level(spdlog::level::info); #endif

10.2 高频日志避免传入耗时表达式

spdlog 支持惰性级别检查,但如果你传入的是一个函数返回值,函数本身仍然会被调用:

// 不推荐:即使日志级别高于 debug,GetComplexInfo() 已经执行了 logger->debug("info={}", GetComplexInfo().ToString()); // 推荐:先判断是否需要输出 if (logger->should_log(spdlog::level::debug)) { logger->debug("info={}", GetComplexInfo().ToString()); }

10.3 日志脱敏

不要在日志中写入明文密码、Token、手机号、身份证号。例如登录失败日志只记录用户名和失败原因,不记录密码;接口调用日志可以对 Authorization 做打码处理。

10.4 多实例日志文件命名

多进程部署时,建议在日志文件名中加入进程号,避免不同实例写到同一个文件:

#include <chrono> #include <string> #ifdef _WIN32 #include <process.h> #define GET_PID _getpid #else #include <unistd.h> #define GET_PID getpid #endif auto pid = GET_PID(); auto logger = spdlog::daily_logger_mt( "service", "logs/service_" + std::to_string(pid) + ".log", 0, 0);

10.5 固定依赖版本

不要跟着 GitHub main 分支乱跑。无论是 FetchContent 还是手动拉源码,都建议固定到某个已验证的 tag。上游更新可能改 pattern 语法、默认行为和构建选项,固定版本可以避免项目日志行为突然变化。

10.6 接入日志采集系统

如果服务端需要集中收集日志,可以按以下路线:

  • spdlog 写文件。
  • 由 Filebeat、Fluentd、Promtail 或自研 Agent 采集文件。
  • 推送到 Elasticsearch、Loki、Kafka 等系统。

这样 spdlog 只负责本机日志落地,不承担投递职责,职责边界更清晰。

10.7 异常退出时的保护

服务被kill -9终止时,没有机会执行spdlog::shutdown()。依赖较小丢失风险的场景,可以加定时 flush;对关键业务日志,建议把flush_every调到 1 秒左右,兼顾性能和可靠性。


日志库的难点不是“能打出日志”,而是“在业务线程里打日志不拖慢业务,又能稳定写到磁盘”。spdlog 的核心思路是用异步队列、多 sink、轮转和 pattern 这套配置,把日志写入变成可管理的工程组件。接入时最稳妥的顺序是:先把同步文件日志跑通,再开异步、配 flush、调 pattern,最后按场景决定是否轮转、是否接采集系统。把这套链路理清楚,这个库基本就能在项目里稳定用下去了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询