日志系统:日志级别、syslog使用、滚动日志实现、日志格式化
日志这东西,说白了就是程序的「黑匣子」。
我刚入行那会儿,觉得日志就是printf的豪华版。直到线上一个服务半夜挂了,我盯着满屏的「debug信息」找了一整晚——嗯,从那以后我再也不敢小看日志系统了。
今天咱们就聊聊,怎么在C语言里搭一套靠谱的日志系统。
一、日志级别:给信息分个三六九等
你想想看,如果所有日志都混在一起,线上排查问题就像在垃圾堆里找针。所以第一件事,就是给日志分级别。
我个人习惯用这六个级别:
| 级别 | 数值 | 含义 | 典型场景 |
|---|---|---|---|
| FATAL | 0 | 致命错误 | 程序无法继续运行 |
| ERROR | 1 | 错误 | 请求失败、数据库连不上 |
| WARN | 2 | 警告 | 磁盘快满了、重试次数过多 |
| INFO | 3 | 信息 | 服务启动、用户登录 |
| DEBUG | 4 | 调试 | 变量值、函数调用栈 |
| TRACE | 5 | 跟踪 | 逐行执行流程 |
为什么要分这么细?我在项目中遇到过,生产环境开了DEBUG级别,结果一天写了几个G的日志,直接把磁盘撑爆了。所以线上一般只开INFO及以上,DEBUG和TRACE留给开发环境。
核心原则:运行时只输出当前级别及以上的日志。比如设置级别为WARN,那就只输出WARN、ERROR、FATAL。
代码实现很简单,一个宏就搞定:
#define LOG(level, fmt, ...) do { \
if (level <= g_log_level) { \
fprintf(stderr, "[%s] " fmt "\n", \
level_str[level], ##__VA_ARGS__); \
} \
} while(0)
这里用do-while包裹,是为了防止宏展开出问题。嗯,老司机都懂。
二、syslog:系统级的日志管家
自己写日志当然可以,但Linux其实自带了一个日志系统——syslog。它会把日志统一写到/var/log/下面,还能按设施(facility)和级别过滤。
使用syslog就三步:
- openlog():打开连接,设置程序名和选项
- syslog():写日志
- closelog():关闭连接
看个例子:
#include <syslog.h>
int main() {
openlog("myapp", LOG_PID | LOG_CONS, LOG_USER);
syslog(LOG_INFO, "服务启动成功,端口: %d", 8080);
syslog(LOG_ERR, "数据库连接失败: %s", strerror(errno));
closelog();
return 0;
}
我曾经踩过一个坑:openlog的第二个参数没加LOG_PID,结果多个进程写日志时,根本分不清是哪条进程写的。加上LOG_PID后,每条日志都会带上进程ID,排查问题方便多了。
小技巧:syslog默认是同步写的,如果日志量很大,可以设置LOG_NDELAY让它在第一次调用syslog时才打开连接,节省资源。
三、滚动日志:别让日志撑爆磁盘
日志不滚动,后果很严重。我见过一个服务跑了半年没重启,日志文件到了50G,最后连tail都卡死了。
滚动日志有两种常见策略:
- 按大小滚动:比如每个文件100MB,满了就新建一个
- 按时间滚动:比如每天一个文件,保留30天
我个人更推荐按大小滚动,因为日志量波动大的时候,按时间滚动可能一天写几十G,也可能一天只写几KB。
实现思路其实不复杂:
void log_write(const char *msg) {
// 检查当前文件大小
if (ftell(log_fp) > MAX_LOG_SIZE) {
fclose(log_fp);
// 重命名当前文件
rename(LOG_FILE, LOG_FILE ".1");
// 打开新文件
log_fp = fopen(LOG_FILE, "a");
}
fprintf(log_fp, "%s\n", msg);
fflush(log_fp);
}
当然,这只是最简版本。实际项目中还要考虑:
- 保留多少个历史文件(比如保留5个,.1到.5)
- 多线程写入时的锁竞争
- 文件重命名时的原子性问题
注意:千万别在信号处理函数里写日志!我曾经这么干过,结果死锁了,程序直接挂掉。信号处理函数里只能做异步信号安全的事,比如写一个pipe通知主线程。
四、日志格式化:让日志可读又可查
日志格式决定了你以后怎么查问题。我见过最离谱的日志,就一行数字,完全不知道在说什么。
一个合格的日志格式应该包含:
- 时间戳:精确到毫秒,最好带时区
- 日志级别:一眼看出严重程度
- 模块/文件名:知道是哪个模块出的问题
- 行号:精准定位代码位置
- 线程ID:多线程下区分不同线程
- 消息正文:描述发生了什么
举个例子:
[2025-01-15 14:23:45.678] [INFO] [http_server.c:123] [tid:4567] 收到请求: GET /api/user
实现时可以用__FILE__、__LINE__这些预定义宏:
#define LOG_INFO(fmt, ...) \
log_write("INFO", __FILE__, __LINE__, fmt, ##__VA_ARGS__)
void log_write(const char *level, const char *file,
int line, const char *fmt, ...) {
char buf[1024];
va_list args;
va_start(args, fmt);
vsnprintf(buf, sizeof(buf), fmt, args);
va_end(args);
// 获取时间
time_t now = time(NULL);
struct tm *tm = localtime(&now);
fprintf(log_fp, "[%04d-%02d-%02d %02d:%02d:%02d] [%s] [%s:%d] %s\n",
tm->tm_year + 1900, tm->tm_mon + 1, tm->tm_mday,
tm->tm_hour, tm->tm_min, tm->tm_sec,
level, file, line, buf);
fflush(log_fp);
}
避坑指南:我曾经在格式化时用了sprintf而不是vsnprintf,结果日志内容太长直接缓冲区溢出,程序崩溃了。从那以后,我所有日志格式化都用snprintf系列函数,安全第一。
五、知识体系总览
下面这张图,把整个日志系统的核心脉络画出来了:
说白了,日志系统就四个核心点:分级别、用syslog、做滚动、格式化输出。把这四点吃透了,你写的日志工具就能在线上稳定跑几年不出问题。
我个人建议,刚开始别搞太复杂。先实现一个简单的日志库,跑起来,然后根据实际需求慢慢加功能。我见过太多人一上来就想搞「通用日志框架」,结果写了三个月还没写完——嗯,先跑起来再说。