第33问:日志系统设计——基于标准库实现分级日志、日志轮转、异步写入

日志系统,说白了就是程序的「黑匣子」。我做了这么多年嵌入式和服务端开发,几乎每个项目都离不开它。你想想看,程序跑着跑着突然崩了,没有日志,你连从哪下手都不知道。

今天咱们就聊聊,怎么用C标准库,自己动手搭一个够用的日志系统。要支持分级、轮转、异步写入——这三个功能,基本覆盖了大部分场景的需求。

一、分级日志:别什么都往文件里写

我个人习惯把日志分成五个级别:DEBUG、INFO、WARN、ERROR、FATAL。为什么分级?因为生产环境你不可能把调试信息也打出来,那日志文件一天能撑爆硬盘。

核心原则:DEBUG只在开发环境开启,线上环境只记录WARN及以上级别。

实现起来其实很简单,一个枚举加一个全局阈值变量就够了:

typedef enum {
    LOG_DEBUG = 0,
    LOG_INFO,
    LOG_WARN,
    LOG_ERROR,
    LOG_FATAL
} log_level_t;

static log_level_t g_log_threshold = LOG_INFO;

void log_set_level(log_level_t level) {
    g_log_threshold = level;
}

void log_write(log_level_t level, const char* fmt, ...) {
    if (level < g_log_threshold) return;  // 低于阈值,直接丢弃
    // 后续处理...
}

我在项目中遇到过一个问题:有人把DEBUG日志直接打印到标准输出,结果线上终端被刷爆,CPU占用飙升。嗯,这里要注意——分级不只是为了控制文件大小,更是为了性能

二、日志轮转:别让日志撑爆磁盘

日志轮转,说白了就是「写到一定大小就换文件」。我记得有一次线上服务挂了,排查原因发现是日志文件把磁盘写满了,系统直接拒绝服务。从那以后,轮转功能就成了我日志系统的标配。

标准库没有直接支持轮转,但我们可以用fwrite + 文件大小检查来实现:

#define MAX_LOG_SIZE (10 * 1024 * 1024)  // 10MB
#define MAX_LOG_FILES 5

static FILE* g_log_fp = NULL;
static int g_file_index = 0;

void log_rotate() {
    if (g_log_fp) {
        fclose(g_log_fp);
    }
    char filename[64];
    snprintf(filename, sizeof(filename), "app_%d.log", g_file_index);
    g_log_fp = fopen(filename, "a");
    g_file_index = (g_file_index + 1) % MAX_LOG_FILES;
}

void log_write(log_level_t level, const char* fmt, ...) {
    // ... 前面分级检查 ...
    if (ftell(g_log_fp) >= MAX_LOG_SIZE) {
        log_rotate();
    }
    // 写入日志...
}

注意:轮转时如果旧文件还存在,建议先删除或重命名。我曾经因为忘记删除旧文件,导致磁盘上积累了上百个日志文件,每个10MB,最后磁盘还是满了。

三、异步写入:别让日志拖慢主流程

同步写入日志,每次都要等磁盘I/O。在高并发场景下,这简直是性能杀手。异步写入的思路很简单:主线程只管把日志丢到队列里,后台线程负责写文件

标准库没有线程支持?别急,我们可以用POSIX线程(pthread)来实现。如果你在Windows上,也可以用_beginthreadex

#include <pthread.h>
#include <string.h>

#define QUEUE_SIZE 1024

static char* log_queue[QUEUE_SIZE];
static int head = 0, tail = 0;
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

void log_async_write(const char* msg) {
    pthread_mutex_lock(&lock);
    // 队列满时阻塞或丢弃
    if ((tail + 1) % QUEUE_SIZE == head) {
        pthread_mutex_unlock(&lock);
        return;  // 队列满,丢弃
    }
    log_queue[tail] = strdup(msg);
    tail = (tail + 1) % QUEUE_SIZE;
    pthread_cond_signal(&cond);
    pthread_mutex_unlock(&lock);
}

