
写游戏客户端日志组件其实挺反直觉的。很多人觉得“记日志有什么难的print 一下不就完了”但真到了王者荣耀这种对帧率极其敏感的游戏里日志组件往往是整个工程里最难替换、最不敢动的模块之一。BqLog 是这款游戏在客户端使用的日志库它最大的卖点就是“快”——快到即使团战特效、视野计算、技能逻辑同时爆发日志也不会成为卡顿的元凶。这个系列我打算把 BqLog 为什么快拆开讲第一篇先聚焦它最硬核的部分高性能实时压缩日志。不是说简单地调一个高压缩比的库就行而是整套链路里每一环都在为“压缩得既快又不阻塞主逻辑”服务。我以前接手过一个对战项目的日志改造最初直接上了某个通用开源库结果一开日志就掉帧关了日志线上问题又查不了非常痛苦。后来参考 BqLog 这类思路把日志从“字符串搬运工”改成“结构化数据管道”效果立竿见影。这篇文章我会从可度量的性能指标出发一步步拆解实时压缩日志背后的设计选择也会把我自己实践时踩过的坑放在最后希望给正在优化客户端日志的同行一些直接能用的参考。1. 先把“快”这个词拆出可量化的指标1.1 一条日志从产生到落盘要经过哪几道门在讨论 BqLog 为什么快之前先要弄清楚一条日志从源码里那行LOG_INFO(player %d moved to (%f, %f), id, x, y)到一个真正可读的文件中间要经过哪些环节。我把这个过程拆成五道门每一道都可能成为性能陷阱第一是格式化。这里要做的事情是把整数、浮点数、指针、字符串转成文本。printf家族看起来简单但int转十进制字符串要经历除法和取模运算浮点数格式化更加昂贵还要考虑本地化、精度控制。第二是内存分配。拼装后的字符串长度不确定常见的实现会malloc一块新内存高频日志下内存分配器压力巨大还会造成碎片。第三是锁竞争。多个游戏线程同时写日志往共享缓冲区里塞数据必须加锁锁一旦争抢激烈线程就被挂起等待。第四是磁盘 IO。日志最终要落盘写小文件的随机 IO 性能很差操作系统页缓存也可能因为突发写入被打穿。第五是压缩。如果做实时压缩压缩算法本身要消耗 CPU如果压缩放在攒够一定量之后再做又可能导致日志延迟过高崩溃时丢大量数据。BqLog 的高明之处不是把这五道门都消灭掉而是把每一道门的成本都压到极低并且把它们从关键路径上拆走。它不是更快地干同样的活而是让主线程“少干活”把格式化、压缩、IO 这些重活交给专门的后台管线。1.2 为什么游戏客户端对日志的“实时性”这么敏感很多后端日志框架允许几秒甚至几分钟的延迟日志先落内存攒到一定量再批量写。但游戏客户端不行。手机游戏在战斗场景里帧率通常是 60Hz高性能机型可能跑到 120Hz一帧的预算只有 8.3 毫秒甚至更短。日志如果突发积压缓冲区的水位会迅速上涨紧接着就是两个坏结果要么缓冲区满了之后新日志被丢弃问题现场记录不全要么为了清空缓冲区触发一次大刷新造成明显的帧率尖峰。更麻烦的是崩溃场景。游戏客户端最常见的排查需求就是“刚才那一帧发生了什么”如果日志延迟过高崩溃前那段最关键的数据可能还窝在内存缓冲里没来得及写盘App 一挂什么都没了。所谓“实时压缩”我理解有双重含义一是时间上的实时日志产生后要尽快收敛成小体积别让缓冲区和目录越撑越大二是空间上的实时不是在出事之后做个离线压缩而是日志边产生边压缩让落盘的就是压缩后的紧凑格式。BqLog 在这两个维度上都做了针对性设计。1.3 BqLog 与传统异步日志最大的一点不同传统异步日志库的动作是主线程格式化字符串 → 把字符串塞进队列 → 后台线程取出 → 写文件。主线程该做的格式化一点没少只是把写盘动作挪走了。BqLog 不一样它的出发点是“日志内容存在大量可压缩的结构”。一条日志可以拆成两部分日志模板和参数。比如player %d moved to (%f, %f)这个字符串是模板id和坐标是参数。模板在整个程序生命周期里只有固定那几种参数才是每帧都在变的。如果不做拆分每条日志都得把模板字符串完整存一遍哪怕你用的是再好的压缩算法压缩器也得先处理这些重复数据。BqLog 把日志模板单独缓存单条日志只需要记录一个模板 ID 加上紧凑编码的参数组合数据量天然就小了一大截。这个“结构化优先”的设计是它后面所有高性能压缩手段的地基。2. 实时压缩的核心先结构化再压缩2.1 模板化日志带来的压缩红利我用一个很简单的数字来说明模板化的威力。一局游戏里可能输出几十万条日志但日志格式往往是有限的几百种。假设一条完整文本日志平均 100 字节其中格式字符串部分占 60 字节、参数部分占 40 字节。传统方案要存 100 字节的文本走压缩算法时压的也是这 100 字节。BqLog 的思路则是日志模板在首次出现时登记为模板 ID之后每条日志只存模板 ID 加参数。参数经过二进制序列化后可能只需要 16 字节算上头部信息也就 20 字节上下。同样是 20 字节的原始数据直接文本方案可能要经过压缩才能从 100 字节降到 30 字节而 BqLog 的 20 字节已经接近了压缩后的体积再配合压缩算法还能继续瘦身。更重要的是省出的部分不只是磁盘空间还有格式化耗时和内存带宽。在我自己复刻这个思路时最先得到的体感是日志缓冲区的占用率直线下降。原本 4MB 环形缓冲区几秒钟就写满改造后能撑十几秒后台线程的刷盘频率也大幅降低。别小看刷盘频率频繁的小规模磁盘写入在手机上会让电池电量肉眼可见地往下掉。2.2 分块压缩怎么切分才算合理模板化把单条日志的体积降下来了但要把压缩率继续往上提还得靠批处理。任何压缩算法都有个特点输入数据越长、重复模式越多压缩率越好。如果一条一条地单独压缩光是一次性开销例如 LZ4 的块首部、Zstd 的帧描述符就占了很大比例压缩率也上不去。BqLog 的处理方式是分块压缩。把一段连续时间内产生的日志先写进一个内存块这个块积累到一定大小后再交给压缩器。块大小的选择要有讲究太小了压缩率差太大了实时性差。从我的实践看块大小设置在 16KB 到 64KB 之间比较合适。16KB 对 LZ4 来说已经能跑出不错的压缩比而 64KB 基本能覆盖一次大规模团战几秒内的日志量兼顾了快速落盘的需求。还有一个容易忽略的细节日志种类在时间上是有局部分布的。战斗日志集中在战斗阶段UI 日志集中在切界面阶段这种天然的语义聚集让压缩器更容易找到重复模式。所以分块时一定要按时间顺序连续切分不要按照线程或模块去隔开。我以前试过按模块分别压结果压缩率反而比以前更差因为每个模块单独的数据流太短重复模式被打散了。2.3 压缩算法怎么选通用选型思路有人会问BqLog 用的什么压缩算法公开资料显示它跟 LZ4、Zstd 这类算法有关联但核心其实不是算法本身而是怎么用。我给自己的项目做压缩选型时列过一张对比表这里也分享给你。算法压缩速度压缩率解压速度适用场景LZ4极快可达 400MB/s 以上中等日志类数据通常 3~5 倍很快日志量大、CPU 紧张、要求低延迟Zstd快且可调级别更高可达 5~10 倍快离线分析、日志体积敏感Deflate一般接近 Zstd 低级别一般兼容旧格式首选它的情况越来越少移动端游戏日志场景我更倾向于 LZ4 这种偏速度的算法或者 Zstd 的低压缩级别。原因很简单日志压缩的目标不是追求极限体积而是让后台线程用最少的 CPU 消耗把数据收敛到可接受的大小。压缩率从 5 倍提到 8 倍省下的存储空间可能就几百 MB但 CPU 开销涨 30% 却可能直接反应到发热和掉电上不划算。BqLog 这类组件还会根据日志量动态调整压缩级别。平时低强度输出用快速压缩日志量暴增时切换到更高压缩级别来缓解 IO 压力。这个思路我复刻过实测下来很有用尤其是在弱网环境下日志需要上传到服务器时压缩率突然变得很值钱。3. 高性能的落地点缓冲区、线程与内存分配3.1 无锁环形缓冲区与批次写出实时压缩要快就不能让主线程动不动就去锁一个全局队列。BqLog 的做法是让每个写日志的线程拥有自己的本地缓冲区写完后再把这批日志整体提交到一个全局的环形缓冲区。这项设计借鉴了“每线程缓冲 合并提交”的思路我后来在项目里也照搬了这套。具体的提交机制用到了原子操作。生产者线程要写入时通过 CASCompare-And-Swap原子地移动写入游标没有传统的 mutex 锁。只有当缓冲区快满或者后台线程主动发起刷新时才需要做一次同步。因为日志写入本身是高频低量的操作CAS 的冲突概率很小性能非常稳定。对比我之前用的 mutex 方案在高并发场景下锁等待时间动辄几十微秒而 CAS 方式基本在个位数微秒以内。这里有个细节值得注意无锁队列的容量设置不能拍脑袋。环形缓冲区太小生产者和消费者之间的水位波动会非常剧烈消费者来不及取走数据生产者就得频繁重试。缓冲区太大内存占用又扛不住。我按“每条日志平均 24 字节峰值每秒 50 万条容忍 1 秒的积压”来算环形缓冲区至少要 12MB。实际项目里我给到了 16MB因为有模板缓存和参数序列化之后单条日志的落盘体积比预期更小16MB 带来的水位余量足够让后台线程平滑工作。3.2 压缩线程和 IO 线程的分工有些日志组件只有一个后台线程既要做压缩又要写文件。压缩是 CPU 密集操作写文件是 IO 密集操作两者混在一起容易互相拖累压缩慢的时候 IO 线程闲着IO 卡住的时候压缩线程又堵在文件上。BqLog 的思路是至少拆两个线程一个压缩线程负责从缓冲区取原始数据、做压缩另一个 IO 线程负责把压缩后的块写入文件。我实际做过一个改动测试单线程下日志吞吐量大约 40 万条/秒把压缩和 IO 拆开后就升到了 70 万条/秒以上。原因很好理解现代手机 CPU 通常有多个核心压缩线程可以绑定到一个闲置的核心上跑IO线程则专注于文件写入和系统调用的处理两者并行度一下子就起来了。另外要提醒一句线程名字一定要起清楚。我见过不少项目所有后台线程都叫“worker”一旦要做性能分析根本分不清哪个线程在跑压缩、哪个在跑 IO。BqLog 在这类工程细节上做的很到位也给排障省了很多时间。3.3 对象池与复用机制为什么重要日志组件的性能杀手还有一个频繁malloc和free。手机上的内存分配器在多线程高并发下表现很不稳定分配速度可能从几十纳秒飙到几微秒而且长时间运行后堆碎片会导致虚拟内存膨胀。BqLog 的思路是尽量复用一切可以复用的对象特别是日志格式的解析结果、缓冲区块、压缩上下文。我自己做项目时对“日志事件对象”做了池化。每条日志从池子里取一个事件结构体填充完参数后投递到队列后台消费完再归还给池子。这样日志的高频路径上几乎不会触发系统堆分配。实测下来压力测试里的分配次数从每百万条日志分配合格几百次降到了几乎为零耗时曲线也变得更平滑没有突然冒出来的毛刺。这里要注意池子的接口设计成“裸指针返回”就行不要用智能指针因为智能指针本身也有引用计数的原子操作开销。池子实现成无锁栈或者简单的互斥栈都可以关键是别再把“是否归还”这件事交给 GC 或者智能指针的析构逻辑手动控制归还时机性能最可控。4. 实操参考我的高性能日志改造清单4.1 先做日志模板的静态注册如果要参考 BqLog 的思路改造自己的日志组件第一步不是换压缩库而是改日志接口设计。你要让日志系统在编译期就知道格式串是什么这样运行时可以只传参数。我采用的方案是给每个日志点一个静态的模板 ID用一个宏把格式串注册到全局表里运行时根据 ID 直接查模板。这个改动落地后日志接口从“传字符串”变成“传模板 ID 和参数列表”主业务线的改动量不小但收益是巨大的。第一次跑通时我很直观地看到同样的日志点CPU 占用下降了 60% 以上因为字符串拼接那部分开销彻底没了。有个小坑要提醒模板注册表的并发初始化要处理好。我一开始用懒加载 std::once_flag结果在极端并发下还是偶发卡顿。后来改成程序启动时预热把所有模板提前注册好运行期完全只读彻底解决了问题。4.2 双缓冲切换与水位线机制实时压缩和 IO 之间的节奏需要一些控制机制不然后台线程要么太勤快地刷盘、浪费 IO 次数要么太懒、日志积累过多。我实现了一套简单的双缓冲切换前台缓冲收集新日志后台缓冲处理和压缩两个缓冲定期交换。交换的时机不由定时器决定而是由“水位线”决定。当前台缓冲使用量超过某个阈值就触发一次交换如果日志量很小则等到超时时间到了再交换。这样既能应对突发日志又避免了空转。具体的参数我推荐这么设前台缓冲 4MB触发交换阈值 60%也就是 2.4MB超时时间 100ms。如果一局游戏里日志量一直很小100ms 刷一次也足够保持实时性如果突发日志把 2.4MB 填满了立即交换延迟极低。这个 60% 的阈值是经验值太高了会在大突发时填满缓冲导致日志丢失太低了又会让交换过于频繁你可以根据自己的日志速率微调。4.3 崩溃现场的日志怎么保住游戏客户端日志另一个核心诉求是崩溃现场可追溯。BqLog 解决这个问题的方式在我看来很聪明压缩后的日志不是直接丢进普通文件而是采用“追加块”的方式写入每个块都带有校验和和块元信息。崩溃发生时已经压缩落盘的块都能完整读取未落盘的日志也会通过信号处理器尽量写到独立的小文件中。我自己实现的崩溃兜底方案比较朴素但也有效崩溃信号发生后用一个最小的内存安全路径把当前环形缓冲区里的日志直接追加写到独立文件不解析、不压缩只做裸拷贝。因为此时调用任何复杂的分配器或者锁都有可能二次崩溃所以最安全的就是裸写。实测下来崩溃前最后几百条日志都能成功落盘对于排查“最后发生了什么”已经完全够用了。在此基础上如果能进一步做到 BqLog 那样的自动拼接持久化块体验会更好。5. 压测与踩坑实录5.1 压测时要盯住的四个指标很多人给日志组件压测只看“每秒多少条日志”这个指标太笼统了。我建议至少看四个维度主线程写入平均耗时、主线程写入 P99 耗时、后台线程压缩吞吐量、以及压缩比。主线程写入平均耗时决定日志系统对游戏帧数的影响P99 决定了极端情况下的卡顿尖峰压缩吞吐量决定了后台是否能及时消化日志压缩比则决定了磁盘占用和网络带宽。我统计这几个指标时都是用日耗时直方图来观察分布而不是只看均值。均值好看但 P99 爆炸的情况在游戏里一样会表现为掉帧。我之前压测时犯过一个错误只在纯 CPU 场景下压测没有加载资源、没有跑真实战斗逻辑结果压缩线程很悠闲主线程也很轻松。后来把日志组件嵌入到一局真实战斗的自动化脚本里数据表现立刻不同——主线程 P99 长尾变长这是因为真实场景中有大量内存分配、GC 和其他线程抢占 CPU。所以压测场景一定要尽量贴近真实战斗负载至少要把敌人 AI、技能特效和 UI 更新同时跑起来。5.2 典型问题一压缩线程抢占主线程 CPU我在第一次启用实时压缩后遇到了一个奇特现象帧率不降但主线程的帧间隔偶尔出现一个小尖峰。用性能分析器一查发现压缩线程和主线程被操作系统调度到了同一个 CPU 核心上压缩线程在高负载下抢占 CPU导致主线程某个时间段拿不到足够的时间片。解决办法是在线程创建时显式指定 CPU 亲和性尽量把压缩线程钉在某个核心上并设置优先级略低于主线程。移动端多数 CPU 采用大小核架构把压缩线程绑到中核或大核 ID 较低的线程上通常效果最好。要注意的是不同手机的核数差异很大绑定策略需要做一次运行时探测再决定不能让代码在某个机型上直接崩溃。还有一点压缩级别调太高也会加剧 CPU 抢占。后来我把 LZ4 的压级调低换上更快的预设CPU 尖峰就消失了。压缩比稍微低一点无所谓掉帧才是不可接受的。5.3 典型问题二模板字符串缓存无限膨胀模板缓存如果设计不好会变成内存泄漏的黑洞。我第一次实现时只是简单地把所有用到的格式串丢进一个 std::vector想着日志格式总共就那么多不会无限增长。结果接入 SDK 和埋点日志后各种拼接起来的长格式串疯狂注册模板表很快膨胀到几百 MB。后来我改成两层结构常用模板走静态注册表数量封顶动态模板走 LRU 缓存超过 100 条后按最近使用时间淘汰。同时还把格式串的哈希做得更稳避免因细微空格差异导致重复注册。这些改动让模板缓存从几百 MB 降到了十几 MB长期运行也能稳定在固定水位。5.4 一组参考数据最后给出我这套改造后的参考数据方便大家心里有个比较基准。压测机是骁龙 8 系列中端机型Android 系统测试场景为制造 100 万条带参数的日志混合压力指标改造前文本日志 单后台线程改造后结构化模板 实时压缩单条日志平均产生耗时约 1.8 微秒约 0.35 微秒主线程 P99 写入耗时约 12 微秒约 3 微秒日志体积100 字节/条压缩后约 15~20 字节/条100 万条日志落盘耗时约 3.2 秒约 1.1 秒这个数据不代表 BqLog 官方性能只是我的个人复刻实验方向是一致的结构化模板先把体积减下来实时压缩再用低开销把体积压到位后台线程并行处理让整体吞吐量翻番。如果你的项目正卡在“日志一开就卡、一关就瞎”的泥潭里这套思路是可行的解药。6. 这套设计还能扩展出什么玩法实时压缩日志的价值不止于“磁盘省了”。一旦日志以结构化参数的形式流动起来后面的玩法空间很大。因为模板 ID 和参数都是二进制可以直接序列化用于回放和自动化测试。我曾经用类似的思路把特定模板的日志抽出来做成“事件流”实现了对局内关键事件的快速回放排查问题效率提高了不少。另一个扩展方向是多级压缩级别自适应。BqLog 这类组件里所谓“动态级别”的最终形态是根据设备发热、电池电量、CPU 负载来自动调整压缩档位发热了就降低压缩率换取更低功耗充电且 CPU 空闲时就提高压缩率给日志做深度瘦身。这个逻辑放到游戏框架里也很自然毕竟游戏的资源调度本来就是动态的。对于定位内存泄漏结构化日志也能派上用场。模板 ID 可以快速聚合“哪个模块在疯狂刷日志”而不用每次去 grep 文本。我在之前那个项目里就写了一个小工具解析日志文件数据库里的模板统计按频次排序后一眼就能看出哪个逻辑在空循环里打日志。这个体验传统文本日志根本给不了。最近我在折腾的还有日志加密和权限控制。因为结构化日志可以按模板维度做白名单特定模板的日志不落盘或者脱敏后落盘敏感信息管控比文本正则要稳得多。这对于有合规要求的游戏来说也是一块必须要补的短板。如果你也在做类似的日志改造我的建议是不要纠结于“抄一个 BqLog”而是先吃透它背后的三个决策结构化模板为什么能省下大头开销、分块压缩为什么比流式压缩更适合日志、后台管线为什么必须拆开来跑。把这三个问题想清楚再用自己的代码落地你会少走很多弯路。下一篇我打算继续拆 BqLog 的另一个核心能力也就是它的运行时日志级别切换和热更新机制那个东西在线上排查问题时同样救命。如果你想看留言告诉我你在日志改造中最头疼的是哪个环节。