加班之余从零写了个 spdlog 风格的日志库

从零手写 C++ 日志库 ------ 一个 spdlog 风格 logger 的完整实现

两周前开始动手,580 行代码,175 万 msg/s。这篇文章记录每一层的设计决策和踩过的坑。


为什么要自己写

spdlog 是 C++ 日志库的事实标准,header-only、性能炸裂、功能齐全。但每次我翻它的源码都想放弃------模板嵌套、工厂模式、fmt 集成,太多高级特性堆在一起,根本看不清架构脉络。

有一天我突然想:与其硬看源码,不如自己从零写一个。目标不是替代 spdlog,是理解它为什么长成那个样子。

从零写的好处是你一个设计点都逃不掉。用 spdlog 的时候 logger->info("hello") 一行就结束了。但你自己实现这一行,背后每个决策都得想清楚------消息怎么存、格式串怎么编、sink 要不要锁、异步队列怎么设计。等你全部亲手做过一遍,再看 spdlog 源码就会发现没什么神秘的了------设计模式你都摸过,剩下的只是工程细节。

这篇文章按我代码的分层结构走,每一层解释"它要解决什么问题、为什么这样设计、最关键的代码是什么"。全部代码都在 GitHub 上,MIT 协议:

my_loggerhttps://github.com/ch0sen1pm/my_logger my_muduohttps://github.com/ch0sen1pm/my_muduo(配套 Reactor 网络库)


项目结构

第一版写了两天------一个 580 行的单头文件,所有类堆在一起。能跑,但回看很累。上周拆成了模块:

makefile 复制代码
my_logger/
├── logger.h                  --- 聚合头文件,用户只 include 这一个
└── core/
    ├── common.h              --- 日志级别、消息结构体、跨平台适配
    ├── blocking_queue.h      --- 线程安全阻塞队列
    ├── formatter.h           --- 策略模式:%flag → formatter 链表
    ├── sink.h                --- 多态输出:控制台/文件/滚动文件
    ├── logger.h              --- 日志记录器
    ├── async_logger.h        --- 异步日志(后台线程写 sink)
    ├── registry.h            --- 全局单例注册表
    └── macros.h              --- LOG_INFO / LOG_ERROR 等宏

跟 muduo 的分层思路一样------每层只做一件事,替换任何一层都不影响其他层。


第一层:common.h --- 消息结构

一条日志长什么样?就是这几个字段:

cpp 复制代码
enum class level {
    trace = 0, debug = 1, info = 2, warn = 3, err = 4, crit = 5, off = 6
};

struct log_msg {
    std::string logger_name;                            // 哪个 logger 产生的
    level lvl;                                          // 日志级别
    std::string payload;                                // 用户传进来的消息正文
    std::chrono::system_clock::time_point time;         // 产生时刻
    size_t thread_id;                                   // 哪个线程产生的
};

就是一个结构体------把一次日志调用的所有信息打包,下一步交给 formatter 处理。thread_id 是用 std::hash<std::thread::id> 算出来的简短数字,方便输出。

这里有个坑:localtime 是 C 标准库函数,Windows 和 Linux 的线程安全版本不一样。Windows 用 localtime_s,Linux 用 localtime_r。我写了一个小包装:

cpp 复制代码
inline void localtime_platform(const std::time_t* t, std::tm* tm) {
#ifdef _WIN32
    localtime_s(tm, t);
#else
    localtime_r(t, tm);
#endif
}

第二层:formatter.h --- 格式串怎么编译

这是整个库最有意思的设计。用户传一个模式串:

perl 复制代码
"[%Y-%m-%d %H:%M:%S.%e] [%n] [%l] %v"

最终输出:

css 复制代码
[2026-07-13 23:15:30.123] [myapp] [info] hello world

问题来了:这段模式串怎么处理?最简单的做法是 format() 里用 if/else 逐个字符解析------遇到 % 就看看后面是什么、拼一段再继续走。能跑,但性能差。每条日志都要重新分析模式串,O(n) 字符串扫描。

spdlog 的思路是把模式串编译成一个对象数组。编译只做一次(构造时),运行时直接遍历对象数组执行虚函数------零解析开销。

