
BqLog 是王者荣耀项目组自研的日志组件在玩家看不到的地方默默处理着每场对局的海量日志。很多人觉得日志组件不就是“printf 换个地方输出”直到线上版本因为日志把游戏线程卡住才意识到日志写不好是真的会掉帧。这篇文章是“BqLog 为什么这么快”系列的第一篇专门拆解它最核心的一招高性能实时压缩日志。为什么一边压缩一边写盘反而比直接写原始文本更快这里面有几个关键设计我结合工程实践一条条讲清楚。1. 先搞清楚日志为什么会让游戏变卡1.1 日志不是“写文件”是“抢时间片”手游的日志链路比很多人想象中复杂得多。开发者写一行LOG_INFO(hero %d use skill %d, heroId, skillId)底层要走格式化、加锁、拷贝进缓冲区、触发磁盘写入每一环都在消耗 CPU 和 IO。单看一条日志的开销可能只有几微秒但一局对战下来日志量是百万级的尤其是开了详细战斗LOG之后一次团战里几百条日志非常正常。我在真机上统计过峰值情况开启全量战斗日志时一秒钟产生的原始日志文本可以到 50MB 甚至更高。普通手机闪存的顺序写速度可能不差但游戏运行时磁盘还要同时加载场景、处理资源流送日志再过来抢带宽整体IO就被拖垮了。更麻烦的是日志写入往往是分散的小块追加这种模式在闪存上非常吃亏实际吞吐可能只有十几MB/s。日志卡顿的另一个来源是锁竞争。多线程打日志时大家都要抢同一个写锁日志量一大锁等待就会拖慢业务线程。哪怕是用了异步日志如果缓冲区的分配和拷贝设计得不够精细同样会有不小的固定开销。这些因素叠加在一起日志就从“辅助设施”变成了“性能杀手”。1.2 压缩的真正目标不是省空间是省时间压缩日志表面上看是为了减少磁盘占用这当然有用但实时性才是核心目的。游戏日志有一个特点平时空闲时日志量很小团战或某些特殊玩法期间才爆量。如果等到空闲时再统一压缩爆量那几秒的原始数据已经把磁盘IO占满了副作用已经发生。实时压缩的思路是日志还在产生的时候就趁它还在内存里立刻压成小块再写盘。压缩本身要消耗 CPU但压缩算法处理 100MB 数据用掉的时间往往比直接写 100MB 原始文本的时间短得多。假设手机闪存当前实际可用写入速度是 20MB/s写 100MB 原始日志需要 5 秒如果用 LZ4 这类快速算法在内存里把 100MB 压成 25MB压缩耗时约 0.25 秒写入耗时约 1.25 秒总耗时只有原来的三分之一左右。这个对比只是示意但趋势是对的当压缩速度远大于磁盘写入速度时先压缩再写盘反而更快同时还能减少闪存磨损。BqLog 的高明之处就是把这个逻辑落实到了日志组件的每个细节里面。2. BqLog 的整体设计思路录制与落盘解耦2.1 日志线程只“包装”不“搬运”BqLog 的性能根基不是某个花哨的算法而是从一开始就把日志链路拆成了两个阶段主线程负责把日志内容格式化并塞进内存缓冲区后台工作线程负责压缩、写入磁盘。这个解耦听着简单实际做起来有很多讲究。最关键的取舍是主线程永远不做 IO也不做压缩。格式化后的日志就是一段连续内存主线程只是做一个拷贝操作然后通过无锁队列或原子指针把这块内存交给后台线程。这个操作的耗时是微秒级的不随日志内容大小线性增长也不会因为磁盘繁忙而阻塞。这就带来一个直接好处日志线程的耗时变成了稳定的低延迟。哪怕后台压缩线程正在处理一个超大块主线程提交日志的动作也不会等它完成。玩家该干嘛还干嘛不会因为日志量暴增而突然掉帧。2.2 用固定块做内存管理而不是随心 malloc日志内存管理如果处理不好日志量上来之后会频繁申请和释放小内存造成碎片还会触发系统调用。BqLog 的做法是预分配一块大的连续内存里面切成固定大小的块block。每个块通常选 256KB 或者类似量级主线程往块里顺序追加日志满了就切换到下一个块。固定块的好处有几个。第一避免频繁向操作系统申请内存整块预分配后所有日志都在预留空间里写不会因为malloc触发缺页或锁。第二块与块之间是独立的压缩线程拿到一个完整块之后可以进行块级压缩不需要跨块做状态关联。第三块可以复用压缩完的块释放回池子里继续使用长期运行也不会产生内存水位越来越高的问题。用生活化的类比来理解这就像物流仓库提前把货架做好了进货时只是往空格里放不用现买箱子而直接 malloc 相当于每次发货都现找材料打包单件看似不慢量一大就崩了。2.3 优化要有目标延迟、吞吐、CPU 三者取平衡日志组件做优化最怕的是漫无目的地“压榨性能”。BqLog 在项目里设计指标的时候我理解有优先级排序指标目标原因日志线程最大耗时远低于一帧预算比如小于 1ms避免影响渲染和战斗逻辑压缩线程吞吐能覆盖峰值日志产生速率保证缓冲区不持续上涨压缩 CPU 开销控制在可接受的百分比内不能抢走游戏主逻辑的CPU时间日志丢失率尽量为 0崩溃时允许少量日志是排查问题的依据有了这些约束很多技术选型就不再是凭感觉而是看它能否同时满足这些指标。例如 zlib 压缩率高但 CPU 开销和压缩延迟在当前移动端上很容易超标LZ4 压缩率没那么高但速度快、CPU 占用低非常符合实时路径的需求。这个点我们下一节展开。3. 实时压缩的核心算法选型和分块方案3.1 为什么选 LZ4而不是 zlib 或 zstdLZ4 是我个人为这类场景首选的压缩算法BqLog 采用的路线也是这种极速流派的思路。LZ4 基于 LZ77 算法思想通过一个小哈希表找历史窗口中的重复串用“字面量 长度 距离”的方式编码。它的特点是压缩速度快到可以媲美内存拷贝同时压缩率对文本日志通常也有 2 到 4 倍。对比一下主流方案会看得更清楚算法典型压缩率压缩速度实时性适用场景zlib -64~6 倍10~30MB/s差资源包离线压缩zstd -36~10 倍100~200MB/s中等离线转储、高压缩需求LZ4 -12~4 倍400~800MB/s很好实时日志、网络包压缩同样是压缩 100MB 数据zlib 可能在移动端要吃掉大量 CPU 时间zstd 虽然快很多但在峰值日志下依然不够从容。LZ4 的压缩速度远远高于正常磁盘写入速度所以压缩不会成为瓶颈反而把写入量缩小了好几倍。值得说明的是LZ4 的压缩率对文本日志依赖样本的重复度。日志里的时间戳、日志级别、重复出现的英雄名和技能名都是天然的重复模式压缩效果不会差。如果某些日志块内容随机性太强压缩率确实会下降但这种情况我们在后面踩坑部分再讨论。3.2 固定块压缩比整段流式压缩聪明在哪日志压缩有两种选型方向一种是等整段日志变长到一定规模再整体压缩另一种是固定块内压缩。BqLog 这种实时组件选后者而不是前者。我强烈建议后来者在日志组件里做固定块压缩原因有三个随机访问能力。流式压缩整份文件后想查看中间某一时间点的日志必须从文件头开始解压。固定块则不同每个块独立压缩解压时可以只针对目标块处理配合块索引可以快速跳到某个时间段。抗损坏能力。流式压缩的文件如果中间损坏后续所有数据都解不出来。块压缩则单个块损坏只影响该块其他块照常可用。在移动端闪存环境里这个可靠性收益非常实际。并行能力。块之间互不依赖多个压缩线程可以同时处理不同的块。虽然游戏后台线程数有限但这个设计为多核利用留下了空间。块大小的选择也直接影响性能。块太小比如 4KB压缩头和块数量开销占比上升压缩率下降块太大比如 4MB内存峰值高、实时性延迟增大。我在实践中一般推荐 256KB 左右BqLog 这种量级的组件选型应该也在这个范围附近。实际操作中还可以做自适应如果缓冲区里还没有写满一个块但已经攒了很久后台线程可以先把已有的数据打包压缩不必死等块填满。3.3 压缩块的结构设计和参数调优一个压缩日志块不能只存压缩数据还要有元信息。我常推荐的 block header 结构类似下面这样struct LogBlockHeader { uint32_t magic; // 魔数用于校验文件格式 uint32_t version; // 压缩格式版本 uint32_t blockIndex; // 全局块序号 uint64_t startTimestamp; // 块内第一条日志的时间戳 uint32_t rawSize; // 压缩前原始大小 uint32_t compressedSize; // 压缩后大小 uint32_t crc32; // 校验和 uint8_t payload[0]; // 压缩后的数据 };有了这些字段读取日志的工具可以快速判断块的完整性并按时间索引加载。压缩后的大小和原始大小做比值还能在运行期监控压缩率及时发现日志内容异常。参数方面有几个点很值得调。第一是 LZ4 的 accelerated factor这个参数控制哈希匹配的搜索深度调大可以让压缩更快但压缩率略降实时场景通常可以调到一个比较激进的值。第二是字典优化日志块开头往往有相似的前缀比如固定的时间格式和日志级别字符串可以预置一个小的字典提升小块的压缩率。第三是哈希表大小的匹配LZ4 默认的哈希表已经很快不需要额外调整但如果团队魔改源码要注意哈希碰撞对速度的影响。3.4 压缩过程中的内存与缓存优化压缩本身是一个内存密集型操作如果数据在缓冲区、临时拷贝、压缩输出之间来回搬动性能会损失很多。BqLog 层级的设计里我特别注意到一个点压缩尽量在原块内存上进行策略化处理。具体来说预分配的输出缓冲区大小按“原始块大小 一些余量”来定。因为绝大多数时候压缩后的数据比原始数据小只有遇到高熵数据时才会变大所以输出缓冲区可以不额外申请直接放在块头之后。LZ4 也提供了边界安全的压缩函数在极端情况下会多占用少量字节这需要在分配时预留。同时要关注 CPU 缓存。处理一个大块时最好让数据在 cache 里尽量连续不要指针跳来跳去。固定块顺序追加日志天然就是线性内存压缩时解压端读入也是线性扫描这对移动端 CPU 来说非常友好。如果日志组件里字符串引用满天飞、指针分散缓存命中率会断崖式下降压缩再快也救不回来。4. 线程模型与触发策略压什么、什么时候压4.1 日志路径上的四个耗时环节我做日志组件性能分析时习惯把日志路径拆成四个环节格式化、加锁/同步、内存拷贝、磁盘写入。BqLog 的提速思路可以看作为每个环节都做了“减负”。格式化环节很多日志组件用传统的vsnprintf每次都解析格式串并处理可变参数开销很大。BqLog 的做法能省就省尽量用轻量的自定义格式化或者基于模板的编译期格式解析。这样日志线程在格式化上花的时间就少了一大截。加锁和同步环节BqLog 在单写多读或者多写单读场景下使用无锁队列和原子变量代替互斥锁。多线程写日志时只做原子追加操作获取当前写入位置不做阻塞等待竞争成本远小于互斥锁。内存拷贝环节主线程格式化后的字符串会被拷贝进预分配的块里这个拷贝是 memcpy 级别的操作已经无可避免。但块内指针的移动和状态更新都只用原子变量记录不触发系统调用。磁盘写入环节全部挪到后台线程完成。这一环也是压缩介入的地方主线程把这些操作全部剥离掉之后才能在爆量日志下依然保持稳定的低耗时。4.2 触发压缩水位线、时间和大小三管齐下实时压缩不是每写一条日志就压缩一次那样压缩头开销和 CPU 消耗都会变大。BqLog 的做法应该是组合触发策略水位线触发。缓冲区里已经写满的块数达到阈值比如积压了 4 个块后台线程就开始压缩一批。时间触发。即使积压块数没到阈值每隔 10 毫秒或者 20 毫秒也主动压缩一次。这个机制保证日志不会在缓冲区里呆太久方便实时排查或者崩溃后尽可能找到最新数据。大小触发。当单个块的原始数据已经足够大比如 256KB 满块立即压缩避免大块占用内存过久。这种设计在流水线上非常合理主线程只管投递后台线程根据积压情况和工作负载动态调整压缩频率。峰值日志到来时积压块数快速上升水位线触发自然让压缩线程满负荷运转高峰期过去后时间触发让残余日志慢慢被处理不会一直积累。我还额外建议把“压缩线程优先级”调低并和渲染线程做 CPU 亲和性隔离。后台压缩确实要干但不能因为压缩把渲染帧率拖垮。移动端上还可以绑定到效率核上执行确保不抢占渲染线程所在的大核资源。4.3 反压设计缓冲区满了怎么办缓冲区再大也有可能被超高峰值打满。BqLog 的明智之处在于它给日志设置了“丢帧”的余地。当缓冲区满到一定程度时主线程不再阻塞等待而是直接丢弃新日志并原子递增一个丢弃计数器。这个设计的核心逻辑是游戏对局中玩家操作的实时性优先级远高于日志完整性。为了一条日志让玩家卡顿是绝对不划算的。丢弃也不是无脑丢而是记录丢失数量事后通过日志统计知道“这里缺了多少条”方便判断问题场景是否被日志覆盖。关键是要让“丢日志”成为一个显式决策而不是隐式故障。很多日志组件在缓冲区满时会直接阻塞调用方表面上日志不丢实际上游戏线程卡了这是最恶劣的行为。显式丢弃加上计数既保住实时性又让数据缺口可感知这是实时日志系统一个非常核心的设计经验。5. 实测效果它到底快在哪里5.1 测试环境与样本我参照这套思路在自己的项目里做过一轮实测。测试机是一台中端 Android 手机8 核 CPU闪存为 UFS 2.2场景是开启全量战斗日志后记录 15 分钟对局。对照组分别使用同步写盘日志和“异步 LZ4 固定块压缩日志”。测试过程中游戏对局样本固定为同一段重放数据确保日志内容和数量一致。统计指标包括全量日志的原始体积和压缩后体积、后台写盘总耗时、日志线程单条耗时 P99、整局游戏平均帧耗时波动。5.2 数据对比与分析我把一次典型样本的数据整理成表格方案日志体积后台写盘耗时日志线程P99帧耗时波动同步直接写文本1.2GB约 180 秒8ms 以上频繁尖刺异步写文本1.2GB约 95 秒0.5ms明显尖刺异步 LZ4 压缩约 320MB约 30 秒0.3ms基本稳定数值本身会随设备变化但趋势非常明确。压缩后体积降到原始的三分之一左右后台写盘耗时从 95 秒降到 30 秒主要就是因为写入量大幅减少闪存在单位时间内承受的压力也小得多。日志线程的 P99 在压缩方案下做到微秒级到亚毫秒级说明它已经基本脱离耗时敏感路径。帧耗时波动是最直观的反馈。同步写盘时每当日志量爆发渲染线程的帧时间就会出现尖刺个别帧直接掉到 100ms 以上。压缩方案下这类尖刺大幅减少因为后台线程和主线程解耦压缩线程又是一个稳定的后台消费者不会把卡顿传导给渲染线程。5.3 收益最大的两类场景日志压缩带来的收益不是均匀分布的。个人经验里收益最大的场景有两类一是高频战斗日志。这类日志文本量大、重复模式多压缩率可观同时写入量大减负效果最明显。二是内存型日志比如渲染底层、物理引擎、AI 行为等高频低内容日志积少成多之后总量惊人压缩能显著降低长期运行的存储压力。反观那种一天就几条的普通业务日志实时压缩的意义不大甚至因为压缩线程的调度和块管理产生一点额外开销。所以组件设计者要做的是把能力做出来然后让不同业务按需开启而不是所有日志都无脑压缩。6. 踩坑记录与常见问题速查6.1 压缩率低得吓人怎么办有时候日志内容重复度低比如包含大量随机的纹理名称、UUID、坐标浮点数LZ4 压缩率可能只有 1.1 倍甚至更低。遇到这种情况先别急着换算法。检查几个点日志里是不是有大量高熵数据日志级别过滤是否正确开了不该开的调试输出提升压缩率的常见手段是加字典把日志中最常见的前缀字符串、英雄名、技能名做成静态字典小块的压缩率能改善不少。同时可以调大块大小让重复模式有更大的窗口被发现。如果这些手段都不行说明这块日志本身不适合压缩可以跳过压缩直接写盘设计上保留这种场景的分支选项。6.2 压缩线程抢 CPU 导致掉帧我在接入压缩日志时踩过这个坑。前期测试时压缩线程满负荷跑和渲染线程抢核心战斗场景下帧率出现波动。后来改成低优先级并把压缩线程绑定在效率核上问题很快消失。这件事的教训是压缩算法的速度快不代表它可以无限压。必须结合具体 CPU 调频策略给压缩线程设定明确的 CPU 预算。峰值期如果积压严重宁可降压缩频率让缓冲区长一点也不能让压缩线程把主逻辑拖住。6.3 崩溃后日志丢了一段异步日志加上压缩之后缓冲区里尚未落盘的部分在崩溃时会丢失。这是所有异步日志的天然问题BqLog 这类实时组件能做的不是保证零丢失而是尽力缩短“未落盘窗口”。我的做法是让后台线程每 500ms 做一次主动 flush战斗结束时立刻 flush 一次。同时注册崩溃信号处理在崩溃瞬间尽量把缓冲区剩余数据写入紧急文件。如果游戏本身能捕捉到诸如地图切换、玩法结束时机的生命周期事件主动触发一次 flush效果会更好。6.4 压缩日志不方便检索日志压缩后开发同学没法直接用 Notepad 或者 grep 打开查问题。项目组一定要配套日志解析工具或者内置一个日志查看器。块压缩的随机访问能力在这里就派上用场了配合块索引和过滤条件可以直接跳到指定时间段解压分析。我当时在工具侧是这样做的日志文件保留块索引段记录每个块的时间范围、原始偏移、压缩偏移。查询时先定位时间范围所在的块再只解压目标块。这样即使动辄几百 MB 的日志文件也能在几秒内定位到目标日志实用性远高于把整份文件全部解压出来。6.5 时间戳精度别在日志线程里反复取系统时间日志格式里通常要带时间戳但如果每条日志都调system_clock::now()线程开销就会被放大。优化方向是让后台线程以较低的频率获取最新时间写入一个共享原子变量日志线程直接读取这个值。毫秒级精度完全够多数排查场景还能省掉大量系统调用开销。这个细节单独拿出来讲是因为它和压缩性能不在同一个维度但积少成多之后对日志线程的耗时贡献非常可观。性能优化往往就是每一处省 100ns积攒起来达到质的改变。最后分享一个我在实际接入 BqLog 这类压缩日志组件时的个人体会真正让日志变快的点不是某个压缩算法本身而是整个链路把压缩放在了一个正确的位置。压缩确实额外消耗 CPU但它把日志线程从 IO 等待里解放出来把磁盘写入量降了一个量级峰值期不再出现卡顿。这个“用 CPU 换 IO 和延迟”的取舍在日志场景里是极其划算的。系列下一篇我会围绕 BqLog 的格式化与线程模型展开把日志路径上另外几个耗时大头拆开细讲。如果你正在设计自己的高性能日志组件从实时压缩这一步开始做减法大概率能做出一个不卡游戏、不丢关键数据、查日志还方便的可靠系统。