33、C++项目实战:日志系统设计

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

我刚开始做C++项目时,总觉得日志可有可无。直到有一次线上服务半夜挂了,我连程序死在哪一步都不知道……嗯,从那以后,我写的每个项目第一件事就是搭日志系统。

今天我们就手撸一个轻量级、可扩展的C++日志库。你想想看,以后排查问题直接翻日志文件,比打断点调试爽多了。

日志系统到底要解决什么问题?

一个合格的日志系统,至少得搞定这几件事:

  • 分级输出:DEBUG、INFO、WARN、ERROR,不同级别能分开看
  • 线程安全:多线程写日志不能乱掉
  • 文件滚动:日志不能无限增长,按大小或时间切分
  • 性能开销低:写日志不能拖慢主业务

我个人习惯把日志系统设计成三层架构,这样后期改起来不费劲。下面这张图能帮你快速理解整体结构:

日志系统三层架构 接口层(API) LOG_DEBUG() LOG_INFO() LOG_WARN() LOG_ERROR() 核心层(Core) 日志格式化 → 级别过滤 → 缓冲区管理 输出层(Sink) 控制台输出 | 文件输出(滚动) | 网络输出(可选)

第一步:定义日志级别和基础结构

先定个枚举,把级别排好。我个人习惯把级别值设成整数,方便比较过滤。

// log_level.h
#pragma once
#include <string>

enum class LogLevel {
    DEBUG = 0,
    INFO  = 1,
    WARN  = 2,
    ERROR = 3,
    FATAL = 4
};

inline std::string levelToString(LogLevel level) {
    switch (level) {
        case LogLevel::DEBUG: return "DEBUG";
        case LogLevel::INFO:  return "INFO";
        case LogLevel::WARN:  return "WARN";
        case LogLevel::ERROR: return "ERROR";
        case LogLevel::FATAL: return "FATAL";
        default: return "UNKNOWN";
    }
}
💡 我的小习惯:FATAL级别我一般会直接触发abort(),因为走到这步程序已经没法继续跑了。线上环境FATAL日志一定要有告警通知。

第二步:实现线程安全的日志缓冲区

多线程写日志,最怕的就是数据竞争。我早期吃过这个亏——两个线程同时写一行日志,结果拼成了乱码。

解决办法其实很简单:用互斥锁保护写操作,或者用无锁队列。这里我选互斥锁,因为实现简单,对大多数项目够用了。

// log_buffer.h
#pragma once
#include <string>
#include <mutex>
#include <vector>

class LogBuffer {
public:
    void append(const std::string& msg) {
        std::lock_guard<std::mutex> lock(mutex_);
        buffer_.push_back(msg);
    }

    std::vector<std::string> flush() {
        std::lock_guard<std::mutex> lock(mutex_);
        std::vector<std::string> ret;
        ret.swap(buffer_);
        return ret;
    }

private:
    std::mutex mutex_;
    std::vector<std::string> buffer_;
};
⚠️ 注意:千万别在锁里面做文件IO!我曾经见过有人把写文件和加锁放一起,结果高并发下性能直接崩了。正确的做法是:先攒一批日志到内存缓冲区,后台线程定时刷盘。

第三步:文件滚动策略

日志文件不能无限大,否则打开都费劲。我一般用两种滚动策略:

策略 触发条件 适用场景
按大小滚动 单个文件超过阈值(如10MB) 日志量稳定的服务
按时间滚动 每天/每小时新建一个文件 需要按时间归档的场景

我个人更推荐「按大小+按时间」混合策略。比如每天一个目录,每个文件最大10MB,超过就新建。这样既好找又好管。

// file_roller.h
#pragma once
#include <string>
#include <fstream>

class FileRoller {
public:
    FileRoller(const std::string& baseName, size_t maxSize = 10 * 1024 * 1024)
        : baseName_(baseName), maxSize_(maxSize), index_(0) {
        openNewFile();
    }

