29、内存池与日志系统:环形缓冲区、异步日志、崩溃恢复

日志系统,说白了就是程序的「黑匣子」。

我做过一个嵌入式网关项目,跑了三个月突然死机。没有日志,连死在哪一行都不知道。后来我花了整整一周,才定位到是内存碎片导致的野指针问题。从那以后,我给自己定了个规矩:任何线上服务,必须配一套高性能日志系统。

今天我们就聊聊,怎么用内存池的思路,搭一个能扛高并发、还能在崩溃后恢复的日志系统。

29.1 环形缓冲区:日志的「临时仓库」

写日志最怕什么?怕写磁盘太慢,把业务线程卡住。

你想想看,业务线程本来在飞快处理请求,突然要等磁盘I/O。这一等,可能就是几毫秒甚至几十毫秒。高并发下,这简直是灾难。

解决方案很简单:先写到内存缓冲区,后台再慢慢刷盘。

环形缓冲区(Ring Buffer)就是干这个的。它是个固定大小的循环数组,写指针追着读指针跑。

核心思想:生产者(业务线程)往缓冲区写日志,消费者(后台线程)从缓冲区读日志刷盘。两者互不阻塞。

我习惯用这种结构:

typedef struct {
    char   *buffer;      // 数据区
    size_t  size;        // 总大小
    size_t  write_pos;   // 写位置
    size_t  read_pos;    // 读位置
    pthread_mutex_t lock;
} ring_buffer_t;

写入时,先检查剩余空间。如果不够,就覆盖旧数据(或者丢弃,看策略)。

int ring_buffer_write(ring_buffer_t *rb, const char *data, size_t len) {
    pthread_mutex_lock(&rb->lock);
    
    // 计算剩余空间
    size_t used = (rb->write_pos - rb->read_pos) % rb->size;
    size_t free = rb->size - used - 1;  // 留一个空位区分满/空
    
    if (len > free) {
        // 空间不够,覆盖旧数据
        // 实际项目中可以丢弃或阻塞
        pthread_mutex_unlock(&rb->lock);
        return -1;
    }
    
    // 写入数据(处理环形回绕)
    size_t first_chunk = min(len, rb->size - rb->write_pos);
    memcpy(rb->buffer + rb->write_pos, data, first_chunk);
    memcpy(rb->buffer, data + first_chunk, len - first_chunk);
    
    rb->write_pos = (rb->write_pos + len) % rb->size;
    
    pthread_mutex_unlock(&rb->lock);
    return 0;
}

小技巧:环形缓冲区的大小最好是2的幂次。这样取模可以用位运算 pos & (size-1),比除法快一个数量级。

29.2 异步日志:把I/O丢给后台

有了环形缓冲区,我们就可以搭异步日志了。

流程很简单:

  1. 业务线程调用 log_write(),把日志塞进环形缓冲区
  2. 后台线程不断从缓冲区取数据,批量写入磁盘文件
  3. 缓冲区满了怎么办?要么阻塞等待,要么丢弃旧日志

我个人建议用「阻塞等待」策略。为什么?因为日志丢了,排查问题时就抓瞎了。我曾经因为日志丢失,硬生生多花了三天定位一个偶发bug。

void *log_flush_thread(void *arg) {
    ring_buffer_t *rb = (ring_buffer_t *)arg;
    char local_buf[4096];
    
    while (1) {
        size_t len = ring_buffer_read(rb, local_buf, sizeof(local_buf));
        if (len > 0) {
            fwrite(local_buf, 1, len, log_file);
            fflush(log_file);  // 定期刷盘
        } else {
            usleep(1000);  // 没数据就睡1ms
        }
    }
    return NULL;
}

注意:fflush() 不能调得太频繁,否则磁盘I/O会成为瓶颈。我一般每100ms刷一次,或者缓冲区积累到4KB再刷。

29.3 崩溃恢复:日志不能白写

程序崩溃了,日志还在缓冲区里没来得及刷盘,怎么办?

这个问题我踩过坑。有一次线上服务突然OOM,重启后日志文件里只有半条记录,关键的错误信息全丢了。

解决方案是:内存映射文件(mmap)

把环形缓冲区直接映射到文件。这样,即使进程崩溃,操作系统也会把脏页刷回磁盘。重启后,从映射文件里恢复缓冲区内容即可。

// 初始化时创建mmap
int log_init_mmap(const char *path, size_t size) {
    int fd = open(path, O_RDWR | O_CREAT, 0644);
    ftruncate(fd, size);
    
    void *addr = mmap(NULL, size, PROT_READ | PROT_WRITE,
                      MAP_SHARED, fd, 0);
    close(fd);
    
    // 把环形缓冲区指向mmap区域
    ring_buffer_t *rb = (ring_buffer_t *)addr;
    rb->buffer = (char *)addr + sizeof(ring_buffer_t);
    rb->size = size - sizeof(ring_buffer_t);
    rb->write_pos = 0;
    rb->read_pos = 0;
    
    return 0;
}

崩溃恢复时,只需要重新mmap同一个文件,检查 write_posread_pos 是否一致。如果不一致,说明有未刷盘的日志,赶紧处理掉。

恢复流程:

  1. mmap打开日志映射文件
  2. 检查 write_pos != read_pos → 有残留日志
  3. 把残留日志写入磁盘文件
  4. 重置 write_pos = read_pos = 0
  5. 继续正常运行

29.4 整体架构图

下面这张图,把整个流程串起来了。我建议你保存下来,做设计时对照着看。

内存池日志系统架构图 业务线程 产生日志 环形缓冲区 mmap 映射到文件 写指针 → 读指针 崩溃后数据不丢失 刷盘线程 批量写入磁盘 日志文件 /var/log/app.log 崩溃后重启,从mmap恢复 内存池 预分配固定大小块 分配日志缓冲区 说明:业务线程写入环形缓冲区,刷盘线程异步写入磁盘 mmap 保证崩溃后缓冲区数据可恢复 内存池预分配日志缓冲区,避免运行时动态分配

29.5 避坑指南

最后,分享几个我踩过的坑:

  • 缓冲区大小别设太小。我曾经设了64KB,高并发下一秒钟就写满了。建议至少1MB起步,根据你的日志量调整。
  • mmap文件要预分配好大小。不要指望它自动增长。文件满了,写入就失败了。
  • 多线程写日志要加锁。但锁的粒度要细。我习惯用CAS无锁队列,性能更好。不过实现起来复杂,初学者先用互斥锁。
  • 崩溃恢复后,记得检查文件完整性。有一次磁盘坏了,mmap返回了错误,我没检查,结果写入了乱数据。

我的经验:日志系统是最后一道防线。宁可性能差一点,也要保证数据不丢。线上环境,我一般会开双写:一份走mmap环形缓冲区,一份直接写文件。虽然浪费点磁盘,但心里踏实。

好了,环形缓冲区 + 异步日志 + 崩溃恢复,这套组合拳打下来,你的日志系统基本就稳了。下次程序崩溃,你至少能知道它「临终前」说了什么。


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