void* log_flush_thread(void* arg) {
    while (1) {
        pthread_mutex_lock(&lock);
        while (head == tail) {
            pthread_cond_wait(&cond, &lock);
        }
        char* msg = log_queue[head];
        head = (head + 1) % QUEUE_SIZE;
        pthread_mutex_unlock(&lock);

        // 写入文件
        fprintf(g_log_fp, "%s", msg);
        fflush(g_log_fp);
        free(msg);
    }
    return NULL;
}

避坑指南:我曾经在队列满时直接阻塞主线程,结果导致死锁——日志线程在等锁,主线程也在等锁。后来改成「队列满就丢弃」,虽然丢了几条日志,但系统稳定多了。

四、整体架构图

下面这张图,把整个日志系统的数据流画清楚了:

日志系统架构图 应用程序 调用 log_write() 分级过滤模块 DEBUG/INFO/WARN/ERROR/FATAL 异步队列 环形缓冲区 + 互斥锁 后台写入线程 从队列取数据,写文件 日志轮转模块 检查文件大小,切换文件 日志文件 app_0.log ~ app_4.log 数据流:应用 → 分级过滤 → 异步队列 → 后台线程 → 轮转控制 → 文件

五、完整代码示例

把上面三个模块整合起来,就是一个可用的日志系统。这里给出核心框架:

// logger.h
#ifndef LOGGER_H
#define LOGGER_H

void log_init(const char* prefix, log_level_t threshold);
void log_write(log_level_t level, const char* fmt, ...);
void log_destroy(void);

#define log_debug(fmt, ...) log_write(LOG_DEBUG, fmt, ##__VA_ARGS__)
#define log_info(fmt, ...)  log_write(LOG_INFO,  fmt, ##__VA_ARGS__)
#define log_warn(fmt, ...)  log_write(LOG_WARN,  fmt, ##__VA_ARGS__)
#define log_error(fmt, ...) log_write(LOG_ERROR, fmt, ##__VA_ARGS__)
#define log_fatal(fmt, ...) log_write(LOG_FATAL, fmt, ##__VA_ARGS__)

#endif
// logger.c
#include "logger.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <pthread.h>

// ... 前面定义的枚举、队列、轮转代码 ...

void log_init(const char* prefix, log_level_t threshold) {
    g_log_threshold = threshold;
    // 初始化文件
    char filename[64];
    snprintf(filename, sizeof(filename), "%s_0.log", prefix);
    g_log_fp = fopen(filename, "a");
    if (!g_log_fp) {
        fprintf(stderr, "Failed to open log file\n");
        exit(1);
    }
    // 启动后台线程
    pthread_t tid;
    pthread_create(&tid, NULL, log_flush_thread, NULL);
    pthread_detach(tid);
}

void log_destroy() {
    if (g_log_fp) fclose(g_log_fp);
}

使用示例:

int main() {
    log_init("myapp", LOG_INFO);
    log_info("系统启动,PID=%d", getpid());
    log_debug("这条不会输出,因为阈值是INFO");
    log_error("发生错误:%s", strerror(errno));
    log_destroy();
    return 0;
}

六、避坑总结

最后,把我这些年踩过的坑列一下:

  • 队列大小要合理:太小容易丢日志,太大浪费内存。1024条对于大多数应用足够了。
  • 别忘了fflush:后台线程写完文件后,记得调用fflush,否则程序崩溃时日志可能还在缓冲区里。
  • 线程安全要覆盖全路径:不只是队列操作要加锁,文件写入、轮转检查也要加锁。我曾经漏了轮转时的锁,结果两个线程同时写同一个文件,日志全乱了。
  • 日志内容要带时间戳:用localtime_r获取时间,格式化成[2025-01-15 10:30:45]这样的字符串,方便排查问题。

嗯,这套日志系统我用了好几年,从嵌入式设备到后端服务,基本都能胜任。当然,如果你需要更高级的功能——比如网络日志、分布式追踪——那还是得用现成的库。但对于大多数场景,自己动手写一个,反而更可控。


专注资料整理