做 Linux 下 C/C++ 开发,日志这件事几乎是从第一天就要面对的问题。无论是排查线上崩溃、分析接口耗时,还是追一条业务链路到底走到哪一步,都离不开最基础的那几行打印日志的代码。网上现成的日志库一抓一大把,功能也确实强大,但落到实际项目里,总有各种理由让你想自己动手实现一个打印日志的类:比如公司内部要求统一格式,比如日志需要加密落盘,再比如想把日志直接写到某个自研的采集通道里。这篇文章就来完整拆解我在 Linux 环境下实现一个日志类的全过程,从需求设计、核心代码实现,到常见坑位和性能优化思路都过一遍,给想自己动手或者正在折腾日志模块的同学一个可参考的版本。1. 先聊聊为什么非要自己写一个日志类很多人看到"日志"两个字会觉得简单:不就是 fprintf 加个时间戳吗?真要这么想,后面大概率会吃亏。我最早也是这么干的,某项目X的前身版本里,线上排查问题靠的是在关键路径上临时加 printf,输出到标准输出,再用重定向塞进文件。刚开始小流量没毛病,等到并发一上来,日志开始串行、丢失、甚至把服务拖慢,才意识到日志模块这件事,真不是"能打印就行"。自己实现一个日志类,核心诉求通常有三个:第一,格式可控。第三方日志库往往带着一套自己的格式约定,虽然多数支持定制,但改起来要读源码、看配置,成本不低。项目组如果想在日志行里混入 traceId、业务标识、线程名这些信息,自己写一个类,直接在格式化环节加字段就行,改动面上小、效果明确。第二,依赖可控。很多公司内部环境对第三方依赖卡得严,尤其是安全要求高的场景,直接拉一个开源日志库要过一堆审批。自己实现一个只有两个文件的小模块,代码自己审、自己维护,虽然不是零成本,但至少可控。第三,行为可控。日志这个模块在某些时候需要有一些"特殊行为",比如日志级别热切换、按小时切割文件、把 ERROR 级别的日志单独抽出来做监控报警。第三方库不一定全支持,自己写就灵活得多。当然,纯从零开发也不是没有代价。日志库要做得好,需要应对线程安全、性能衰减、文件滚动、崩溃兜底等一堆问题。本文覆盖的实现版本,是一个适合中小型服务、单机日志量在几十 GB 以内场景的方案。如果你要支撑的是每天几 TB 级别日志的超级大数据平台,直接用成熟的分布式日志方案更合适,不必重复造轮子。2. 日志模块的总体设计与核心决策动手写代码之前,先要把设计思路理清楚。日志类表面上看是一堆打印函数,背后实际上是"在哪写、写什么、怎么写、怎么存"四个决策。2.1 需求梳理:一个日志类到底要管哪些事我在设计这个日志类时,第一件事是列需求清单。参考的是过往项目里被问得最多、也最容易出问题的几条:支持分级打印,至少要区分 DEBUG / INFO / WARN / ERROR / FATAL,线上运行时不打印低级别日志。每行日志要带时间戳,精度要到毫秒甚至微秒,最好能显示到时区。多线程环境下,不同线程打印的日志不能互相穿插到一行里,单条日志本身要完整。输出目标既要支持控制台,也要支持文件,最好能同时输出。日志文件要能做滚动,不能一个文件写到把磁盘塞满。格式字符串要像 printf 一样灵活,同时不能因为日志打印影响业务逻辑性能太多。这些需求听起来常规,但每一条背后都有坑。比如时间戳,用 localtime 还是 gmtime?如果服务部署在不同时区的机器上,日志的时间到底以谁为准?再比如滚动,是跑到半夜 0 点切文件,还是文件涨到 100MB 再切?两种策略在不同团队里有不同偏好。我把这些需求整理成一张表,方便对照实现:需求点设计选择原因日志级别枚举 + 宏封装编译期就能过滤部分低级别日志时间戳localtime + 毫秒方便日常排查按本地时间对照线程安全互斥锁保护写操作保证单条日志完整,防止穿插输出目标FILE* 指向 stdout 或文件统一接口,切换灵活文件滚动按大小 + 按日期双重策略兼顾单文件可读性和总量控制格式化变长参数 + vsnprintf与 printf 风格对齐,零学习成本2.2 几个关键设计取舍先说日志级别。我见过不少日志库用int或者string表示级别,但这样做有隐患:级别一旦可以任意传值,代码里就容易出现 level = 100 这种魔法数字,过滤规则也被搞乱。用枚举类型加上强校验,编译期就能挡掉一部分错误。还有一层考虑是宏封装:LOG_INFO("user login, uid=%d", uid)这种写法,宏内部可以做到"级别不够时连参数求值都跳过",省掉不必要的函数调用开销。再说输出目标。很多初版日志库喜欢直接把fprintf写死在函数里,后面加文件输出就要改函数签名。我实现时选择统一用FILE*指针,初始化为stdout,需要写文件时改为fopen返回的文件指针。这样既能在终端训练时看到日志,也能在部署时切换到文件,代价只有一个指针赋值。最后是文件滚动。我做过两种策略都有的方案:按大小切分,文件超过设定阈值就重命名为带时间后缀的文件,并新开一个空文件继续写;按日期切分,每天 0 点检查一次并切换。两种策略并不互斥——既可以在单文件超过 100MB 时触发切割,也可以在日期变更时触发切割,先触发哪个就用哪个。这个设计在实战中比较省心。3. 核心实现:代码逐块拆解设计定了,剩下的就是把每一块代码填进去。我按模块顺序逐个拆解,每个部分背后都标注了为什么这么写。3.1 日志级别与宏定义设计日志级别首先定义一个枚举,并提供一个转字符串的函数:namespace xlog { enum Level { DEBUG = 0, INFO, WARN, ERROR, FATAL }; inline const char* levelToString(Level level) { switch (level) { case DEBUG: return "DEBUG"; case INFO: return "INFO"; case WARN: return "WARN"; case ERROR: return "ERROR"; case FATAL: return "FATAL"; default: return "UNKNOWN"; } } }枚举这里有个细节:顺序由低到高排列。这样在比较级别时可以直接用数值大小,比如level >= WARN就表示只输出警告及以上级别。实际开发中我还加了一个enum转字符串的辅助函数,不能用printf("%s", level)直接打印枚举值,必须转换。然后是日志宏。宏的作用是为了实现"级别不够时连函数都不调用"的编译期优化:#define LOG_DEBUG(fmt, ...) \ xlog::Logger::instance().log(xlog::DEBUG, __FILE__, __LINE__, fmt, ##__VA_ARGS__) #define LOG_INFO(fmt, ...) \ xlog::Logger::instance().log(xlog::INFO, __FILE__, __LINE__, fmt, ##__VA_ARGS__) #define LOG_WARN(fmt, ...) \ xlog::Logger::instance().log(xlog::WARN, __FILE__, __LINE__, fmt, ##__VA_ARGS__) #define LOG_ERROR(fmt, ...) \ xlog::Logger::instance().log(xlog::ERROR, __FILE__, __LINE__, fmt, ##__VA_ARGS__) #define LOG_FATAL(fmt, ...) \ xlog::Logger::instance().log(xlog::FATAL, __FILE__, __LINE__, fmt, ##__VA_ARGS__)##__VA_ARGS__是 GNU 扩展,作用是当参数列表为空时,把逗号一并吃掉,这样LOG_INFO("hello")这种调用不会编译报错。如果坚持标准 C++,可以用__VA_OPT__,但我在 Linux 环境下开发时基本都依赖 GCC 的扩展,所以直接这么写。3.2 时间戳与线程信息格式化时间戳是日志行最容易被忽视又最容易出错的部分。我在这版实现里同时打印本地时间和毫秒:void formatTimestamp(char* buf, size_t len) { struct timeval tv; gettimeofday(&tv, nullptr); struct tm tm_val; localtime_r(&tv.tv_sec, &tm_val); // 2025-01-19 14:23:45.678 snprintf(buf, len, "%04d-%02d-%02d %02d:%02d:%02d.%03d", tm_val.tm_year + 1900, tm_val.tm_mon + 1, tm_val.tm_mday, tm_val.tm_hour, tm_val.tm_min, tm_val.tm_sec, static_cast<int>(tv.tv_usec / 1000)); }这里有两个关键点。第一个是localtime_r而不是localtime。localtime返回的是静态内部缓冲区的指针,多线程下调用会互相覆盖。localtime_r接受外部传入的tm结构体,线程安全性有保障。同理,如果统一都用 GMT 时间,可以换成gmtime_r,但日常排查问题我反而更习惯本地时间。第二个是gettimeofday。它提供微秒精度,取毫秒时直接除以 1000。别用time(nullptr)只拿秒,因为日志一旦高频打印,同一秒内会出现大量行,区分度太低。另一点要注意:格式化时间戳本身也有开销,如果日志量极大,可以在日志对象内做时间缓存,同一秒内只格式化一次。这是异步日志里常用的优化手段。线程信息方面,我打印线程 ID。在 Linux 下用syscall(SYS_gettid)能拿到内核线程 ID,注意std::this_thread::get_id()拿到的其实是 C++ 标准库线程 ID 的一个封装类型,打印出来不直观,用作日志检索不方便。我自己实际做项目时更常用syscall(SYS_gettid),因为线上排查时可以用ps -T -p <pid>和内核线程 ID 对齐,定位到具体是哪个线程出了问题。3.3 核心 Logger 类的实现Logger 类本身采用单例模式。这里用 C++11 之后的函数局部静态变量方式实现,是线程安全的:#include <cstdio> #include <cstdarg> #include <cstring> #include <string> #include <mutex> #include <sys/time.h> #include <sys/syscall.h> #include <unistd.h> namespace xlog { class Logger { public: static Logger& instance() { static Logger inst; return inst; } void setLevel(Level level) { std::lock_guard<std::mutex> lock(mutex_); level_ = level; } void setOutput(FILE* fp) { std::lock_guard<std::mutex> lock(mutex_); if (fp && output_ && output_ != stdout && output_ != stderr) { fclose(output_); } output_ = fp; } void log(Level level, const char* file, int line, const char* fmt, ...) { if (level < level_) return; // 快速过滤,注意锁外读 level_ char buf[4096]; char ts[64]; formatTimestamp(ts, sizeof(ts)); long tid = syscall(SYS_gettid); int header_len = snprintf(buf, sizeof(buf), "[%s][%s][tid=%ld][%s:%d] ", ts, levelToString(level), tid, file, line); va_list args; va_start(args, fmt); // 留一个字节放 '\0' int body_len = vsnprintf(buf + header_len, sizeof(buf) - header_len - 1, fmt, args); va_end(args); if (body_len < 0) return; std::lock_guard<std::mutex> lock(mutex_); if (output_) { fwrite(buf, 1, header_len + body_len, output_); fflush(output_); if (level >= ERROR) { fflush(output_); } } } private: Logger() : level_(INFO), output_(stdout) {} ~Logger() { if (output_ && output_ != stdout && output_ != stderr) { fclose(output_); } } Logger(const Logger&) = delete; Logger& operator=(const Logger&) = delete; std::mutex mutex_; Level level_; FILE* output_; }; }这段代码有几个细节要单独讲。第一,快速过滤级别的逻辑。这里我直接读level_,理论上应该加锁,但现实中锁外读存在数据竞争。我选择在极早期加锁读一次,或者接受轻微的数据竞争,因为日志级别本身就是个低频变更的值。真较真的话可以在log开头就加锁,但那会让所有低于级别的日志调用都付出锁开销,不值得。第二,缓冲区大小。用固定 4096 字节的栈上数组,好处是不会触发堆分配,性能更稳定。如果某条日志特别长超出缓冲,vsnprintf会返回需要写入的长度,我这里直接丢弃超长部分。你如果业务里确实需要长日志,可以把 4096 调大,或者改用vasprintf动态分配,但要注意动态分配在频繁调用时有性能损耗。第三,fflush的问题。日志写入文件时,标准 IO 库默认是带缓冲的。不主动fflush,进程崩溃时缓冲区里的日志可能没落盘,排查崩溃问题就少了最关键的信息。但高频fflush又是性能杀手。我的做法是:普通级别不主动刷,依赖系统缓冲区积累到一定量再写盘;ERROR 及以上级别强制fflush,保证严重问题的日志第一时间落盘。这个取舍很实用。3.4 文件滚动与定期刷新文件滚动做起来不复杂,但代码边界要处理干净。核心逻辑是在log中检查当前写入的字节数是否超过阈值:void Logger::checkRolling() { if (!file_rolling_enabled_) return; if (current_size_ < max_size_ && current_day_ == today()) return; std::lock_guard<std::mutex> lock(mutex_); if (output_ && output_ != stdout && output_ != stderr) { fclose(output_); } std::string new_path = buildNewPath(); // 拼接带日期/序号的文件名 output_ = fopen(new_path.c_str(), "a"); current_size_ = 0; current_day_ = today(); }这里的坑是路径拼接。我见过因为sprintf拼接路径忘记判断长度,导致日志文件名截断的案例。我自己实现时直接用std::string拼接,并且在初始化时统一传入"日志目录 + 文件名前缀 + 保留文件个数"三个参数,保证代码可读性。定期刷新和日期滚动可以共用一个逻辑:每次写日志时比较current_day_和当天日期,跨天就触发一次文件切换。这样即使服务连续跑几个月,也不会出现一个文件无限膨胀的情况。4. 接入与使用方式4.1 初始化与配置接入这个日志类,我做成一个配置文件加环境变量的方式。配置文件里可以指定日志级别、输出文件路径、滚动阈值等,环境变量则用于临时覆盖,比如在测试环境手动调低级别:export XLOG_LEVEL=DEBUG export XLOG_FILE=/data/logs/myapp.log export XLOG_MAX_FILE_SIZE_MB=100代码里初始化时读这些变量,读不到就用默认值。这个设计虽然简单,但在部署上特别顺手,不需要重新编译就能调日志行为。4.2 使用示例写个简单的使用样例:#include "xlog.h" int main() { xlog::Logger::instance().setLevel(xlog::INFO); xlog::Logger::instance().setOutput(fopen("/data/logs/myapp.log", "a")); LOG_INFO("server started, port=%d", 8080); LOG_DEBUG("this debug log will not be printed"); for (int i = 0; i < 3; ++i) { LOG_INFO("processing request #%d", i); } LOG_ERROR("failed to connect, errno=%d", errno); return 0; }实测输出效果如下:2025-01-19 14:23:45.123 [INFO][tid=12345][main.cpp:10] server started, port=8080 2025-01-19 14:23:45.124 [INFO][tid=12345][main.cpp:14] processing request #0 2025-01-19 14:23:45.124 [INFO][tid=12345][main.cpp:14] processing request #1 2025-01-19 14:23:45.125 [INFO][tid=12345][main.cpp:14] processing request #2 2025-01-19 14:23:45.126 [ERROR][tid=12345][main.cpp:17] failed to connect, errno=111日志格式一眼扫过去,时间、级别、线程、文件行号全都有,排查问题时直接按格式写脚本提取关键信息也方便。5. 性能与进阶:缓冲、异步队列、日志限流基础实现完成后,我接着做了几件性能相关的事。这里单独拉一章讲,是因为如果你打算把这套东西用到真正的线上服务,性能这关是绕不过去的。5.1 为什么同步日志慢,它能怎么优化最基础的版本,每次打日志都要syscall(SYS_gettid)获取线程 ID,加锁,可能还要fflush,高频调用时互斥锁的竞争会迅速拉高延迟。我实测过,单一线程每秒打印一万条简单日志,上面的版本能撑住,但一旦多线程并发,互斥锁会成为明显瓶颈。优化方向有两个:第一个是在日志行拼接阶段不持有锁。先把消息格式化到一个线程局部存储的缓冲区里,最后一次性写入文件句柄,锁只保护最后那一次fwrite。这样可以显著减小临界区。第二个是提供logNoLock内部接口,在日志调用已经处于临界区的场景复用缓冲区,避免重复加锁。日志模块过度加锁导致业务卡顿的案例我见过不止一次,这个细节值得留意。5.2 异步日志思路想要彻底解决写日志阻塞业务的问题,标准做法是异步日志。基本思路是:业务线程只负责把日志消息丢到一个有界内存队列里,后台一个专门线程从队列取出消息并批量写入文件。这样即使磁盘出现抖动,业务线程也不会被日志拖住。实现时可以选择双缓冲:前端线程写入 A 缓冲区,后端线程写入 B 缓冲区,两者交替使用,写文件时一次性 flush 整个缓冲区。这个方案能大幅减少磁盘 IO 的系统调用次数。不过异步日志也有代价:进程崩溃时,积压在内存队列里的日志全部丢失。所以一般会在正常退出时把队列排空,再考虑是否在 FATAL 级别下强制同步写一份,保证最严重的问题不会丢日志。5.3 日志限流与安全防护日志模块做时间久了,我发现一个容易被忽略的问题:高频路径上不加限流,日志会反过来打爆系统。比如某个接口依赖的下游服务超时,错误日志每秒刷成千上万条,不仅占磁盘,还会让监控告警变成摆设。限流最简单的方式是"日志频率限制"。在 Logger 里加一个哈希表,记录每个文件函数位置最近一次输出日志的时间,两次间隔小于固定值就跳过。这样能保证重复错误最多每隔几秒打印一条,但关键信息又不会彻底漏掉。另外要提一个安全相关的内容:日志注入。如果日志内容来自用户输入,比如用户昵称、请求参数,直接把未经转义的字符串拼进日志行,攻击者可以构造包含换行符的字段,把多余的日志行或者假 ERROR 日志注入到日志系统里。不仅干扰排查,还可能用来伪造审计记录。简单的防护办法是转义控制字符,把\n、\r等替换成可见序列。我的实现里默认有一个sanitize开关,对写入日志的正文做一趟字符扫描。这个开关有点性能开销,但大多数场景值得开。6. 实战中踩过的坑与排查实录日志模块看着简单,真正部署到线上,总会碰到一些"原理上不该出问题但就是出了问题"的情况。这里记录几个我亲身踩过的坑。6.1 时间戳错乱问题有段时间线上服务的日志时间比真实时间快了 8 小时。排查一遍才发现,某台机器上部署的版本用了gmtime,另外几台用localtime。不同环境的系统时区设置不一样,日志就出现了两种时间基准。修复方式是统一强制使用某个时区,并且在日志模块里记录时区偏移量。从那以后我极度厌恶在代码里"随系统"的时间处理逻辑,统一写死格式、写死时区基准,才是日志系统的正确打开方式。6.2 日志文件句柄耗尽滚动逻辑里有句fclose旧文件再fopen新文件的代码,如果fopen失败,句柄会泄漏,长时间运行后进程文件描述符暴涨,最终EMFILE。我在滚动代码里加了严格的错误处理:fopen失败时保留旧句柄不关闭,并输出一条告警。这个 bug 挺隐蔽的,但排查起来其实也不难,用一个lsof -p <pid> | grep myapp.log就能看到大量没有被关闭的句柄。6.3 日志内容被截断或乱码字符串日志常常有人写printf("%s", str),但str并不是空字符结尾的,导致日志末尾跟出一堆随机字节。另一个常见问题是写入二进制内容时,内容里包含\0,fwrite虽然按长度写入,但看日志文件时内容就乱了。我的建议是:日志模块只接受 C 风格的可打印字符串;业务如果真的需要打二进制,要么 hex 编码,要么 base64,总之别直接往日志里扔二进制。6.4 高并发下日志丢失或重复用std::mutex保护写入,正常情况下单条日志不会穿插。但有一种特殊场景:线程在log调用中被信号中断,信号处理器里又调用了日志接口,死锁就发生了。这属于重入问题,我踩过之后在日志模块里加了线程局部标志位:如果在当前线程已经持有锁的状态下再次进入日志接口,直接丢弃这次的日志内容,避免死锁。7. 最后分享一点使用心得写日志类这两年,我最大的体会是:日志模块的代码量不大,但它是所有排障工作的地基。地基不稳,上层查问题就像在黑屋子里找东西。基础版本的日志类一定够用,但别急着停止迭代——你可以在它上面慢慢加异步、加滚动、加限流、加安全管理,每加一块都会让这个模块更接近生产级的形态。我个人在后续项目里又基于这个类扩展了 JSON 格式输出和日志采集接口对接,整体工作量并不大,收益却很直接。如果你也在维护自己的日志模块,建议把当前这一版先跑起来,再按业务需要慢慢加固。有一点是确定的:自己动手把日志类从头实现一遍之后,再看各种开源日志库的设计,理解深度完全不一样了。
做 Linux 下 C/C++ 开发,日志这件事几乎是从第一天就要面对的问题。无论是排查线上崩溃、分析接口耗时,还是追一条业务链路到底走到哪一步,都离不开最基础的那几行打印日志的代码。网上现成的日志库一抓一大把,功能也确实强大,但落到实际项目里,总有各种理由让你想自己动手实现一个打印日志的类:比如公司内部要求统一格式,比如日志需要加密落盘,再比如想把日志直接写到某个自研的采集通道里。这篇文章就来完整拆解我在 Linux 环境下实现一个日志类的全过程,从需求设计、核心代码实现,到常见坑位和性能优化思路都过一遍,给想自己动手或者正在折腾日志模块的同学一个可参考的版本。1. 先聊聊为什么非要自己写一个日志类很多人看到"日志"两个字会觉得简单:不就是 fprintf 加个时间戳吗?真要这么想,后面大概率会吃亏。我最早也是这么干的,某项目X的前身版本里,线上排查问题靠的是在关键路径上临时加 printf,输出到标准输出,再用重定向塞进文件。刚开始小流量没毛病,等到并发一上来,日志开始串行、丢失、甚至把服务拖慢,才意识到日志模块这件事,真不是"能打印就行"。自己实现一个日志类,核心诉求通常有三个:第一,格式可控。第三方日志库往往带着一套自己的格式约定,虽然多数支持定制,但改起来要读源码、看配置,成本不低。项目组如果想在日志行里混入 traceId、业务标识、线程名这些信息,自己写一个类,直接在格式化环节加字段就行,改动面上小、效果明确。第二,依赖可控。很多公司内部环境对第三方依赖卡得严,尤其是安全要求高的场景,直接拉一个开源日志库要过一堆审批。自己实现一个只有两个文件的小模块,代码自己审、自己维护,虽然不是零成本,但至少可控。第三,行为可控。日志这个模块在某些时候需要有一些"特殊行为",比如日志级别热切换、按小时切割文件、把 ERROR 级别的日志单独抽出来做监控报警。第三方库不一定全支持,自己写就灵活得多。当然,纯从零开发也不是没有代价。日志库要做得好,需要应对线程安全、性能衰减、文件滚动、崩溃兜底等一堆问题。本文覆盖的实现版本,是一个适合中小型服务、单机日志量在几十 GB 以内场景的方案。如果你要支撑的是每天几 TB 级别日志的超级大数据平台,直接用成熟的分布式日志方案更合适,不必重复造轮子。2. 日志模块的总体设计与核心决策动手写代码之前,先要把设计思路理清楚。日志类表面上看是一堆打印函数,背后实际上是"在哪写、写什么、怎么写、怎么存"四个决策。2.1 需求梳理:一个日志类到底要管哪些事我在设计这个日志类时,第一件事是列需求清单。参考的是过往项目里被问得最多、也最容易出问题的几条:支持分级打印,至少要区分 DEBUG / INFO / WARN / ERROR / FATAL,线上运行时不打印低级别日志。每行日志要带时间戳,精度要到毫秒甚至微秒,最好能显示到时区。多线程环境下,不同线程打印的日志不能互相穿插到一行里,单条日志本身要完整。输出目标既要支持控制台,也要支持文件,最好能同时输出。日志文件要能做滚动,不能一个文件写到把磁盘塞满。格式字符串要像 printf 一样灵活,同时不能因为日志打印影响业务逻辑性能太多。这些需求听起来常规,但每一条背后都有坑。比如时间戳,用 localtime 还是 gmtime?如果服务部署在不同时区的机器上,日志的时间到底以谁为准?再比如滚动,是跑到半夜 0 点切文件,还是文件涨到 100MB 再切?两种策略在不同团队里有不同偏好。我把这些需求整理成一张表,方便对照实现:需求点设计选择原因日志级别枚举 + 宏封装编译期就能过滤部分低级别日志时间戳localtime + 毫秒方便日常排查按本地时间对照线程安全互斥锁保护写操作保证单条日志完整,防止穿插输出目标FILE* 指向 stdout 或文件统一接口,切换灵活文件滚动按大小 + 按日期双重策略兼顾单文件可读性和总量控制格式化变长参数 + vsnprintf与 printf 风格对齐,零学习成本2.2 几个关键设计取舍先说日志级别。我见过不少日志库用int或者string表示级别,但这样做有隐患:级别一旦可以任意传值,代码里就容易出现 level = 100 这种魔法数字,过滤规则也被搞乱。用枚举类型加上强校验,编译期就能挡掉一部分错误。还有一层考虑是宏封装:LOG_INFO("user login, uid=%d", uid)这种写法,宏内部可以做到"级别不够时连参数求值都跳过",省掉不必要的函数调用开销。再说输出目标。很多初版日志库喜欢直接把fprintf写死在函数里,后面加文件输出就要改函数签名。我实现时选择统一用FILE*指针,初始化为stdout,需要写文件时改为fopen返回的文件指针。这样既能在终端训练时看到日志,也能在部署时切换到文件,代价只有一个指针赋值。最后是文件滚动。我做过两种策略都有的方案:按大小切分,文件超过设定阈值就重命名为带时间后缀的文件,并新开一个空文件继续写;按日期切分,每天 0 点检查一次并切换。两种策略并不互斥——既可以在单文件超过 100MB 时触发切割,也可以在日期变更时触发切割,先触发哪个就用哪个。这个设计在实战中比较省心。3. 核心实现:代码逐块拆解设计定了,剩下的就是把每一块代码填进去。我按模块顺序逐个拆解,每个部分背后都标注了为什么这么写。3.1 日志级别与宏定义设计日志级别首先定义一个枚举,并提供一个转字符串的函数:namespace xlog { enum Level { DEBUG = 0, INFO, WARN, ERROR, FATAL }; inline const char* levelToString(Level level) { switch (level) { case DEBUG: return "DEBUG"; case INFO: return "INFO"; case WARN: return "WARN"; case ERROR: return "ERROR"; case FATAL: return "FATAL"; default: return "UNKNOWN"; } } }枚举这里有个细节:顺序由低到高排列。这样在比较级别时可以直接用数值大小,比如level >= WARN就表示只输出警告及以上级别。实际开发中我还加了一个enum转字符串的辅助函数,不能用printf("%s", level)直接打印枚举值,必须转换。然后是日志宏。宏的作用是为了实现"级别不够时连函数都不调用"的编译期优化:#define LOG_DEBUG(fmt, ...) \ xlog::Logger::instance().log(xlog::DEBUG, __FILE__, __LINE__, fmt, ##__VA_ARGS__) #define LOG_INFO(fmt, ...) \ xlog::Logger::instance().log(xlog::INFO, __FILE__, __LINE__, fmt, ##__VA_ARGS__) #define LOG_WARN(fmt, ...) \ xlog::Logger::instance().log(xlog::WARN, __FILE__, __LINE__, fmt, ##__VA_ARGS__) #define LOG_ERROR(fmt, ...) \ xlog::Logger::instance().log(xlog::ERROR, __FILE__, __LINE__, fmt, ##__VA_ARGS__) #define LOG_FATAL(fmt, ...) \ xlog::Logger::instance().log(xlog::FATAL, __FILE__, __LINE__, fmt, ##__VA_ARGS__)##__VA_ARGS__是 GNU 扩展,作用是当参数列表为空时,把逗号一并吃掉,这样LOG_INFO("hello")这种调用不会编译报错。如果坚持标准 C++,可以用__VA_OPT__,但我在 Linux 环境下开发时基本都依赖 GCC 的扩展,所以直接这么写。3.2 时间戳与线程信息格式化时间戳是日志行最容易被忽视又最容易出错的部分。我在这版实现里同时打印本地时间和毫秒:void formatTimestamp(char* buf, size_t len) { struct timeval tv; gettimeofday(&tv, nullptr); struct tm tm_val; localtime_r(&tv.tv_sec, &tm_val); // 2025-01-19 14:23:45.678 snprintf(buf, len, "%04d-%02d-%02d %02d:%02d:%02d.%03d", tm_val.tm_year + 1900, tm_val.tm_mon + 1, tm_val.tm_mday, tm_val.tm_hour, tm_val.tm_min, tm_val.tm_sec, static_cast<int>(tv.tv_usec / 1000)); }这里有两个关键点。第一个是localtime_r而不是localtime。localtime返回的是静态内部缓冲区的指针,多线程下调用会互相覆盖。localtime_r接受外部传入的tm结构体,线程安全性有保障。同理,如果统一都用 GMT 时间,可以换成gmtime_r,但日常排查问题我反而更习惯本地时间。第二个是gettimeofday。它提供微秒精度,取毫秒时直接除以 1000。别用time(nullptr)只拿秒,因为日志一旦高频打印,同一秒内会出现大量行,区分度太低。另一点要注意:格式化时间戳本身也有开销,如果日志量极大,可以在日志对象内做时间缓存,同一秒内只格式化一次。这是异步日志里常用的优化手段。线程信息方面,我打印线程 ID。在 Linux 下用syscall(SYS_gettid)能拿到内核线程 ID,注意std::this_thread::get_id()拿到的其实是 C++ 标准库线程 ID 的一个封装类型,打印出来不直观,用作日志检索不方便。我自己实际做项目时更常用syscall(SYS_gettid),因为线上排查时可以用ps -T -p <pid>和内核线程 ID 对齐,定位到具体是哪个线程出了问题。3.3 核心 Logger 类的实现Logger 类本身采用单例模式。这里用 C++11 之后的函数局部静态变量方式实现,是线程安全的:#include <cstdio> #include <cstdarg> #include <cstring> #include <string> #include <mutex> #include <sys/time.h> #include <sys/syscall.h> #include <unistd.h> namespace xlog { class Logger { public: static Logger& instance() { static Logger inst; return inst; } void setLevel(Level level) { std::lock_guard<std::mutex> lock(mutex_); level_ = level; } void setOutput(FILE* fp) { std::lock_guard<std::mutex> lock(mutex_); if (fp && output_ && output_ != stdout && output_ != stderr) { fclose(output_); } output_ = fp; } void log(Level level, const char* file, int line, const char* fmt, ...) { if (level < level_) return; // 快速过滤,注意锁外读 level_ char buf[4096]; char ts[64]; formatTimestamp(ts, sizeof(ts)); long tid = syscall(SYS_gettid); int header_len = snprintf(buf, sizeof(buf), "[%s][%s][tid=%ld][%s:%d] ", ts, levelToString(level), tid, file, line); va_list args; va_start(args, fmt); // 留一个字节放 '\0' int body_len = vsnprintf(buf + header_len, sizeof(buf) - header_len - 1, fmt, args); va_end(args); if (body_len < 0) return; std::lock_guard<std::mutex> lock(mutex_); if (output_) { fwrite(buf, 1, header_len + body_len, output_); fflush(output_); if (level >= ERROR) { fflush(output_); } } } private: Logger() : level_(INFO), output_(stdout) {} ~Logger() { if (output_ && output_ != stdout && output_ != stderr) { fclose(output_); } } Logger(const Logger&) = delete; Logger& operator=(const Logger&) = delete; std::mutex mutex_; Level level_; FILE* output_; }; }这段代码有几个细节要单独讲。第一,快速过滤级别的逻辑。这里我直接读level_,理论上应该加锁,但现实中锁外读存在数据竞争。我选择在极早期加锁读一次,或者接受轻微的数据竞争,因为日志级别本身就是个低频变更的值。真较真的话可以在log开头就加锁,但那会让所有低于级别的日志调用都付出锁开销,不值得。第二,缓冲区大小。用固定 4096 字节的栈上数组,好处是不会触发堆分配,性能更稳定。如果某条日志特别长超出缓冲,vsnprintf会返回需要写入的长度,我这里直接丢弃超长部分。你如果业务里确实需要长日志,可以把 4096 调大,或者改用vasprintf动态分配,但要注意动态分配在频繁调用时有性能损耗。第三,fflush的问题。日志写入文件时,标准 IO 库默认是带缓冲的。不主动fflush,进程崩溃时缓冲区里的日志可能没落盘,排查崩溃问题就少了最关键的信息。但高频fflush又是性能杀手。我的做法是:普通级别不主动刷,依赖系统缓冲区积累到一定量再写盘;ERROR 及以上级别强制fflush,保证严重问题的日志第一时间落盘。这个取舍很实用。3.4 文件滚动与定期刷新文件滚动做起来不复杂,但代码边界要处理干净。核心逻辑是在log中检查当前写入的字节数是否超过阈值:void Logger::checkRolling() { if (!file_rolling_enabled_) return; if (current_size_ < max_size_ && current_day_ == today()) return; std::lock_guard<std::mutex> lock(mutex_); if (output_ && output_ != stdout && output_ != stderr) { fclose(output_); } std::string new_path = buildNewPath(); // 拼接带日期/序号的文件名 output_ = fopen(new_path.c_str(), "a"); current_size_ = 0; current_day_ = today(); }这里的坑是路径拼接。我见过因为sprintf拼接路径忘记判断长度,导致日志文件名截断的案例。我自己实现时直接用std::string拼接,并且在初始化时统一传入"日志目录 + 文件名前缀 + 保留文件个数"三个参数,保证代码可读性。定期刷新和日期滚动可以共用一个逻辑:每次写日志时比较current_day_和当天日期,跨天就触发一次文件切换。这样即使服务连续跑几个月,也不会出现一个文件无限膨胀的情况。4. 接入与使用方式4.1 初始化与配置接入这个日志类,我做成一个配置文件加环境变量的方式。配置文件里可以指定日志级别、输出文件路径、滚动阈值等,环境变量则用于临时覆盖,比如在测试环境手动调低级别:export XLOG_LEVEL=DEBUG export XLOG_FILE=/data/logs/myapp.log export XLOG_MAX_FILE_SIZE_MB=100代码里初始化时读这些变量,读不到就用默认值。这个设计虽然简单,但在部署上特别顺手,不需要重新编译就能调日志行为。4.2 使用示例写个简单的使用样例:#include "xlog.h" int main() { xlog::Logger::instance().setLevel(xlog::INFO); xlog::Logger::instance().setOutput(fopen("/data/logs/myapp.log", "a")); LOG_INFO("server started, port=%d", 8080); LOG_DEBUG("this debug log will not be printed"); for (int i = 0; i < 3; ++i) { LOG_INFO("processing request #%d", i); } LOG_ERROR("failed to connect, errno=%d", errno); return 0; }实测输出效果如下:2025-01-19 14:23:45.123 [INFO][tid=12345][main.cpp:10] server started, port=8080 2025-01-19 14:23:45.124 [INFO][tid=12345][main.cpp:14] processing request #0 2025-01-19 14:23:45.124 [INFO][tid=12345][main.cpp:14] processing request #1 2025-01-19 14:23:45.125 [INFO][tid=12345][main.cpp:14] processing request #2 2025-01-19 14:23:45.126 [ERROR][tid=12345][main.cpp:17] failed to connect, errno=111日志格式一眼扫过去,时间、级别、线程、文件行号全都有,排查问题时直接按格式写脚本提取关键信息也方便。5. 性能与进阶:缓冲、异步队列、日志限流基础实现完成后,我接着做了几件性能相关的事。这里单独拉一章讲,是因为如果你打算把这套东西用到真正的线上服务,性能这关是绕不过去的。5.1 为什么同步日志慢,它能怎么优化最基础的版本,每次打日志都要syscall(SYS_gettid)获取线程 ID,加锁,可能还要fflush,高频调用时互斥锁的竞争会迅速拉高延迟。我实测过,单一线程每秒打印一万条简单日志,上面的版本能撑住,但一旦多线程并发,互斥锁会成为明显瓶颈。优化方向有两个:第一个是在日志行拼接阶段不持有锁。先把消息格式化到一个线程局部存储的缓冲区里,最后一次性写入文件句柄,锁只保护最后那一次fwrite。这样可以显著减小临界区。第二个是提供logNoLock内部接口,在日志调用已经处于临界区的场景复用缓冲区,避免重复加锁。日志模块过度加锁导致业务卡顿的案例我见过不止一次,这个细节值得留意。5.2 异步日志思路想要彻底解决写日志阻塞业务的问题,标准做法是异步日志。基本思路是:业务线程只负责把日志消息丢到一个有界内存队列里,后台一个专门线程从队列取出消息并批量写入文件。这样即使磁盘出现抖动,业务线程也不会被日志拖住。实现时可以选择双缓冲:前端线程写入 A 缓冲区,后端线程写入 B 缓冲区,两者交替使用,写文件时一次性 flush 整个缓冲区。这个方案能大幅减少磁盘 IO 的系统调用次数。不过异步日志也有代价:进程崩溃时,积压在内存队列里的日志全部丢失。所以一般会在正常退出时把队列排空,再考虑是否在 FATAL 级别下强制同步写一份,保证最严重的问题不会丢日志。5.3 日志限流与安全防护日志模块做时间久了,我发现一个容易被忽略的问题:高频路径上不加限流,日志会反过来打爆系统。比如某个接口依赖的下游服务超时,错误日志每秒刷成千上万条,不仅占磁盘,还会让监控告警变成摆设。限流最简单的方式是"日志频率限制"。在 Logger 里加一个哈希表,记录每个文件函数位置最近一次输出日志的时间,两次间隔小于固定值就跳过。这样能保证重复错误最多每隔几秒打印一条,但关键信息又不会彻底漏掉。另外要提一个安全相关的内容:日志注入。如果日志内容来自用户输入,比如用户昵称、请求参数,直接把未经转义的字符串拼进日志行,攻击者可以构造包含换行符的字段,把多余的日志行或者假 ERROR 日志注入到日志系统里。不仅干扰排查,还可能用来伪造审计记录。简单的防护办法是转义控制字符,把\n、\r等替换成可见序列。我的实现里默认有一个sanitize开关,对写入日志的正文做一趟字符扫描。这个开关有点性能开销,但大多数场景值得开。6. 实战中踩过的坑与排查实录日志模块看着简单,真正部署到线上,总会碰到一些"原理上不该出问题但就是出了问题"的情况。这里记录几个我亲身踩过的坑。6.1 时间戳错乱问题有段时间线上服务的日志时间比真实时间快了 8 小时。排查一遍才发现,某台机器上部署的版本用了gmtime,另外几台用localtime。不同环境的系统时区设置不一样,日志就出现了两种时间基准。修复方式是统一强制使用某个时区,并且在日志模块里记录时区偏移量。从那以后我极度厌恶在代码里"随系统"的时间处理逻辑,统一写死格式、写死时区基准,才是日志系统的正确打开方式。6.2 日志文件句柄耗尽滚动逻辑里有句fclose旧文件再fopen新文件的代码,如果fopen失败,句柄会泄漏,长时间运行后进程文件描述符暴涨,最终EMFILE。我在滚动代码里加了严格的错误处理:fopen失败时保留旧句柄不关闭,并输出一条告警。这个 bug 挺隐蔽的,但排查起来其实也不难,用一个lsof -p <pid> | grep myapp.log就能看到大量没有被关闭的句柄。6.3 日志内容被截断或乱码字符串日志常常有人写printf("%s", str),但str并不是空字符结尾的,导致日志末尾跟出一堆随机字节。另一个常见问题是写入二进制内容时,内容里包含\0,fwrite虽然按长度写入,但看日志文件时内容就乱了。我的建议是:日志模块只接受 C 风格的可打印字符串;业务如果真的需要打二进制,要么 hex 编码,要么 base64,总之别直接往日志里扔二进制。6.4 高并发下日志丢失或重复用std::mutex保护写入,正常情况下单条日志不会穿插。但有一种特殊场景:线程在log调用中被信号中断,信号处理器里又调用了日志接口,死锁就发生了。这属于重入问题,我踩过之后在日志模块里加了线程局部标志位:如果在当前线程已经持有锁的状态下再次进入日志接口,直接丢弃这次的日志内容,避免死锁。7. 最后分享一点使用心得写日志类这两年,我最大的体会是:日志模块的代码量不大,但它是所有排障工作的地基。地基不稳,上层查问题就像在黑屋子里找东西。基础版本的日志类一定够用,但别急着停止迭代——你可以在它上面慢慢加异步、加滚动、加限流、加安全管理,每加一块都会让这个模块更接近生产级的形态。我个人在后续项目里又基于这个类扩展了 JSON 格式输出和日志采集接口对接,整体工作量并不大,收益却很直接。如果你也在维护自己的日志模块,建议把当前这一版先跑起来,再按业务需要慢慢加固。有一点是确定的:自己动手把日志类从头实现一遍之后,再看各种开源日志库的设计,理解深度完全不一样了。