日志系统:日志级别、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就三步:

  1. openlog():打开连接,设置程序名和选项
  2. syslog():写日志
  3. 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 滚动日志 日志格式化 FATAL / ERROR WARN / INFO DEBUG / TRACE openlog() syslog() closelog() 按大小滚动 按时间滚动 保留历史文件 时间戳 文件名:行号 线程ID 核心目标:可读、可查、可控、不丢、不爆

说白了,日志系统就四个核心点:分级别、用syslog、做滚动、格式化输出。把这四点吃透了,你写的日志工具就能在线上稳定跑几年不出问题。

我个人建议,刚开始别搞太复杂。先实现一个简单的日志库,跑起来,然后根据实际需求慢慢加功能。我见过太多人一上来就想搞「通用日志框架」,结果写了三个月还没写完——嗯,先跑起来再说。

公众号:蓝海资料掘金营,微信deep3321