案例实战:高性能日志系统——异步日志的设计、多生产者单消费者模型、日志文件的滚动

各位同学,今天我们来聊一个实战性很强的话题——高性能日志系统。

说实话,日志这东西,平时不起眼。但线上出问题的时候,它就是你的救命稻草。我见过太多系统,平时跑得挺欢,一崩溃就啥也查不到,就是因为日志系统没设计好。

今天我就带大家手撸一个高性能的异步日志系统。核心就三个点:异步写入多生产者单消费者模型日志文件滚动

为什么日志系统需要异步?

先问大家一个问题:日志写入慢不慢?

慢。磁盘IO是出了名的慢。如果你在业务线程里直接写日志,那每次写日志都得等磁盘。高并发下,这简直就是灾难。

我在一个金融交易系统里遇到过这种情况。业务线程每笔交易都要写日志,结果日志成了瓶颈,整个系统的吞吐量直接腰斩。后来改成异步日志,问题就解决了。

异步日志的核心思路很简单:业务线程只管把日志丢到缓冲区,后台有个专门的线程负责写入磁盘。这样业务线程不会被阻塞,日志写入也不会拖慢业务。

多生产者单消费者模型

这个模型的名字听起来挺唬人,其实说白了就是:多个线程往一个队列里写日志,一个线程从队列里取日志并写入文件。

多生产者——就是你的业务线程。它们并发地产生日志。

单消费者——就是那个后台的日志写入线程。

这里有个关键问题:多线程写队列,怎么保证线程安全?

嗯,你可能会想到加锁。但加锁在高并发下性能堪忧。我推荐用无锁队列,或者用双缓冲技术。

双缓冲的思路是这样的:

  • 准备两个缓冲区,A和B。
  • 业务线程往缓冲区A写日志。
  • 当缓冲区A满了,或者到了刷新时间,就交换A和B。
  • 后台线程把缓冲区B的内容写入文件。
  • 写完后,缓冲区B清空,等待下一次交换。

这样做的好处是:业务线程几乎不需要等待,后台线程也能批量写入,效率很高。

核心要点:双缓冲技术本质上是「空间换时间」,用两个缓冲区来避免生产者和消费者之间的直接竞争。

日志文件的滚动

日志文件不能无限增长。否则磁盘会爆,查日志也会很痛苦。

日志滚动,就是按一定规则切分日志文件。常见的策略有两种:

  • 按大小滚动:单个日志文件达到指定大小(比如100MB),就新建一个文件。
  • 按时间滚动:每天或每小时生成一个新的日志文件。

我个人习惯两种策略结合使用。比如:每天一个文件,但如果某个文件超过了200MB,也强制滚动。

我曾经遇到过一个案例:某个服务日志量特别大,按天滚动的话,一天能写几十GB。结果运维查日志时,光打开文件就要等半天。后来改成按小时滚动,问题就解决了。

滚动时还要注意:旧日志文件的清理。保留最近7天或30天的日志,更早的自动删除。不然磁盘迟早会满。

代码实现要点

下面我给出一个简化版的异步日志系统核心代码。大家注意看关键部分。

// 异步日志类
class AsyncLogger {
public:
    AsyncLogger(const std::string& basename, size_t rollSize)
        : basename_(basename), rollSize_(rollSize), running_(false) {}

    void start() {
        running_ = true;
        // 启动后台写入线程
        thread_ = std::thread([this] { this->threadFunc(); });
    }

    void stop() {
        running_ = false;
        cond_.notify_one();
        if (thread_.joinable()) {
            thread_.join();
        }
    }