flag_formatter 继承体系

每个 %flag 对应一个子类:

cpp 复制代码
class flag_formatter {
public:
    virtual ~flag_formatter() = default;
    virtual void format(const log_msg& msg, const std::tm& tm_time,
                        std::string& dest) = 0;
};

然后 %Yyear_flag%mmonth_flag%dday_flag,依此类推。每个子类就干一件小事:

cpp 复制代码
class year_flag : public flag_formatter {
public:
    void format(const log_msg&, const std::tm& tm, std::string& dest) override {
        dest += std::to_string(tm.tm_year + 1900);
    }
};

class message_flag : public flag_formatter {
public:
    void format(const log_msg& msg, const std::tm&, std::string& dest) override {
        dest += msg.payload;
    }
};

编译 → 执行

cpp 复制代码
class pattern_formatter {
    std::vector<std::unique_ptr<flag_formatter>> formatters_;

    void compile_pattern_(const std::string& pattern) {
        std::string literal;    // 攒着 %flag 之间的普通文本块
        for (auto it = pattern.begin(); it != pattern.end(); ++it) {
            if (*it == '%' && std::next(it) != pattern.end()) {
                // 先把之前攒的文本块输出
                if (!literal.empty()) {
                    formatters_.push_back(make_unique<literal_flag>(move(literal)));
                    literal.clear();
                }
                // 读取 flag 字符,创建对应的 formatter
                char flag = *++it;
                formatters_.push_back(create_flag_(flag));
            } else {
                literal += *it;
            }
        }
        // 剩的文本块(如果有)
        if (!literal.empty())
            formatters_.push_back(make_unique<literal_flag>(move(literal)));
    }

    // 实际执行格式化就是简单遍历
    void format(const log_msg& msg, std::string& dest) {
        for (auto& f : formatters_) {
            f->format(msg, cached_tm_, dest);
        }
        dest += '\n';
    }
};

逻辑很直接:遍历模式串,% 后面跟的字符是一个 flag 指令,其他字符是普通的文字块。literal_flag 直接追加文字,name_flag 追加 logger 名称,level_flag 追加级别名称,等等。

时间缓存

还有一个重要的优化。一条消息可能发给多个 sink(比如同时输出到屏幕和文件),如果每个 sink 都调一次 localtime,浪费时间。实际上同一条消息的时间不可能变------秒级之内都一样。所以我缓冲了一个 cached_tm_,秒变了才重新调 localtime

cpp 复制代码
void format(const log_msg& msg, std::string& dest) {
    auto secs = duration_cast<seconds>(msg.time.time_since_epoch());
    if (secs != last_log_secs_) {        // 秒变了才更新
        time_t t = system_clock::to_time_t(msg.time);
        localtime_platform(&t, &cached_tm_);
        last_log_secs_ = secs;
    }
    for (auto& f : formatters_)
        f->format(msg, cached_tm_, dest);
    dest += '\n';
}

第三层:sink.h --- 多态输出 + 模板注入锁

日志的输出目标可以是控制台、文件、甚至网络。每种目标行为不同------控制台直接 cout,文件需要 ofstream,滚动文件还需要检测大小、重命名。但它们有统一的接口:

cpp 复制代码
class sink {
public:
    virtual ~sink() = default;
    virtual void log(level lvl, const std::string& msg) = 0;
    virtual void flush() = 0;

    void set_level(level lvl) { level_ = lvl; }
    level get_level() const { return level_; }

    bool should_log(level msg_level) {
        return static_cast<int>(msg_level) >= static_cast<int>(level_);
    }

protected:
    level level_{ level::trace };
};

sink 只定义接口,具体的输出行为留给子类。同时每个 sink 有自己的 level 阈值------比如 stdout_sink 设 info,file_sink 设 trace,就能做到"屏幕只看重要信息,文件记录全部"。

锁策略------模板注入

多线程下写文件需要加锁,但控制台输出版本如果是单线程就不需要锁------白费 CPU。怎么让一份代码同时支持两种锁策略?答案是模板参数

cpp 复制代码
// 空锁------编译后直接消失,零开销
struct null_mutex {
    void lock() {}
    void unlock() {}
};

