63、实战:日志系统(单例+多线程安全+文件滚动)

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

我做了这么多年C++开发,几乎每个项目都离不开它。调试时靠它定位问题,线上运行时靠它排查故障。但很多人写日志就是简单fprintf一下,结果多线程一跑就乱码,日志文件几天就撑爆磁盘。

嗯,今天我们就手撸一个靠谱的日志系统。它要满足三个硬指标:单例模式多线程安全文件滚动

一、整体架构设计

先画张图,让你对整个系统有个直观认识。

日志系统核心架构 线程A(调用LOG_INFO) 线程B(调用LOG_WARN) 线程C(调用LOG_ERROR) Logger(单例) mutex 保护 日志级别过滤 格式化时间戳 判断滚动条件 写入文件 文件滚动管理 按大小滚动 按日期滚动 保留N个历史文件 自动删除旧文件 重命名/压缩 app.log / app.1.log / ...

从图里能看出来,所有线程都通过同一个Logger实例写日志。这个实例内部用互斥锁保护,保证同一时刻只有一个线程在写文件。同时,它还会检查当前文件大小,超过阈值就自动滚动。

二、单例模式实现

单例模式,说白了就是整个进程里只存在一个Logger对象。C++11之后实现起来很简单,用static local变量就行——它天然是线程安全的。

核心要点:不要自己加双重检查锁,C++11标准已经保证了static局部变量的初始化是线程安全的。

// Logger.h
#pragma once
#include <string>
#include <mutex>
#include <fstream>
#include <memory>

class Logger {
public:
    static Logger& instance() {
        static Logger inst;
        return inst;
    }

    // 禁止拷贝和赋值
    Logger(const Logger&) = delete;
    Logger& operator=(const Logger&) = delete;

    void setLogLevel(int level);
    void setLogFile(const std::string& filename, size_t maxFileSize = 10*1024*1024);

    void log(int level, const char* file, int line, const char* fmt, ...);

private:
    Logger() = default;
    ~Logger();

    void rollFile();
    void writeLog(const std::string& msg);

    std::mutex mtx_;
    std::unique_ptr<std::ofstream> file_;
    std::string filename_;
    size_t maxFileSize_;
    size_t currentSize_{0};
    int logLevel_{0};
};

我个人习惯把构造函数设为私有,然后通过instance()静态方法获取唯一实例。这样外部想new一个Logger?门儿都没有。

三、多线程安全设计

多线程安全的核心就一句话:所有写操作都加锁

但这里有个坑——日志系统本身不能死锁。比如你在日志输出函数里又调用了日志输出,那就完蛋了。我曾经在一个项目里见过这种递归锁导致的死锁,排查了整整两天。

避坑指南:日志输出函数内部不要调用任何可能触发日志输出的代码。比如不要在日志格式化时调用复杂的业务逻辑。

具体实现上,我用了std::lock_guard,RAII风格,异常安全。

void Logger::log(int level, const char* file, int line, const char* fmt, ...) {
    if (level < logLevel_) return;  // 级别过滤,不加锁

    char buf[4096];
    va_list args;
    va_start(args, fmt);
    vsnprintf(buf, sizeof(buf), fmt, args);
    va_end(args);

    // 格式化时间戳
    char timeBuf[64];
    auto now = std::chrono::system_clock::now();
    auto t = std::chrono::system_clock::to_time_t(now);
    std::strftime(timeBuf, sizeof(timeBuf), "%Y-%m-%d %H:%M:%S", std::localtime(&t));

    // 拼装完整日志行
    std::string msg = std::string(timeBuf) + " [" + levelToString(level) + "] " 
                      + file + ":" + std::to_string(line) + " - " + buf + "\n";

    // 加锁写文件
    {
        std::lock_guard<std::mutex> lock(mtx_);
        writeLog(msg);
    }
}

void Logger::writeLog(const std::string& msg) {
    if (!file_ || !file_->is_open()) return;

    *file_ << msg;
    file_->flush();  // 确保立即写入磁盘
    currentSize_ += msg.size();

    if (currentSize_ >= maxFileSize_) {
        rollFile();
    }
}