    // 业务线程调用此方法写入日志
    void append(const std::string& logLine) {
        std::lock_guard<std::mutex> lock(mutex_);
        if (currentBuffer_->avail() > logLine.size()) {
            currentBuffer_->append(logLine);
        } else {
            // 当前缓冲区满了,交换缓冲区
            buffers_.push_back(currentBuffer_);
            if (nextBuffer_) {
                currentBuffer_ = nextBuffer_;
            } else {
                currentBuffer_.reset(new Buffer);
            }
            currentBuffer_->append(logLine);
            cond_.notify_one(); // 通知后台线程
        }
    }

private:
    void threadFunc() {
        // 后台线程:不断从缓冲区取数据并写入文件
        while (running_) {
            std::vector<BufferPtr> buffersToWrite;
            {
                std::unique_lock<std::mutex> lock(mutex_);
                cond_.wait_for(lock, std::chrono::seconds(3), [this] {
                    return !buffers_.empty() || !running_;
                });
                buffersToWrite.swap(buffers_);
            }

            // 写入文件
            for (auto& buf : buffersToWrite) {
                fwrite(buf->data(), 1, buf->length(), fp_);
                // 检查是否需要滚动
                if (ftell(fp_) > rollSize_) {
                    rollFile();
                }
            }
            fflush(fp_);
        }
    }

    // 日志滚动:关闭旧文件,打开新文件
    void rollFile() {
        fclose(fp_);
        std::string newFile = basename_ + "_" + getCurrentTimeStr() + ".log";
        fp_ = fopen(newFile.c_str(), "a");
    }

    std::string basename_;
    size_t rollSize_;
    std::atomic<bool> running_;
    std::thread thread_;
    std::mutex mutex_;
    std::condition_variable cond_;

    using Buffer = std::vector<char>;
    using BufferPtr = std::shared_ptr<Buffer>;
    BufferPtr currentBuffer_;
    BufferPtr nextBuffer_;
    std::vector<BufferPtr> buffers_;

    FILE* fp_ = nullptr;
};

提示:上面的代码是简化版,实际生产环境还需要考虑:日志格式、时间戳、线程ID、日志级别、异常处理等。但核心架构就是这样的。

架构流程图

下面我用一张图来展示整个异步日志系统的核心流程:

异步日志系统架构流程图 业务线程1(生产者) 业务线程2(生产者) 业务线程N(生产者) 缓冲区A(当前写入) 缓冲区B(待写入) 交换 后台写入线程(消费者) 日志文件(磁盘) 文件大小 > 阈值? → 滚动文件 多生产者(业务线程) 双缓冲区 单消费者(后台线程) 日志文件(磁盘)

避坑指南

我做了这么多年系统,日志系统踩过的坑真不少。这里给大家列几个常见的:

  • 缓冲区溢出:如果业务线程写得太快,后台线程来不及写入,缓冲区会爆。解决方案:设置缓冲区上限,超过上限时直接丢弃或阻塞。
  • 日志丢失:程序崩溃时,缓冲区里还没写入磁盘的日志就丢了。解决方案:定期强制刷新(比如每3秒),或者用内存映射文件。
  • 磁盘IO打满:日志量太大,磁盘IO成为瓶颈。解决方案:限制日志级别(线上只记录WARNING及以上),或者使用更快的存储。
  • 日志文件权限:我曾经遇到过,日志文件权限设置不对,导致程序启动时无法写入,直接崩溃。嗯,这个坑我印象很深。

警告:千万不要在日志系统中使用全局锁!否则你的异步日志就变成了同步日志,性能还不如直接写文件。

性能对比

下面这张表是我在实际项目中测出来的数据,大家可以感受一下异步日志和同步日志的差距:

场景 同步日志(每秒写入条数) 异步日志(每秒写入条数) 提升倍数
单线程 50,000 200,000 4x
4线程 80,000 600,000 7.5x
8线程 90,000 1,000,000 11x
16线程 95,000 1,500,000 15.8x

你看,线程越多,异步日志的优势越明显。因为同步日志的锁竞争会随着线程数增加而急剧恶化,而异步日志几乎不受影响。

总结

好了,今天的内容就到这里。我们讲了异步日志的核心设计:多生产者单消费者模型、双缓冲区、日志滚动。这些都是我在实际项目中反复验证过的方案。

你想想看,一个高性能的日志系统,说白了就是:让业务线程只管生产,让后台线程专心消费,中间用缓冲区解耦。再加上日志滚动防止磁盘爆炸,就是一个能上生产环境的方案了。

如果你正在设计自己的日志系统,我建议你从最简单的版本开始,先跑通,再优化。不要一开始就追求完美,否则很容易陷入过度设计的泥潭。

好,今天就到这里。下次见。


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