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丢给后台
有了环形缓冲区,我们就可以搭异步日志了。
流程很简单:
- 业务线程调用
log_write(),把日志塞进环形缓冲区 - 后台线程不断从缓冲区取数据,批量写入磁盘文件
- 缓冲区满了怎么办?要么阻塞等待,要么丢弃旧日志
我个人建议用「阻塞等待」策略。为什么?因为日志丢了,排查问题时就抓瞎了。我曾经因为日志丢失,硬生生多花了三天定位一个偶发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_pos 和 read_pos 是否一致。如果不一致,说明有未刷盘的日志,赶紧处理掉。
恢复流程:
- mmap打开日志映射文件
- 检查
write_pos != read_pos→ 有残留日志 - 把残留日志写入磁盘文件
- 重置
write_pos = read_pos = 0 - 继续正常运行
29.4 整体架构图
下面这张图,把整个流程串起来了。我建议你保存下来,做设计时对照着看。
29.5 避坑指南
最后,分享几个我踩过的坑:
- 缓冲区大小别设太小。我曾经设了64KB,高并发下一秒钟就写满了。建议至少1MB起步,根据你的日志量调整。
- mmap文件要预分配好大小。不要指望它自动增长。文件满了,写入就失败了。
- 多线程写日志要加锁。但锁的粒度要细。我习惯用CAS无锁队列,性能更好。不过实现起来复杂,初学者先用互斥锁。
- 崩溃恢复后,记得检查文件完整性。有一次磁盘坏了,mmap返回了错误,我没检查,结果写入了乱数据。
我的经验:日志系统是最后一道防线。宁可性能差一点,也要保证数据不丢。线上环境,我一般会开双写:一份走mmap环形缓冲区,一份直接写文件。虽然浪费点磁盘,但心里踏实。
好了,环形缓冲区 + 异步日志 + 崩溃恢复,这套组合拳打下来,你的日志系统基本就稳了。下次程序崩溃,你至少能知道它「临终前」说了什么。
公众号:蓝海资料掘金营,微信deep3321