BqLog实时压缩日志:LZ4批量缓冲如何让游戏日志吞吐翻倍 做移动端性能优化做了这么多年日志组件是我见过最容易“看起来简单、做起来复杂”的东西。王者荣耀团队开源的BqLog第一眼给人的印象就是“快”字但我一开始并不服气——一个打日志的库再快能快到哪去直到我把项目里的spdlog换掉按他们给出的基准思路压了一轮数据才意识到问题的核心不在于“快”这个结果而在于他们重新设计了传统日志组件的整条数据路径相当于把一条乡间小路改成了全程高架。这篇主要聊BqLog的第一大杀器高性能实时压缩日志。实时压缩这四个字说来轻巧做起来相当折腾——压缩算法要选对压缩时机要选好缓冲体系要撑得住异步线程还要不拖后腿。任何一个环节拍脑袋最终都会反映到帧率和CPU占用上。如果你正在做游戏客户端、或者维护一套高吞吐日志服务这篇拆解应该能帮你在选型和实现上少走很多弯路。1. 先说结论BqLog到底解决了什么问题1.1 游戏日志的三重困境手游客户端日志这个话题平时没人关注出事的时候没人能睡好觉。王者荣耀这种量级的项目一个对局里要记录战斗、网络、适配、渲染等大量信息一局下来日志量轻松到几十MB排障时找不到关键帧、客户端崩溃时日志缺一段定位起来地狱难度。传统上大家会想到log4cpp、spdlog这类通用组件但拿到游戏环境测一圈就会发现三个问题吞吐上不去写多了卡主线程、磁盘占太多、最关键的是——日志写盘太慢崩溃时还没来得及落盘就丢了。BqLog正是冲着这三件事来的。第一每秒吞吐比通用日志库高出一截第二落盘前做了实时压缩日志体积小不少第三基于mmap的缓冲设计让崩溃场景下尽量保住日志。首篇主要聊压缩这条线为什么说“实时压缩”这四个字没那么简单以及它是怎么做到既压得快又写不卡的。1.2 从“事后压缩”到“实时压缩”这一步有多难常见的用法是把原始日志先写下来跑完一轮再开一个后处理进程去gzip。好处是逻辑简单坏处是日志落地时已经是最大体积磁盘峰值和崩溃丢数据的风险都没解决。实时压缩可以从源头压缩数据体积但难在压缩本身是个消耗CPU和内存的活如果你在打日志的路径上同步做压缩吞吐立刻跳水如果开线程异步做又面临数据传递、线程调度、背压处理一整套并发问题。BqLog选择的是一条比较务实的路把压缩拆成“批量缓冲FIFO异步线程轻量级算法”三件组合避免为追求极致压缩率而牺牲实时性。说到底游戏日志组件追求的是“用最小的代价把日志留下来”而不是“把日志压到最小”这两者在方案取舍上差别很大。2. 为什么快核心设计拆解2.1 压缩算法选型为什么是LZ4而不是ZSTD/Gzip先聊选型这件事。压缩这件事业界有很多算法gzip用deflate压缩率好但速度一般ZstandardZSTD是Facebook开源的压缩率接近甚至超越gzip速度还快在高性能场景下通常很能打LZ4呢压缩率中等速度极其快官方标的单核压缩速度可达400MB/s以上。BqLog最终押注LZ4这条线是有充分理由的。手游端CPU是非常宝贵的资源一个对局里能留给日志系统的CPU预算往往只有几个百分点。LZ4HC这类高压缩率模式虽然压缩率更高但开销大很多不适合在客户端高频路径里使用。游戏日志的场景有一个特点日志文本重复性高时间戳、模块名、日志级别的重复串很多用LZ4虽然压缩率不如gzip但文本场景下依然能拿到一个可观的比例实测大约3到5倍压缩关键是消耗的时间极其可控。对BqLog来说“快”远不止是压缩算法的速度更重要的是它不会成为整个日志链路的瓶颈点。选型背后还有个工程考量解压方也要简单。游戏客户端日志可能在进游戏后由工具链读取也可能由内部日志分析平台解密。LZ4的解压速度同样快到让人放心单核解压能到几个GB/s后续不管是做日志采集还是故障排查解析侧都不会成为瓶颈。相比gzip需要逐步维护状态LZ4的块式结构几乎没有隐藏开销很适合作为一种“在传输之前做瘦身”的中间层。下面是我自己对比几个常见选项的结果以文本日志为样本算法文本日志压缩率压缩速度解压速度CPU开销适用场景gzip(-6)5~7x20~50MB/s150MB/s左右高离线打包ZSTD(默认档)6~8x150MB/s左右500MB/s左右中高网络传输/归档LZ4(默认档)3~5x400MB/s以上数GB/s中低实时路径2.2 缓冲体系和批量压缩机制算法选好了第二步是“在什么时机压缩”。BqLog采用了一个非常工程化的思路不是一条日志一条日志地去压缩而是把一批日志攒在缓冲区里凑到一定大小或一定条数之后以“块Block”为单位一次性压缩。这个思路跟数据库的page、消息队列的batch很像核心都是摊薄开销。具体到BqLog它内部维护了日志写缓冲Writer Buffer日志格式化完成后先落进缓冲缓冲满了之后把这一整块内存通常几十KB到MB级别交给异步压缩线程处理压缩后的数据再追加到日志文件。这样一来单条日志路径上完全没有压缩开销用户侧看到的接口就是一个普通的log()调用背后压缩是批量发生的。批量机制最直观的好处是吞吐。假设每条日志文本平均200字节一次攒256KB差不多上千条日志压缩一次的开销平均到每一条就非常低了。常写业务代码的朋友可能觉得这点开销无所谓但在游戏里一帧内可能产生数百条日志主线程每一微秒都很重要任何一条日志路径上的同步阻塞都会被放大成掉帧。缓冲分级的做法也很讲究。BqLog有多个缓冲块在轮流使用写入线程永远只操作当前块写满就切换到一个空闲块已完成块才交给异步线程。这本质上是生产者消费者模型日志线程是生产者压缩/IO线程是消费者两者之间通过一个近乎无锁的队列衔接避免在高频日志场景下出现锁竞争。2.3 异步刷盘与线程模型日志写完内存不落盘总有风险。但如果一写就落盘吞吐就完蛋了。BqLog的模型里有一个独立的writer线程负责把已经压缩完成的缓冲块刷到磁盘。这个线程是全局唯一的所有日志线程共享它这样磁盘IO就能顺序化。顺序写是机械硬盘时代的真理在闪存时代同样是性能关键——随机小写代价极高而顺序大块写可以把IOPS利用率打到最高。线程模型上比较核心的一个决策是“绝不阻塞日志调用方”。日志函数里做的事情就是改指针、塞内存、更新偏移量。只要当前块还有空间日志线程就不需要跟writer线程做任何同步自然也不会因为磁盘慢而卡住主线程。只有在块切换的极短瞬间需要CAS一个原子变量这个开销在纳秒级到微秒级完全可接受。那writer线程怎么决定什么时候写BqLog支持按大小触发和定时触发。大小触发是缓冲区累积到阈值立刻写适合高吞吐场景定时触发是每隔X毫秒把当前未满的缓冲也写掉防止低流量时日志迟迟不落盘。这个参数跟游戏类型有关。MOBA对局中途突然崩溃最后几秒日志往往是最关键的所以低延迟落盘能力很考验配置默认值一般都比较保守。3. 实时压缩链路完整走读3.1 一条日志从产生到落盘的完整路径把核心组件都说清楚了现在串一走一遍才能直观理解“为什么快”。一次典型的BqLog日志调用比如logger-Log(LOG_INFO, player killed monster, id%d, id)大概经历以下几步日志调用进入格式化字符串。这一步会生成一条带时间戳、日志级别、模块标识、日志内容的原始记录写入当前活动块Active Block的内存区更新偏移量。判断当前块是否写满达到阈值。没有写满函数直接返回开销就是一次memcpy加几个指针移动通常在几十纳秒到几百纳秒之间。写满后把当前活动块标记为“待处理”从空闲块池取下一块作为新的活动块。这个切换操作是原子的旧块则进入一个FIFO队列。后台压缩线程从FIFO取到旧块对整个块做LZ4压缩得到一份压缩数据。压缩完成后把它交给写盘线程同时原块内存在处理完成后归还空闲块池。写盘线程拿到压缩后的块追加到当前日志文件必要时更新文件头信息有些版本会把块索引写在文件尾然后触发一次顺序写。整个过程日志侧最重的操作是字符串格式化和缓冲拷贝没有系统调用没有锁没有磁盘IO。这正是它“快”的本质不是某个黑魔法让单条日志更快而是把“快”定义为“日志操作不进入慢路径”。3.2 关键参数与权衡BqLog里几个核心参数值得拿出来单独讲一下。块大小是最先要定的。块太小压缩边界多压缩率上不去IO次数也多块太大内存占用高块切换不频繁低流量时尾部日志落盘延迟就会拉长。通常块大小在64KB到1MB之间调节战斗类高吞吐场景选大块普通应用场景选小块。我一般建议手游客户端起始用256KB压力测试后再细调。其次是压缩级别。LZ4默认的压缩级别速度极快再往上走压缩率有提升但速度下降明显。移动端CPU核少强烈建议先跑默认档除非你确认在弱机上CPU有余量否则不要去追求压缩率多一两个点。实测数据是LZ4默认档在双核A53机器上压缩256KB文本块大约耗时几百微秒这个量级可以忽略但如果换成HC模式耗时可能上升到好几十毫秒那整个异步线程就会被打爆。还有一个容易被忽略的权衡是“压缩线程的优先级”。游戏客户端后台线程较多如果日志压缩线程优先级过低可能在负载高时被饿死导致积压优先级过高又会抢掉渲染线程资源。我的经验是把日志线程优先级设成略低于主线程渲染线程高于网络线程比较合适具体还得结合厂商和机型统计决定。4. 性能实测真刀真枪的数据对比4.1 吞吐量压力测试理论说了半天没有数据没有说服力。参考BqLog仓库以及团队分享的基准测试思路我在自己的一个自研小型客户端runtime里做了个对照同样的日志内容分别打给spdlog默认同步模式、打给BqLog开压缩、以及打给BqLog关压缩在Android中端机和iOS上各跑一轮。测法很简单写一个循环每条日志80到150字节连续打100万条计时算每秒吞吐。结果大致是spdlog同步模式在Android中端机上大约每秒几十万条BqLog开压缩后能到百万条级别关压缩还能再高一些。压缩开关对吞吐的影响大约在20%到40%之间也就是说实时压缩是有代价的但相对spdlog这类通用方案总吞吐依然高出一大截。iOS端差距类似但绝对吞吐更高。注不同CPU、不同日志内容下数字会有波动重点看数量级和相对差距。自己做对比测试时建议至少跑3轮取中位数避免被系统调度噪声干扰。4.2 压缩率与运行时开销压缩率方面我拿三种典型的日志内容做了对比第一种是纯文本战斗日志每行十几个token、数字多第二种是带HTTP请求参数的调试日志重复串多第三种是带堆栈的异常日志大段重复前缀。结果LZ4压缩率分别在3.5倍、5倍、2.8倍左右。别被gzip 6倍的印象带偏在实时链路上2到5倍已经很有价值——磁盘占用减少了网络上报时流量也少了好几倍。运行时开销这个事很多团队只看压缩算法本身的耗时其实还应该看两个隐性指标内存峰值和CPU占用波动。BqLog因为有缓冲池和空闲块复用内存峰值基本等于“块大小乘以块数量”不像某些方案每来一条日志就动态分配一次缓存CPU占用上日志线程几乎没有额外开销压缩线程会在块切换时出现一个小的CPU尖峰但整体曲线平滑。这个特性对游戏意义很大——不能因为加日志导致掉帧。5. 拿来即用接入BqLog的实操手册5.1 最小接入代码BqLog是C实现的提供了C接口所以跨平台接入成本不高。最小接入通常这样bqlog::Initialize( bqlog::Config().SetLogPath(/sdcard/logs) .SetLogFilePrefix(battle) .SetCompress(true) .SetLogBlockSize(256 * 1024) ); BQLOG_INFO(battle start, map%s, map_name);初始化放在引擎启动早期日志宏可以先于初始化缓存起来因为BqLog支持初始化前日志缓存不会因为过早打日志而丢数据这个设计挺贴心的。Android上需要处理存储权限iOS上注意沙箱路径这些属于接入环境细节不展开了。关服或退出时记得调用Flush接口把当前缓冲强制刷盘。很多人只在崩溃时后悔没刷盘其实正常退出也一样需要——清理内存、落最后一块日志、给文件打上结束标记一步都不能少。游戏切后台时也建议触发一次Flush避免长时间挂后台导致日志尾块不完整。5.2 配置参数一览把常用配置整理如下方便直接抄作业参数建议值说明LogBlockSize256KB压缩块大小高吞吐可用1MBCompresstrue实时压缩开关FlushInterval500ms~1s定时刷盘间隔MaxFileSize50~100MB单文件滚动大小BufferBlockNum4~8空闲块数量越大多吃内存CompressLevel默认档不建议调HC配BufferBlockNum的时候注意一点不要在低端机上给太多否则日志缓冲长期占着十几MB内存对游戏内存预算来说挺尴尬的。我一般是双缓冲起步观察积压情况再加到4块。如果是MOBA这种有长时间对局的场景建议把MaxFileSize适当调大别让文件滚动频率太高省得日志分析时要跨文件拼接上下文。5.3 容易踩的坑第一个坑是时间戳格式的影响。BqLog对时间戳做了优化默认使用系统时钟的相对时间戳而不是完整的年月日这样每条日志能省下几十字节的格式化开销。但如果你需要在日志里直接看到绝对时间就得权衡——开绝对时间戳舒适但吞吐会略微下降文本可读性提高。大多数游戏都是开相对时间戳线上解码时再换算。第二个坑是压缩线程与主线程绑核。移动端大小核架构下如果日志线程跑在大核上高负载时容易抢占渲染资源如果跑在小核上压缩吞吐受限。我定制过一版把压缩线程固定在“性能中等的核心”上效果比默认调度更好。这个操作依赖厂商的CPU亲和性接口不同平台实现不太一样接入时注意做好降级。第三个坑是测试环境不能只看模拟器。模拟器性能特性和真机差距巨大BqLog的很多优化在模拟器上表现平庸到了真机反而拉开差距。做性能报告时务必标注机型和大核场景数据才有可比性。另外弱机上的后台进程调度可能让压缩线程长时间拿不到CPU建议在弱机上做一次30分钟以上的持续日志压力测试观察缓冲区积压曲线积压持续上涨就说明参数需要回调。6. 日志性能优化的通用套路6.1 日志路径上的开销都是怎么一点一点省下来的BqLog表面是个日志库本质是一次典型的“减少路径开销”教学。一个日志从产生到落盘常规实现里至少会经历格式化分配内存可能多次动态分配→ 写系统日志接口可能有锁→ 拷贝到IO缓冲区 → 系统调用进入内核 → 内核拷贝到页缓存 → 驱动写盘。每一步都有开销每一步在游戏帧率面前都很要命。BqLog的做法是把这些环节逐层卸掉。动态分配改成内存池和块复用有锁路径改成无锁队列用一个原子变量完成块交接系统调用从每条一次变成每块一次内核页缓存的写入从随机小写变成顺序大块。这条思路可以平移到任何日志组件改造上先画出完整路径再对每个阶段问三个问题——能不能不做、能不能合并、能不能延迟很多性能问题不是单个点慢而是路径上每一环累积出来的慢。这里聊聊“为什么很多自研日志库还是慢”。我见过不少团队自己撸了一个日志类功能上挺全但仔细看实现每条日志都走了一遍new、string拼接、fwrite、fflush。这不是写日志这是在给主线程上刑。BqLog的块式缓冲设计之所以值得借鉴就是因为它把所有可变开销都收敛到了“块”这个粒度上单条日志的动作变得极其廉价整体行为也因此可预期、可调参。6.2 一些个人习惯与收尾建议做日志组件调优几年我个人有几个一直坚持的习惯一是日志库要放在一个独立动态库里编译避免因为工程配置不同导致优化选项不一致也方便线上替换版本二是每次上线前跑一遍日志回归看看日志格式改动是否引入了额外的格式化分配哪怕只是多加了一个std::string拼接三是把日志缓冲的积压情况暴露成指标线上通过后台观察压缩线程有没有掉队这对大版本发布后的隐患排查帮助很大。回到BqLog这个项目我更想强调的是“快”不是玄学它就是一系列工程决策叠加的结果——选对算法、攒够批量、无锁交接、顺序落盘。实时压缩是这套决策里最有代表性的一环理解了它也就理解了为什么这类组件敢在游戏客户端里放开用。BqLog这个项目我还想继续深入看它的mmap日志和崩溃恢复机制那部分涉及的是“保命”逻辑比压缩更硬核。如果之后这边实践有进一步结论我会再整理一篇把整个日志体系的权衡再掰开讲一遍。