尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

王者荣耀日志组件BqLog为什么这么快之1——高性能实时压缩日志

发布时间:2026/9/28 21:17:00

资讯中心
01
ARTICLE

王者荣耀日志组件BqLog为什么这么快之1——高性能实时压缩日志

王者荣耀日志组件BqLog为什么这么快之1——高性能实时压缩日志
在追求极致性能的系统中减少一切不必要的计算是优化的核心。以手游为例帧率和流畅度是其基础体验的关键而游戏的发行版本往往被一个“不可能三角”所困扰性能足够好日志少写方便追述问题日志应写尽写节约存储空间日志最好就别写国内发行的手游通常优先选择保1和3放弃2。而HOK作为《王者荣耀》的国际服由于面向全球的发行背景面临时差、语言和隐私观念等问题在遇到疑难杂症时很难直接与用户沟通。这种时候我们就需要一种产品能帮助我们“既要又要”打破这个不可能三角。BqLog就是在这样的背景下诞生的。目前王者万象棋洛克王国等多款最近两年自研的游戏都引入了BqLog来解决这样的问题。BqLog不仅适用于客户端也适用于服务器能用于多种编程语言也能兼容多种操作系统具体请见Github地址https://github.com/Tencent/BqLog本文是系列文章的第一篇点击查看全部文章为何 BqLog 如此快之一从一行文本推导出压缩日志格式English · 系列目录 · 下一篇数据总线线上日志要留够排查问题的信息但写入不能拖慢业务文件也不能无限增长。移动设备上这几个要求尤其容易冲突。一种常见办法是先写文本关闭文件后再压缩。这能节省归档空间却省不了写入时的格式化、拷贝和首次写盘。BqLog 选择在写入之前利用日志本身的结构减少这些工作。先看一行普通日志如何产生再拆开其中重复的部分。后面会沿着同一条思路讲文件布局、续写和加密。本文参照 BqLog 2.5.0 编写。1. 一行文本日志的写入成本以一个订单系统为例业务代码可能是log.info(New order, order ID:{}, price:{}, username:{},32422144,324.42,张三);最后落到文件里的是这样一行2026-09-25 12:00:00.001 [Info] [Shop.Order] [Tid-2025 Worker25] New order, order ID:32422144, price:324.42, username:张三方括号里依次是级别、分类和线程标识正文由格式字符串和参数拼成。写出这行文本需要把时间戳、整数和浮点数转成字符串与固定文本拼接再写入文件。编码不同时还要转换字符集。即使把格式化交给后台线程这些工作也仍要做。紧接着又来了一条订单2026-09-25 12:00:00.002 [Info] [Shop.Order] [Tid-2025 Worker25] New order, order ID:32422145, price:174.45, username:李四两条记录只在时间和三个参数上不同固定文本却被重新拼接、重新写入。若一天有一千万条这样的日志每条重复 50 字节固定内容仅重复部分就约 500 MB。这些字节还会经过拷贝和 I/O。业务代码其实已经给出了边界**格式字符串固定参数变化。**文件可以直接保存这个边界不必每次都保存完整句子。2. 用模板和参数代替完整句子第一次见到格式字符串时存下它并分配编号。此后同一格式只写编号和参数模板 0: New order, order ID:{}, price:{}, username:{} 日志 A: 模板 0, 32422144, 324.42, 张三 日志 B: 模板 0, 32422145, 174.45, 李四这样做把格式化移到了读取时。很多诊断日志最终并不会被打开对它们而言写入时就省掉了格式化。真正需要读取时再按模板还原文本。读取工作还可以放到研发机器或分析环境减少业务设备上的开销。日志级别、分类、线程 ID 和线程名也会重复。不过同一条业务格式可能由多个线程输出。若把所有字段放进一个模板换一个线程就要再存一份格式字符串。所以 BqLog 把稳定信息拆成两种模板格式化模板日志级别、分类索引、格式字符串。线程信息模板线程 ID 与线程名。每条日志分别引用这两个模板。线程换了格式照样复用业务格式换了线程信息也照样复用。分类列表在创建 Log 对象一个日志器实例时已经确定可以放在文件开头格式模板只保存分类索引。这样分类表、格式模板、线程模板和日志记录按各自的更新频率分开保存。3. 模板和日志混在一起读取时怎么分辨文件里现在有格式模板、线程模板和日志记录。新线程或新格式随时可能出现所以这三类数据会交错写入。若分别存到几个文件就要维护文件间的对应关系。放进同一个文件读取时怎么知道每一项到哪里结束格式字符串和参数个数都不固定没法预设宽度靠结束符一路扫到尾也不可靠二进制参数里什么字节都可能有结束符还得转义。直接的办法是开头写明长度读完头部就知道这一项的范围也知道下一项从哪开始。BqLog 把它们写成连续的Data Item每项先写类型和长度再写内容。图 1一个 item 的头部和 body以及两种长度情况下的具体 bit 布局。横向相邻的框在文件中也相邻。固定用 4 字节表示长度很简单但多数日志很短长度字段本身就会占去不少空间。这里改用变长整数编码。3.1 小数字用少量字节BqLog 用的是带前缀的 VLQ 编码。为了看清楚先只考虑无符号整数1 字节: 1xxxxxxx 2 字节: 01xxxxxx xxxxxxxx 3 字节: 001xxxxx xxxxxxxx xxxxxxxx ... 9 字节: 00000000 8 个数据字节前缀里第一个 1 出现在第几位就告诉 decoder解码器这个数总共占几个字节。编码变长之后不再重复表示前一个长度已经覆盖的数字而是直接从新区间的起点开始计数字节数起点可表示的区间100–1272128128–1651131651216512–2113663比如 128 不会再在两字节里原样编码成 128而是表示“第二个区间的第 0 个数”编码结果是40 0016512 是第三个区间的第 0 个数结果是20 00 00。这里的 VLQ 不是常见的 LEB128那种编码每字节存 7 位、最高位作延续标志。BqLog 的前缀布局保证多字节编码的首 bit 恒为 0——这个空位在 3.2 节还有别的用途。区间起点、前缀和字节顺序以 log_utils.h 为准。3.2 类型位复用长度前缀Data Item 顶层只区分“模板”和“日志”模板内部再分格式、线程所以顶层类型只要一位0 为模板1 为日志。如果类型单独占一字节短记录的头部比例又会增加。观察 VLQ 前缀编码长度超过一字节时首 bit 一定为 0可以用它保存类型。比如长度 128 的编码是40 00。如果它是一条日志把首 bit 置 1变成C0 00类型和长度加起来还是只占两字节。长度 5 的 VLQ 是85首 bit 已经被长度前缀占了借不了。这时候才额外写一个字节日志类型80长度85合起来80 85。decoder 先从首 bit 读类型再检查低 7 位。若全零就从下一字节解长度否则清掉类型位从当前字节开始解。这样长度至少为 128 的 body 可以复用首字节短 body 则显式保存类型。当前 body 长度是 uint32所以 item 头实际占 2–5 字节。注意长度不包括头本身这一点在回填和解码时都得保持一致。4. 再把模板里的每一项拆开图 2从模板到单个参数逐层展开。颜色对应内容种类字段中的字节数对应实际编码。格式模板的 body 依次是subtype (1 B) | level (1 B) | category_index (VLQ) | format bytessubtype0 表示格式来自 UTF-8subtype2 表示来自 UTF-16后者在文件里用 UTF-Mixed 表示第 6 节细讲。格式字符串占满 body 剩下的空间所以不必再存一个字符串长度也不需要结尾零字符。格式模板不需要显式索引文件中第一次出现的是 0 号第二次是 1 号以此类推。writer写入端和 decoder 按相同顺序编号。decoder 因而可以用数组下标访问模板。线程模板稍有不同subtype1 (1 B) | thread_template_index (VLQ) | thread_id (VLQ) | thread_name这里显式写索引是因为线程信息可能在新的运行里被重新定义。一个明文文件可能被同一个程序多次打开续写但上一次的线程 ID、线程名对应关系不能直接套到这一次。新的线程模板可以覆盖某个索引的定义decoder 按文件顺序更新映射后面的日志引用新信息。**文件索引和内存缓存槽位是两回事。**writer 的模板缓存可以淘汰条目被淘汰的格式再次出现时文件会再写一份模板并分配新索引。已有记录仍指向旧索引缓存淘汰只影响之后的复用率和文件大小。格式哈希只供 writer 查找模板。decoder 使用文件索引因此磁盘记录不保存这个哈希。5. 每条日志真正变化的内容还能继续压缩模板抽走之后日志记录的 body 只剩下时间差 | 格式模板索引 | 线程模板索引 | 参数0 | 参数1 | ...格式和线程索引通常从小数字开始适合用 VLQ。时间戳和参数还有各自的编码方式。5.1 时间戳很大时间差很小毫秒级 Epoch 时间戳从 1970 年起算的毫秒数要 64 位表示逐条存就是 8 字节。可连续两条日志的间隔往往只有几毫秒甚至同一毫秒里好几条。既然上一条时间已知下一条只记差值就够了。比如三条日志的时间分别是 1000、1002、1001 ms差值就是 1000、2、-1。第一条以 0 为基准后面每条以前一条为基准。第三条比第二条早是怎么发生的多线程交错写入时记录被消费的顺序本来就可能不同于取时间戳的顺序这是第二篇的主题。消费端在编码前会把时间戳钳成非递减挡掉这类回退——代价是乱序那几条的时间被改写成上一个值同一运行内文件里的差值不会出现负数。但有一种情况钳制帮不上忙明文文件续写时时间基准是从旧文件里恢复出来的新运行的记录若赶上系统时钟回拨就会比基准还早。格式必须允许负差存在又不能让它占空间。ZigZag 正好解决小负数的问题原值: 0 -1 1 -2 2 -3 3 映射后: 0 1 2 3 4 5 6把有符号值映射成无符号再做 VLQ。这样 1 和 -1 都很小不会因为负数补码的高位全是 1被迫占满 8 或 9 个字节。5.2 整数不必转成字符浮点也不必提前格式化参数前面存一个类型字节告诉 decoder 后面该读几字节、要不要解 VLQ参数去掉类型字节后的内容null不需要内容pointer统一 8 字节bool、char、int8、uint81 字节char16、char32、uint16、uint32、uint64无符号 VLQint16、int32、int64ZigZag VLQfloat / double原始 4 / 8 字节UTF-8 字符串字节长度 VLQ 内容UTF-16 字符串转成 UTF-Mixed 后保存长度与内容浮点数直接保存 4 或 8 字节二进制值。它的位模式和数值范围不同于整数文本输出还涉及精度格式在写入端先转成十进制字符串没有必要。bool、int8等类型本身只有一字节再做变长编码也不会更省。每条记录也不再单独存参数个数。按类型读完一个参数看看是否到了 item 末尾就知道了。6. UTF-16性能与体积之间再做一次取舍C#、Java、Unreal 经常用 UTF-16。对 ASCII 字符来说每个字符的高字节都是 0两字节存一个英文字母确实浪费。但把 UTF-16 无条件转成 UTF-8遇到中文、代理对和混合文本时判定会变多而且有些字符的 UTF-8 表示反而更长。UTF-Mixed 先把前面的 ASCII 收窄为一字节。遇到不适合继续快速处理的内容就写一个FF标记后面的 UTF-16 作为整体拷贝。图 3以标量切分为例abc中x的 10 个 UTF-16 字节变成 8 字节。末尾 x 仍留在 UTF-16 后缀里。注意这里的“剩余”包括后面再出现的英文字符——不会为了省一个字节反复切回来。SIMD单指令多数据的并行指令路径还可能在包含非 ASCII 的块之前停手所以相同内容的合法切分不必完全一致。图中的字符串完整转成 UTF-8 只需 7 字节UTF-Mixed 却用了 8 字节。这里选的是更少的编码工作而非最小的单条输出后缀可以直接批量拷贝无需逐字符转换。decoder 在已知长度里找FF前缀直接拷贝后缀转 UTF-8。这里的 FF 不是字符串结束符而是明确的编码切换标志。至于前面那段收窄怎么用 SIMD 跑第三篇再讲。7. 同一条日志在内存和文件里为什么完全不同到这里为止我们一直在看文件格式。现在把它放回整条处理路径BqLog 是异步日志业务线程生产者不直接写文件而是把记录放进内存队列总线后台线程消费者取出记录交给负责具体输出的组件Appender编码、写盘。这条总线是第二篇的主题。文件格式已经很紧凑生产者为什么不直接生成它文件模板索引由 Appender 分配不同输出的模板集合也可能不同。如果生产者直接编码文件记录就要访问这些共享字典。文件里的变长字段还会让内存中定位参数更费事。所以 BqLog 的总线记录保留固定头部和适当对齐消费端再转成紧凑的文件表示。用一条很小的日志看得最清楚log.info(hp{},int32_t(42));图 4格式为 hp{}线程名为 Main。图上方是总线内的记录下方是模板已存在且时间差为 0 时的文件记录。内存头有 40 字节完整时间戳、分类、线程 ID、格式哈希和长度都在里面。5 字节的格式内容按 4 字节边界对齐成 8 字节int32 参数占类型区和数值区最后还有线程扩展信息。这个例子的内部记录共 61 字节走 LP 路径还会加上 context 和 ring 的块头LP 是第二篇里的低频路径context 和块头正是那里的主角。文件里则可以只有80 85 | 80 | 80 | 80 | 0B D4 类型/长5 Δt0 fmt0 thread0 int32(42)0B表示 int3242 经 ZigZag 得到 84编码为D4整项共 7 字节。文件头和模板由多条记录共用。内存布局方便生产者写入和消费者读取文件布局尽量缩小记录。8. 一条数据流之外为什么还需要多段结构前面的例子默认程序持续运行模板表和加密状态一直在内存中。程序重启后这些状态需要重新建立。不支持续写的话加密日志崩溃一次就只能丢掉没写完的部分或者重开时把旧文件整个解密重写一遍——比起分段结构多付的那点成本这两种做法贵得多。**续写压缩日志必须知道已有的模板编号和时间基线。**只把文件指针移到末尾还不足以生成后续记录。8.1 续写要恢复的不只是文件位置假设旧文件里已经有三条格式模板编号 0、1、2。新日志又用到其中一种格式我们得找到它原来的编号来了新格式也得知道下一个编号从 3 开始。日志存的是时间差所以最后一条日志的时间戳也得恢复。如果不读取旧文件就从 0 重新编号decoder 会把新记录的编号按旧字典解释参数可能被代入错误的格式。对明文压缩文件解决办法很直接打开时扫描一遍已有 item恢复格式模板表、下一个模板编号和最后的时间戳然后接着写。线程信息按本次运行重新建立。UTF-16 格式在文件里是以 UTF-Mixed 存的扫描时还要恢复它原始表示的哈希后面才能继续查找和复用。8.2 加密文件的问题写入端手里没有私钥加密模式下客户端只配置公钥私钥既不随客户端分发也不存在日志文件里。公钥能用来保护新生成的加密材料却解不开旧文件的内容。只有公钥的客户端无法读取旧模板、最后一个时间戳和旧加密材料也就不能按明文文件的方式恢复状态。旧状态既然拿不回来那就重建一套模板从头记时间基线重新开始加密材料重新生成再写新的 payload。写入端不需要持有私钥更不需要为了追加几条日志把整个旧文件解密重写一遍。文件因此用 Segment 区分不同的数据范围每段有自己的头部和可选加密材料。段头记录这一段怎样解密模板索引则由压缩数据流解释。例如后一段仍然可以引用前一段定义的模板 7只换一套加密材料。同一文件内的段可以复用前面写下的模板加密重启则是另一个场景在 2.5.0 的流程里新压缩数据流会放到新编号的文件里模板表和时间戳随之重置。已有文件内的多段结构负责把恢复数据和新数据接起来下面接着看它的布局。图 5第一条横条接着第二、第三条换行只是为了展示。S0、S1、S2 是绝对文件偏移不是内存指针。外层 File Header 固定 8 字节版本 4 字节、文件格式 1 字节、padding 3 字节。压缩格式当前是 version10、format2。每段先写 12 字节 Segment Headernext_seg_pos: 8 B | seg_type: 1 B | enc_type: 1 B | padding: 2 Bnext_seg_pos指向下一段在文件里的绝对偏移最后一段用 UINT64_MAX。这样 decoder 不必扫描当前段里的全部 item就能知道本段在哪结束按当前段范围读缓存也防止一次读取跨过段头、误用下一段的加密状态。seg_type区分普通数据、Appender 缓存恢复、总线恢复。它不是在每条日志头里重复加来源字段而是在整个阶段变化时记一次。首段 payload 还保存 52 字节元数据和分类定义后面的段不用再重复这些文件级信息。段头既不重复存文件级分类表也不会自动清空模板字典。这样恢复时可以给一批数据换上新的加密材料同时让它继续用前面已经写下的模板。8.3 先追加新段再把前段指向它在文件尾追加一段时当前实现先检查段链是否有效再写新段头和必要的加密材料最后修改旧末段的next_seg_pos。主体日志数据没有被搬来搬去动的只是链接关系。这很像单向链表的尾部连接只不过节点用文件偏移表示。用绝对偏移关掉程序重新打开还能继续解释也不依赖这次映射到了哪个虚拟地址。追加段要检查段链、写头和密钥材料还要做相应 I/O。这份成本放在文件创建、恢复和状态切换时付一次后面大量日志共同摊薄它不用逐条重复。8.4 为什么恢复数据还需要单独的段异常退出时日志可能停在不同阶段一部分还在数据总线里没编码另一部分已经变成压缩 item留在 Appender 的写缓存里没写完文件。这份缓存由内存映射文件mmap支撑进程崩溃后内容仍可检查——第二篇 §7.3 会用到这个性质。假设旧文件已经定义了格式模板 7Appender 缓存里还有一条引用模板 7 的记录没有写完。它的时间差也以上一条日志为基准。这批字节属于旧数据流恢复时必须保留这些含义。做法是在经过校验的原文件后面追加恢复段生成新的加密材料再写入缓存中完整的 item。decoder 持有私钥读过旧段后已经知道模板 7 和时间基线进入新段时只需切换加密材料就能继续解码。写入端不用解密旧文件也不用重新编码这批记录。总线里还没编码的记录保留着原始格式和参数。例如同样一条订单日志在这里仍然是格式字符串和三个参数还没有绑定文件里的模板 7。在加密重启场景中它可以进入新文件重新建立模板从索引 0 开始编码。消费者处理完这些恢复记录再处理本次运行产生的新记录。图 6Appender 缓存里的模板 7 必须接回旧文件总线里的原始记录可以在新文件中重新编码为模板 0。分段让已经编码的工作得以保留。恢复时沿用哪一份模板状态取决于日志停在了总线还是 Appender 缓存中。9. 加密怎么放才不会把省下的时间又花回去加密开销取决于操作发生的频率。若每条日志都做一次公钥运算固定成本会随日志条数增长若先复制完整缓冲再加密还要额外读写一次内存。当前实现把加密分成两层段创建时生成 AES-256 密钥和 IV初始向量用 RSA-2048 保护 AES 密钥再用 AES-CBC 加密一个 32 KiB 的掩码块。持续写入时payload 在 Appender 的批量缓存里按文件位置与掩码块循环异或。图 7上半部分是每段的一次性准备下半部分是 payload 的常规处理。注意两部分的执行频率完全不同。加密材料占 33048 字节公钥指纹 8 字节、RSA 密文 256 字节、IV 16 字节、掩码密文 32768 字节。假设一段只有 1 KiB 内容这份固定材料比正文还大一段有几十 MiB它才被摊薄。所以评价加密格式不能只看每字节处理速度还得看段创建频率和数据规模。payload 路径按文件偏移算mask_index file_offset (32768 - 1) data[i] ^ mask[(file_offset i) (32768 - 1)]32768 是 2 的幂取模可以用位与。不同批次从自己的绝对文件位置出发不用把“上次处理到掩码哪里”这种隐式状态串起来。按块处理时可以用 SSE、AVX2 或 NEON循环里载入数据和掩码异或写回。Appender 还会调整缓存 padding让数据地址和掩码位置满足相对对齐关系减少非对齐处理的麻烦。写出不完整时缓存里没写完的部分要恢复成可继续处理的状态——不能让下一次 flush 把已经变换过的内容又当明文处理一遍。逐批处理的主要工作是循环异或。这里也把保护强度说清楚这是防止日志被直接阅读的混淆层——payload 不是逐批走 AES而是与 AES 保护的掩码异或掩码每 32 KiB 重复一次已知明文可以推出掩码段。格式没有 AEAD带认证的加密认证标签不防篡改也挡不住有动机的攻击者需要这些保证时要再加机制。10. 写入和读取分别把成本放在哪里把前面的步骤串起来一条新日志到达压缩 Appender 时大致经历取格式哈希、级别、分类 → 查格式模板未命中则写新模板 → 查线程模板未命中则写线程定义 → 时间差 ZigZag / VLQ → 编码模板索引与参数 → 回填 item 长度 → 标记本条记录已完整写入缓存UTF-Mixed 和变长整数的最终长度在编码后才确定。Appender 先预留空间写完 body 再回填长度避免为精确测量先遍历一遍。第三篇会展开这一步。读取则反过来打开容器恢复段状态加载分类表遇到格式模板就追加到数组遇到线程模板就更新映射遇到记录就还原时间和参数再调用公共 layout 生成文本。格式化挪到了这里频繁写入的那一端不再逐条生成完整文本。当然代价也跟着挪到了读取端二进制内容没法直接用文本工具看从文件中间开始读也不一定有字典和时间基线。writer 的模板缓存有上限、会淘汰条目文件里的模板编号却可能越积越多decoder 要容纳这份完整的历史不能套用 writer 的上限。文件轮转既控制磁盘也影响打开和解码的成本。回头看这个格式做的是同一件事找出每条日志都躲不掉的重复工作把它挪到不常发生的位置——格式化挪到读取时加密准备挪到段创建时状态重建挪到打开文件时。11. 看结果时要把完整文件和完整流程算进去公开 README 的 400 万条体积用例里BqLog 文本为 283 MB压缩为 45 MB不到前者的六分之一。这是那组内容测出来的结果不是格式承诺的固定比例。如果每条日志的模板都不一样或者参数本身就是高熵的大块数据模板复用的收益自然会变。同样写入 benchmark 也要看完整多少线程、最后是否 flush、加密段初始化算不算进去、比较的是文本还是压缩。设计的价值在于减少了重复工作具体少多少得让实际内容和测量来回答。写入路径省下的时间有多少第二篇开头的跑分表会给出一组实测。下一篇回到内存中的数据总线多个生产者怎样向同一个消费者提交日志。对照源码继续看appender_file_compressed.cpp模板、时间差、参数编码和 item 回填。appender_file_binary.h、appender_file_binary.cpp文件头、段、加密材料和恢复。appender_decoder_compressed.cpp、appender_decoder_base.cpp对应解码过程。log_utils.h、util.cppVLQ、ZigZag 与 UTF-Mixed。test_log_appender.h、test_compressed_cache.h分段、恢复和模板缓存相关测试。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。