10、日志系统设计:spdlog库集成、日志级别、异步日志、跨平台文件写入
日志系统,说白了就是程序的「黑匣子」。
我见过太多线上事故,因为没有日志,排查问题就像大海捞针。有一次,一个后台服务在客户现场崩溃了,日志文件里只有一行「Error: unknown」。嗯,那种无力感,至今难忘。
所以这一章,我们来聊聊怎么设计一个靠谱的日志系统。我会用 spdlog 这个库,它是 C++ 社区里公认的「日志神器」。速度快、功能全、跨平台,而且用起来特别顺手。
10.1 为什么选 spdlog?
市面上 C++ 日志库不少,比如 log4cxx、glog、boost.log。但我个人习惯用 spdlog,原因有三:
- 头文件库:只需要包含头文件,不需要链接动态库。对跨平台项目来说,少一个依赖就少一份麻烦。
- 性能极好:它用了编译期格式字符串检查,还有异步模式,写日志几乎不阻塞业务逻辑。
- 社区活跃:GitHub 上 20k+ star,遇到问题基本都能搜到答案。
核心观点:spdlog 不是「能用」,而是「好用」。它的设计哲学是「零成本抽象」——你不需要的功能,不会带来任何性能开销。
10.2 快速集成 spdlog
集成 spdlog 很简单。我建议用 CMake 的 FetchContent 或者直接复制源码到项目里。我个人更推荐 FetchContent,因为可以自动管理版本。
// CMakeLists.txt 示例
include(FetchContent)
FetchContent_Declare(
spdlog
GIT_REPOSITORY https://github.com/gabime/spdlog.git
GIT_TAG v1.12.0
)
FetchContent_MakeAvailable(spdlog)
// 然后在代码里直接包含
#include <spdlog/spdlog.h>
#include <spdlog/sinks/basic_file_sink.h>
你看,就这么几行,日志系统就搭好了。我第一次用的时候也惊讶——这也太简单了吧?
10.3 日志级别:从 DEBUG 到 CRITICAL
spdlog 支持 6 个日志级别,从低到高分别是:
| 级别 | 含义 | 使用场景 |
|---|---|---|
| trace | 跟踪 | 最细粒度的调试信息,比如函数进入/退出 |
| debug | 调试 | 开发阶段用的调试信息 |
| info | 信息 | 正常运行的提示信息 |
| warn | 警告 | 可能出现问题,但程序还能继续运行 |
| error | 错误 | 发生了错误,但程序不会崩溃 |
| critical | 致命 | 严重错误,程序即将崩溃或需要立即处理 |
我在项目中遇到过一个问题:线上环境日志量太大,磁盘很快就满了。后来我加了一个策略——生产环境只记录 warn 及以上级别,debug 和 trace 只在开发环境开启。
小技巧:用 spdlog::set_level() 动态调整日志级别。比如在配置文件中设置一个字段,运行时根据环境切换级别,非常灵活。
// 设置全局日志级别
spdlog::set_level(spdlog::level::info);
// 也可以针对某个 logger 单独设置
auto logger = spdlog::basic_logger_mt("file_logger", "logs/app.log");
logger->set_level(spdlog::level::warn);
10.4 异步日志:别让日志拖慢你的程序
同步日志有个问题:每次写日志都要做 I/O 操作,如果磁盘慢,业务线程就会被阻塞。你想想看,一个高并发的服务器,如果每次请求都要等日志写完再返回,那性能得多差?
异步日志的思路很简单:把日志消息丢到一个队列里,后台线程负责批量写入。spdlog 内置了异步支持,用起来非常方便。
#include <spdlog/async.h>
#include <spdlog/sinks/basic_file_sink.h>
// 设置异步日志的队列大小和线程数
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("这是一条异步日志");
我曾经在一个游戏服务器项目里做过压测:同步日志模式下,QPS 是 5000;换成异步日志后,直接飙到 12000。为什么?因为业务线程不再等磁盘 I/O 了。
注意:异步日志虽然快,但有个代价——如果程序突然崩溃,队列里还没写入的日志会丢失。所以关键日志(比如 critical 级别)建议用同步模式,或者强制刷新队列。
// 强制刷新队列,确保日志写入磁盘
spdlog::apply_all([&](std::shared_ptr<spdlog::logger> l) {
l->flush();
});
10.5 跨平台文件写入:Windows、Linux、macOS 一视同仁
跨平台文件写入,说白了就是处理路径分隔符和权限问题。Windows 用反斜杠 \,Linux 和 macOS 用正斜杠 /。spdlog 内部已经处理了这些差异,但我们在使用时还是要注意几点:
- 路径分隔符:建议统一用正斜杠
/,spdlog 会自动转换。 - 日志目录:确保目录存在。spdlog 不会自动创建目录,需要自己处理。
- 文件权限:Linux 下要注意日志文件的权限,避免其他用户无法读取。
#include <filesystem>
#include <spdlog/sinks/rotating_file_sink.h>
// 跨平台创建日志目录
std::filesystem::path log_dir = "logs";
if (!std::filesystem::exists(log_dir)) {
std::filesystem::create_directories(log_dir);
}
// 创建滚动文件日志器(按大小滚动)
auto logger = spdlog::rotating_logger_mt("rotating_logger",
(log_dir / "app.log").string(),
1024 * 1024 * 5, // 每个文件 5MB
3 // 保留 3 个备份文件
);
嗯,这里要注意:rotating_logger_mt 的 mt 表示多线程安全。如果你的程序是单线程的,可以用 st 版本,性能会更好一点。
10.6 日志系统的整体架构
说了这么多,我们来画一张图,看看日志系统的整体架构是什么样的。
从这张图可以看出,日志系统分三层:
- 应用层:你的业务代码,调用 spdlog API 写日志。
- 队列层:异步模式下,日志先进入队列,由后台线程处理。
- 输出层:支持多种输出目标,比如控制台、文件、远程服务器。
10.7 实战:封装一个跨平台日志管理器
理论说完了,我们来写点实际的。我习惯把日志系统封装成一个单例类,方便全局使用。
class LogManager {
public:
static LogManager& instance() {
static LogManager inst;
return inst;
}
bool init(const std::string& log_dir, spdlog::level::level_enum level) {
try {
// 创建日志目录
std::filesystem::path dir(log_dir);
if (!std::filesystem::exists(dir)) {
std::filesystem::create_directories(dir);
}
// 创建异步文件日志器
spdlog::init_thread_pool(8192, 1);
auto file_sink = std::make_shared<spdlog::sinks::basic_file_sink_mt>(
(dir / "app.log").string(), true);
// 创建控制台日志器
auto console_sink = std::make_shared<spdlog::sinks::stdout_color_sink_mt>();
// 组合多个 sink
std::vector<spdlog::sink_ptr> sinks{file_sink, console_sink};
auto logger = std::make_shared<spdlog::logger>("multi_sink", sinks.begin(), sinks.end());
// 设置日志级别
logger->set_level(level);
logger->flush_on(spdlog::level::warn); // warn 及以上级别立即刷新
// 注册为默认 logger
spdlog::set_default_logger(logger);
return true;
} catch (const std::exception& e) {
// 初始化失败,至少保证控制台能输出
spdlog::error("日志系统初始化失败: {}", e.what());
return false;
}
}
void shutdown() {
spdlog::shutdown();
}
};
// 使用示例
int main() {
if (LogManager::instance().init("logs", spdlog::level::info)) {
SPDLOG_INFO("日志系统启动成功");
SPDLOG_WARN("这是一条警告");
SPDLOG_ERROR("这是一条错误");
}
LogManager::instance().shutdown();
return 0;
}
个人经验:我习惯在 init() 里加一个「降级策略」——如果文件日志初始化失败,至少保证控制台还能输出。这样即使磁盘满了,开发人员也能在终端看到错误信息。
10.8 避坑指南
最后,分享几个我踩过的坑:
- 日志文件权限问题:我曾经在 Linux 服务器上部署程序,日志文件默认权限是 644,结果运维同事说看不到日志。后来我改成 640,并加入了日志组。
- 日志文件句柄泄漏:spdlog 默认会在程序退出时自动关闭文件,但如果你手动创建了 sink,记得在
shutdown()之前释放。 - 异步日志的队列大小:如果队列太小,高并发下会丢日志。我一般设置 8192 或 16384,具体看你的业务量。
- 不要在生产环境用
trace级别:有一次我忘了改配置,结果一天产生了 10GB 的日志文件,直接把磁盘撑爆了。
重要提醒:日志系统是程序的「最后一道防线」。如果日志系统本身出了问题,你连排查问题的线索都没有。所以,一定要给日志系统加监控——比如检查日志文件是否在增长、磁盘空间是否充足。
好了,这一章的内容就到这里。日志系统看似简单,但要做好、做稳,还是有不少门道的。希望这些经验能帮你少走弯路。
公众号:蓝海资料掘金营,微信deep3321