一、高性能并发日志系统:从需求到实战

日志系统这东西,说白了就是程序的「黑匣子」。我在做后台服务的时候,遇到过太多次线上问题排查全靠日志。要是日志系统本身拉胯——丢消息、写太慢、把磁盘撑爆——那真是雪上加霜。

今天咱们就手撸一个高性能的并发日志系统。嗯,我会把我在项目中踩过的坑、总结的经验都揉进去。你跟着走一遍,以后自己设计日志库心里就有底了。

1.1 日志系统的核心需求

先别急着写代码。咱们得想清楚:一个工业级的日志系统,到底要满足什么?

  • 低延迟:打日志不能阻塞业务线程。你想想看,一个高并发的Web服务器,每条请求都等日志写完再返回,那性能直接崩了。
  • 高吞吐:每秒能处理几十万条日志写入。我见过一个网关服务,高峰期每秒产生50万条访问日志,扛不住就丢数据。
  • 线程安全:多线程同时写日志,不能出现乱序、数据错乱。
  • 日志轮转:按时间或文件大小切分,防止磁盘写满。
  • 异步写入:业务线程只管把日志丢到缓冲区,后台线程负责刷盘。

核心原则:日志系统的设计目标,就是「让业务线程几乎感觉不到日志的存在」。

1.2 多线程日志缓冲设计

我个人习惯用「双缓冲」方案。为什么?因为单缓冲加锁太频繁,性能瓶颈明显。

双缓冲的思路很简单:

  • 两个缓冲区:bufferAbufferB
  • 业务线程往 bufferA 写日志(无锁或轻量锁)
  • 后台线程把 bufferB 刷到磁盘
  • bufferA 满了,交换指针:业务线程写 bufferB,后台线程刷 bufferA

这样做的好处很明显:业务线程几乎不会被阻塞。我曾经在一个项目中用单缓冲,压测时发现日志写入占了20%的CPU时间。换成双缓冲后,直接降到3%以下。

来看核心数据结构:

// 日志缓冲区结构
typedef struct {
    char *data;          // 缓冲区数据
    size_t size;         // 缓冲区总大小
    size_t write_pos;    // 当前写入位置
    pthread_mutex_t lock; // 互斥锁
} LogBuffer;

// 双缓冲管理器
typedef struct {
    LogBuffer *current;  // 当前写入缓冲区
    LogBuffer *pending;  // 待刷盘缓冲区
    pthread_mutex_t swap_lock; // 交换锁
    pthread_cond_t flush_cond; // 刷盘条件变量
} DoubleBuffer;

小技巧:缓冲区大小建议设为4KB的整数倍(比如64KB),因为磁盘块大小通常是4KB,对齐后写入效率更高。

1.3 异步日志写入机制

异步写入的核心,就是「生产者-消费者」模型。业务线程是生产者,后台刷盘线程是消费者。

我建议用条件变量来做同步,而不是忙等待。忙等待虽然简单,但会白白消耗CPU。你想想看,如果日志量不大,刷盘线程大部分时间都在空转,多浪费。

异步写入的流程:

  1. 业务线程调用 log_write(),将日志追加到当前缓冲区
  2. 如果缓冲区满了,触发交换信号
  3. 后台线程收到信号,交换缓冲区,开始刷盘
  4. 刷盘完成后,通知业务线程可以继续写入

这里有个坑:如果业务线程写入速度远大于刷盘速度,缓冲区会频繁交换,导致性能下降。我曾经遇到过这种情况,后来加了一个「水位线」机制——当缓冲区使用量超过80%时,提前触发交换,而不是等到满了再换。

// 异步写入核心逻辑
void log_write(Logger *logger, const char *msg) {
    pthread_mutex_lock(&logger->buffer->lock);
    
    // 检查剩余空间
    if (logger->buffer->write_pos + len > logger->buffer->size) {
        // 触发缓冲区交换
        pthread_cond_signal(&logger->flush_cond);
        // 等待刷盘完成
        pthread_cond_wait(&logger->write_cond, &logger->buffer->lock);
    }
    
    // 写入日志
    memcpy(logger->buffer->data + logger->buffer->write_pos, msg, len);
    logger->buffer->write_pos += len;
    
    pthread_mutex_unlock(&logger->buffer->lock);
}

