先说一个我最初的偏见:看到“实时压缩日志”这几个字时,我第一反应是营销话术。日志压缩不是应该攒一批、等空闲时再慢慢压吗?实时压缩听着就像要把游戏帧率拖垮的操作。直到我把 BqLog 的设计思路和实测数据理了一遍,才意识到这个思路的价值比想象中大得多。
BqLog 是王者荣耀团队放出来的日志组件,定位很明确:解决移动端游戏日志“采集难、存储难、回传难”的问题。一局王者荣耀十分钟打下来,产生的日志量是几十 MB 的量级,如果客户端直接以文本形式写闪存,带来的掉帧、发热、电量损耗都是一线开发能直接感受到的痛。而 BqLog 能做到“边产出边压缩”,核心不是堆算力,而是把压缩做进了一条完整的异步流水线,再配上一颗足够快的压缩核心。这篇是这个系列的第一篇,我围绕“高性能实时压缩”这个主题,从技术层面拆开讲一讲它到底快在哪。
阅读本文的受众,我默认是游戏客户端开发者、中间件开发者和对高性能日志方案感兴趣的后端工程师。如果你只是好奇“日志组件有什么好研究的”,我也尽量把每个设计背后的为什么讲清楚。
1. 一个反直觉的起点:实时压缩凭什么不拖垮游戏帧率
1.1 移动游戏日志的真正困境
先聊实际场景。MOBA 类游戏对日志的依赖极重:技能释放、伤害结算、寻路、同步状态、行为数据埋点,一局下来几十万条记录非常正常。这些日志一方面要用于线上问题排查,另一方面要回传做数据分析。但移动端的环境约束非常残酷:
- 闪存写入是耗电大头,高频小 IO 对电池寿命和发热都有实际影响;
- 文本格式天然膨胀,同样信息量比二进制多出几倍体积;
- 日志采集不能阻塞游戏逻辑线程,一旦日志成为卡顿源,开发团队很快就会因为“开日志掉帧”而放弃日志。
所以很多人采取“先缓存、再异步、最后上报”的折中方案:日志先写内存,攒够一批后后台线程刷盘或上报。这个方案能用,但有一个问题——日志在内存里依然是纯文本,体积没有降下来,等后台开始写盘或回传时,IO 压力依然很大。
BqLog 的破局点是:把压缩动作前置到“实时”阶段,也就是日志还在内存里的时候,就把它压缩到位。这听起来更费 CPU,实际上却省掉了不少 IO 和带宽成本。而它敢这么做的底气,来自两个前提:压缩器得足够快,以及压缩动作必须发生在正确的线程上。
1.2 实时压缩的前提是压缩器本身要足够快
实时压缩最大的风险在于 CPU 消耗。如果选错压缩算法,哪怕放在后台线程,也会把整机性能拖垮。移动端的 CPU 预算非常紧张,留给日志系统的份额通常只在个位数百分比以内。在这种约束下,压缩器的吞吐必须到达几百 MB/s 级别,耗时才能被压到几乎无感。
LZ4 是这类场景的经典选择。它最核心的卖点不是压缩率,而是“极快的压缩+更快的解压”。在移动端中高端 SoC 上,LZ4 的压缩吞吐能跑到数百 MB/s 以上,解压速度更是比压缩还快一个量级。这就产生了一个非常妙的效果:压缩数据时 CPU 只忙很短一阵,而后续读取、上传、分析时解压几乎不构成瓶颈。
对比一下就能看出选型逻辑:zlib 压缩率高,但 CPU 开销大,不适合客户端实时链路;zstd 压缩率更好、速度也不错,但在游戏客户端的后台线程上,长期跑的 CPU 成本还是明显高于 LZ4。BqLog 这一类追求实时吞吐的组件,选择 LZ4 系列算法是符合直觉的。
1.3 我理解的 BqLog 整体流水线结构
实时压缩能成立,真正的秘密不在压缩算法本身,而在整个流水线设计。把日志从产生到落盘拆开看,大体是四段:
日志格式化 → 写入环形缓冲 → 后台压缩线程消费 → IO 线程落盘或回传。
这里的核心原则是:游戏逻辑线程只做“格式化 + 入队”,压缩和写盘全部交给后台线程。这样,实时压缩表面上发生在“日志产生的同时”,实际上它发生在和游戏线程隔离的另一条执行路径上。游戏线程付出的额外代价,仅限一次有界缓冲写入。
所以“实时压缩”这个说法,值得更精确地表达为“热数据内存压缩”。日志还在缓存里热乎着的时候,后台线程就已经把这块内存拿走去压缩了。压缩发生在内存中,而不是等数据落盘后再压,缓存友好度完全不同。理解这一点,才能想明白为什么实时压缩非但不慢,反而是一条更合理的路径。
2. 第一层快:LZ4 系压缩核与预置字典设计
2.1 为什么选 LZ4 而不是 zlib 或 zstd
选压缩算法,本质上是在压缩率、压缩速度、解压速度三个维度里做取舍。日志组件和文件备份工具的取舍完全不一样:文件备份追求压缩率,因为很少反复压缩;日志组件追求的是不干扰业务的前提下尽量减小体积,所以压缩速度和解压速度的权重更高。
我整理了一张对比表,方便直观看清差异:
| 算法 | 压缩速度 | 压缩率 | 解压速度 | 适合场景 |
|---|---|---|---|---|
| zlib | 慢 | 较高 | 中等 | 静态资源、安装包、离线数据归档 |
| zstd | 中 | 高 | 高 | 大数据传输、需要高压缩率的实时场景 |
| LZ4 快速模式 | 极快 | 中低 | 极快 | 日志、缓存、内存压缩等热路径 |
| LZ4 HC 模式 | 较慢 | 较高 | 极快 | 允许离线压缩但需要快速解压的场景 |
日志实时链路最怕的就是压缩线程占用过高。zstd 确实可以靠 level 参数调节速度,但整体 CPU 开销依然比 LZ4 高。客户端日志这个场景,压缩率少几个百分点,换来 CPU 大幅下降,是非常划算的买卖。还有一个容易忽略的点:日志文件经常会被开发工具反复打开查看,解压速度直接决定日常排查效率。LZ4 的解压速度遥遥领先,这在工程上是很舒服的体验。
2.2 预置字典让压缩率上了一个台阶
LZ4 的短板是压缩率。如果硬压纯文本日志,它的表现只能说及格,远不如 zlib。这里就需要第二个关键设计:预置字典。
日志和普通文本最大的区别是“模板重复率极高”。战斗日志里,“英雄 A 对英雄 B 造成 xxx 点伤害”这句话可能出现成千上万次,变化的部分通常只有英雄 ID、伤害数值、时间戳等少量字段。如果压缩器在开始时就知道这些高频模板,就能把整句模板当作已知上下文,压缩时只需要记录变化的部分。
实现上,这个思路类似给压缩器“预热历史窗口”。LZ4 的压缩状态里有一个历史 buffer,压缩前把字典内容灌进去,后续数据流就能引用字典里的重复字符串,效果等同于把所有高频模板预先放入滑动窗口。字典体积通常控制在几十 KB 到几百 KB,压缩级别不同会有差异。
打个比方:不带字典的压缩,像让一个人每次从零开始读一篇论文然后概括;带字典的压缩,像发给这个人一份论文提纲和常用词汇表,他只需要记录有变化的几处内容。日志数据的高冗余性,使得字典带来的收益特别明显。
需要强调的是,字典是客户端和解析端必须共同维护的资产。客户端用版本 X 的字典压缩日志,解析端也必须用同一版本字典去解压,否则会解出一堆乱码。这就意味着字典升级必须规划好兼容策略,老日志还能不能解、新旧版本怎么过渡,都要提前设计。
2.3 分块压缩与流式解压的工程价值
压缩日志和压缩一个整文件还有一个重要的工程差异:日志最好能“从中间开始读”。一局游戏回放、一次线上问题定位,往往只需要读某一时间段的数据,如果整个日志是一个不可分割的大压缩块,那就必须全部解压才能找到想要的部分。
BqLog 这类组件通常会把日志流按块切分,每块独立压缩,块头记录元信息。典型的块大小在 64KB 到 256KB 之间。为什么是这样一个范围?太小了,压缩率会损失,因为每个块的字典前缀都只积累了一点点;太大了,实时性变差,后台线程必须等块填满才能开始工作,内存占用也更高。
每个块的头信息一般包含这几项:魔数、版本、块号、原始长度、压缩后长度、校验值。有了这些信息,解压端就可以:
- 按块号或时间范围定位,只解压需要的那几块;
- 遇到损坏块时跳过,其余日志不受影响;
- 流式解压,不需要把整个日志文件一次性加载进内存。
分块还有一个附带的好处:如果单条日志特别长(比如一个大的战场快照),可以允许它跨块;正常情况下一块内塞几百条日志,任何一块写入失败都不至于影响全局。这种“按块自包含”的结构,实际上是日志系统稳定性的基石。
3. 第二层快:无锁环形缓冲与线程分工
3.1 游戏线程永远不碰压缩
压缩算法的速度再快,如果设计上让游戏线程亲自参与压缩,那实时压缩依然是灾难。真正让实时压缩成立的是这条铁律:日志生产端只负责把数据送进缓冲,剩下的事全部交给其他线程。
这个思路的具体执行方式是环形缓冲(ring buffer)。游戏线程拿到一条日志后,先在缓冲区里找到一个空闲位置,写入数据,然后更新写指针;后台压缩线程则从另一个位置读取,更新读指针。整个过程套路非常成熟,但性能差异往往藏在细节里。
一个常见的优化是:游戏线程申请空间时,直接返回缓冲区的内存地址,把格式化动作直接做在这块目标内存里。也就是说,格式化环节不产生临时字符串,最终数据从产生到进入缓冲只有一次写入,而不是“先拼字符串,再 memcpy”。这一步能省掉一次大块内存拷贝,对吞吐的影响是数量级的。
我画不出源码级的细节,但从工程经验看,这类环形缓冲的通用结构是下面这个样子:
// 示意代码:生产者只做写入,绝不碰压缩 void LogProducer::Append(LogEvent* ev) { uint32_t slot = ring->Reserve(ev->Size()); if (slot == INVALID_SLOT) { drop_count++; // 队列满,按策略丢弃 return; } ring->Write(slot, ev); // 直接写入目标内存 ring->Commit(slot); // 更新写指针,唤醒消费者 }生产者端的所有操作都是有限次内存写,没有锁竞争、没有系统调用。后台压缩线程拿到块后,才开始真正的压缩,再交给 IO 线程落盘。每一层线程只做一件事,配合起来整条流水线才不会卡壳。
3.2 伪共享:被忽略的性能杀手
环形缓冲最容易翻车的地方,不是并发控制本身,而是 CPU 缓存。写线程更新写指针,读线程更新读指针,这两个变量如果坐落在同一条缓存行(cache line)里,就会出现一个经典的性能陷阱:伪共享(false sharing)。
什么叫伪共享?CPU 缓存的最小单位是缓存行,通常是 64 字节。两个线程各自更新两个紧挨着的变量时,哪怕它们逻辑上没有因果关系,也会因为共用一条缓存行而互相拖累。写线程一更新变量,读线程所在的核发现这条缓存行失效了,必须重新从内存加载;读线程一更新变量,写线程那边同样要重新加载。双方来回作废缓存,吞吐量可以瞬间掉一个量级。
解决方式也简单粗暴:把读指针和写指针分别放在不同的缓存行里,中间用 padding 填充。这看起来是个小细节,实际影响巨大。我以前优化过一个类似的双线程队列,单纯给两个计数器加了 padding,吞吐从每秒两百万条直接涨回六百万条。那种“算法没变、性能翻倍”的体验,能让每一个做性能工程的人都记住缓存行的重要性。
3.3 压缩线程的 CPU 亲和与时间片预算
移动端处理器的 CPU 大小核架构让线程调度变得更有讲究。游戏逻辑线程通常跑在大核上,如果压缩线程也长期绑在大核,两者就会互抢资源。反过来说,把压缩线程绑到小核,省电但吞吐有限,日志多的时候可能压不过来。
更稳妥的做法是让压缩线程“按需工作”:平时队列水位低,它基本休眠;队列水位超过阈值时被唤醒,集中压一批后继续休眠。这个方案能避免常驻轮询对 CPU 时间的持续消耗。水位阈值需要反复调参,太高了容易积压,太低了压缩线程频繁唤醒,反而增加调度开销。
还有一个容易忽略的细节:压缩线程的优先级尽量放低。游戏里偶发一次激烈的团战,日志量瞬间暴增,这时候如果压缩线程和游戏逻辑线程抢 CPU,用户感受到的就是掉帧。优先级低的压缩线程会在 CPU 紧张时自动让位,日志少压一点没关系,掉帧才是不可接受的。
4. 第三层快:结构化二进制格式化,源头就在“减负”
4.1 日志的很多性能问题,在格式化阶段就埋下了
我看过很多团队优化日志,思路都卡在“怎么让 printf 更快”上。实际上,传统文本格式化本身就是性能杀手。游戏逻辑线程要输出一条战斗日志,如果走 sprintf 的路线,要解析格式字符串、做各种类型转换、拼接字符串,然后再分配内存、再拷贝一次,最后入队。这条链路里的每一步都是成本。
BqLog 这类高性能日志组件的思路完全不同:它不做传统文本格式化,而是直接输出结构化二进制日志。每条日志的本质是“一个模板 ID + 一组字段值”。游戏逻辑线程要做的,只是按预定义格式把字段写进缓冲区。模板本身是编译期或启动期注册好的,运行时根本不需要再做字符串解析。
结构化日志的好处是多层次的。首先,运行时的格式化开销降到了最低,类型字段直接用二进制写,不需要转成十进制字符串。其次,二进制数据天然比文本更紧凑,压缩前的体积就比文本小。最关键的还是压缩阶段:因为数据里的重复模板信息集中在模板 ID 上,字典的命中率会高很多。
4.2 varint 与字段压缩:小数字享受小体积
既然走结构化日志路线,字段本身的编码方式也值得优化。游戏日志里有大量数值字段:伤害值、坐标、血量、冷却时间。这些数值的通常分布有一个特点:大部分时候都很小,少数时候很大。
针对这种分布,varint 编码是标配方案:小数值用 1 个字节,大数值用 5 个字节。相比固定用 4 字节存一个 int32,varint 在日志场景下平均可以省掉一半以上空间。
浮点数也有优化空间。一个三维坐标如果用 float 存,每个分量 4 字节,三个分量 12 字节;如果改成相对坐标并用低精度定点数,可能压到 6 字节以内。很多日志字段根本不需要浮点精度,保留两位小数已足够,那就可以干脆把 float 转成整数再走 varint。时间戳也一样——存绝对时间会很大,存相对于对局开始的毫秒数,再配合 varint,体积能压得非常小。
这些细节加在一起,效果是乘法级别的。一条战斗日志如果文本形式需要 150 字节,结构化二进制可能只需要 30 字节。这个差异在压缩前就已经让数据瘦了一大圈,最终压缩包的体积自然更有优势。
4.3 压缩率收益的粗算
给一组我自己做日志压测时得到的估算量级,不同场景会有浮动,但方向是一致的:
| 形态 | 假设一局 20 万条日志 | 体积 |
|---|---|---|
| 纯文本 | 平均每条 150 字节 | 约 30 MB |
| 结构化二进制 | 平均每条 30 字节 | 约 6 MB |
| 二进制 + 字典压缩 | 重复模板被大幅消除 | 约 2~3 MB |
能看出两层差异:第一层是结构化二进制本身带来的体积下降,第二层是字典压缩在结构化数据上的放大效果。如果反过来拿纯文本直接上 LZ4,压缩率会差不少。所以 BqLog 这类方案真正的思路是:先在协议层把数据变瘦,再用字典把重复变少,最后用 LZ4 高效兜底。压缩率不是靠某一个环节单打独斗,而是整条链路的设计叠加。
5. 实测与压测:读懂数据,也读懂压测方法
5.1 自己动手压一套日志流水线
前面讲了那么多原理,最终还是要回归到数据上。如果你想验证这套“结构化 + 异步 + LZ4 字典压缩”的设计,完全可以用开源组件搭一个最小验证环境。我自己常用的方式是:
- 录制一段真实战斗日志事件流(或者从生产环境导出一段脱敏日志);
- 用脚本按固定模板生成模拟日志,控制每条日志的字段分布和大小;
- 分别对比三种路径:文本直写、文本 + LZ4、结构化二进制 + 字典 + LZ4;
- 统计吞吐、压缩率、CPU 占用、P99 延迟。
压测时最容易犯的错误是用“均匀低负载”来测。真实游戏场景是突发性的:平时日志量很小,团战一开瞬间爆发几千条。如果只测平均吞吐,很多抖动问题都会被平均掩盖。正确的做法是用录制的真实事件流回放,让压测负载的突发特性尽量贴近线上。
5.2 关注的不只是压缩率,还有 P99 和 CPU 稳定性
衡量日志组件性能,压缩率只是最直观的一个指标。真正影响线上体验的是另外两个:P99 延迟和 CPU 占用稳定性。
P99 延迟指的是 99% 的日志从产生到入队完成的时间。这个指标直接决定游戏逻辑线程会不会被日志卡住。就算平均延迟很低,只要那 1% 的慢操作发生在团战的关键帧里,玩家就能感受到卡顿。所以压测时一定要看长尾,而不是只看平均值。
CPU 占用稳定性则要看压缩线程是否频繁“抢跑”。如果压缩线程每次被唤醒都要抢占大核,那么即使总 CPU 时间不高,对游戏帧率的影响也可能很明显。合理的设计是让压缩线程在低优先级下运行,并且在批量任务之间保持休眠状态。
5.3 一个我自己压测时常用的简易方案
这里分享一个我常用的快速验证脚本思路,不依赖具体组件,核心是把它跑成可对比的基准:
# 示意:对比不同路径的日志吞吐 import lz4.frame import time import random logs = [generate_log() for _ in range(200000)] # 基线:纯文本写入内存 start = time.perf_counter() for log in logs: text = log_to_text(log) memory_file.write(text) base_time = time.perf_counter() - start # 对比:结构化日志 + 字典 + LZ4 start = time.perf_counter() packed = pack_structured(logs) compressed = lz4.frame.compress(packed, dictionary=dictionary) lz4_time = time.perf_counter() - start print(f"text path: {base_time:.3f}s, size={len(memory_file.getvalue())}") print(f"bq-style path: {lz4_time:.3f}s, size={len(compressed)}")这样的对比实验能非常直观地展示:文本日志在大规模场景下,不管是耗时还是体积,都会被结构化 + 压缩方案拉开差距。当然,真实 BqLog 的实现细节远比这段示意代码复杂,但方向是一致的。
6. 工程落地中躲不开的几类坑
6.1 背压与丢日志策略,游戏端和服务器端完全不同
服务器日志系统通常默认“宁可慢,不能丢”,因为运维排查依赖完整日志。但游戏客户端的优先级完全不同:日志丢了最多查问题难一点,游戏卡了用户直接流失。所以客户端日志在队列满的时候,必须牺牲一部分日志来保住帧率。
最常见的策略是“丢弃新日志,保留旧日志”,并记录丢弃计数。这样事后可以通过丢日志数判断当时负载有多大,至少能知道系统曾经处在过载状态。另一种思路是“截断写”,把新日志的部分字段截掉,只保留关键信息,这适合单条日志特别大的情况。
在设计这个逻辑时,一定要把队列水位、丢弃数、压缩耗时这几个指标暴露出来。否则线上出了问题,你根本分不清日志缺失是业务没打点,还是组件丢的。
6.2 压缩线程导致的掉帧,怎么排查
如果上了实时压缩之后,游戏偶发掉帧,先别急着怪压缩算法。我排查这类问题有一套固定流程:
第一,用性能剖析工具看 CPU 时间分布,确认掉帧时间段内压缩线程是否频繁占用大核。第二,看线程唤醒次数。如果压缩线程每秒被唤醒几千次,那即便每次只干很短的活,调度开销也不可忽视。第三,检查游戏线程是否存在“隐藏碰锁”——比如队列满时,游戏线程被迫等待锁释放。只要生产线程有一次同步等待,帧率就会有一个刺眼的分叉。
基于这三点,常见的修正方案是:批量消费(攒够 N 块再压缩)、降低线程优先级、把压缩线程的主要工作时间控制在加载和结算这类非战斗时段。
6.3 兼容性、内存碎片与异常退出
日志组件上线后,还有一些长期运维才会遇到的坑,这里提前打个预防针。
第一是字典版本兼容。客户端更新了字典,服务端还没升级,老日志解不出来,排查问题的效率会受到严重影响。所以字典文件最好独立于日志格式,并带上版本号;解析端要支持多版本字典共存。
第二是内存碎片。高频写入小块日志,如果每次都单独分配内存,跑久了内存碎片会越来越严重。比较好的做法是按块复用内存池,块级分配和释放,减少小对象频繁分配。
第三是异常退出。移动应用随时可能被系统杀掉,缓冲里还有没来得及压缩或落盘的日志就会丢。工程上能做的,是在主流 crash 路径上尽量 flush 一次,但不要为此阻塞主线程。对未落盘日志,能救多少算多少,这是移动端必须接受的现实约束。
7. 从 BqLog 里带走的三条设计习惯
研究类似 BqLog 这种高性能日志组件,最大的收获往往不是某个具体的压缩参数,而是它在设计过程中的价值取向。我总结了自己最想“抄作业”的三条习惯。
第一条,每个热路径都要问一句“这份拷贝能省掉吗”。很多团队做日志库,一条日志从产生到落盘要经历三四次内存拷贝,而每一次拷贝都是在烧 CPU。BqLog 的思路是把格式化直接做在目标缓冲上,生产线程几乎不产生额外拷贝。这个习惯迁移到任何高性能模块都适用。
第二条,不同日志级别该有不同的技术命运。调试日志直接丢弃,普通日志异步压缩,错误日志强制落盘并上报。日志不只是在记数据,更是在表达产品优先级。对开发者来说,最关心的日志优先级最高,绝不能因为节省性能丢在队列里。
第三条,性能工程的核心是压测环境贴近真实。用均匀负载压测得到的吞吐数据,在真实突发场景里经常会失真。录一段真实的战斗事件流回放,看 P99,看 CPU 抖动,这才是能指导线上调优的数据。
最后说点个人体会。以前我总觉得日志组件是工程里最不起眼的一环,无非是把 printf 换了个地方。直到自己动手做过几轮优化,才明白越是这种所有人都依赖、但又没人愿意花时间细看的基础组件,越能折射团队的工程素养。BqLog 让我最受触动的不是某个炫技的压缩算法,而是一整套设计都在回答同一个问题:能不能让日志这件事少消耗一些系统资源。这种克制,比炫技值钱得多。下一篇我计划沿着这个系列继续挖,把日志检索、解析端和解压效率这部分也拆开聊聊。