Spots

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

在追求极致性能的系统中,减少一切不必要的计算是优化的核心。以手游为例,帧率和流畅度是其基础体验的关键,而游戏的发行版本往往被一个“不可能三角”所困扰:

国内发行的手游通常优先选择保1和3,放弃2。而HOK作为《王者荣耀》的国际服,由于面向全球的发行背景,面临时差、语言和隐私观念等问题,在遇到疑难杂症时很难直接与…

国内发行的手游通常优先选择保1和3,放弃2。而HOK作为《王者荣耀》的国际服,由于面向全球的发行背景,面临时差、语言和隐私观念等问题,在遇到疑难杂症时很难直接与用户沟通。这种时候我们就需要一种产品,能帮助我们“既要又要”,打破这个不可能三角。BqLog就是在这样的背景下诞生的。 目前王者万象棋,洛克王国等多款最近两年自研的游戏都引入了BqLog来解决这样的问题。 BqLog不仅适用于客户端,也适用于服务器,能用于多种编程语言,也能兼容多种操作系统,具体请见Github地址: github.com/Tencent/BqL… 为何 BqLog 如此快之一:从一行文本推导出压缩日志格式 线上日志要留够排查问题的信息,但写入不能拖慢业务,文件也不能无限增长。移动设备上,这几个要求尤其容易冲突。 一种常见办法是先写文本,关闭文件后再压缩。这能节省归档空间,却省不了写入时的格式化、拷贝和首次写盘。BqLog 选择在写入之前利用日志本身的结构,减少这些工作。 先看一行普通日志如何产生,再拆开其中重复的部分。后面会沿着同一条思路讲文件布局、续写和加密。 方括号里依次是级别、分类和线程标识,正文由格式字符串和参数拼成。写出这行文本,需要把时间戳、整数和浮点数转成字符串,与固定文本拼接,再写入文件。编码不同时还要转换字符集。即使把格式化交给后台线程,这些工作也仍要做。 两条记录只在时间和三个参数上不同,固定文本却被重新拼接、重新写入。 若一天有一千万条这样的日志,每条重复 50 字节固定内容,仅重复部分就约 500 MB。这些字节还会经过拷贝和 I/O。 业务代码其实已经给出了边界:**格式字符串固定,参数变化。**文件可以直接保存这个边界,不必每次都保存完整句子。 第一次见到格式字符串时存下它,并分配编号。此后同一格式只写编号和参数: 这样做把格式化移到了读取时。很多诊断日志最终并不会被打开,对它们而言,写入时就省掉了格式化。真正需要读取时,再按模板还原文本。 读取工作还可以放到研发机器或分析环境,减少业务设备上的开销。 日志级别、分类、线程 ID

和线程名也会重复。不过,同一条业务格式可能由多个线程输出。若把所有字段放进一个模板,换一个线程就要再存一份格式字符串。…

和线程名也会重复。不过,同一条业务格式可能由多个线程输出。若把所有字段放进一个模板,换一个线程就要再存一份格式字符串。 每条日志分别引用这两个模板。线程换了,格式照样复用;业务格式换了,线程信息也照样复用。 分类列表在创建 Log 对象(一个日志器实例)时已经确定,可以放在文件开头;格式模板只保存分类索引。这样,分类表、格式模板、线程模板和日志记录按各自的更新频率分开保存。 文件里现在有格式模板、线程模板和日志记录。新线程或新格式随时可能出现,所以这三类数据会交错写入。

若分别存到几个文件,就要维护文件间的对应关系。放进同一个文件,读取时怎么知道每一项到哪里结束?格式字符串和参数个数都不固定,没法预设宽度;靠结束符一路扫到尾也不…

若分别存到几个文件,就要维护文件间的对应关系。放进同一个文件,读取时怎么知道每一项到哪里结束?格式字符串和参数个数都不固定,没法预设宽度;靠结束符一路扫到尾也不可靠,二进制参数里什么字节都可能有,结束符还得转义。直接的办法是开头写明长度:读完头部就知道这一项的范围,也知道下一项从哪开始。 BqLog 把它们写成连续的 Data Item:每项先写类型和长度,再写内容。 图 1:一个 item 的头部和 body,以及两种长度情况下的具体 bit 布局。横向相邻的框在文件中也相邻。 固定用 4 字节表示长度很简单,但多数日志很短,长度字段本身就会占去不少空间。这里改用变长整数编码。 BqLog 用的是带前缀的 VLQ 编码。为了看清楚,先只考虑无符号整数: 前缀里第一个 1 出现在第几位,就告诉 decoder(解码器)这个数总共占几个字节。编码变长之后,不再重复表示前一个长度已经覆盖的数字,而是直接从新区间的起点开始计数: 比如 128 不会再在两字节里原样编码成 128,而是表示“第二个区间的第 0 个数”,编码结果是 40 00;16512 是第三个区间的第 0 个数,结果是 20 00 00。

