☰
自研C++异步双缓冲日志库:高并发下CPU占用降10倍
2026/10/13 21:51:29 网站建设 项目流程

先说结论:在高并发后端项目里,最常被低估的性能杀手,往往是日志。我在某个 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_guard<std::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_unique<LogBuffer>(); } current_->append(msg, len); cv_.notify_one(); } } private: std::unique_ptr<LogBuffer> current_; std::unique_ptr<LogBuffer> next_; std::vector<std::unique_ptr<LogBuffer>> 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、序列号全是整数。C++17 提供了std::to_chars,这是目前整数到字符串最快的方式,但我的编译环境里还有部分老模块,所以我写了一个等价的手工版本,原理就是把数字对 10 取模,逐个从尾部往头部落到临时数组里,再整体拷到目标缓冲区。代码量不大但效率很高:

char* AppendInt(char* dst, int64_t v) { char tmp[20]; int len = 0; uint64_t n = v < 0 ? static_cast<uint64_t>(-(v + 1)) + 1 : static_cast<uint64_t>(v); do { tmp[len++] = static_cast<char>('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 时机

后端线程拿到一批缓冲块后,怎么把数据交给操作系统是影响吞吐量的最大变量。如果每个缓冲块都单独调用一次write,4 条日志可能触发 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 优化,并开启 C++17 标准。压测方法围绕几个公开的标准场景来做:每个场景固定产生 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<<才能输出的业务对象。日志库最擅长的场景是接收已经序列化好的、长度有限制的字符串和基础类型。把序列化工作前置到业务逻辑层,远比在日志库里做各种花式重载要高效得多。毕竟日志只是系统状态的快照,它的存在是为了让你在出问题时快速定位,而不是成为你线上服务的第二个负担。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询