先说结论在高并发后端项目里最常被低估的性能杀手往往是日志。我在某个 RPC 网关项目里接过一次优化任务业务峰值每秒要处理几十万次请求日志模块单独占了 30% 的 CPU磁盘 IO 也被打到接近饱和。当时第一反应是换一个成熟的开源日志库结果评估下来各有各的坑要么依赖一堆第三方库要么想改格式和落盘策略时得动大量内部代码要么在极限负载下本身就成了新的瓶颈。于是我自己实现了一个 C 高性能日志库把同步写改成异步批量写把流式格式化换成接近零分配的定制格式化最终把日志链路的 CPU 占用降到原来的十分之一单机每秒能稳定写入百万条量级。这篇文章就把当时的设计取舍、核心实现、压测数据以及踩过的坑完整整理出来供同样被日志性能困扰的开发者参考。1. 需求拆解高性能日志库到底要解决什么1.1 为什么值得自研现成方案的三个痛点先说结论如果你只是写一个内部小工具日志量每天几十万条直接用现成框架一点问题都没有。但一旦进入服务端高并发场景日志库的选择就变成了一种架构决策。我当时梳理了现成方案绕不开的三个痛点。第一是依赖和定制成本高。很多成熟日志库为了支持多种输出、动态配置、网络传输内部结构非常复杂改一个格式或者换一种滚动策略需要理解它的一整套插件机制。有些库还引用了大量的头文件模板编译时间感人部署到嵌入式或资源受限的容器里也很痛苦。第二是同步 IO 模型占住了业务线程。旧系统的日志接口在每条日志上做了文件写入和刷新高峰期一个业务线程打一条日志就要在系统调用上等一次等于把日志拖累放大到所有业务线程上。更糟的是多线程同时写同一个文件时锁竞争极度激烈日志越多系统越慢形成恶性循环。第三是格式化开销被严重低估。iostream 的流式写法用起来爽但内部涉及本地化、虚函数调用、临时对象构造一次operator可能触发多次堆分配。用snprintf虽然快一些但在每条日志都要格式化日期、线程 ID、进程 ID、等级这些公共字段时重复调用的累计开销也非常可观。所以我决定自研目标非常明确日志调用端必须极快写文件交给独立线程批量处理公共字段尽量减少重复计算内存尽量复用。这个定位决定了后面所有设计。1.2 性能基线把“高性能”翻译成可量化指标做性能优化最忌讳“感觉很快到底有多快”这种模糊描述。我一开始就把目标定成可量化的指标后续每次改动都用同一套压测脚本对比不拍脑袋。性能指标我定了四个维度吞吐量、延迟、CPU 占用、峰值内存。吞吐量主要看每秒能写入多少条日志。以单条约 100 字节的文本日志为基准初始目标定在单机百万条/秒以上。这个数字听起来吓人其实换算成带宽也就 100MB/s 左右顺序写文件完全能达到真正难的是让各个线程的调用端不互相拖后腿。延迟我看两个值业务线程从调用日志接口到函数返回的时间以及日志从生成到真正落盘并可以通过cat命令读到的延迟。前者要求调用端不做磁盘 IO后者要求后端线程尽量及时刷新。实测下来调用端 P50 控制在 100 纳秒级P99 控制在 1 微秒级落盘延迟则由刷新周期决定一般在 200ms 以内可以接受。CPU 占用目标很直接在 8 个业务线程持续打日志的压测场景下日志库整体占用不超过 1.5 个核。如果超过这个数日志优化就没有意义因为省下来的 CPU 还不如业务直接跑更快。峰值内存我限制在 64MB 以内。很多日志库为了追求性能会无限分配缓冲区峰值内存冲到几百 MB在容器环境里很容易触发 OOM。我要求缓冲池必须有大小的硬上限超过上限进入背压逻辑宁可丢弃也不把进程拖死。2. 整体架构设计异步双缓冲为核心2.1 同步写日志为什么不可行很多人刚接触日志优化时都会问为什么一定要异步同步写直接在业务线程里fprintf不行吗我用一个类比说明做菜的时候厨师每炒完一道菜就亲自跑一趟后厨洗碗那整个出菜节奏就全乱套了。同步写日志就是这个状态业务线程每打一条日志就要亲自承担时间格式化、系统调用、等待磁盘写入这些开销等于把脏活累活全压在了最宝贵的业务链路上。实测数据更直接。我在压测环境里用同步方式写日志每个线程只管往同一个文件里逐行写入线程数一多性能立刻崩。当 4 个线程同时写一个文件时每秒只能写入几万条大量时间耗费在锁等待和系统调用上。而且这还只是 fwrite 写进 libc 缓冲层的表现如果在每条日志后面都调用fflush性能会再掉一个数量级。所以自研版本一开始就定死了方向业务线程只做两件事拼装日志内容和把内容交给缓冲区。磁盘写入全部交给后台的独立日志线程业务线程永不直接触碰文件描述符。2.2 双缓冲机制一次批量交换的智慧日志库里最常见的异步实现是“生产者消费者 队列”。生产者把日志丢进一个无锁队列消费者线程从队列里取出来批量写盘。这种方案能工作但队列方案在小对象频繁入队出队时会有不小的内存分配和指针搬运开销。我最后采用的是双缓冲机制思路很朴素准备两个大缓冲区一个叫当前缓冲一个叫备用缓冲。前端业务线程只往当前缓冲里追加数据当前缓冲写满后就整体交换出去由后端线程把整块缓冲一次性写盘而前端立刻用备用缓冲继续写。双缓冲真正的优势在于它把“每条日志都要做的同步操作”变成了“一个缓冲装满才做一次交换操作”。假设缓冲大小 4MB、日志平均 100 字节意味着积累 4 万条日志才需要一次锁操作和一次交换锁竞争频率降到极低。用伪代码表示核心逻辑大概是这样的class AsyncLogger { public: void Append(const char* msg, size_t len) { std::lock_guardstd::mutex lock(mutex_); if (current_-avail() len) { current_-append(msg, len); } else { buffers_.push_back(std::move(current_)); if (next_) { current_ std::move(next_); next_ nullptr; } else { current_ std::make_uniqueLogBuffer(); } current_-append(msg, len); cv_.notify_one(); } } private: std::unique_ptrLogBuffer current_; std::unique_ptrLogBuffer next_; std::vectorstd::unique_ptrLogBuffer buffers_; std::mutex mutex_; std::condition_variable cv_; std::thread thread_; };这里我没有直接用环形队列而是用一个buffers_向量保存待写的缓冲块。好处是后端线程一次可以从buffers_里搬走多块缓冲减少唤醒次数坏处是需要保证buffers_的积压量有上限否则内存会不断膨胀这一点后面专门讲。2.3 线程模型与数据装配流程整个日志库只有两类线程角色业务线程负责调用日志接口充当生产者另一个常驻后台线程负责消费缓冲并写盘充当消费者。调度策略不依赖定时器唤醒而是两个条件当前缓冲写满时通知一次加上一个 200ms 的周期刷新定时器。周期刷新是为了防止低流量时日志长时间憋在内存里半天看不到输出。数据装配的完整流程是业务线程拿到一条日志先取当前线程的线程 ID、时间、日志等级这些公共字段然后拼上用户传入的消息体写进当前缓冲。公共字段的生成是有讲究的不是每条日志都重算而是以毫秒为单位缓存时间串同一毫秒内只更新时间戳中的毫秒部分。后台线程被唤醒后第一步把buffers_里的所有待写缓冲一次性移到自己的局部列表里第二步按顺序调用文件接口批量写入写完后把缓冲块清空归还给缓冲池而不是直接销毁。整个过程中前端线程绝不会等待磁盘 IO后端线程也绝不参与业务逻辑。这个模型单独跑的时候很简单但放到多线程高并发环境里缓冲区的并发安全和内存池设计就成了新的关键点。3. 核心实现细节与关键代码3.1 格式化层绕开流式 IO 的隐藏开销如果直接把日志消息传入operator链性能一定上不去。iostream 图层层打包除了构造临时对象外格式化数字还走默认的 locale多线程下很可能触发内部锁。我用一个非常关键的手段绕开了这条路自定义一个极简的格式化接口只支持日志模块需要的有限类型。整数格式化是最高频的操作时间戳、线程 ID、进程 ID、序列号全是整数。C17 提供了std::to_chars这是目前整数到字符串最快的方式但我的编译环境里还有部分老模块所以我写了一个等价的手工版本原理就是把数字对 10 取模逐个从尾部往头部落到临时数组里再整体拷到目标缓冲区。代码量不大但效率很高char* AppendInt(char* dst, int64_t v) { char tmp[20]; int len 0; uint64_t n v 0 ? static_castuint64_t(-(v 1)) 1 : static_castuint64_t(v); do { tmp[len] static_castchar(0 n % 10); n / 10; } while (n); if (v 0) tmp[len] -; while (len 0) *dst tmp[--len]; return dst; }浮点数和字符串格式同样有讲究。浮点我优先用std::to_chars的普通模式不追求精度稳定而是尽量少分配字符串则直接做 memcpy。这个格式化层设计好后我再也没有使用过ostringstream或者snprintf做日志拼接公共字段全部由日志库内部直接生成。3.2 缓冲写入从加锁竞争到原子位移如果一个日志库只被单线程调用那很简单写指针一直往上加就行。但真实场景往往是多个业务线程同时打日志这时候有两种设计思路。第一种是每个线程独立一个缓冲写完以后再合并到全局队列。好处是完全没有锁竞争坏处是会产生大量内存碎片而且日志顺序会乱达不到“按时间顺序排列”的需求。第二种是守护一个共享缓冲用原子操作来分配写入位置。具体做法是维护一个原子变量used_每条日志进来时执行pos used_.fetch_add(len)拿到一个唯一的写入起点再把消息内容拷贝到data_ pos这段区间。这个方案在多线程环境下基本表现不错唯一要小心的是缓冲区剩余空间不足时要用一个 fallback 分支去触发交换否则可能出现越过缓冲边界的问题。我最终采用的是第二种方案的改良版即将写满时先判断剩余空间是否足够容纳一条日志不够就快速把当前缓冲交换出去并尽量复用备用缓冲避免后端线程刚刚写完就开始分配新内存。在压测中这个改良把多线程下的锁等待时间从原本的占比 20% 降到了 3% 以下。拷贝操作本身也是可以优化的。对于长度小于 64 字节的短日志我直接用普通 memcpy对于更长日志优先调用平台的 SIMD 优化版本 memcpy几乎能跑到接近内存带宽。还有一点容易被忽略缓冲块分配时尽量按 4KB 对齐这样写文件时的系统调用更容易走到 page cache 的快速路径。3.3 落盘策略聚合写、滚动与 fsync 时机后端线程拿到一批缓冲块后怎么把数据交给操作系统是影响吞吐量的最大变量。如果每个缓冲块都单独调用一次write4 条日志可能触发 4 次系统调用如果先把多个块拼接成一个大块再写系统调用次数大幅减少。聚合写是异步日志库性能高的核心原因之一实测证明同样数据量下每次写 4MB 比每次写 4KB 要快将近一个数量级。滚动策略我做了两层按大小滚和按日期滚。按大小滚用了 512MB 一个文件的阈值达到阈值就把文件关闭、改名再创建新文件按日期滚则让日志文件名带日期便于后续归档和清理。滚动操作本身也是由后台线程执行的所以不会阻塞业务线程。fsync 的取舍是对一致性要求最敏感的部分。常规做法是每次写文件后不 fsync让数据停留在 page cache由操作系统按脏页阈值自动回写。这种做法吞吐最高但断电或进程崩溃时数据可能丢失一部分。如果业务要求严格的持久性可以在滚动文件或定期间隔时主动 fsync但每次 fsync 的代价是几十到几百毫秒不能频繁触发。我的默认策略是每秒主动 fsync 一次既保证一定的可靠性又不至于拖垮吞吐。3.4 生命周期管理与崩溃兜底异步日志库最容易出的隐性 bug 是在程序退出时丢日志。如果主线程return 0直接结束后台线程可能还在缓冲区里有大量数据没有写盘日志内容就永远消失了。所以我在析构函数里做了完整的收尾先设置停止标志通知后台线程把剩余缓冲全部排空写盘然后 join 等待后台线程退出最后再关闭文件。这个顺序不能反如果你先 join 再设置标志后台线程可能一直阻塞在等待通知上导致析构卡死。崩溃场景是另一个坑。进程被SIGSEGV杀掉时析构函数不会执行内存里未落盘的日志全部丢失。为了缓解这个问题我给日志库注册了一个崩溃处理函数尝试在崩溃瞬间把当前缓冲直接以只写方式追加到文件里。这个处理逻辑必须尽量简单不能调用可能不信号安全的函数否则二次崩溃更麻烦。实际上信号处理函数里我做的只是两件事用write把已完成的缓冲块追加到日志文件然后重新触发默认行为让进程停止。这套兜底方案并不是万能的但它能保住大部分崩溃现场的关键日志。4. 基准测试与调优过程4.1 压测方法与测试环境测试环境我固定用一台四核八线程的开发机日志文件写入本地 SSD 分区。编译器开启 O2 优化并开启 C17 标准。压测方法围绕几个公开的标准场景来做每个场景固定产生 100 万条日志日志内容约 100 字节统计总耗时、每秒条数、P99 调用端延迟和最终文件大小。我特别强调固定环境这个细节因为日志性能受磁盘类型、文件系统 cache 模式、CPU 主频影响很大。每次对比只改变被测变量比如线程数或某个开关这样得到的差异才有参考意义。为了降低噪音每轮压测跑三次取中间值而不是最优值。测试场景包括单线程写日志、多线程写日志、日志长度随机分布、以及混合日志格式。真正压出问题的是多线程场景各类并发 bug 在单线程下往往完全暴露不出来。4.2 实测数据对比同步落盘的惨烈现场我用两个版本做对比一个是用传统同步逐行fwrite加fflush的基线版本一个是我新的异步双缓冲版本。线程数同步逐行版本异步双缓冲版本1 线程2.1 万条/秒63 万条/秒4 线程3.7 万条/秒97 万条/秒8 线程3.2 万条/秒102 万条/秒16 线程2.8 万条/秒89 万条/秒那个“同步逐行 每次刷新”的基线版本本质上是把 fflush 的代价摊到每条日志上线程越多越糟糕因为文件锁争抢会越来越严重。异步版本 16 线程时性能略有下降是因为单条 SSD 的顺序写带宽到了上限瓶颈已经不在日志库而在磁盘设备本身。调用端延迟的差异同样明显。同步版本在业务线程里的平均耗时约 200 微秒因为主要时间花在fflush和相关系统调用上异步版本的调用端耗时稳定在 100 到 200 纳秒业务线程几乎感觉不到日志的存在。这个差距对追求低延迟的服务端系统是决定性的。4.3 三个关键调优点第一个调优点是减少系统调用。最初版本为了实现简单每凑满 64KB 缓冲就让后端线程写一次文件压测结果大约是 50 万条/秒。后来把缓冲阈值改大到 4MB系统调用次数一下子减少了 60 多倍性能直接跳到 90 万条/秒。系统调用的开销远比很多人想象的大这也是把异步日志库称作为“攒批”的原因。第二个调优点是消除伪共享。多线程打日志时本来共享一个 buffer 的used_原子变量但和它相邻的其他字段放在同一缓存行里导致一个线程修改used_时其他线程的缓存行全部失效。我在关键字段之间加了 padding把这个原子变量隔离到单独缓存行多线程场景的 P99 延迟下降了约 30%。第三个调优点是把公共字段的计算从“每条日志都算”改成“周期性缓存”。时间戳是格式化中最贵的部分原来每个日志都调用一次系统调用获取当前时间改造后只在新的毫秒才更新缓存其余时间直接读内存。在高吞吐场景下这一项省掉了约 15% 的整体时钟周期。5. 常见问题与排查技巧实录5.1 日志条目神秘丢失前端速度超过消费端我在上线后遇到的第一类问题是日志总量比预期少了而且越在高负载下丢得越厉害。最开始时我怀疑是缓冲写溢出后来打印了每次缓冲交换的总日志数量发现消费端写文件的速度跟不上生产端的速度积压缓冲越来越多最终超过了缓冲区上限进入丢数据保护分支。排查思路很直接在后端线程里维护一个计数器每写入一条日志加一前端每个日志调用也维护一个计数器在程序周期性打印两个计数器的差值。如果差值持续增长说明消费端存在瓶颈如果差值不增长但日志缺失那才是写入错误。解决方案有两个方向。一是增加后端线程把单消费者变成多消费者但要注意实现每个线程独立写文件不能共享文件指针否则还是要加锁。二是给日志库增加背压通知前端检测到积压超过阈值时把高频日志降级为采样记录。我最终选择了后者因为日志本来就是容灾场景的数据丢一部分次要信息比拖垮业务更可接受。5.2 多线程下性能不升反降伪共享与锁有段时间用户反馈说他们的 16 核机器跑日志还不如 4 核机器快我一开始怎么都想不通后来用 perf 排查才发现问题出在两个地方。第一处是共享缓冲里的原子变量和后面紧跟的文件指针等字段挤在一个缓存行里。前面说过伪共享会导致不同线程频繁失效缓存。即使你用的是无锁原子操作只要这个原子变量和其他字段共享了缓存行性能一样会崩。我用 alignas 把关键变量隔开后性能立刻回到正常。第二处是格式化时使用了一个全局的时间缓存变量所有线程都去读它导致读操作也变成热点。我把它改成 thread_local 之后多线程版本才真正跑出了线性提升。这类问题从代码上看每个方案都很合理不亲自压测和用性能分析工具看不到根因。5.3 内存占用持续膨胀缓冲池没有上限刚开始实现双缓冲时我只想着怎么让日志写得快缓冲池里的缓冲块只申请不释放结果长时间运行下来内存占用从预期的 20MB 一路涨到 300MB。原因很简单消费端速度跟不上时buffers_里积压的可用备件越来越多缓冲池无限膨胀。修复方式是给缓冲池设置硬上限。我控制在最多保留 8 个 4MB 的缓冲块也就是 32MB。超过这个上限时新产生的日志不再分配新缓冲而是直接进入抽样保留策略只把最近的一部分日志写入一个兜底的小块循环缓冲。这样做的确会丢失少量日志但保证了内存占用始终在可控范围不会影响主进程稳定性。后来又遇到了一个很有趣的场景程序 fork 子进程时子进程完全看不到父进程日志线程的状态双双把日志写到同一个文件就会互相踩踏。我的处理是在 fork 前停掉日志线程子进程里重新初始化一套独立日志父进程恢复后再拉起来。这个坑如果不在设计阶段考虑线上偶发乱序和文件损坏会非常难查。最后说一点个人体会这个日志库从立项到稳定使用前后经历了三轮重构。第一轮跑通了异步双缓冲的主流程第二轮把格式化层替换成定制实现第三轮才是真正把内存和并发调优做扎实。回头复盘如果只让我保留一条设计经验那就是日志库的性能问题多半不在日志本身而在它跟线程调度、内存分配、系统调用的交叉点上。你写出一个能跑的版本只是开始能长期稳定地跑在高负载下才是合格。另外给后来者一个很实用的小建议日志消息里尽量不要拼接复杂对象尤其是需要重载operator才能输出的业务对象。日志库最擅长的场景是接收已经序列化好的、长度有限制的字符串和基础类型。把序列化工作前置到业务逻辑层远比在日志库里做各种花式重载要高效得多。毕竟日志只是系统状态的快照它的存在是为了让你在出问题时快速定位而不是成为你线上服务的第二个负担。