你想想看,如果这里不加锁,两个线程同时写文件,那日志行就会互相穿插,读起来根本没法看。加锁之后,每个线程的日志行都是完整的。

四、文件滚动机制

文件滚动,说白了就是日志文件太大时自动切分。我见过有人把日志写到几十GB,打开文件都能把编辑器卡死。

滚动策略一般有两种:

  • 按大小滚动:文件超过10MB就切分
  • 按日期滚动:每天生成一个新文件

我这里实现的是按大小滚动,同时保留最近N个历史文件。

void Logger::rollFile() {
    // 关闭当前文件
    file_->close();

    // 重命名:app.log -> app.1.log, app.1.log -> app.2.log, ...
    // 保留最多10个历史文件
    const int maxBackup = 10;
    for (int i = maxBackup - 1; i >= 1; --i) {
        std::string oldName = filename_ + "." + std::to_string(i) + ".log";
        std::string newName = filename_ + "." + std::to_string(i + 1) + ".log";
        if (std::filesystem::exists(oldName)) {
            std::filesystem::rename(oldName, newName);
        }
    }

    // 把当前文件重命名为 .1.log
    std::string backupName = filename_ + ".1.log";
    if (std::filesystem::exists(filename_)) {
        std::filesystem::rename(filename_, backupName);
    }

    // 重新创建新文件
    file_ = std::make_unique<std::ofstream>(filename_, std::ios::app);
    currentSize_ = 0;
}

小技巧:滚动操作本身也放在锁里面,因为涉及文件重命名和创建,必须保证原子性。另外,std::filesystem是C++17的特性,如果你的编译器不支持,可以用rename()remove()等C函数替代。

五、宏定义与使用示例

为了方便调用,我通常会定义几个宏。这样写起来就像printf一样简单。

#define LOG_TRACE(fmt, ...)  Logger::instance().log(0, __FILE__, __LINE__, fmt, ##__VA_ARGS__)
#define LOG_DEBUG(fmt, ...)  Logger::instance().log(1, __FILE__, __LINE__, fmt, ##__VA_ARGS__)
#define LOG_INFO(fmt, ...)   Logger::instance().log(2, __FILE__, __LINE__, fmt, ##__VA_ARGS__)
#define LOG_WARN(fmt, ...)   Logger::instance().log(3, __FILE__, __LINE__, fmt, ##__VA_ARGS__)
#define LOG_ERROR(fmt, ...)  Logger::instance().log(4, __FILE__, __LINE__, fmt, ##__VA_ARGS__)

使用起来非常直观:

int main() {
    Logger::instance().setLogFile("app.log", 5*1024*1024);  // 5MB滚动
    Logger::instance().setLogLevel(1);  // 只输出DEBUG及以上级别

    LOG_INFO("系统启动,进程ID: %d", getpid());

    for (int i = 0; i < 1000; ++i) {
        LOG_DEBUG("处理第 %d 条数据", i);
        // 模拟业务处理...
    }

    LOG_WARN("内存使用率超过80%%");
    LOG_ERROR("数据库连接失败,错误码: %d", -1);

    return 0;
}

六、性能与注意事项

关注点 说明 我的建议
锁粒度 整个写操作加锁,包括格式化 如果性能要求极高,可以用双缓冲队列,但实现复杂度大增
磁盘IO 每次写日志都flush 调试阶段可以flush,线上可以去掉flush提升性能
日志级别 先判断级别再格式化 避免不必要的字符串拼接,尤其是TRACE级别
文件句柄 滚动时关闭旧文件,打开新文件 注意异常情况下的文件句柄泄漏
线程安全 所有public方法都加锁 包括setLogLevel和setLogFile,防止配置变更时竞态

核心总结:一个工业级的日志系统,单例保证全局唯一,互斥锁保证线程安全,文件滚动防止磁盘爆炸。这三者缺一不可。

嗯,这套代码我用了好几年,从Windows到Linux,从单线程到上百线程,从来没出过问题。你拿去改改就能用在自己的项目里。

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