// 模板基类:通过 Mutex 参数决定加不加锁
template <typename Mutex = null_mutex>
class base_sink : public sink {
public:
    void log(level lvl, const std::string& msg) final {
        std::lock_guard<Mutex> lock(mtx_);    // 如果是 null_mutex,lock() 是空函数
        sink_it_(lvl, msg);                    // → 编译器内联,直接消失
    }
    void flush() final {
        std::lock_guard<Mutex> lock(mtx_);
        flush_();
    }
protected:
    virtual void sink_it_(level lvl, const std::string& msg) = 0;
    virtual void flush_() = 0;
    Mutex mtx_;
};

子类继承的时候选模板参数:

cpp 复制代码
// 控制台------默认单线程,template 用的是 null_mutex
class stdout_sink : public base_sink<> {
    void sink_it_(level, const std::string& msg) override { std::cout << msg; }
    void flush_()   override { std::cout << std::flush; }
};

// 文件------多线程安全,template 用的是 std::mutex
class file_sink : public base_sink<std::mutex> {
    std::ofstream file_;
public:
    file_sink(const std::string& filename) { file_.open(filename); }
    void sink_it_(level, const std::string& msg) override { file_ << msg; }
    void flush_()   override { file_ << std::flush; }
};

这就是"编译期多态"------template 不是运行时选的,是编译时就确定了。null_mutex 版本在生成的汇编里根本没有锁操作。

滚动文件 sink

生产环境最实用的 sink:文件大到一定尺寸(比如 1MB),它会关掉旧文件、给它改个名(app_20260713_231530.log),然后开一个新 app.log 继续写。核心是 std::rename 这个系统调用,原子操作,跨平台。

cpp 复制代码
void rotate_() {
    auto now = system_clock::now();
    auto t = system_clock::to_time_t(now);
    tm tm_buf;
    localtime_platform(&t, &tm_buf);
    char ts[32];
    strftime(ts, sizeof(ts), "%Y%m%d-%H%M%S", &tm_buf);

    auto old = base_name_ + ".log";
    auto rotated = base_name_ + "_" + ts + ".log";
    rename(old.c_str(), rotated.c_str());
}

第四层:logger.h --- 组合 formatter + sink

有了 formatter 和 sink,logger 的工作就是把两者串起来:

cpp 复制代码
class logger {
    std::string name_;
    level level_{ level::info };
    std::unique_ptr<pattern_formatter> formatter_;
    std::vector<std::shared_ptr<sink>> sinks_;   // 可以有多个 sink

public:
    void log(level lvl, const std::string& payload) {
        if (!should_log(lvl)) return;               // ① 级别过滤

        log_msg msg;                                // ② 打包消息
        msg.logger_name = name_;
        msg.lvl = lvl;
        msg.payload = payload;
        msg.time = system_clock::now();
        msg.thread_id = hash<thread::id>{}(this_thread::get_id());

        std::string formatted;
        formatter_->format(msg, formatted);         // ③ 格式化

        for (auto& s : sinks_) {                    // ④ 分发给每个 sink
            if (s->should_log(lvl))
                s->log(lvl, formatted);
        }
    }

    // 加一个 sink
    void add_sink(std::shared_ptr<sink> s) { sinks_.push_back(move(s)); }
};

用户实际用的便捷接口:

cpp 复制代码
void trace(const std::string& msg) { log(level::trace, msg); }
void debug(const std::string& msg) { log(level::debug, msg); }
void info(const std::string& msg)  { log(level::info,  msg); }
void warn(const std::string& msg)  { log(level::warn,  msg); }
void error(const std::string& msg) { log(level::err,   msg); }
void crit(const std::string& msg)  { log(level::crit,  msg); }

第五层:async_logger.h --- 异步日志

同步 logger 有一个性能坑:写文件是 I/O 操作,可能慢,会阻塞业务线程。解决方案是把"写 sink"这个慢操作移到后台线程去,业务线程只负责格式化消息和丢队列。

blocking_queue --- 生产者消费者