注意:条件变量的使用一定要配合互斥锁,否则会出现「虚假唤醒」的问题。我刚开始写多线程代码时就吃过这个亏,debug了一整天才发现是条件变量没用对。

1.4 日志轮转策略

日志轮转,说白了就是「别让一个日志文件无限变大」。常见的策略有两种:

策略 触发条件 适用场景
按大小轮转 文件超过指定大小(如100MB) 通用场景,控制磁盘占用
按时间轮转 到达指定时间点(如每天0点) 需要按天归档的场景
混合轮转 大小或时间任一条件满足 生产环境推荐

我个人习惯用混合轮转。比如设置「每天轮转一次,且单个文件不超过200MB」。这样既方便按天查日志,又不会出现某个文件特别大的情况。

轮转时要注意:旧日志文件要重命名,不能直接覆盖。我见过一个系统,轮转时直接把旧文件删了,结果线上出问题想查昨天的日志,发现已经没了……

// 日志轮转实现
void log_rotate(Logger *logger) {
    char old_path[256], new_path[256];
    
    // 关闭当前文件
    fclose(logger->file);
    
    // 重命名:app.log -> app.2025-01-15.log
    snprintf(new_path, sizeof(new_path), "%s.%s", 
             logger->base_path, get_current_date());
    rename(logger->base_path, new_path);
    
    // 创建新文件
    logger->file = fopen(logger->base_path, "a");
    
    // 检查旧文件数量,超过保留数则删除最旧的
    clean_old_logs(logger->base_path, logger->max_files);
}

1.5 性能测试与调优

系统写完了,得测一测。我一般关注三个指标:

  • 吞吐量:每秒能写入多少条日志
  • 延迟:业务线程从调用 log_write 到返回的时间
  • CPU占用:日志系统消耗的CPU百分比

测试方法很简单:开N个线程,每个线程疯狂写日志,统计总耗时。

我记得有一次压测,发现吞吐量上不去。排查了半天,发现是 fwrite 调用太频繁。后来改成批量写入——攒够一批日志再一次性 fwrite,吞吐量直接翻了三倍。

性能数据参考(我的测试环境:4核8线程,SSD磁盘):

  • 单线程同步写入:约 5万条/秒
  • 双缓冲异步写入:约 50万条/秒
  • 批量写入优化后:约 120万条/秒

你看,同样的硬件,不同的设计,性能差距能有20倍。这就是为什么我总说「架构设计决定性能上限」。

1.6 整体架构图

下面这张图展示了整个日志系统的核心流程。我特意把数据流和控制流分开画,方便你理解。

高性能并发日志系统架构图 业务线程 Thread 1 Thread 2 Thread N 写入日志 双缓冲管理器 Buffer A Buffer B 互斥锁 + 条件变量 交换/刷盘 后台线程 刷盘线程 轮转检查 磁盘 日志轮转模块 按大小轮转 | 按时间轮转 旧日志归档 & 清理 触发轮转 归档文件 图例 数据流(日志写入) 控制流(轮转触发) 归档流(文件迁移) 双缓冲 + 异步刷盘 + 日志轮转 = 高性能日志系统

从图上你能看到,整个系统的核心就是「双缓冲 + 异步刷盘」。业务线程只管往缓冲区里写,后台线程负责刷盘和轮转。两者通过条件变量同步,互不阻塞。

我的建议:刚开始实现时,先跑通单线程版本,再扩展到多线程。每一步都做单元测试,别等到最后一起debug,那会非常痛苦。

好了,这一章的内容就到这里。日志系统的需求分析、缓冲设计、异步写入、轮转策略,咱们都过了一遍。下一章我会带你手写完整的代码实现,包括双缓冲的初始化、日志格式化、以及性能压测脚本。到时候咱们一起把代码跑起来,看看实际效果。

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