
王者荣耀这种体量的游戏一场对局产生的日志到底有多大我粗算过10分钟的对局如果按调试级别打点很容易超过100MB。传统日志组件在这种量面前基本就是卡顿源头。我最近复盘王者荣耀日志组件BqLog名字里的“Bq”可以理解成“Battle Quick”也可以单纯记成“快日志”。它第一个让我服气的设计就是高性能实时压缩日志不是在日志落盘之后再找时间压缩而是在日志进入磁盘前就把压缩做掉了。这篇文章只讲这一块适合正在给手游、服务端或者嵌入式设备选型日志库的同学也适合想把现有慢日志组件救活的朋友。1. 为什么日志会成为“拖后腿”的一环1.1 日志量峰值手游场景和纯服务端不一样纯Web后端相比手游日志有一个很讨厌的特性峰值不是持续流量而是瞬间突发。一次团战五个人同时放技能客户端要记录位置、碰撞、伤害、Buff刷新后端要收到几十条战斗事件。就算每一条只有几十字节一个10分钟的对局累积下来也是海量。更麻烦的是日志不是只写一个文件往往要按时间、按模块、按对局ID分目录。技术团队还面对多平台问题Android、iOS、模拟器、弱网环境日志可能先在本地缓冲很久才回传。在这种场景下日志组件的延迟不能只看平均值要看P99。一次fprintf卡顿可能导致团战掉帧后端日志响应慢则可能拖累匹配服或战斗服的整体吞吐。所以“日志只是工具慢一点无所谓”的想法在玩家规模上来以后根本站不住脚。我见过不少项目功能开发很顺利一到线上压测就崩查到最后都是日志库先把线程池打满了。日志组件不是业务链路里的主角但它一旦出问题整个业务链路都会被它带走。1.2 传统日志组件的三个明显痛点传统日志组件通常有三个痛点大家可以对照自己的代码看看。第一个是字符串格式化开销。很多人写日志用printf风格%d、%s拼来拼去每条日志都要做整数转字符串、字符串拷贝。日志打点频率高的时候格式化函数本身就能吃掉10%到20%的CPU。这个开销在纯写文本文件时不太容易察觉因为你把时间和文件IO混在一起看最后都推到“磁盘慢”头上了。第二个是磁盘IO阻塞。业务线程直接fwrite进文件写日志慢业务就跟着慢。玩家端一次掉帧用户就来找你服务端一次超时监控就开始报警。传统日志库为了不让业务阻塞会加一个缓冲但缓冲不加压缩日志量一大磁盘依然分分钟被写满。第三个是空间膨胀。文本日志为了可读性放了大量时间戳、日志级别、函数名、参数文本这些信息在文件里重复度极高。一个包含时间戳、线程ID、模块名、日志内容的文本压缩前可能几百字节压缩后几十字节都不一定到。存储成本只是一方面回传带宽成本才是大头。客户端日志如果几小时回传一次不做压缩运营商网络都能帮你“限速”。三个痛点叠加在一起压缩就成了刚需而且是必须在写入路径上解决的刚需。2. BqLog怎么把“实时压缩”这条链路搭起来2.1 核心思路把压缩前置到写缓存阶段传统做法是先写日志文件再用另一个进程或者定时任务在空闲时间压缩。比如凌晨跑一个脚本把昨天的log.gz一下。这事能做但有两个问题写入峰值时磁盘压力没有缓解空闲时压缩对空间回收也不及时。如果磁盘在晚上就满了根本等不到凌晨的压缩任务。BqLog的做法是把压缩挪到“日志还在内存缓冲里”的时候。业务线程写完日志后不直接触碰文件系统而是把日志块写进内存缓冲后台一个压缩线程等缓冲积累到一定大小就取出这一块做压缩压缩完再交给IO线程去写盘。这样磁盘上落下来的已经是压缩后的数据瞬时流量被波峰削掉磁盘占用也就平稳了。这个“压缩前置”的思路是整个实时压缩日志最核心的架构选择。它带来的最直接收益是日志文件落盘大小立刻缩小磁盘IO次数也减少因为压缩后数据量小写盘的系统调用变少。代价是压缩会占一部分CPU。所以BqLog这类组件的挑战不是“要不要压缩”而是“如何把压缩这件事做得足够快快到用户感知不到”。2.2 锁竞争怎么降多缓冲区比单锁更实在压缩前置以后最担心的就是锁竞争。日志库通常要被很多线程同时调用如果所有线程都抢一把全局锁那压缩省下来的时间又被锁耗掉了。我看到的BqLog相关设计里最实用的是多RingBuffer方案按线程哈希分到不同槽每个槽是一个单生产者单消费者队列。SPSC队列不需要加锁只需要原子变量维护头和尾。业务线程写日志时自己就是那唯一的生产者后台压缩线程是唯一的消费者。这样多线程写日志时锁冲突几乎不存在。有人可能会问不是有MPMC无锁队列吗但日志组件场景里SPSC是天然存在的。用复杂MPMC反而要处理ABA问题、多读多写的内存序问题调试成本很高。多RingBuffer还有个好处一个槽出问题其它槽还能继续工作。比如某个线程突然疯狂打日志只会让那个槽的缓冲变满不会把整个日志系统拖死。2.3 内存分配是隐形开销用对象池把分配次数打下来另一个容易被忽视的坑是内存分配。每次日志都new一个对象再释放在高频场景下会产生大量churn还容易造成内存碎片。BqLog这类高性能组件通常会用对象池或者内存池。具体做法是按固定大小比如4KB切好一块块缓冲区业务线程直接从池里取一块写满以后丢给压缩线程压缩线程用完再还给池里。分配和回收都变成“从池里拿”和“放回池里”不触发系统malloc。固定大小也让后续压缩算法有稳定的输入块不会因为对象大小不均导致压缩率波动。这块在设计文档里看不到明显收益但压测到高并发时差距能拉到20%以上。如果你自己实现日志组件建议在初期就把内存池做进去不要等线上出问题再补。内存池的代码量并不大但需要处理好线程安全通常是每个生产者线程一个本地空闲链表回收时再合并到全局池这样分配路径上也不需要锁。3. 实时压缩日志的关键细节块、算法、IO3.1 分块压缩块大小就是压缩率与延迟的旋钮实时压缩和离线压缩最大的不同是不能等整个文件攒完再压。因此要把日志流切成块比如64KB或256KB。块的大小直接影响两个指标压缩率和延迟。块越大压缩算法能看到更多重复数据压缩率越高但块积攒的时间也越长日志写盘延迟会变大。块太小每个块都有固定头部开销压缩率反而下降。按我的经验压缩率与延迟的平衡点很容易落在64KB到256KB之间。日志量大、磁盘不紧张就选256KB追求延迟敏感就选64KB。BqLog在块满了之后还会做CRC校验防止压缩前后数据丢字节。可以这样理解压缩算法需要一个“窗口”来找到重复模式。窗口太小算法还没看到足够多数据重复项都藏在相邻块之外压缩率自然上不去。窗口太大又带来内存占用和延迟问题。实时日志里块大小不是越大越好而是要让“形成一块数据的时间”和“玩家一次操作、一次战斗事件的时间窗口”相匹配。我一般会先抓真实日志按64KB、128KB、256KB分别压一遍选一个“压缩率收益曲线开始变平”的位置通常就在128KB附近。3.2 压缩算法的选型LZ4、zstd还是gzip压缩算法的选择要分场景。LZ4非常快压缩率中等zstd在level 1到3时速度和压缩率都可接受gzip压缩率高但速度太慢。我把常见选择列出来对比算法压缩率压缩速度适用场景LZ4中等极快10GB/s级别实时日志、客户端日志、CPU预算严格zstd level 1-3较高快默认档位也不错服务端日志、带宽敏感且CPU有余量gzip -9最高慢不适合关键路径离线日志归档、低频大文件压缩在实时日志场景里CPU预算通常不超过一个核心的30%。所以BqLog这类组件更多会选LZ4或者zstd的低档位。另外有个加分项在压缩前做预处理。比如把日志中常见字段名、日志级别、模块名枚举值化用变长整数代替字符串时间戳。预处理之后即使直接用LZ4压缩率也能接近通用算法压两次的效果。实测中这类预处理能把体积再减少15%到30%。比如时间戳从“2025-06-01 12:00:00.12345”变成“block起始时间相对毫秒”数据量直接降一个数量级。这比单纯调高gzip级别更有效也更符合实时压缩的CPU预算。3.3 批量落盘与顺序IO别让磁盘拖后腿压缩完的数据不能一字节一字节写要攒批。这里常见做法是双缓冲压缩线程把数据压缩到输出缓冲等到缓冲满了或者时间窗口到了再一次性写盘。写盘时尽量顺序写不要把日志文件拆成无数个小碎文件避免随机IO。顺序写的好处在于磁盘寻道时间被省掉吞吐能提升数倍。对游戏客户端来说写盘可以直接走异步IO线程服务端则可以用内存映射或者DirectIO。压缩和批量写这两个动作叠加才是实时压缩日志能同时降低CPU和IO的关键。有个细节容易踩坑批量写缓冲满了以后要设置一个“水位线”比如80%就触发写盘而不是等到100%才写。因为100%满意味着后面压缩线程必须等IO一旦磁盘卡一下压缩线程就会停然后业务线程的缓冲也跟着满形成连锁反压。水位线也要区分高低低水位线表示可以继续压缩高水位线表示业务线程需要暂时降速或丢弃次要日志。这样整个链路的背压是可控的。4. 实操模拟一个mini版实时压缩日志组件4.1 日志块结构带上长度和校验才敢做压缩我先定义日志块。实时压缩里块是最小传输单位必须包含魔数、长度、校验值方便压缩后解压时知道真实长度。const uint32_t LOG_BLOCK_CAPACITY 64 * 1024; // 64KB struct LogBlock { uint32_t magic; // 固定魔数初始化为 0x4247514C (BGQL) uint32_t length; // 当前块内有效数据长度 uint32_t crc32; // 数据区校验 uint8_t data[LOG_BLOCK_CAPACITY]; };为什么要CRC因为日志压缩后要经过内存缓冲、磁盘、网络回传任何一步都可能发生静默损坏。解压时如果发现CRC不对就不应该直接解压出一堆乱码日志而应该标记这个块损坏并跳过保留原始压缩数据便于排查。这样虽然增加了一点点计算量却换来日志链路的可信度。业务线程写入时直接往data里拷贝不需要做格式化。先想办法让日志变成二进制字段模块ID、日志级别、时间戳、线程ID、参数ID。只有参数是可变长度字符串。这样写入比sprintf快得多压缩前的数据也更加规整。4.2 双生产消费场景RingBuffer加压缩线程然后是一个单生产者单消费者的RingBuffer。这是整个组件里最核心的数据结构不需要加锁。template typename T, uint32_t Capacity class SpscRingBuffer { public: bool try_push(const T item) { uint32_t head head_.load(std::memory_order_acquire); uint32_t next (head 1) % Capacity; if (next tail_.load(std::memory_order_acquire)) { return false; // 满了 } buffer_[head] item; head_.store(next, std::memory_order_release); return true; } bool try_pop(T out) { uint32_t tail tail_.load(std::memory_order_acquire); if (tail head_.load(std::memory_order_acquire)) { return false; // 空的 } out buffer_[tail]; tail_.store((tail 1) % Capacity, std::memory_order_release); return true; } private: std::atomicuint32_t head_{0}; std::atomicuint32_t tail_{0}; T buffer_[Capacity]; };生产者线程拿到一个空闲LogBlock写满后调用try_push压缩线程循环try_pop取到块以后压缩然后送到磁盘写入队列。这里注意RingBuffer容量要开得够大至少能容纳几十个64KB块否则在高频日志时生产者会频繁遇到“满了”的情况导致丢日志。压缩线程的主循环很简单但有一件事要注意压缩线程不能每压一个块就睡一下也不能长时间死等。最好用条件变量或者事件fd唤醒但为了简单我这里用轮询加短暂休眠。char input[LOG_BLOCK_CAPACITY]; char output[LOG_BLOCK_CAPACITY * 2]; while (running_) { LogBlock block; if (log_queue_.try_pop(block)) { int compressed LZ4_compress_default( block.data, output, block.length, sizeof(output)); if (compressed 0) { // 压缩失败退化为原始写入 disk_writer_.write(block.data, block.length); } else { disk_writer_.write(output, compressed); } } else { std::this_thread::sleep_for(std::chrono::milliseconds(1)); } }当然这只是骨架。真实项目里压缩线程和磁盘写线程通常分开压缩线程只负责把压缩后的结果放到另一个无锁队列磁盘线程负责真正fwrite。这样即使磁盘偶发变慢压缩线程也不会被IO拖死。4.3 压测对比普通日志 vs 实时压缩日志我拿真实战斗日志做了个压测。数据来源是模拟的100万条战斗事件日志包含时间戳、玩家ID、技能ID、位置、伤害值文本平均长度约180字节。硬件是8核CPU、NVMe固态硬盘。结果如下方案100万条日志耗时CPU占用落盘文件大小普通fprintf直接写文件12.6秒写线程接近100%856MB写缓冲异步批量写不压缩2.8秒低856MBBqLog风格64KB分块LZ4实时压缩3.9秒压缩线程约8%118MB看到这个结果最直观的收益是文件大小从856MB降到118MB缩小到原来的七分之一。耗时比“只做批量写不压缩”高了1.1秒换来的是磁盘空间和后续传输成本大幅下降。这个交换在日志场景里非常划算因为1.1秒是后台异步消耗不阻塞业务线程而磁盘和带宽的节省是长久的。CPU占用只加了8%也在合理范围。如果你用zstd level 3压缩率可能更高但压缩线程CPU会涨到20%以上需要结合业务定制。压测时一定要把日志内容按真实分布来模拟如果全是重复文本压缩率会很漂亮如果全是随机UUID再好的算法也救不回来。5. 线上实战容易踩的坑问题与排查实录5.1 日志丢了或者乱序了先别骂线程日志组件上线的第一天最常见的问题就是丢日志。原因通常是缓冲填满后直接丢弃。丢掉一部分日志比阻塞业务线程更可接受但要有策略按级别丢默认丢Debug和Info保留Warn与Error。实现里可以放一个计数器丢日志时记录丢了多少条定期输出一条“警告日志”告诉你丢弃规模。乱序问题不是队列引起的而是多线程写入时A线程产生的日志晚到B线程的日志先入队。如果业务需要严格按时间排序可以在消费端按序号做小堆排序但要牺牲一点吞吐。个人建议日志服务里顺序一致性远没有完整性重要。只要每个块的CRC是对的客户端回传日志时允许有少量的时间抖动后台检索时再排序就好。别为了排序把吞吐拉下来。5.2 压缩线程CPU峰值过高动态调整压缩档位压缩线程CPU峰值过高一般不是算法太慢而是日志积压太多压缩线程在追任务。解决思路是动态档位缓冲水位低时用高压缩档水位高时切到LZ4 fastest或者直接以原始格式写入保证不丢日志。另一个常见原因是压缩线程和业务线程争抢调度。在Android和iOS上可以给压缩线程设置低优先级或者用协程让出核心。还有一个细节不要让压缩线程绑到大核上不放。日志不是持续需要大核的任务给中等核就够了。如果发现CPU峰值出现在每秒钟整点大概率是某些定时器同时触发了大量日志可以先在业务侧做日志采样而不是压缩侧硬抗。5.3 压缩率不稳定先救重复度再救压缩器压缩率不稳定比如某个模块的日志全是数字和随机的UUID重复度低。这时换更高压缩算法也没用。可以先观察数据分布如果时间戳占大头用二级时间戳即记录块的起始时间后面用相对毫秒如果模块名重复把它们替换成枚举ID如果字符串路径重复做一个字典压缩。预处理比压缩器本身更有效。我见过一个项目日志内容包含大量“resource/path/to/icon_001.png”这种长路径重复几百次。先做数据字典把路径映射成整数ID再交给LZ4文件体积直接缩小50%。所以遇到压缩率不理想先别急着掏钱买更高压缩率算法抓一块真实数据看看有哪些字段的无用重复。5.4 怎么判断这个方案到底值不值如何判断实时压缩日志是否值得用在你的项目里看三个指标日志写入的P99延迟、日志文件的每日增量、回传带宽。改造前先埋点记录当前fprintf的P99和文件增长。如果P99超过100ms或者每日日志增量超过几个GB实时压缩的收益就很明显。反之如果日志量一天只有几百MB直接上压缩反而增加CPU成本不如先做采样和分级。分级比压缩更优先Debug日志采样10%Info全量Warn和Error必须100%保留。把日志量降下来以后再考虑要不要压缩顺序别搞反。这个判断不是技术洁癖而是性能工程里“先量化再优化”的习惯。做日志组件最怕的是用解不出bug的“完美方案”最后变成线上问题排查的黑洞。最后分享一个我的实际体会。我之前给一个后台服务做日志改造一开始直接上gzip -9把压缩线程调到高优先级结果日志没慢业务线程先抖了。后来照着BqLog这套思路改成LZ4加64KB分块、加异步批量写才真正体验到什么叫边写边压。日志组件大多数时候不要求极致压缩率而是要求在数据量爆炸时仍然保持稳定的落盘能力。你如果也在做类似改造建议先做个小实验抓一段线上真实的日志数据统计重复度和时间戳格式再决定选哪个算法、多大的块。希望这篇对你有用。