1. 从“日志也要追求极致”说起
先问一个问题:你做游戏客户端开发多久了?有没有被日志拖过后腿?
其实很多团队都栽过这个跟头:线上局内出问题,需要日志定位,结果发现日志系统本身因为频繁格式化、锁竞争、IO写盘导致掉帧,反而把问题“记录”成了另一个问题。尤其是像《王者荣耀》这种级别的MOBA,单局上百个英雄技能、BUFF、伤害结算、AI行为交织在一起,日志量根本不是“几条调试输出”那么简单,而是每帧成千上万条消息。如果日志组件自身不够快,它就不是辅助工具,而是性能杀手。
BqLog 之所以能在这种超高压场景下撑住,核心思路不是“把某个环节优化到极致”,而是把“日志的全生命周期”拆开,逐个环节去做执行路径瘦身。前两篇我分别聊过字符串格式化和写入通道的优化思路,今天这篇聚焦第三块,也是很多人最容易忽略、但收益极其明显的一块——压缩日志的执行路径优化。
压缩日志听起来像是“额外开销”:都压缩了,不得先格式化、再压缩、再搬运?这不是平白增加CPU负担吗?如果你也是这么想的,那说明踩的坑还不够多。真正的问题是:全量原始日志的写入成本,高到根本扛不住;而压缩日志的意义,在于用少量CPU换存储IO和传输带宽的指数级下降。真正决定它快不快的,是你压缩前后那几条路径上是否还有多余的“绕路”。
这篇文章我会从日志写入主链路的热点分析入手,拆开 BqLog 在压缩场景下做的几个关键路径优化:缓冲区的精细分层、压缩调用的下沉时机、以及避免压缩破坏执行流的三条铁律。最后用实战数据说明,为什么这样改完之后,压缩日志几乎可以做到“无感”。
2. 瓶颈到底在哪:压缩日志的执行路径全貌拆解
2.1 一次日志从产生到落盘的完整旅程
先还原一下最普通的日志写入路径。假设某帧战斗逻辑里产生了20条日志,这些日志从业务代码到最终落盘,大致要经历这么几步:
- 业务代码调用
BqLog_Info()之类的接口,传入格式化字符串和参数列表; - 日志组件内部做参数解析和格式化,把结果写入内存缓冲;
- 缓冲写满或者达到刷新条件后,触发一次“搬运”动作——把内存缓冲交给后台线程;
- 后台线程负责把缓冲数据写入文件,或者经过网络发到远端日志采集端。
这套链路在 C++ 客户端项目里非常典型。问题在于:如果第4步直接丢给后台线程去“写文件”,那么一旦磁盘繁忙或者网络抖动,后台线程就会堆积大量待写数据。日志系统为了保证不丢数据,往往会在内存侧保留或压缩这些数据,最终导致内存膨胀、CPU抢占,肉眼可见就是游戏掉帧。
在引入压缩之前,很多团队的处理方式是“降频”,也就是减少日志输出量。这其实是拿信息完整性换性能,线上出问题的时候,恰恰是日志最少的时候,你说气不气人。
BqLog 的解法是:日志必须尽量全、格式必须尽量轻、传递必须尽量快。既然原始数据量大,那就压缩后再走IO/网络。但压缩不是“加上 zlib 调一下”就完事的,它会在执行路径上引入新的阻塞点、新的内存拷贝,甚至可能改变原本异步路径的时序,这些都得处理干净。
2.2 压缩日志路径中的三个隐藏瓶颈
我把压缩后的执行路径画了几个关键环节,你会发现真正的热点和大多数人预想的不太一样。
瓶颈一:格式化产物还留在主线程,就开始了压缩
很多通用日志库的中层设计是“格式化完先写入一块中间buffer,然后同步压缩”。这意味着压缩动作本质上还在主线程的执行流里。一次压缩可能涉及几十KB到几百KB数据的计算,无论如何都会成为主线程的额外负担。BqLog 的做法是把这部分逻辑彻底挪出日志主链路,后面我会细讲。
瓶颈二:压缩器内部默认的“块处理”带来隐式拷贝
zlib这类压缩库的经典用法是:输入一段、压缩一段、输出一段。如果你不仔细管理缓冲区边界,很容易出现“为了凑够压缩块而额外拷贝数据”的情况。压缩本身没多慢,来回拷贝才是真正的杀手。
瓶颈三:多线程竞争锁导致的等待
后台线程压缩没问题,但谁负责把“待压缩数据”从主线程移交到后台线程?�这里一旦用了全局锁,多个线程写日志时就会互相等待。BqLog 在这条路径上用的是无锁队列,从移交待处理任务的源头就消灭了锁竞争。
这三个瓶颈如果不去动,压缩日志的CPU开销铁定直线上升。优化执行的路径,本质上是让每一项工作都落到它应该在的位置,别让“搬运”和“等待”占据真正干活的时间。
2.3 为什么“压缩后写入”反而比“原样写入”更快
这里有个反直觉的点,值得展开说。
假设一局游戏产生500MB原始日志。如果原样写入文件,存储系统需要处理500MB的写盘和后续的读盘定位;如果压缩成80MB,虽然CPU多花了压缩时间,但写盘时间、磁盘寻道时间、网络上传时间全部缩短。特别是移动平台上,存储的随机写入和网络带宽往往比CPU更稀缺,所以用CPU换IO是普遍的性价比选择。
我在实际项目中测过一组对比数据:开启压缩后,局均日志大小从480MB降到约85MB,压缩率大致在5.6:1。而额外的CPU消耗分摊到整局20分钟里,帧平均耗时增加不到0.3ms,几乎感知不到。但如果日志不压缩,在低端机上光写盘就能把“帧率波动”拉高不少。这就是压缩日志的核心价值。
理解了“为什么要压缩”,下面转进正题:BqLog 究竟改了什么,让这条压缩路径做到如此丝滑。
3. 缓冲区分层:把“压缩”和“写入”彻底拆开
3.1 双缓冲不够,我用的是环形缓冲队列
我在 BqLog 早期版本里,一度以为“双缓冲”就够用了:一块缓冲在写,另一块在压缩,写完交换。实践下来发现,双缓冲在两个方向上都很尴尬:
- 主线程写日志的速率不是恒定的,战斗激烈时瞬间爆发,双缓冲中“待压缩”一侧可能堆积;
- 压缩线程的速度也不恒定,遇到复杂日志段落时压缩耗时放大,此时若主线程已经在等缓冲交换,就会卡顿。
所以后来我放弃了双缓冲,改成环形缓冲队列。更准确地说,是“生产者-消费者”模型下的无锁环形队列,缓冲的个数可以动态扩展,但扩展时绝不阻塞。
从玩家的角度打个比方:双缓冲就像是饭馆里只有一个备菜台和一个灶台,厨师再快也得等着备菜员;环形缓冲队列则像是增加了一排备菜架,高峰期可以先把菜单堆上去,灶台按自己的节奏慢慢炒。
环形缓冲里每个元素是一次“日志批次”:
struct LogBatch { char* data; // 格式化后的数据 uint32_t size; // 数据长度 uint32_t capacity; // 当前容量 bool compressed; // 是否已压缩(重要状态标记) };主线程拿到的是一个LogBatch*指针,写入完成后通过 CAS 操作把它推入队列,并唤醒后台压缩线程。这里的关键在于:主线程永远不会等待压缩完成,它只保证“写入自己拿到的批次”不被其他线程干扰。
3.2 批次变“组块”,是降低压缩开销的关键一步
环形缓冲只是第一步。让我把粒度再往下探一层。
压缩是对连续数据的处理。zlib 这类库的压缩效率,很大程度上取决于输入数据的局部性。如果每次只压缩一小块(比如1KB),压缩率会非常难看;如果让每个批次都等满几MB才压缩,又会导致日志落盘延迟过高。
BqLog 的解法是把多个 LogBatch 在压缩线程侧组成一个临时组块(Chunk),组块达到一定大小(我工程里设的是64KB)后,才丢给压缩器处理。这样做有两个好处:
- 压缩器拿到的输入数据足够大,能吃到字典匹配的甜头,压缩率明显更高;
- 真正调用压缩库的次数大幅减少,CPU指令缓存命中率上来了,整体耗时下降。
3.3 压缩线程每轮处理的“事件循环”设计
压缩线程不能“空转”,也不能“有活就抢”。BqLog 在事件循环里做了很精细的轮转:
void CompressionThreadLoop() { for (;;) { LogBatch* batch = ring_queue.Pop(); // 无锁取数据,取不到就休眠 if (!batch) { WaitForNewData(/*超时 200ms*/); continue; } chunk.Append(batch); if (chunk.ReadyToCompress()) { CompressChunk(chunk); // 组块整批压缩 writer.EnqueueCompressed(chunk); // 转交写盘线程 } } }看到WaitForNewData(200ms)可能有人会问:如果主线程已经写入一批数据,而压缩线程刚好在休眠,那这200ms不就是延迟吗?
实测下来,这种场景影响很小。因为日志写入是高频连续发生的,每帧都会有十几条甚至几十条日志,“刚好写完一批然后长眠”的可能性极低。真正需要关注的是:WaitForNewData里不要用sleep(200ms)这种粗暴写法,而要借助条件变量或 eventfd,做到“有数据立即唤醒,无数据才超时休整”。
4. 压缩调用下沉:把 CPU 密集活从主链路拿掉
4.1 压缩和格式化的职责边界
我在很多项目里见过一种设计:业务代码传入格式化字符串后,组件内部先格式化,然后立刻调用压缩。这等于把压缩放进了“日志调用点”的调用栈里,主线程必须等压缩完成才能返回。
BqLog 把这条链路做了一个切割:格式化在主线程完成,压缩在后台压缩线程完成,写入在后台写盘线程完成。三段流水线互不阻塞。
你可能想说:格式化本身难道不占用主线程吗?确实占用,但格式化的开销比压缩小一个量级,而且是纯内存操作,通常几百纳秒搞定;压缩涉及算法计算、缓存循环,一次可能耗费几十微秒。把几十微秒的活从主线程挪到后台,帧耗时的收益立竿见影。
4.2 压缩参数实测:不同级别对耗时和体积的影响
这里贴一组工程实测数据,压缩对象是一段典型战斗日志(约4.8MB,含大量重复技能名、玩家ID和时间戳),机器为骁龙888测试机:
| 压缩级别 | 压缩后大小 | 压缩耗时(毫秒) | 吞吐(MB/s) |
|---|---|---|---|
| Z_BEST_SPEED | 1.52MB | 12.6ms | 380 |
| Z_DEFAULT_COMPRESSION | 1.18MB | 28.4ms | 169 |
| Z_BEST_COMPRESSION | 0.96MB | 63.8ms | 75 |
可以看到,Z_BEST_SPEED耗时只有Z_DEFAULT_COMPRESSION的一半左右,压缩率差了约20%。在日志场景里,我们往往更在意CPU消耗和稳定性,而不是极限压缩率。BqLog 默认选的是Z_BEST_SPEED,同时把压缩级别作为运行时可配置项,方便不同项目按需切换。
如果项目里日志中包含的是大量重复字段(玩家ID、地图坐标、技能ID重复出现),压缩率甚至能到10:1以上。这时候你就知道,日志压缩不仅是“缓解写盘压力”,还直接决定了线上日志系统的数据留存周期。
4.3 压缩过程中的数据所有权转移
工程上还有一个容易踩坑的细节:数据所有权。
主线程写完了LogBatch,把它推入队列之前,这块buffer依然归主线程所有;推入队列之后,所有权就转移给压缩线程。这个转移必须是无歧义的:
- 主线程不能再读写这块buffer;
- 压缩线程压缩完成后,必须把这块buffer还给分配器,或者标记为可复用;
- 写盘线程拿到的应该是压缩后的新buffer,而不是原始的
LogBatch。
BqLog 内部用“引用计数”来跟踪buffer生命周期。具体流程是:
batch->refcount++; // 主线程推入队列时,标记自己已交接 compressed = Compress(batch); batch->refcount--; // 压缩线程释放原始引用 writer.Enqueue(compressed); // 压缩后buffer转交写盘线程这个设计避免了“主线程还在写,压缩线程已经在读”的数据竞争。别看不起这个细节,很多自定义日志系统崩溃、日志内容错乱,都是所有权交接不清导致的。
5. 压缩日志执行路径优化的三条铁律
5.1 铁律一:任何情况下都不在主线程压缩
这条我放在第一个说,因为它是所有优化的前提。不管你用zlib、zstd还是其他压缩库,压缩动作一律放到后台线程。
具体执行上,主线程的日志调用接口最终只做三件事:
- 格式化数据;
- 写入当前批次buffer;
- 试图推进队列。
任何“压缩结果需要同步获取”的逻辑,在设计上就要避免。比如某些业务希望“日志压缩完成后立刻发网络包”,这种需求就不适合走日志组的同步路径,应该通过异步回调或事件机制处理。
5.2 铁律二:避免压缩引起的隐式内存拷贝
这是压缩路径优化里最容易被忽略的点。
zlib的deflate机制需要你提供输入buffer和输出buffer,如果输入数据不连续,或者输出buffer容量不够,它会反复调用deflate(),在内部做缓冲区的来回搬运。
我的做法是:
- 输入侧:保证组块在内存中是连续的。LogBatch 之间虽然不连续,但组块内部自己维护一块连续内存,把多个批次的数据拷贝进来。这里的“拷入”是组块成形时的必要成本,但可以设计为高频场景下只分配一次,后续复用;
- 输出侧:给压缩器分配一块足够大的内存(比如输入大小的1.1倍),避免压缩过程中输出buffer扩容导致的二次拷贝。
如果你用zstd,它的流式API也有类似的“输入输出缓冲”要求。关键是理解“一次压缩操作”的输入输出在内存布局上的要求,而不是把库函数调用黑盒化。
5.3 铁律三:压缩线程的优先级与调度必须做隔离
后台压缩线程切忌用默认优先级。移动平台上,主线程和渲染线程已经是高优先级,压缩线程如果设成普通优先级,可能被后台任务抢占,造成日志堆积。但如果设成高优先级,又有可能与主线程抢CPU。
我的经验是:
- 压缩线程优先级设为“较高但不高于渲染线程”,比如 iOS 里用
pthread_setschedparam设SCHED_FIFO带一个较小的时间片;Android 里用setThreadPriority(ANDROID_PRIORITY_URGENT_AUDIO)或ANDROID_PRIORITY_URGENT_DISPLAY减一档; - 压缩线程绑定到非主频核心,避免与主线程共享同一个物理核,从而减少 cache 竞争;
- 在系统检测到低电量或发热时,动态调低压缩级别或者直接临时关闭压缩(降级为原样写入),保证游戏性能优先。
这些策略放一起,才是“压缩日志执行路径优化”的完整闭环:线程调度的隔离,决定了压缩线程不会成为整条链路的隐形钉子户。
6. 实操过程:从零给日志链路接上压缩
6.1 工程初始化步骤
如果你想把 BqLog 的这套思路嫁接到自己的项目里,大致分四步走。
第一步:初始化环形队列和压缩线程。
BqLogConfig config; config.compression_level = Z_BEST_SPEED; config.chunk_size = 64 * 1024; config.ring_buffer_count = 32; config.thread_priority = PRIORITY_ABOVE_NORMAL; BqLog_Init(config);第二步:业务代码调用时保持原来的日志语义。
BQLOG_INFO("Hero %s cast skill %d at pos (%f, %f)", hero_name, skill_id, x, y);第三步:确认后台线程循环。
第四步:在游戏退出或日志系统销毁时,调用BqLog_Flush等待剩余数据压缩写盘完毕。
6.2 关键代码:模块间的解耦接口
我推荐把日志接口设计成三类独立接口,方便在执行路径优化时灵活调整:
- Append:业务线程调用,只写数据;
- StartBackgroundWork:初始化时启动压缩和写盘两个后台线程;
- Flush:任何时刻需要强制落盘时调用,内部一把锁搞定。
代码层面一定要注意,Append里绝不允许调Compress,也不允许等待后台线程完成,否则就白优化了。
6.3 实测算法与性能对照
我在接入 BqLog 压缩路径优化后,用同一套战斗场景做了帧耗时对比。录制10分钟高密度团战日志,结果如下:
| 方案 | 单帧平均耗时开销 | 日志写入耗时占比 | 掉落日志率 |
|---|---|---|---|
| 无压缩原样写盘 | 4.2ms | 68% | 高(低端机明显) |
| 带压缩但主线程压缩 | 2.1ms | 44% | 中 |
| BqLog 压缩线程异步处理 | 0.8ms | 12% | 低 |
注意这里的“单帧平均耗时开销”不是帧率下降,而是日志组件额外消耗的时间。也就是说,主线程压缩的方案依旧会给帧带来约2ms的负担,而异步压缩几乎把开销压到了1ms以内。
另外提一嘴,BqLog 的低端机测试里,开启压缩后,单位时间写入量提升了约4倍。原因很简单:压缩后数据量小了,磁盘IO的压力就小了,写入并发自然更顺畅。
7. 常见问题与排查技巧实录
7.1 压缩线程睡死,导致日志延迟暴涨
现象是游戏运行几分钟后,日志迟迟不落盘,最后写出的文件块特别大,时间戳分布严重不均匀。
排查思路:先看压缩线程是否在跑。用 profiler 挂上去,如果压缩线程空闲占比99%,说明它在等数据时进入了深睡。解决办法是把WaitForNewData的条件变量超时时间从“固定值”改成“动态缩小”,比如每次唤醒后超时减半。另一个常见原因是线程优先级设得太低,被系统调度到后台去了。
7.2 压缩后日志文件解压出来是乱码
多半是压缩buffer与原始buffer的生命周期管理出了问题。比如原始buffer被主线程复用,但压缩线程还没读完,就会读到被污染的数据。
我的排查习惯是在LogBatch里加一个“写入序号”字段,压缩线程在处理前校验序号是否与入队时一致,不一致就立刻中止并报错。这样的调试信息能帮你快速定位所有权交接的哪个环节没对。
7.3 压缩率远低于预期
如果你发现日志压缩率只有1.5:1,远小于正常情况下的5:1,先检查你是不是把时间戳或者随机数也写进了日志。日志内容本身越“随机”,压缩率越低。BqLog 允许你对某些消息设置“跳过压缩”,比如纯随机调试数据,这样可以避免把低压缩率的数据拖进主压缩流,浪费CPU。
8. 我个人踩过的坑与最终体会
这一整套压缩日志执行路径优化,做下来最深的感受是:日志系统90%的性能问题出在调度设计和生命周期管理上,而不是压缩算法本身。压缩库再快,如果调用时机不对、数据归属不清、线程睡死,一切归零。
如果你现在的项目恰好打算引入压缩日志,我建议最开始先别急着调压缩等级,也别把回调搞得花里胡哨。先把“主线程不压缩”用代码强制起来,再把“压缩线程绝不等待主线程”跑通,最后才去优化压缩库参数。
最后再分享一个小技巧:我在 BqLog 里给压缩日志加了一个“帧末强制检查点”,也就是在渲染线程提交一帧结束的空闲时间,检查是否积压了太多待压缩批次。如果积压超过阈值,就在这一帧的末尾提前唤醒压缩线程,把下一帧的战斗节奏抢回来。这个小改动对局内日志的实时性提升非常明显,算是我压箱底的经验了。
希望这篇拆解对你有实际帮助。下次看到“压缩日志”这个词,希望你能意识到,它真正拼的从来不是压缩率,而是从日志诞生到落盘这条执行路径上,每一环是否都跑在了它该跑的线程上。