[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"$f27gdwgryqyycb":3},{"_id":4,"slug":5,"title":6,"subtitle":7,"kind":8,"cards":9,"tags":57,"categories":58,"source":59,"lang":62,"author":63,"audioState":66,"stats":67,"publishedAt":70,"renderer":71},"6abc613aca21c797c7e9eaa9","bqlog1-9ae22602","王者荣耀日志组件BqLog为什么这么快之1——高性能实时压缩日志","在追求极致性能的系统中，减少一切不必要的计算是优化的核心。以手游为例，帧率和流畅度是其基础体验的关键，而游戏的发行版本往往被一个“不可能三角”所困扰：","news",[10,12,17,22,27,32,37,42,47,52],{"headline":6,"body":7,"imageUrl":11,"sourceImageUrl":11},"https:\u002F\u002Fp3-xtjj-sign.byteimg.com\u002Ftos-cn-i-73owjymdk6\u002Fdefb4b5125864b6db506ea4399193229~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcGlwcG9jYW8=:q75.awebp?rk3s=f64ab15b&x-expires=1791188723&x-signature=cxga2SS%2FxN%2BIe54FnKwyH%2BJtXgE%3D",{"headline":13,"body":14,"imageUrl":15,"images":16},"国内发行的手游通常优先选择保1和3，放弃2。而HOK作为《王者荣耀》的国际服，由于面向全球的发行背景，面临时差、语言和隐私观念等问题，在遇到疑难杂症时很难直接与…","国内发行的手游通常优先选择保1和3，放弃2。而HOK作为《王者荣耀》的国际服，由于面向全球的发行背景，面临时差、语言和隐私观念等问题，在遇到疑难杂症时很难直接与用户沟通。这种时候我们就需要一种产品，能帮助我们“既要又要”，打破这个不可能三角。BqLog就是在这样的背景下诞生的。 目前王者万象棋，洛克王国等多款最近两年自研的游戏都引入了BqLog来解决这样的问题。 BqLog不仅适用于客户端，也适用于服务器，能用于多种编程语言，也能兼容多种操作系统，具体请见Github地址： github.com\u002FTencent\u002FBqL… 为何 BqLog 如此快之一：从一行文本推导出压缩日志格式 线上日志要留够排查问题的信息，但写入不能拖慢业务，文件也不能无限增长。移动设备上，这几个要求尤其容易冲突。 一种常见办法是先写文本，关闭文件后再压缩。这能节省归档空间，却省不了写入时的格式化、拷贝和首次写盘。BqLog 选择在写入之前利用日志本身的结构，减少这些工作。 先看一行普通日志如何产生，再拆开其中重复的部分。后面会沿着同一条思路讲文件布局、续写和加密。 方括号里依次是级别、分类和线程标识，正文由格式字符串和参数拼成。写出这行文本，需要把时间戳、整数和浮点数转成字符串，与固定文本拼接，再写入文件。编码不同时还要转换字符集。即使把格式化交给后台线程，这些工作也仍要做。 两条记录只在时间和三个参数上不同，固定文本却被重新拼接、重新写入。 若一天有一千万条这样的日志，每条重复 50 字节固定内容，仅重复部分就约 500 MB。这些字节还会经过拷贝和 I\u002FO。 业务代码其实已经给出了边界：**格式字符串固定，参数变化。**文件可以直接保存这个边界，不必每次都保存完整句子。 第一次见到格式字符串时存下它，并分配编号。此后同一格式只写编号和参数： 这样做把格式化移到了读取时。很多诊断日志最终并不会被打开，对它们而言，写入时就省掉了格式化。真正需要读取时，再按模板还原文本。 读取工作还可以放到研发机器或分析环境，减少业务设备上的开销。 日志级别、分类、线程 ID","\u002Fapi\u002Fmedia\u002Fposts\u002Fbqlog1-9ae22602\u002F1.webp",{"local":15},{"headline":18,"body":19,"imageUrl":20,"images":21},"和线程名也会重复。不过，同一条业务格式可能由多个线程输出。若把所有字段放进一个模板，换一个线程就要再存一份格式字符串。…","和线程名也会重复。不过，同一条业务格式可能由多个线程输出。若把所有字段放进一个模板，换一个线程就要再存一份格式字符串。 每条日志分别引用这两个模板。线程换了，格式照样复用；业务格式换了，线程信息也照样复用。 分类列表在创建 Log 对象（一个日志器实例）时已经确定，可以放在文件开头；格式模板只保存分类索引。这样，分类表、格式模板、线程模板和日志记录按各自的更新频率分开保存。 文件里现在有格式模板、线程模板和日志记录。新线程或新格式随时可能出现，所以这三类数据会交错写入。","\u002Fapi\u002Fmedia\u002Fposts\u002Fbqlog1-9ae22602\u002F2.webp",{"local":20},{"headline":23,"body":24,"imageUrl":25,"images":26},"若分别存到几个文件，就要维护文件间的对应关系。放进同一个文件，读取时怎么知道每一项到哪里结束？格式字符串和参数个数都不固定，没法预设宽度；靠结束符一路扫到尾也不…","若分别存到几个文件，就要维护文件间的对应关系。放进同一个文件，读取时怎么知道每一项到哪里结束？格式字符串和参数个数都不固定，没法预设宽度；靠结束符一路扫到尾也不可靠，二进制参数里什么字节都可能有，结束符还得转义。直接的办法是开头写明长度：读完头部就知道这一项的范围，也知道下一项从哪开始。 BqLog 把它们写成连续的 Data Item：每项先写类型和长度，再写内容。 图 1：一个 item 的头部和 body，以及两种长度情况下的具体 bit 布局。横向相邻的框在文件中也相邻。 固定用 4 字节表示长度很简单，但多数日志很短，长度字段本身就会占去不少空间。这里改用变长整数编码。 BqLog 用的是带前缀的 VLQ 编码。为了看清楚，先只考虑无符号整数： 前缀里第一个 1 出现在第几位，就告诉 decoder（解码器）这个数总共占几个字节。编码变长之后，不再重复表示前一个长度已经覆盖的数字，而是直接从新区间的起点开始计数： 比如 128 不会再在两字节里原样编码成 128，而是表示“第二个区间的第 0 个数”，编码结果是 40 00；16512 是第三个区间的第 0 个数，结果是 20 00 00。","\u002Fapi\u002Fmedia\u002Fposts\u002Fbqlog1-9ae22602\u002F3.webp",{"local":25},{"headline":28,"body":29,"imageUrl":30,"images":31},"这里的 VLQ 不是常见的 LEB128（那种编码每字节存 7 位、最高位作延续标志）。BqLog 的前缀布局保证多字节编码的首 bit","这里的 VLQ 不是常见的 LEB128（那种编码每字节存 7 位、最高位作延续标志）。BqLog 的前缀布局保证多字节编码的首 bit 恒为 0——这个空位在 3.2 节还有别的用途。区间起点、前缀和字节顺序以 log_utils.h 为准。 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 字节。注意长度不包括头本身，这一点在回填和解码时都得保持一致。 图 2：从模板到单个参数逐层展开。颜色对应内容种类，字段中的字节数对应实际编码。 subtype=0 表示格式来自 UTF-8，subtype=2 表示来自 UTF-16，后者在文件里用 UTF-Mixed 表示（第 6 节细讲）。格式字符串占满 body 剩下的空间，所以不必再存一个字符串长度，也不需要结尾零字符。 格式模板不需要显式索引：文件中第一次出现的是 0 号，第二次是 1 号，以此类推。writer（写入端）和 decoder 按相同顺序编号。decoder 因而可以用数组下标访问模板。","\u002Fapi\u002Fmedia\u002Fposts\u002Fbqlog1-9ae22602\u002F4.webp",{"local":30},{"headline":33,"body":34,"imageUrl":35,"images":36},"这里显式写索引，是因为线程信息可能在新的运行里被重新定义。一个明文文件可能被同一个程序多次打开续写，但上一次的线程…","这里显式写索引，是因为线程信息可能在新的运行里被重新定义。一个明文文件可能被同一个程序多次打开续写，但上一次的线程 ID、线程名对应关系不能直接套到这一次。新的线程模板可以覆盖某个索引的定义，decoder 按文件顺序更新映射，后面的日志引用新信息。 **文件索引和内存缓存槽位是两回事。**writer 的模板缓存可以淘汰条目；被淘汰的格式再次出现时，文件会再写一份模板并分配新索引。已有记录仍指向旧索引，缓存淘汰只影响之后的复用率和文件大小。 格式哈希只供 writer 查找模板。decoder 使用文件索引，因此磁盘记录不保存这个哈希。 格式和线程索引通常从小数字开始，适合用 VLQ。时间戳和参数还有各自的编码方式。 毫秒级 Epoch 时间戳（从 1970 年起算的毫秒数）要 64 位表示，逐条存就是 8 字节。可连续两条日志的间隔往往只有几毫秒，甚至同一毫秒里好几条。既然上一条时间已知，下一条只记差值就够了。 比如三条日志的时间分别是 1000、1002、1001 ms，差值就是 1000、2、-1。第一条以 0 为基准，后面每条以前一条为基准。","\u002Fapi\u002Fmedia\u002Fposts\u002Fbqlog1-9ae22602\u002F5.webp",{"local":35},{"headline":38,"body":39,"imageUrl":40,"images":41},"第三条比第二条早，是怎么发生的？多线程交错写入时，记录被消费的顺序本来就可能不同于取时间戳的顺序（这是第二篇的主题）。消费端在编码前会把时间戳钳成非递减，挡掉这…","第三条比第二条早，是怎么发生的？多线程交错写入时，记录被消费的顺序本来就可能不同于取时间戳的顺序（这是第二篇的主题）。消费端在编码前会把时间戳钳成非递减，挡掉这类回退——代价是乱序那几条的时间被改写成上一个值；同一运行内，文件里的差值不会出现负数。但有一种情况钳制帮不上忙：明文文件续写时，时间基准是从旧文件里恢复出来的，新运行的记录若赶上系统时钟回拨，就会比基准还早。格式必须允许负差存在，又不能让它占空间。 把有符号值映射成无符号，再做 VLQ。这样 +1 和 -1 都很小，不会因为负数补码的高位全是 1，被迫占满 8 或 9 个字节。 参数前面存一个类型字节，告诉 decoder 后面该读几字节、要不要解 VLQ： 浮点数直接保存 4 或 8 字节二进制值。它的位模式和数值范围不同于整数，文本输出还涉及精度格式；在写入端先转成十进制字符串没有必要。 bool、int8 等类型本身只有一字节，再做变长编码也不会更省。 每条记录也不再单独存参数个数。按类型读完一个参数，看看是否到了 item 末尾，就知道了。","\u002Fapi\u002Fmedia\u002Fposts\u002Fbqlog1-9ae22602\u002F6.webp",{"local":40},{"headline":43,"body":44,"imageUrl":45,"images":46},"C#、Java、Unreal 经常用 UTF-16。对 ASCII 字符来说，每个字符的高字节都是 0，两字节存一个英文字母确实浪费。但把 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 跑，第三篇再讲。","\u002Fapi\u002Fmedia\u002Fposts\u002Fbqlog1-9ae22602\u002F7.webp",{"local":45},{"headline":48,"body":49,"imageUrl":50,"images":51},"到这里为止，我们一直在看文件格式。现在把它放回整条处理路径：BqLog 是异步日志，业务线程（生产者）不直接写文件，而是把记录放进内存队列（总线）；后台线程（消…","到这里为止，我们一直在看文件格式。现在把它放回整条处理路径：BqLog 是异步日志，业务线程（生产者）不直接写文件，而是把记录放进内存队列（总线）；后台线程（消费者）取出记录，交给负责具体输出的组件（Appender）编码、写盘。这条总线是第二篇的主题。 文件模板索引由 Appender 分配，不同输出的模板集合也可能不同。如果生产者直接编码文件记录，就要访问这些共享字典。文件里的变长字段还会让内存中定位参数更费事。 所以 BqLog 的总线记录保留固定头部和适当对齐，消费端再转成紧凑的文件表示。用一条很小的日志看得最清楚： 图 4：格式为 hp={}，线程名为 Main。图上方是总线内的记录，下方是模板已存在且时间差为 0 时的文件记录。","\u002Fapi\u002Fmedia\u002Fposts\u002Fbqlog1-9ae22602\u002F8.webp",{"local":50},{"headline":53,"body":54,"imageUrl":55,"images":56},"内存头有 40 字节：完整时间戳、分类、线程 ID、格式哈希和长度都在里面。5 字节的格式内容按 4 字节边界对齐成 8","内存头有 40 字节：完整时间戳、分类、线程 ID、格式哈希和长度都在里面。5 字节的格式内容按 4 字节边界对齐成 8 字节，int32 参数占类型区和数值区，最后还有线程扩展信息。这个例子的内部记录共 61 字节，走 LP 路径还会加上 context 和 ring 的块头（LP 是第二篇里的低频路径；context 和块头正是那里的主角）。 0B 表示 int32；42 经 ZigZag 得到 84，编码为 D4，整项共 7 字节。文件头和模板由多条记录共用。内存布局方便生产者写入和消费者读取，文件布局尽量缩小记录。 前面的例子默认程序持续运行，模板表和加密状态一直在内存中。程序重启后，这些状态需要重新建立。不支持续写的话，加密日志崩溃一次就只能丢掉没写完的部分，或者重开时把旧文件整个解密重写一遍——比起分段结构多付的那点成本，这两种做法贵得多。 **续写压缩日志必须知道已有的模板编号和时间基线。**只把文件指针移到末尾，还不足以生成后续记录。 假设旧文件里已经有三条格式模板，编号 0、1、2。新日志又用到其中一种格式，我们得找到它原来的编号；来了新格式，也得知道下一个编号从 3 开始。日志存的是时间差，所以最后一条日志的时间戳也得恢复。 如果不读取旧文件就从 0 重新编号，decoder 会把新记录的编号按旧字典解释，参数可能被代入错误的格式。","\u002Fapi\u002Fmedia\u002Fposts\u002Fbqlog1-9ae22602\u002F9.webp",{"local":55},[],[],{"name":60,"url":61},"Juejin","https:\u002F\u002Fjuejin.cn\u002Fpost\u002F7690401920711655467","zh",{"handle":64,"displayName":65},"spots","Spots",null,{"views":68,"likes":69,"saves":69,"shares":69,"completions":69,"opens":69,"skips":69,"depthSum":69},1,0,"2026-09-30T01:09:14.268Z","local"]