
1. 从一条日志写入链路说起BqLog 为什么要在压缩路径上动刀王者荣耀的日志组件 BqLog 在圈内一直有个口碑——快。但“快”这个字太笼统了落到工程上它其实是一整套取舍的结果。前两篇聊过它的整体架构和内存布局这次我们把镜头拉近专门盯住压缩日志的执行路径看看一条日志从产生到落盘中间到底被优化掉了哪些环节。先说清楚这个内容适合谁看。如果你在做移动端高性能日志、嵌入式埋点、或者任何需要“高频写入 低开销压缩”的场景这篇的拆解思路可以直接借鉴。如果你只是好奇一个日志组件凭什么敢说自己快那也能从里面看到不少工程上的细节取舍。核心关键词就几个BqLog、压缩日志、执行路径优化、CRC、哈希。这几个词不是并列关系而是层层递进的——压缩是目标执行路径是战场CRC 和哈希是路径上最容易被忽视、也最值得抠的两个点。很多人一提到日志压缩第一反应是“上 zlib / zstd 不就行了”。这个思路在离线场景没问题但在游戏这种每帧都可能写日志、主线程卡 1ms 都能被玩家感知的环境里通用压缩库的开销是不可接受的。BqLog 的做法不是“压缩得更狠”而是让压缩这件事在正确的时机、用正确的方式、只对正确的数据发生。这就是执行路径优化的本质不是把某一步做得更快而是让不该走的步骤根本不走。我先把结论摆在这BqLog 压缩路径快的核心在于它把“压缩”从一次性的重操作拆成了一条带前置判断、带增量校验、带惰性触发的流水线。CRC 负责快速判断数据是否值得压哈希负责在字典和去重环节把重复内容挡在压缩之前真正进入压缩算法的数据量已经被砍掉了一大截。下面我按这条链路的顺序一层层拆开讲。2. 压缩日志执行路径的整体设计思路2.1 为什么通用压缩方案在游戏日志里会翻车先算一笔账。假设一局对战平均每秒产生 200 条日志每条平均 120 字节那就是 24KB/s 的原始数据。一局 20 分钟下来接近 28MB。如果不压缩磁盘 IO 和存储都是压力如果每条日志都单独调一次压缩压缩库的初始化和上下文切换开销会直接吃掉主线程预算。通用压缩库的问题在于三点。第一压缩比和速度是强耦合的你想要高压缩比就得接受高延迟想要低延迟压缩比就上不去。第二压缩是无状态的每次调用都要重新建立上下文短数据上这个开销占比极高。第三压缩不区分内容哪怕这条日志和上一条一模一样它也会老老实实再压一遍。BqLog 的思路是反过来的先想办法让进入压缩的数据变少、变规整再让压缩本身变成一件“顺带完成”的事。这就引出了执行路径优化的三个支点——前置过滤、增量校验、惰性压缩。2.2 执行路径优化的三个支点前置过滤指的是在数据进入压缩模块之前先用轻量的手段判断它值不值得压。这里 CRC 和哈希就派上用场了。CRC 校验码计算本身很快它能帮我们快速识别数据块是否发生了变化哈希则用来做内容指纹把重复的日志行直接映射到同一个引用上避免重复压缩。增量校验是指不每次都全量比对而是只校验变化的部分。日志有个特点大量内容是模板化的比如“英雄A对英雄B造成了X点伤害”变化的只有 A、B 和 X。如果每次都对整条日志做完整校验浪费的是 CPU只对变化字段做增量校验才能把开销压到最低。惰性压缩是指压缩不在写入时同步发生而是攒到一定量、或者到某个空闲时机再批量处理。这样压缩库的上下文可以复用压缩比也能因为数据块更大而提升。但惰性压缩有个前提——你得保证攒起来的数据是有序的、可校验的否则一旦出错整个批次都废了。这就是 CRC 和哈希在路径里必须存在的原因。提示惰性压缩不是“延迟压缩”而是“择机压缩”。延迟是被动的择机是主动的区别在于你有没有能力判断什么时候压最划算。2.3 路径优化的收益边界在哪里任何优化都有边界BqLog 这套路径也不是万能的。它的收益在高频、模板化、重复度高的日志场景下最明显如果日志本身就是随机内容、每条都不一样那前置过滤和哈希去重的收益会迅速下降这时候压缩路径的额外开销反而可能成为负担。所以 BqLog 在实际实现里做了一个动态判断当检测到重复率低于某个阈值时会退化到更直接的写入路径跳过部分优化环节。这个设计思路很关键——优化路径本身也要有退出机制否则优化就变成了新的瓶颈。这一点在后面讲常见问题时还会展开。3. CRC 与哈希在压缩路径里的分工与实现细节3.1 CRC 校验码计算为什么选它做快速判断CRC 校验码计算在日志路径里的角色不是“保证数据绝对正确”而是“快速判断数据有没有变”。这两者的要求完全不同。前者需要强校验后者只需要低碰撞、高速度。BqLog 用的是 CRC32原因很实际它在主流移动芯片上都有硬件指令支持单条指令就能算一个块速度比软件实现快一个数量级。相比之下SHA 系列虽然碰撞概率更低但计算开销大得多放在每条日志的写入路径上完全不划算。热搜里有人问“crc sha右键菜单显示关闭”其实反映的就是大家日常对这两类校验的混淆——CRC 是快速校验SHA 是安全哈希用途根本不一样。具体到实现BqLog 不是对整条日志算 CRC而是对日志的结构化字段分段算。比如时间戳一段、日志级别一段、消息体一段每段单独算 CRC 并缓存。这样当只有消息体变化时前面几段的 CRC 可以直接复用不需要重算。这个细节看起来小但在每秒几百条日志的场景下省下来的就是实打实的 CPU 时间。3.2 哈希表与字典重复日志是怎么被挡在压缩之前的哈希表在这里的作用是去重。BqLog 维护了一个日志模板字典每条日志先经过哈希算法算出一个指纹拿这个指纹去哈希表里查。如果命中说明这条日志之前出现过直接写一个引用 ID 就行不需要把完整内容再走一遍压缩。这里有个容易踩的坑哈希表和字典的区别。哈希表是结构字典是语义。很多人把两者混为一谈导致设计时只考虑了查找速度没考虑字典的更新策略。BqLog 的字典是有容量上限的当模板数量超过阈值时会触发淘汰把低频模板踢出去保证哈希表的负载因子维持在一个合理区间。如果只用一个无界哈希表内存会持续增长最终拖垮整个组件。指纹的位数选择也有讲究。位数太少碰撞率高会把不同日志误判为同一条位数太多哈希表本身的内存开销就上来了。BqLog 实测下来用的是 64 位指纹在日志模板数量十万级的场景下碰撞概率可以忽略同时哈希表的内存占用也在可接受范围内。3.3 增量校验只对变化的部分算 CRC这是执行路径优化里最容易被忽略、但收益最直接的一环。传统做法是每条日志全量算 CRCBqLog 改成了增量校验维护一个上次校验的状态新日志进来时先和上一条做字段级比对只对变化的字段重算 CRC未变化的字段直接沿用缓存值。举个例子。假设连续三条日志是[INFO] 英雄A 对 英雄B 造成 100 点伤害 [INFO] 英雄A 对 英雄B 造成 120 点伤害 [INFO] 英雄A 对 英雄B 造成 150 点伤害时间戳、级别、英雄A、英雄B 这几个字段都没变只有伤害数值在变。增量校验只需要对伤害字段重算 CRC其余部分直接复用。实测下来这种模板化日志的 CRC 计算量能降到全量计算的 20% 以下。注意增量校验的前提是字段边界清晰。如果日志是纯字符串拼接、没有结构化那增量校验就无从谈起。这也是为什么 BqLog 在写入前会先做一次轻量结构化这个开销是值得的。3.4 三者协作的时序关系把 CRC、哈希、增量校验放在一条时间线上看顺序是这样的日志产生后先做结构化拆分然后对变化字段做增量 CRC接着用哈希指纹查字典判断是否重复重复则写引用不重复则进入待压缩队列最后在合适时机批量压缩落盘。这个顺序不能乱。如果先压缩再查重那压缩就白做了如果先查重再算 CRC那 CRC 就失去了快速过滤的意义。BqLog 把这个顺序固化在路径里每一步都为下一步减少工作量这才是“执行路径优化”的真正含义。4. 压缩日志执行路径的完整实操流程4.1 日志产生到进入队列的预处理第一步是结构化。日志在产生时并不是直接拼成字符串而是先以字段数组的形式存在。这一步在很多日志组件里被省掉了直接拼字符串结果后面所有优化都做不了。BqLog 坚持保留字段结构就是为了给后面的增量校验和哈希去重留出操作空间。结构化之后是字段级 CRC 计算。这里有个实操细节CRC 的计算顺序要和字段的稳定性挂钩。时间戳、级别这类高频变化的字段放在最后算消息体这类可能重复的字段放在前面算这样一旦发现消息体没变后面的字段甚至可以跳过。这个顺序调整在代码里就是几行的事但收益在压测里能看出来。预处理完成后日志进入一个环形缓冲区。用环形缓冲区而不是普通队列是为了避免频繁的内存分配。日志写入是高频操作每次 malloc/free 的开销累积起来很可观环形缓冲区把这块开销摊薄了。4.2 哈希去重与字典更新的具体操作进入缓冲区后先算哈希指纹。指纹算法用的是 64 位非加密哈希具体选型上偏向于计算速度快、分布均匀的实现。算完指纹后查字典字典的键是指纹值是一个引用计数和模板 ID。如果命中且引用计数未超限直接递增计数并记录引用 ID这条日志的压缩路径到此结束。如果未命中把新模板加入字典同时检查字典容量。容量超限时触发淘汰淘汰策略是 LRU 的变体——但不是单纯按时间而是按“最近使用频率 模板长度”加权因为长模板占用的压缩收益更大值得保留更久。这里有个实操心得字典更新最好放在独立的低优先级线程里做不要阻塞写入路径。BqLog 的做法是写入线程只负责查表和写引用字典的插入和淘汰交给后台线程异步处理。这样即使字典在扩容写入路径也不会被卡住。4.3 惰性压缩的触发条件与批量处理压缩不是随时触发的而是满足以下任一条件时才启动待压缩队列达到阈值大小、距离上次压缩超过时间阈值、或者系统进入空闲状态。这三个条件对应三种场景数据攒够了、数据等太久了、系统有空了。批量压缩时会把队列里的日志按模板分组同一模板的日志连续排列这样压缩算法能更好地利用数据的局部性。实测下来分组后的压缩比能比乱序压缩提升 15% 到 30%因为相同模板的重复内容更多压缩字典的命中率更高。压缩完成后还要做一次整体 CRC 校验确保压缩过程中没有引入错误。这个校验是对压缩块的不是对单条日志的所以开销被摊薄到整个批次上可以忽略不计。4.4 落盘与校验的收尾环节落盘时BqLog 会把压缩块和它的 CRC 一起写入。读取时先校验 CRC通过后再解压。这个设计的好处是即使磁盘出现坏块也能快速定位到具体是哪个压缩块出了问题而不是整个日志文件报废。落盘还有一个细节写入采用追加模式不做原地修改。这样即使写入过程中断电已写入的部分仍然是完整的最多丢失最后一个未完成的块。对于游戏日志这种“丢了就丢了、但不能损坏已有数据”的场景追加模式是最稳妥的选择。5. 常见问题与排查技巧实录5.1 CRC 校验失败但数据看起来正常这是最常见的问题。CRC 失败但数据内容肉眼看着没问题通常有三种原因。第一种是 CRC 计算的范围和校验的范围不一致比如写入时算的是压缩前数据读取时算的是压缩后数据那必然对不上。第二种是增量校验的缓存状态被污染了比如多线程环境下缓存没有正确隔离。第三种是字节序问题CRC 对字节序敏感跨平台读写时如果没统一字节序就会出这种“看着正常但校验失败”的现象。排查顺序建议是先确认计算范围再确认缓存隔离最后确认字节序。前两个是逻辑问题第三个是环境问题从逻辑查到环境效率最高。5.2 哈希碰撞导致的日志错乱哈希碰撞的表现是两条不同的日志被判定为同一条导致其中一条的内容丢失或被替换。64 位指纹在十万级模板下碰撞概率极低但不是零。BqLog 的应对方式是碰撞检测兜底当指纹命中时不直接信任而是再比对一次模板的短校验值比如模板前 16 字节的 CRC。这个二次比对的开销很小但能把碰撞导致的错误彻底挡住。提示任何用哈希做去重的系统都必须有碰撞兜底机制。指望哈希不碰撞本质上是在赌概率工程上不能这么干。5.3 压缩队列积压导致内存上涨惰性压缩的副作用就是队列可能积压。如果日志产生速度持续高于压缩速度队列会一直涨最终吃光内存。BqLog 的处理是给队列设硬上限达到上限时强制触发压缩哪怕系统不空闲。如果强制压缩后仍然积压就降级到不压缩直接写入保证不丢日志。这个降级策略很关键。很多日志组件在压力下会选择丢日志但游戏日志往往用于问题回溯丢了就查不了。宁可写慢一点、占多一点磁盘也不能丢。5.4 字典淘汰引发的性能抖动字典淘汰如果做得不好会在淘汰瞬间引发明显的性能抖动因为要遍历和重建哈希表。BqLog 的做法是分批淘汰每次只淘汰一小部分把抖动摊平到多个时间片上。同时淘汰在后台线程做不阻塞写入路径。实测下来分批淘汰能把单次抖动从几十毫秒降到几毫秒以内对于游戏这种对卡顿敏感的场景这个差别是决定性的。5.5 常见问题速查表问题现象可能原因排查方向解决思路CRC 校验失败计算范围不一致对比读写两侧的 CRC 输入统一计算范围CRC 校验失败缓存状态污染检查多线程缓存隔离加线程局部存储CRC 校验失败字节序不统一检查跨平台读写统一为大端或小端日志内容错乱哈希碰撞检查指纹位数和模板量增加碰撞兜底比对内存持续上涨压缩队列积压监控队列长度和压缩速度设硬上限并降级性能周期性抖动字典集中淘汰观察淘汰时机和耗时改分批淘汰压缩比不达预期日志未按模板分组检查压缩前是否分组增加分组步骤6. 我在实际优化中踩过的坑和几条经验第一个坑是过早优化 CRC 计算。我一开始想着把 CRC 算得越快越好用了各种位运算技巧结果发现瓶颈根本不在 CRC 本身而在字段拆分的字符串操作上。后来把字段拆分改成预分配缓冲整体性能才上来。教训是先定位真正的瓶颈再动手优化别凭直觉猜。第二个坑是哈希表容量设得太大。想着容量大碰撞少结果内存占用上去了缓存命中率反而下降因为哈希表太大导致访问局部性变差。后来把容量调到实际模板数量的 1.5 倍左右性能和内存都达到了比较好的平衡。第三个坑是忽略了压缩块的独立性。早期设计时把多个压缩块串在一起结果一个块损坏导致后面全读不了。改成每个块独立校验、独立解压后容错性好了很多代价只是每个块多几字节的头部信息完全值得。最后分享一个小技巧在压测时不要只看平均耗时一定要看 P99 和 P999。日志路径的优化效果往往体现在长尾上平均值好看但长尾抖动大的方案在真实游戏场景里照样会卡。BqLog 的很多优化比如分批淘汰、异步字典更新本质上都是在压长尾而不是压平均。这个思路我觉得比具体的代码技巧更值得带走。