    void write(const std::string& msg) {
        if (file_.tellp() > maxSize_) {
            file_.close();
            openNewFile();
        }
        file_ << msg << std::endl;
    }

private:
    void openNewFile() {
        std::string fileName = baseName_ + "_" + std::to_string(index_++) + ".log";
        file_.open(fileName, std::ios::app);
    }

    std::ofstream file_;
    std::string baseName_;
    size_t maxSize_;
    int index_;
};

第四步:组装成完整的日志器

把上面这些零件拼起来,就是一个能用的日志系统了。这里我用了单例模式,因为整个程序只需要一个日志实例。

// logger.h
#pragma once
#include "log_level.h"
#include "log_buffer.h"
#include "file_roller.h"
#include <thread>
#include <chrono>

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

    void setLevel(LogLevel level) { minLevel_ = level; }

    void log(LogLevel level, const std::string& msg) {
        if (level < minLevel_) return;
        std::string formatted = "[" + levelToString(level) + "] " + msg;
        buffer_.append(formatted);
    }

    void startFlushThread() {
        flushThread_ = std::thread([this]() {
            while (running_) {
                std::this_thread::sleep_for(std::chrono::seconds(2));
                auto logs = buffer_.flush();
                for (auto& log : logs) {
                    roller_.write(log);
                    std::cout << log << std::endl; // 同时输出到控制台
                }
            }
        });
        flushThread_.detach();
    }

    void stop() { running_ = false; }

private:
    Logger() : minLevel_(LogLevel::DEBUG), running_(true) {
        startFlushThread();
    }
    ~Logger() { stop(); }

    LogLevel minLevel_;
    LogBuffer buffer_;
    FileRoller roller_{"app"};
    std::thread flushThread_;
    std::atomic<bool> running_;
};

// 便捷宏
#define LOG_DEBUG(msg) Logger::instance().log(LogLevel::DEBUG, msg)
#define LOG_INFO(msg)  Logger::instance().log(LogLevel::INFO, msg)
#define LOG_WARN(msg)  Logger::instance().log(LogLevel::WARN, msg)
#define LOG_ERROR(msg) Logger::instance().log(LogLevel::ERROR, msg)
🔑 关键设计点:
  • 后台线程每2秒刷一次盘,避免频繁IO
  • 日志级别过滤在写入缓冲区之前,减少无用操作
  • 宏定义让调用更简洁,同时保留__FILE__和__LINE__的扩展空间

使用示例

#include "logger.h"

int main() {
    Logger::instance().setLevel(LogLevel::INFO);

    LOG_DEBUG("这条不会输出,因为级别是INFO");  // 被过滤
    LOG_INFO("服务启动成功");
    LOG_WARN("内存使用率超过80%");

    // 模拟业务逻辑
    for (int i = 0; i < 5; ++i) {
        LOG_INFO("处理第" + std::to_string(i) + "个请求");
    }

    LOG_ERROR("数据库连接超时");

    std::this_thread::sleep_for(std::chrono::seconds(3));
    return 0;
}

避坑指南

做日志系统这些年,我踩过不少坑。挑几个典型的说说:

  • 日志打太多拖慢程序——我曾经在热循环里打DEBUG日志,结果性能直接腰斩。解决办法:热路径上只打ERROR级别,或者用条件宏控制。
  • 日志文件把磁盘撑爆——有一次线上服务跑了半年,日志文件占了200GB。后来我加了最大文件数和自动清理策略,保留最近7天的日志就够了。
  • 多线程日志乱序——如果多个线程同时写,日志时间戳可能错乱。我后来在每条日志里加了线程ID,排查时能按线程过滤。
📌 进阶建议:如果你的项目对性能要求极高,可以考虑用「双缓冲区」技术——一个缓冲区写,一个缓冲区刷盘,交替使用。这样几乎零锁竞争。我曾在QPS 10万+的网关服务上用过,效果很好。

好了,一个完整的日志系统就这样搭起来了。你想想看,以后出问题了直接翻日志,是不是比瞎猜强多了?

实际项目中,你还可以加上异步日志、日志压缩、远程日志收集等功能。但核心思想不变——分层设计、线程安全、异步刷盘。


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