第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;
}
避坑指南:我曾经在队列满时直接阻塞主线程,结果导致死锁——日志线程在等锁,主线程也在等锁。后来改成「队列满就丢弃」,虽然丢了几条日志,但系统稳定多了。
四、整体架构图
下面这张图,把整个日志系统的数据流画清楚了:
五、完整代码示例
把上面三个模块整合起来,就是一个可用的日志系统。这里给出核心框架:
// 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]这样的字符串,方便排查问题。
嗯,这套日志系统我用了好几年,从嵌入式设备到后端服务,基本都能胜任。当然,如果你需要更高级的功能——比如网络日志、分布式追踪——那还是得用现成的库。但对于大多数场景,自己动手写一个,反而更可控。