cpp 复制代码
template <typename T>
class blocking_queue {
    std::queue<T> q_;
    std::mutex mtx_;
    std::condition_variable cv_;
    bool stopped_ = false;

    void enqueue(T item) {
        { lock_guard<mutex> lock(mtx_); q_.push(move(item)); }
        cv_.notify_one();                      // 唤醒一个等待的消费者
    }

    optional<T> dequeue() {
        unique_lock<mutex> lock(mtx_);
        cv_.wait(lock, [this] { return !q_.empty() || stopped_; });
        if (stopped_ && q_.empty()) return nullopt;  // 退出了
        T item = move(q_.front());
        q_.pop();
        return item;
    }

    void stop() {
        { lock_guard<mutex> lock(mtx_); stopped_ = true; }
        cv_.notify_all();                      // 唤醒所有消费者
    }
};

async_logger 包装

cpp 复制代码
class async_logger {
    shared_ptr<logger> backend_;        // 同步 logger(做格式化)
    blocking_queue<async_msg> q_;       // 消息队列
    vector<thread> workers_;            // 后台线程池

    void log(level lvl, const string& payload) {
        // 第一步:格式化(前端线程做,快)
        log_msg msg = ...;
        string formatted;
        backend_->format(msg, formatted);

        // 第二步:丢队列(前端线程的活干完了)
        async_msg amsg{lvl, move(formatted)};
        q_.enqueue(move(amsg));
    }

    // 后台线程:出队 → 写 sink(慢,不在业务线程上)
    void run_() {
        while (true) {
            auto msg = q_.dequeue();
            if (!msg) break;
            backend_->log_formatted(msg->lvl, msg->formatted);
        }
    }

    ~async_logger() {
        q_.stop();          // 停止队列
        for (auto& t : workers_) t.join();  // 等所有 worker 刷盘
    }
};

析构时 q_.stop() → 所有 worker 被唤醒 → 把队列里的还没处理完的消息刷盘 → join() 等所有线程退出。优雅关闭,不会丢日志。


第六层:registry.h --- 全局管理

照着 spdlog 的 registry 写了一个单例注册表:

cpp 复制代码
class registry {
public:
    static registry& instance() {
        static registry r;            // C++11 保证线程安全
        return r;
    }

    void register_logger(shared_ptr<logger> log) {
        lock_guard<mutex> lock(mtx_);
        loggers_[log->name()] = move(log);
    }

    shared_ptr<logger> get(const string& name) {
        lock_guard<mutex> lock(mtx_);
        auto it = loggers_.find(name);
        return it != loggers_.end() ? it->second : nullptr;
    }
};

macros.h --- 级别过滤宏

cpp 复制代码
#define LOG_TRACE(logger, msg) if ((logger)->should_log(level::trace)) (logger)->trace(msg)
#define LOG_DEBUG(logger, msg) if ((logger)->should_log(level::debug)) (logger)->debug(msg)
#define LOG_INFO(logger, msg)  if ((logger)->should_log(level::info))  (logger)->info(msg)
#define LOG_WARN(logger, msg)  if ((logger)->should_log(level::warn))  (logger)->warn(msg)
#define LOG_ERROR(logger, msg) if ((logger)->should_log(level::err))   (logger)->error(msg)
#define LOG_CRIT(logger, msg)  if ((logger)->should_log(level::crit))  (logger)->crit(msg)

if 在前面------如果级别没达标,连 payload 的字符串拼接都不执行。


Benchmark

cpp 复制代码
// 同步单线程
auto log = make_shared<logger>("bench", make_shared<file_sink>("bench.log"));
log->set_level(level::info);
for (int i = 0; i < 100000; i++) LOG_INFO(log, "bench message");
bash 复制代码
同步单线程 100K 条: 57ms → 1,754,385 msg/s
同步 4 线程  100K 条:201ms →   497,512 msg/s
异步单线程 100K 条:250ms →   400,000 msg/s
异步 4 线程  100K 条:1470ms →  68,027 msg/s

同步单线程 175 万/秒------基本是 std::string 拼接 + 内存拷贝的上限。异步 4 线程下的锁竞争是下一步优化点(ring buffer / 无锁队列)。


跟 muduo 合体