这里的 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 因而可以用数组下标访问模板。

这里显式写索引,是因为线程信息可能在新的运行里被重新定义。一个明文文件可能被同一个程序多次打开续写,但上一次的线程…

这里显式写索引,是因为线程信息可能在新的运行里被重新定义。一个明文文件可能被同一个程序多次打开续写,但上一次的线程 ID、线程名对应关系不能直接套到这一次。新的线程模板可以覆盖某个索引的定义,decoder 按文件顺序更新映射,后面的日志引用新信息。 **文件索引和内存缓存槽位是两回事。**writer 的模板缓存可以淘汰条目;被淘汰的格式再次出现时,文件会再写一份模板并分配新索引。已有记录仍指向旧索引,缓存淘汰只影响之后的复用率和文件大小。 格式哈希只供 writer 查找模板。decoder 使用文件索引,因此磁盘记录不保存这个哈希。 格式和线程索引通常从小数字开始,适合用 VLQ。时间戳和参数还有各自的编码方式。 毫秒级 Epoch 时间戳(从 1970 年起算的毫秒数)要 64 位表示,逐条存就是 8 字节。可连续两条日志的间隔往往只有几毫秒,甚至同一毫秒里好几条。既然上一条时间已知,下一条只记差值就够了。 比如三条日志的时间分别是 1000、1002、1001 ms,差值就是 1000、2、-1。第一条以 0 为基准,后面每条以前一条为基准。

第三条比第二条早,是怎么发生的?多线程交错写入时,记录被消费的顺序本来就可能不同于取时间戳的顺序(这是第二篇的主题)。消费端在编码前会把时间戳钳成非递减,挡掉这…

第三条比第二条早,是怎么发生的?多线程交错写入时,记录被消费的顺序本来就可能不同于取时间戳的顺序(这是第二篇的主题)。消费端在编码前会把时间戳钳成非递减,挡掉这类回退——代价是乱序那几条的时间被改写成上一个值;同一运行内,文件里的差值不会出现负数。但有一种情况钳制帮不上忙:明文文件续写时,时间基准是从旧文件里恢复出来的,新运行的记录若赶上系统时钟回拨,就会比基准还早。格式必须允许负差存在,又不能让它占空间。 把有符号值映射成无符号,再做 VLQ。这样 +1 和 -1 都很小,不会因为负数补码的高位全是 1,被迫占满 8 或 9 个字节。 参数前面存一个类型字节,告诉 decoder 后面该读几字节、要不要解 VLQ: 浮点数直接保存 4 或 8 字节二进制值。它的位模式和数值范围不同于整数,文本输出还涉及精度格式;在写入端先转成十进制字符串没有必要。 bool、int8 等类型本身只有一字节,再做变长编码也不会更省。 每条记录也不再单独存参数个数。按类型读完一个参数,看看是否到了 item 末尾,就知道了。

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 跑,第三篇再讲。

到这里为止,我们一直在看文件格式。现在把它放回整条处理路径:BqLog 是异步日志,业务线程(生产者)不直接写文件,而是把记录放进内存队列(总线);后台线程(消…

到这里为止,我们一直在看文件格式。现在把它放回整条处理路径:BqLog 是异步日志,业务线程(生产者)不直接写文件,而是把记录放进内存队列(总线);后台线程(消费者)取出记录,交给负责具体输出的组件(Appender)编码、写盘。这条总线是第二篇的主题。 文件模板索引由 Appender 分配,不同输出的模板集合也可能不同。如果生产者直接编码文件记录,就要访问这些共享字典。文件里的变长字段还会让内存中定位参数更费事。 所以 BqLog 的总线记录保留固定头部和适当对齐,消费端再转成紧凑的文件表示。用一条很小的日志看得最清楚: 图 4:格式为 hp={},线程名为 Main。图上方是总线内的记录,下方是模板已存在且时间差为 0 时的文件记录。

内存头有 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 会把新记录的编号按旧字典解释,参数可能被代入错误的格式。

News

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

在追求极致性能的系统中,减少一切不必要的计算是优化的核心。以手游为例,帧率和流畅度是其基础体验的关键,而游戏的发行版本往往被一个“不可能三角”所困扰:

@spots
Source: Juejin
See more like this