做日志的人多少都听过一句话日志是软件最后的底裤但日志本身也可能成为压垮游戏的最后一根稻草。王者荣耀这种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 方案的几微秒是很正常的量级变化而文件体积在文本模式下如果有 200MBLZ4 压缩后通常在 20MB 到 60MB 之间取决于日志内容的重复程度。6.2 调参优先级先调攒批阈值再调压缩级别最后动落盘策略很多团队拿到日志库就急着调压缩级别、换更快的压缩算法其实方向错了。依照 BqLog 执行路径的优化逻辑调参优先级应该是这样的第一优先是攒批阈值。日志量大、主线程压力高时把文本缓冲阈值从 16KB 调到 32KB 或 64KB让每次压缩处理更多数据压缩效率提升明显对延迟的影响却很小。第二优先是压缩线程和落盘线程的 CPU 核绑定。移动端设备上把压缩线程绑定到大核落盘线程绑定到另一个核能避免线程被系统调度到小核上导致 enqueue 后积压。第三才考虑压缩算法选型。LZ4 已经兼顾了速度和压缩率除非你的日志文本里大量是高度重复的固定格式否则换 zstd 的收益并不明显而 CPU 消耗会明显增加。6.3 可以直接搬走的核心优化清单最后把整条压缩日志执行路径的优化点归纳成一份可执行清单你能直接用在自己项目里调用线程只做二进制打包绝不做字符串格式化延迟格式化是前提。用多生产者单消费者环形缓冲替代互斥锁队列写指针用 release读指针用 acquire。攒批处理文本缓冲达到体积阈值或时间阈值才触发压缩避免逐条压缩。压缩器优先考虑 LZ4输入块大小设置在 16KB 到 64KB 之间。落盘采用双缓冲加顺序大块写入按时间或体积周期 flush不在调用路径上做同步刷盘。队列饱和时定义降级策略优先保 ERROR 级日志其他日志允许丢弃。我在自己的项目里按照这条路径做了一轮重构最直观的感受是日志模块从此不再出现在主线程性能分析的热点列表里。对于一套每局要产生千万级日志事件的游戏客户端来说这本身就是最大的胜利。