做日志的人多少都听过一句话:日志是软件最后的底裤,但日志本身也可能成为压垮游戏的最后一根稻草。王者荣耀这种MOBA类游戏,一局对战里技能释放、伤害计算、装备变化、 AI 行为的事件量是百万级的,如果每个事件都即时格式化、即时落盘,帧率没崩,磁盘也得先崩。BqLog 这个日志组件能被王者荣耀团队拿出来讲,核心卖点就是“在保证日志完整性的前提下,把日志对游戏主线程的影响压到最低”。而压缩日志的执行路径优化,正是这套设计里最值得拆解的一段,也是这个系列第三篇的主角。这篇文章我会从执行链路的角度,把“为什么异步压缩能省这么多时间”“无锁队列在日志链路上怎么落地”“压缩和落盘到底怎么配合”这些点一个个拆开来讲,适合正在做移动端日志库、或者想优化自家日志模块性能的客户端工程师参考。
1. 压缩日志的执行链路:先分清主线程在忙什么,优化才算找对地方
日志库的性能问题,永远集中在两个点上:一个是从业务代码调用日志接口的那一刻起,到日志数据离开当前线程为止,这段路径花了多少时间;另一个是日志数据最终能不能稳定、有序、不丢失地落到磁盘上。传统的文本日志把这两件事揉在一起做了,所以在调用点格式字符串、拼接参数、拿锁、写文件,所有开销都发生在业务线程里。BqLog 的压缩日志路径则完全不同,它的核心思路是把这两件事拆开:调用线程只负责“把原始数据丢进队列”,格式化、压缩、落盘全部交给后台线程处理。
1.1 为什么选择“延迟格式化”而不是在调用点直接格式化
这是整个压缩日志路径优化最本质的一个设计取舍。我见过不少团队优化日志性能,上来就把 printf 换成 spdlog 的异步模式,然后发现性能还是上不去,原因就在于格式化操作仍然发生在调用线程里。spdlog 的异步模式确实把 I/O 挪走了,但fmt::format这种字符串格式化操作本身并不便宜,它涉及可变参数解析、数字转字符串、内存分配,一次调用可能就有几百纳秒到几微秒的开销。在每秒要写几十万条日志的场景下,这个开销会被无限放大。
BqLog 的思路更狠:调用线程根本不碰字符串。日志调用传入的参数保留原始数据形态,比如整数还是整数、浮点还是浮点、指针还是指针,只把这些原始字节按预定格式打包进一条二进制记录里,然后直接塞进无锁队列。格式化的动作被推迟到了压缩线程,由压缩线程在读队列时统一处理。这么做的直接收益有两个:其一,调用线程的单次日志操作成本被压缩到纳秒级,只剩一次内存拷贝加一次原子入队;其二,因为不需要在调用点解析可变参数,调用路径上的分支和类型判断被全部移除了,这对 CPU 分支预测和指令缓存都非常友好。
1.2 执行路径上的三个角色:写入线程、压缩线程、落盘线程
把整条路径画出来看,BqLog 的压缩日志执行链路其实是一条三段式的流水线。第一段是业务线程,也就是任意调用日志接口的线程,它们只做数据打包和无锁入队;第二段是压缩线程,通常只有一个,负责从队列里批量取出原始日志数据块,做格式化,再把格式化后的文本块交给压缩器处理;第三段是落盘线程,负责把压缩后的数据块写入文件。
这里有个容易误解的地方:很多人以为“压缩日志”的压缩只是 LZ4 那一步,但实际执行路径上,格式化和压缩是两个相互纠缠的步骤。因为 BqLog 保存的是原始二进制参数而不是最终文本,所以格式化动作天然只能发生在后台线程——而这个格式化结果通常不是一条一条文本地写出去,而是先累积到一个足够大的中间缓冲区里,凑够一定体量再做压缩。换句话说,延迟格式化不仅仅是性能优化,它也是“能压缩”的前提:只有先拿到一批连续、成块的文本数据,压缩器才有足够的上下文去发挥。
2. 三条日志路径的对比:为什么 BqLog 的压缩方案能快一个数量级
在讲具体优化点之前,我用一个实际例子把三条路径的差异摆出来,这样后面聊无锁队列和压缩策略时,你心里会有一个明确的对比基准。
2.1 传统同步文本日志:每条日志全流程占主线程
最传统的做法,业务代码里写一行LOG(INFO, "player %d deal damage %d", id, damage),这条日志在调用线程内经历的动作是:解析格式串、逐个把参数转成字符串、输出进用户缓冲区、对输出缓冲加锁、写入文件描述符、可能触发一次 flush。在 Android 真机上,如果遇到磁盘 I/O 抖动或者缓冲页写满,单条日志花费几百微秒是很正常的。而一场对局里可能有几十万条这样的日志,你说卡不卡。
2.2 异步文本日志:I/O 挪走了,但格式化还在
异步日志改进了一步,把“写文件”挪到后台,但格式化仍在调用线程里完成,所以调用线程省下的只是 I/O 时间,字符串格式化的开销一点没少。而且格式化越复杂,调用线程的耗时占比就越高。对于数值型日志特别多的游戏场景,format的整数转字符操作可能占掉日志调用一半以上的耗时。你想优化这一点,单纯把队列做大、把后台线程加多都没有用,因为瓶颈在调用线程的 CPU 指令数上,不挪走格式化,优化空间始终有限。
2.3 BqLog 压缩日志路径:调用线程只做“搬运工”
在 BqLog 的压缩路径上,调用线程的工作量被降到了极致。日志数据以原始二进制形态写入一段预分配的内存块,这一段是纯内存拷贝;然后执行一次无锁入队,这一步是一个原子读改写操作;最后更新一下指针并检查是否需要切换缓冲区。整套动作与日志参数的数量、类型基本无关,复杂度是 O(1) 且系数极小。
后台侧则把格式化、压缩、落盘集中在一个低优先级线程池里慢慢消化。对游戏主线程来说,日志调用的感知仅仅是一次向队列写数据。实测下来,这种路径在移动端设备上的单次调用开销大约只有全格式化的百分之一到几十分之一,这才是“快一个数量级”这句话的真正含义。
| 路径类型 | 调用线程执行的工作 | 单条开销量级(移动端) | 主线程卡顿风险 |
|---|---|---|---|
| 同步文本日志 | 格式化 + 加锁 + 写文件 | 数十微秒级 | 高 |
| 异步文本日志 | 格式化 + 入队 | 数百纳秒到数微秒级 | 中 |
| BqLog 压缩日志 | 二进制打包 + 无锁入队 | 数十纳秒级 | 低 |
3. 写入端优化:参数以“原始形态”穿过无锁队列
整个压缩日志路径优化的第一个关键节点,就是日志调用入口处的轻量化封装。这一层的设计目标只有一个:让主线程少做事。
3.1 日志调用点的数据打包方式
BqLog 在调用点做的事情,可以理解为写一段紧凑的二进制协议。每一条日志记录由两部分组成:元信息头和参数数据区。元信息头里包含了日志级别、时间戳、日志 ID、参数个数、参数总长度这些固定字段;参数数据区则按顺序存放各参数的原始二进制值。调用点的 C++ 代码经过层层模板内联后,实际执行的动作就是几个内存拷贝指令。
举个简化到极点的例子:如果日志接口是logInfo(2, 1001, 3.14f),调用点只需要在缓冲区里依次写下kLevelInfo、kLogId1001、int参数2、float参数3.14f这些原始字节,然后把写入长度加到缓冲区头部。由于 BqLog 在设计参数传递时使用了固定长度的类型标签,压缩线程在解析时能精确知道每个参数字段的边界,不需要从头扫描、不需要按分隔符解析。这一点在批量读取时非常关键,因为压缩线程可以像解析一个结构体数组一样逐条切分日志,效率远超字符串分割。
3.2 无锁队列设计与内存序处理
队列是写入线程和压缩线程之间的唯一交接点,BqLog 用的是典型的多生产者单消费者环形缓冲,配合原子变量记录读写位置。生产端用fetch_add申请一段连续空间,拿到空间后直接写入;消费端用load观察写位置的变化,判断是否有新数据可读。
这里有个工程细节值得重点说:内存序的选择不能图省事全用memory_order_seq_cst。生产端在写入数据时必须保证“先写数据、再更新写指针”,所以写指针用release;消费端读指针用acquire,保证它看到的写指针之后的数据都是已完成的。如果在环形缓冲满了的情况下还需要做覆盖,那就必须记录覆盖位置并做持久化保护,否则会丢日志。BqLog 的默认方案是阻塞式等待:队列满时生产线程自旋或者让出 CPU。游戏主线程里出现队列满的概率极低,因为压缩线程把批次处理时间控制在微妙级别,但自旋上限和让出阈值一定要设计好,否则极端打印场景下会把主线程锁住。
3.3 批次入队:一次申请多块空间减少原子操作次数
单条日志一个原子操作已经很快了,但 BqLog 在写入端还有一个批处理优化:日志事件突发时,写入线程不是一条一个fetch_add申请空间,而是按块申请。也就是说,线程第一次入队时一次性申请能容纳多条日志的空间,后面的日志记录直接顺序写入这块空间,只有空间不足或日志级别过滤导致中止时才再次更新原子变量。这种设计能把原子指令的开销摊薄到多条日志上,尤其适合 MOBA 这类有大量连续事件输出的场景。
注意,批次入队不是把多条日志合并成一条,而是“预留一整块连续内存”供同一个线程写入。它依赖的是日志事件的时间局部性——一个线程在同一个帧内通常会产生大量连续日志,批次申请能减少队列头部的竞争。
4. 压缩线程:从“边收边压”到“攒够再压”的取舍
压缩线程是执行路径上最核心的调优点,因为这里的策略选择会直接影响 CPU 占用、日志时延和数据体积三者的平衡。很多日志库的压缩策略是“每收到一批日志就压一次”,看起来延迟低,但这恰恰会浪费压缩器的潜力。BqLog 的做法是攒批压缩:把格式化产生的文本块累积起来,达到一定体积阈值或数量阈值后才进行压缩。
4.1 为什么攒批压缩比逐条压缩快这么多
压缩算法的效率是高度依赖输入长度的。LZ4 这类算法在压缩 1KB 以内的短文本时,能压缩掉的比例很有限,因为匹配窗口太小、可用的重复模式太少;而当输入块达到几十 KB 甚至几百 KB 时,文本里的重复模式(比如游戏里大量相似的技能名、坐标、玩家 ID 文本)会显著增加,压缩率提升明显。
更重要的是压缩本身的固定开销。每调用一次LZ4_compress_fast,都有初始化上下文、解析输入、写出压缩结果的过程,这个固定开销大概在几微秒到十几微秒之间。如果你每攒 4KB 就压一次,那每兆字节日志要压缩 256 次;如果攒到 32KB 再压,只需要 32 次。后者的固定开销直接少了一个数量级,而单次压缩耗时并不会线性增加多少。
4.2 BqLog 对批量大小和压缩线程的协调策略
BqLog 在压缩线程内部维护了一个格式化和压缩的组合流程。从无锁队列取出的原始日志记录首先被逐条解析、格式化,写入一个可扩展的文本缓冲区;文本缓冲区增长到预设阈值(BqLog 的常见阈值是 16KB 到 128KB 之间,具体按设备磁盘性能和日志量来配)后,触发一次 LZ4 压缩;压缩结果追加到待落盘的数据块列表中。
这里的批量大小不是越大越好。过大的批量虽然压缩率高,但会让日志从产生到落盘的端到端延迟变长。在 MOBA 对战中,你希望一场团战的日志能在几秒内落盘以便复盘分析,而不是压到最后一次性写盘。所以阈值通常是动态平衡的结果:体积阈值负责保证压缩效率,时间阈值负责保证落盘及时性,两者取先到者。
4.3 LZ4 的参数选择与压缩级别的坑
LZ4 有两个常用参数需要注意:加速系数和压缩级别。在移动端日志场景,压缩级别没有太大意义,因为日志数据量大、CPU 宝贵,LZ4_compress_fast的默认加速级别已经足够快。真正值得调的是“输入块大小”。LZ4 内部处理输入时分块进行,每一块都有独立的匹配窗口,块大小设置太小时,跨块的重复模式无法被利用,压缩率下降;块大小设置太大时,缓存压力的边际收益递减。BqLog 通常会选 LZ4 推荐的 64KB 作为最大输入块,但在攒批文本缓冲区不够大时,实际参与压缩的块会小于这个值,这是正常的。
还有一个容易踩坑的点:压缩线程必须保证格式化后的文本缓冲区可重入。也就是说,如果压缩过程中因为缓冲区不足需要重新分配内存,不能让正在格式化日志的同一个线程崩溃。处理方式通常是压缩线程内部使用独立的物理页缓存,避免把数据拷贝到堆上的临时对象里。这些细节看起来是小优化,但在几小时的对局场景里,内存碎片带来的问题远比想象严重。
5. 落盘环节:双缓冲加顺序写,把磁盘性能问题消化在后台
压缩后的日志数据最终要落到磁盘上,如果这一步处理得粗糙,前面优化得再好也是白搭。移动端设备的闪存写入本来就受 IOPS 和写入放大系数约束,日志落盘如果频繁触发小的随机写,会既慢又伤存储。BqLog 在落盘阶段的策略归纳起来就是四个字:顺序大块。
5.1 双缓冲设计与 I/O 线程的动作
压缩线程产出的压缩数据块,不会直接交给落盘线程逐块写,而是先进入一个落盘缓冲队列。落盘线程每次从队列中取出尽可能多的连续数据块,拼接成一个大缓冲,然后以write系统调用的方式一次写入文件。这里的关键是:数据写入磁盘文件时,BqLog 会尽可能保证它是顺序扩展的——每次写入的偏移量都是文件当前的末尾。闪存对顺序写的吞吐能力远超随机写,顺序写大块数据时能轻松跑满设备带宽。
为了让压缩线程不因为落盘线程慢而阻塞,BqLog 采用了双缓冲机制:压缩线程写缓冲 A 时,落盘线程同时写缓冲 B 的数据,两者互换。这样的好处是压缩线程永远不会因为等待 I/O 而暂停,只要总体的压缩速度高于磁盘写入速度,链路就能跑满。
5.2 避免fsync刷盘坑
说到落盘,很多人的第一反应是要不要调fsync,要不要保证每条日志断电后不丢。游戏日志的安全等级其实很低:日志丢了就丢了,要的是快、不卡、不占太多空间。BqLog 的默认设计不会每条日志都做同步刷盘,而是根据 flush 策略控制:要么按时间周期(比如每秒一次),要么按数据量(比如累积 1MB),要么按日志级别(比如 ERROR 级强制刷)。这套设计的依据是:游戏日志的消费场景是赛后回放、崩溃分析和性能统计,少量日志在进程崩溃时丢失是可以接受的,换来的主线程不卡顿是更值钱的。
如果你想在自己的项目里复刻这套路径,我建议你至少保留一个不等式作为参考:落盘线程的积压量 = 压缩线程产出速度 - 磁盘写入速度。积压超过某个阈值时,优先处理 ERROR 级日志,丢弃其他可恢复日志。这不是偷懒,而是高负载场景下保证存活的基本手段。
6. 从整体看执行路径优化的收益:实测思路、调参建议与可复用清单
技术方案讲到这里,如果没有实测数据支撑,总觉得缺点分量。BqLog 的实际测试数据我无法在这里贴出完整版本,但可以根据公开资料和这类组件的通用表现,给出一套你可以照做的压测方法和预期量级,以及几条我反复验证过的调参经验。
6.1 一套可复现的日志性能测试方法
想验证压缩日志执行路径的优化效果,最可靠的方法不是直接跑一个 benchmark 程序,而是把测试用例做成“模拟一局对战”的负载模型。具体做法是我自己常用的三步:
- 录制真实对局中一局的日志序列,包括日志类型分布、参数类型分布、每秒日志条数峰值。如果没有现成的序列,可以用一个简单的模拟器生成:例如每帧(16ms)产生 100 条常规日志、50 条技能相关日志、5 条高频数值日志。
- 把这套序列分别打在三种实现上:同步文本日志、异步文本日志、BqLog 风格压缩日志。
- 统计主线程各帧耗时的 P99、单条日志平均调用耗时、日志文件最终体积和崩溃场景下的日志完整率。
从我的实际测试经验看,主线程单帧日志耗时从同步方案的几百微秒降到 BqLog 方案的几微秒是很正常的量级变化,而文件体积在文本模式下如果有 200MB,LZ4 压缩后通常在 20MB 到 60MB 之间,取决于日志内容的重复程度。
6.2 调参优先级:先调攒批阈值,再调压缩级别,最后动落盘策略
很多团队拿到日志库就急着调压缩级别、换更快的压缩算法,其实方向错了。依照 BqLog 执行路径的优化逻辑,调参优先级应该是这样的:
- 第一优先是攒批阈值。日志量大、主线程压力高时,把文本缓冲阈值从 16KB 调到 32KB 或 64KB,让每次压缩处理更多数据,压缩效率提升明显,对延迟的影响却很小。
- 第二优先是压缩线程和落盘线程的 CPU 核绑定。移动端设备上,把压缩线程绑定到大核,落盘线程绑定到另一个核,能避免线程被系统调度到小核上导致 enqueue 后积压。
- 第三才考虑压缩算法选型。LZ4 已经兼顾了速度和压缩率,除非你的日志文本里大量是高度重复的固定格式,否则换 zstd 的收益并不明显,而 CPU 消耗会明显增加。
6.3 可以直接搬走的核心优化清单
最后,把整条压缩日志执行路径的优化点归纳成一份可执行清单,你能直接用在自己项目里:
- 调用线程只做二进制打包,绝不做字符串格式化,延迟格式化是前提。
- 用多生产者单消费者环形缓冲替代互斥锁队列,写指针用 release,读指针用 acquire。
- 攒批处理:文本缓冲达到体积阈值或时间阈值才触发压缩,避免逐条压缩。
- 压缩器优先考虑 LZ4,输入块大小设置在 16KB 到 64KB 之间。
- 落盘采用双缓冲加顺序大块写入,按时间或体积周期 flush,不在调用路径上做同步刷盘。
- 队列饱和时定义降级策略,优先保 ERROR 级日志,其他日志允许丢弃。
我在自己的项目里按照这条路径做了一轮重构,最直观的感受是:日志模块从此不再出现在主线程性能分析的热点列表里。对于一套每局要产生千万级日志事件的游戏客户端来说,这本身就是最大的胜利。