我把这个 logger 跟我另一个从零写的 Reactor 网络库串了起来,写了一个多人聊天室:

cpp 复制代码
auto console = make_shared<stdout_sink>();
auto file    = make_shared<file_sink>("chat.log");
auto log     = make_shared<logger>("chat", console);
log->add_sink(file);

EventLoop loop;
TcpServer server(&loop, 8080);
server.setThreadNum(4);
vector<TcpConnection*> clients;

server.setConnectionCallback([&](TcpConnection* conn) {
    clients.push_back(conn);
    LOG_INFO(log, "新客户端连接,在线: " + to_string(clients.size()));
});

server.setMessageCallback([&](TcpConnection* conn, const char* data, size_t len) {
    string msg(data, len);
    LOG_INFO(log, "收到: " + msg);
    for (auto* c : clients)
        if (c != conn) c->send(msg + "\r\n");     // 广播
});

server.start();
loop.loop();

多个终端 telnet 到 8080,打字互发消息。chat.log 同步记录每一条:

csharp 复制代码
[22:56:52] 聊天服务器启动,端口 8080...
[22:57:03] 新客户端连接,在线:1
[22:57:31] 收到:hello from client 1
[22:57:36] 收到:hello from client 2

两个自己造的轮子第一次协同工作,那种感觉很难描述。以前写出来的东西都是用别人的库------这是第一次自己的两个库在同一个进程里配合,"我写的库能给我的另一个库当基础设施"。


踩过的坑

  1. localtime 不是线程安全的。 C 标准库返回内部缓冲区指针,多线程同时调会互相覆盖。必须用 localtime_r(Linux) 或 localtime_s(Windows),或者自己加锁。
  2. pattern 编译和格式化要分离。 最初在 format() 里边解析 %flag 边拼字符串------每条日志都重新扫描模式串。改成"构造函数编译、运行时遍历对象数组"之后,格式化从 O(n) 字符串扫描变成了 O(n) 数组遍历------常数差距很大。
  3. lock_guard vs unique_lock lock_guard 轻量但不可解锁,只用于简单的保护块。unique_lock 可以手动 unlock + relock,配合 condition_variable::wait 必须用它。
  4. 异步析构的顺序不能错。 async_logger 的析构里必须先 q_.stop() 唤醒所有 worker,再 join()。如果反过来------join 之前没 stop------worker 永远阻塞在 dequeue() 上,整个程序 hang 住。

计划

接下来还会更新:

  • color_stdout_sink(按级别着色:error 红、warn 黄)
  • daily_rolling_sink(按天切文件,不是按大小)
  • flush_on_crash(SIGSEGV 时自动刷盘)
  • rate limiter(相同日志 N 秒内只输出一次)
  • benchmark vs spdlog(性能对比表格)

两个库都在 GitHub 上,欢迎 star 或提 issue:

my_loggerhttps://github.com/ch0sen1pm/my_logger my_muduohttps://github.com/ch0sen1pm/my_muduo

相关推荐
吃着火锅x唱着歌1 小时前
Effective C++ 学习笔记 条款33 避免遮掩继承来的名称
c++·笔记·学习
库克克2 小时前
【C++】类和对象--类对象模型与大小计算
开发语言·jvm·c++
从零开始的代码生活_16 小时前
C++ 内存管理:从内存分区到 new/delete 底层原理
开发语言·c++·后端
aaPIXa62217 小时前
C++ 用 13 条规则让模型写出安全现代 C++
java·c++·安全
枕星而眠18 小时前
【数据结构】红黑树入门指南
运维·数据结构·c++·后端
2601_9498177219 小时前
C++指针与引用深度精讲:底层原理、差异对比与高阶实战陷阱
java·jvm·c++
烟锁池塘柳020 小时前
【C/C++】解决C++控制台输出中文乱码问题
c语言·开发语言·c++
zh路西法20 小时前
【10天速通Navigation2】(九):LQR最优控制器的原理推导与Nav2插件实现
c++·ros2·最优控制·lqr·navigation2
粘稠的浆糊21 小时前
[AtCoder - abc465_d ]X to Y题解
数据结构·c++·算法