19、日志系统设计:跨平台日志库的架构、日志级别、文件滚动
日志系统,说白了就是程序的「黑匣子」。
我做了十几年跨平台开发,最怕的就是程序在客户机器上崩了,又拿不到任何现场信息。那种感觉,就像闭着眼睛修车。所以日志系统,我一直认为是基础设施中的基础设施。
今天我们就来聊聊,怎么设计一个能跨平台、好移植、还靠谱的日志库。
19.1 日志库的核心架构
一个日志库,本质上就干三件事:收集日志 → 格式化 → 输出。
但跨平台环境下,这三件事每一件都有坑。我个人习惯把架构分成四层:
- 接口层:对外暴露的 API,比如
LOG_INFO("hello %s", name)。这一层要尽量简单,最好用宏封装,方便后续开关日志。 - 过滤层:根据日志级别、模块标签,决定这条日志要不要输出。说白了就是「把关」的。
- 格式化层:把参数拼成字符串,加上时间、线程ID、文件名行号。这里要注意线程安全。
- 输出层:写文件、写控制台、或者发到网络。跨平台的差异主要集中在这一层。
核心原则:接口层和输出层之间,一定要解耦。这样你换输出方式时,上层代码不用动。
下面这张图,是我常用的日志库分层结构:
19.2 日志级别:别什么都记
日志级别这东西,看着简单,但用起来很容易走极端。
我记得刚入行时,有个同事把所有日志都打成 ERROR,结果线上查问题,满屏都是 ERROR,根本分不清轻重缓急。反过来,也有人把所有日志都打成 DEBUG,上线时忘了关,一天写了几十 G 日志,直接把磁盘撑爆了。
我建议至少保留这五个级别:
| 级别 | 数值 | 含义 | 使用场景 |
|---|---|---|---|
| FATAL | 0 | 致命错误 | 程序无法继续运行,比如内存分配失败 |
| ERROR | 1 | 错误 | 操作失败但程序还能跑,比如打开文件失败 |
| WARN | 2 | 警告 | 不正常的现象,但不影响当前功能 |
| INFO | 3 | 信息 | 关键流程节点,比如服务启动、连接建立 |
| DEBUG | 4 | 调试 | 开发阶段用,上线后建议关闭 |
小技巧:我习惯在编译时通过宏来控制 DEBUG 级别的开关。比如 #ifdef LOG_DEBUG_ENABLE,这样发布版本直接不编译 DEBUG 日志,零开销。
19.3 文件滚动:别让日志撑爆磁盘
日志文件如果不做滚动,后果很严重。我曾经在嵌入式设备上遇到过,日志文件写到了 2GB,然后 fwrite 直接返回 -1,程序后续所有写操作都失败了。排查了半天,才发现是日志搞的鬼。
常见的滚动策略有三种:
- 按大小滚动:每个文件写满 10MB 就换下一个。保留最近 5 个文件,最老的删掉。
- 按时间滚动:每小时或每天生成一个新文件。适合长期监控。
- 混合策略:同时按大小和时间滚动。我个人最推荐这种。
下面是一个简单的文件滚动实现思路:
// 伪代码:按大小滚动
void log_write(const char* msg) {
if (current_size > MAX_FILE_SIZE) {
// 关闭当前文件
fclose(g_log_file);
// 删除最旧的文件 (log.3 -> 删除)
// 依次重命名: log.2 -> log.3, log.1 -> log.2, log.0 -> log.1
// 创建新文件 log.0
g_log_file = fopen("log.0", "w");
current_size = 0;
}
current_size += fwrite(msg, 1, len, g_log_file);
}
注意:文件重命名操作在 Windows 和 Linux 上行为不同。Windows 不允许重命名一个正在被打开的文件。所以跨平台时,要么先关闭文件再操作,要么用 MoveFileEx 加延迟标记。这个坑我踩过,印象很深。
19.4 跨平台细节:那些不得不处理的事
日志库的跨平台,主要卡在三个地方:
- 线程 ID:Linux 用
gettid(),Windows 用GetCurrentThreadId()。需要条件编译。 - 时间精度:Linux 有
clock_gettime纳秒级,Windows 用QueryPerformanceCounter。我一般统一到毫秒就够了。 - 文件路径分隔符:Linux 是
/,Windows 是\。写死路径的代码,换平台就炸。
嗯,这里要注意:不要用 __FILE__ 的完整路径。因为不同编译器展开的路径长度不一样,而且可能包含用户目录。我习惯在编译时用 -D 传入相对路径,或者运行时只取文件名部分。
19.5 一个实用的日志宏封装
最后,分享一个我常用的日志宏模板。它支持可变参数,自动添加文件名和行号:
#define LOG_ERROR(fmt, ...) \
log_write(LOG_LEVEL_ERROR, __FILE__, __LINE__, fmt, ##__VA_ARGS__)
#define LOG_INFO(fmt, ...) \
log_write(LOG_LEVEL_INFO, __FILE__, __LINE__, fmt, ##__VA_ARGS__)
// 内部实现
void log_write(int level, const char* file, int line,
const char* fmt, ...) {
// 级别过滤
if (level > g_log_level) return;
// 格式化时间
char time_buf[32];
format_time(time_buf, sizeof(time_buf));
// 拼接完整日志
char msg[1024];
int n = snprintf(msg, sizeof(msg), "[%s][%s:%d] ",
time_buf, file, line);
va_list args;
va_start(args, fmt);
vsnprintf(msg + n, sizeof(msg) - n, fmt, args);
va_end(args);
// 输出
fputs(msg, g_log_file);
fflush(g_log_file); // 关键:确保日志及时落盘
}
避坑指南:我曾经在日志宏里忘了加 do { ... } while(0) 包裹,结果在 if-else 语句里用的时候,编译直接报错。所以多语句宏,一定要用 do-while(0) 包起来。
日志系统设计,说到底就是「收、管、出」三个字。收得全、管得严、出得稳,就是一个好日志库。跨平台环境下,多花点心思在条件编译和路径处理上,后面能省很多事。
公众号:蓝海资料掘金营,微信deep3321