在追求极致性能的系统中减少一切不必要的计算是优化的核心。以手游为例帧率和流畅度是其基础体验的关键而游戏的发行版本往往被一个“不可能三角”所困扰性能足够好日志少写方便追述问题日志应写尽写节约存储空间日志最好就别写国内发行的手游通常优先选择保1和3放弃2。而HOK作为《王者荣耀》的国际服由于面向全球的发行背景面临时差、语言和隐私观念等问题在遇到疑难杂症时很难直接与用户沟通。这种时候我们就需要一种产品能帮助我们“既要又要”打破这个不可能三角。BqLog就是在这样的背景下诞生的。BqLog不仅适用于客户端也适用于服务器能用于多种编程语言也能兼容多种操作系统具体请见Github地址https://github.com/Tencent/BqLog本文是系列文章的第三篇点击查看全部文章本篇是系列第三篇。前两篇定了文件格式和数据通道本篇沿着一条日志的写入路径往下走看每两个相邻步骤的交接处还能省掉多少重复工作。为何 BqLog 如此快之三一次内存读取能顺便做多少事情前两篇分别讲了压缩文件和生产者到消费者的数据总线。这两处定下来后还可以沿着一条日志的写入路径继续找重复工作格式字符串是否被多次扫描查表失败后是否又探测同一批槽位编码时是否为了回填长度而移动正文注意这三处浪费都不在某一步的内部而在两步的交接处——上一步已经算出的东西下一步又算了一遍。本文就从调用线程走到 Appender把每个交接处捡起来看既看哪些工作可以省掉也看剩下的指令为什么会让 CPU 等待。本文对应 BqLog 2.5.0。文中的简化伪码用于解释思路实际分支以文末列出的源码为准。1. 一条日志经过哪些步骤假设业务执行log.info(player {} enters level {},player_id,level_id);在当前异步压缩路径上它大致要经历业务线程 判断级别和分类是否启用 计算格式与参数所需空间 向总线申请内存 复制格式和参数 提交记录 消费者线程 消费端查找格式模板和线程模板 把时间差、索引和参数编码到文件写缓存 按批次处理并输出第二篇解决了两边怎样交接记录。接下来分别看两边执行的工作业务线程要尽快完成调用消费者则要及时处理队列中的日志。格式和线程模板都由消费端的 Appender 管理生产者不用一起争用这张表。例如复制格式字符串进总线时处理器已经读到了字符。计算哈希能否使用这次载入的值查找模板失败时哈希表已探测到一个空槽。插入时也可以记住这个位置。此外指令数相同也未必耗时相同。后一条指令可能在等前一条的计算结果也可能在等数据从内存进入 CPU 缓存。后面的 CRC循环冗余校验§4 细说和模板表正好分别说明这两种等待。2. 在序列化之前过滤日志如果级别或分类已被关闭序列化之后再丢弃日志会浪费前面的长度计算和缓冲分配。C 封装层wrapper先检查级别位图bitmap和分类掩码mask。级别位图合并各 Appender 配置的级别没有任何 Appender 配置接收的级别可以直接跳过Log 层关闭的分类也在这里过滤。各 Appender 的启用状态、分类等条件再由消费端检查。但 C 会在进入函数之前求值参数表达式log.debug(state{},build_expensive_state_string());即使 debug 日志被过滤build_expensive_state_string()也已经执行。如果构造参数很贵调用方还要先检查该日志是否启用。库内过滤能省下的是进入 wrapper 之后的序列化和提交工作。这也是全路径里唯一一处“能省多少取决于调用方”的步骤后面每一步的节省都是库自己能保证的。3. 已知的长度直接传下去int32、double、bool的大小由类型决定不必在运行时扫描内容。字符串有不同来源有的对象自带 size有的是零结尾指针还有的字面量长度可由编译器确定。若都退化成strlen已有的长度信息就被丢掉了。对于无需转码的 UTF-8、UTF-16 输入wrapper 可以使用数组或对象已有的长度只有零结尾指针需要扫描到末尾。UTF-32 要转换成总线中的 UTF-16 表示还需根据内容计算目标字节数知道源字符数并不一定知道转换后占多少空间。分配总线记录前需要总尺寸依次写参数时又需要各参数的尺寸。size_seq参数尺寸序列把编译期已知大小和运行时计算的大小组合起来让分配与写入共用这些结果。假设参数是int32、double和内容占 n 字节的字符串总尺寸就是固定头部、固定参数区、n 字节内容和对齐开销之和——第一篇图 4 里那条 61 字节的记录就是这样加出来的。算出后可直接写入总线记录无需先构建临时参数数组。这里计算的是总线布局整数仍用固定大小并按需要对齐。第 9 节回填的是文件编码后的长度同一个整数压成 VLQ 后占几字节要到那一步才确定。两个阶段各用适合自己的布局长度信息在各自阶段内复用。3.1 Java、C# 参数直接写到总线中如果 Java 端先创建一份临时字节数组填完参数再交给 native 拷贝仍会逐条产生对象和中间数据。BqLog 的接口先向总线申请记录再让 wrapper 把参数写进这块内存。Java 通过直接字节缓冲区direct ByteBuffer访问 native ring相关视图在适用路径上复用C# 的值类型log_context保存写入句柄通过目标地址填写参数。最后调用提交接口消费者才开始读取这条完整记录。图 1先取得总线中的目标区域再填写参数并提交省去中间序列化缓冲。Java 的固定参数个数重载还可以避免可变参数数组基本类型需要避免装箱时bq.utils.param.no_boxing(...)从线程本地池借用参数包装序列化后归还。池首次使用和增长仍会分配调用方构造字符串、收集调用栈也各有成本。低分配路径依靠目标内存直写与对象复用共同完成。4. 拷贝的时候顺手把哈希算了异步消费时调用方的格式字符串可能已经失效因此要把它复制进总线。压缩 Appender 还需要哈希来查找相同格式。最直观的写法是memcpy(destination,source,length);hashhash_bytes(destination,length);即使把哈希计算移到消费者线程字符串仍会被完整读取第二遍。第二遍可能命中 CPU 缓存但还是要执行加载指令把字节重新送进寄存器。融合拷贝和哈希要省掉的就是这轮重复工作。当前__api_log_write_begin对有直接地址的 UTF-8/UTF-16 格式字符串用的是融合函数head-format_hashbq::util::bq_memcpy_with_hash(handle.format_data_addr,format_str_data,format_str_bytes_len);看循环里的载入和写回就能看到两项工作怎样共用数据。图 232 字节普通循环一次得到四个 64 位值同一份值既写进目标内存也用于更新 CRC。下方还画出了长度 45 时的尾部重叠。它的主体可以简化成这样// 示意生产实现包含长度分支、硬件选择和尾部处理load_8_bytes(v0,source0);load_8_bytes(v1,source8);load_8_bytes(v2,source16);load_8_bytes(v3,source24);store_8_bytes(destination0,v0);store_8_bytes(destination8,v1);store_8_bytes(destination16,v2);store_8_bytes(destination24,v3);h0crc_update(h0,v0);h1crc_update(h1,v1);h2crc_update(h2,v2);h3crc_update(h3,v3);四个载入值v0..v3同时用于写目标缓冲和更新 CRC 状态。CRC 本来是循环冗余校验码现代 CPU 为它提供了单条指令BqLog 借这条指令算哈希——一次更新就是一条指令它的执行特性直接决定哈希有多快下一节展开。哈希部分因此不必再为整段字符串单独发出一轮加载。固定 8 字节的memcpy通常会被编译器展开为载入和存储指令也避免源码直接对未对齐指针解引用。4.1 为什么不用一个 CRC 累加器从头算到尾如果所有 64 位字都更新同一个状态h crc(h, v0) h crc(h, v1) h crc(h, v2) h crc(h, v3)第二次更新需要第一次算出的新h第三次又需要第二次的结果。这是数据依赖CPU 即使支持乱序执行也必须等输入准备好才能开始下一次更新。这里要区分指令的延迟和吞吐能力。一条 CRC 指令可能经过几个流水级才给出结果但执行单元在它结束之前已经能接收下一条独立的 CRC 指令。假设一次更新要等 3 个周期流水线每周期能接收一条新指令。单个h只能让四次更新在第 0、3、6、9 个周期开始中间的发射机会用不上。换成上一节的h0..h3四次更新互不依赖就可以在第 0、1、2、3 个周期陆续进入流水线。数字是假设的区别在于下一条指令是否必须等上一条的结果。BqLog 因此把一条长依赖链拆成四条链。每条链内部仍有依赖链与链之间可以独立推进CPU 在等待某个状态更新时可以执行另一个状态的更新。乱序调度器还能穿插执行已经准备好的加载和存储让流水线少一些空等。这是同一 CPU 核内的指令级并行不需要额外线程。循环结束后四个状态通过旋转、异或组合为 64 位哈希。这里定义的是供模板查找使用的哈希结果并不等于把全部字节交给单个累加器算出的标准 CRC。融合拷贝和纯哈希遵循相同算法因而仍能互相替代。4.2 为了少几个尾部分支允许少量重读处理至少 32 字节的输入时主循环每次取 32 字节。末尾若还有余数可以按剩余的 16、8、4、2、1 字节逐段处理但需要多次分支判断。当前实现改为从字符串末尾取最后 32 字节短于 32 字节的输入另走短串分支。以长度 45 为例主循环处理 [0,32)尾部处理 [13,45)因此 [13,32) 会重读。目标偏移相同拷贝结果不变哈希算法也按这个重叠方式定义。这样减少了尾部分支。这里用少量重读换取更简单的尾部控制。结合前两节看融合拷贝减少加载与循环次数四条 CRC 链减少结果依赖造成的等待尾部重叠则减少分支三处优化作用在不同的开销上。4.3 算好的哈希必须跟着消息一起走生产者算出的哈希放在 40 字节日志头的format_hash字段中就是第一篇图 4 里那个 40 字节内存头的字段随记录交给消费者否则消费端还得重算。消费端遇到非零format_hash就直接使用为零时走纯哈希路径。调用栈拼接、wrapper 填充格式和部分编码转换路径未必能在 begin 阶段提供格式字符串地址。各语言 wrapper 的覆盖范围不同Go 的融合 native 接口会把格式地址传给 beginJNI、NAPI 要看各自取得数据的方式。哈希值恰好为零时也会重算不会丢弃日志。这个 64 位运行时 key 由多条 CRC 状态组合而成不写入文件。decoder 依赖文件中的模板索引读取时无需重现 writer 的哈希值。5. 模板查找先想清楚访问模式再决定哈希表长什么样第一篇把重复格式抽成了模板每条日志只记录模板索引。消费端因此要反复回答一个问题这个格式对应哪个索引文件里省掉了重复字符串内存中的模板查找也就成了每条日志必经的步骤。哈希表平均查找复杂度是 O(1)但这没有告诉我们一次查找要等多少次内存访问。若表项分散在许多节点中CPU 可能先读到一个指针再沿它读取节点数据不在 CPU 缓存时查找就得等待。减少探测次数、让常用数据留在缓存里都能缩短这条路径。日志有很明显的时间局部性程序反复执行同一批日志语句刚用过的格式很可能马上再用。Appender 因此在大表前放一张很小的直接映射表格式 L1 有 256 槽线程 L1 有 64 槽。每个 key 只对应一个候选槽比对完整 key 后命中就直接取索引不用继续探测。这里的L1、L2 是 Appender 自己维护的两层软件缓存。小 L1 在常见的 64 位布局下分别约为 4 KiB 和 1 KiB反复访问的范围集中更容易留在 CPU 数据缓存中。一次软件 L1 命中既省去大表查找也减少了访问大范围内存的机会这个命名并不意味着两张表被固定放在处理器的某一级缓存。L2 容量更大保存更多格式到索引的映射。两个 key 映射到同一 L1 槽时后来的会替换原来的下次找不到就去 L2 查询并把结果填回 L1。这样常用格式走短路径其余格式仍有较大的表可查。生产者通过线程本地存储TLS缓存线程 ID 和线程名减少反复查询系统信息。消费端的线程模板在 L1 前还有一步当前线程 ID 若与上一条相同直接复用上次的模板索引。第二篇的 SISO 批读会让同一线程的记录连续到来因此这时连选槽和比对 key 都可以省掉。格式 key 是把字符串哈希、分类和级别组合起来的keyformat_hash^((uint64_t(category_index)32)|level);同一格式字符串若用于不同分类或级别仍对应不同模板因此 key 同时包含这几个字段。图 3L2 的 keys 和 values 是两个连续数组查找走过的空槽位置可以交给后续插入复用。6. 让 L2 少访问内存、少做重复探测6.1 为什么 keys 和 values 要分开存L1 没命中时仍要访问 L2。L2 使用开放寻址表项直接放在数组槽位中目标槽被占用就向后探测。这样省去了逐项分配节点也不必沿指针寻找下一处数据。线性探测还有一个硬件上的好处。CPU 以缓存行cache line为单位取数读入一个槽位时邻近槽位往往也一起进入缓存。继续向后探测就有机会直接使用它们。这是空间局部性与上一节反复使用同一批格式的时间局部性配合起来减少访存等待。每项 key 是 8 字节value 是 4 字节。常见的 64 位布局下两者放在同一个 struct 中会因对齐占 16 字节拆成keys[]和values[]后每槽合计 12 字节主体空间少四分之一。同样大小的 CPU 缓存能够容纳更多表数据。具体查找时每个槽仍要读取values[]判断是否为空再读取keys[]比对。分开存储的收益来自去掉对齐空隙、缩小整张表的占用而不是只读取其中一个数组。默认格式上限 100000 条、约 50% 装载率时数组容量取 2 的幂可能达到 262144 槽主体约 3 MiB。估算总内存还要加上 L1、allocator、扩容时并存的新旧数组以及其他 Log 的缓存。6.2 查找失败时记住空槽假设一个 key 从 3 号槽开始探测slot 3: 被别的 key 占了 slot 4: 被别的 key 占了 slot 5: 空查找在 5 号槽发现空位。若接口只有find(key)和insert(key, value)Appender 写完模板再插入时还得重新探测 3、4、5 号槽。当前find在未命中时返回insert_token保存槽位号和table_revision。如果表没有被修改insert直接使用该槽revision 变化后则重新查找。例如扩容后key 对应的槽位可能改变旧 token 就不能继续使用。表保持稳定时则可直接插入。即使刚查过的槽位还在 CPU 缓存中重新探测也要再次计算、判空、比较token 连这些重复指令一起省掉了。6.3 为什么槽位 hash 还要再搅一遍格式内容的哈希和最终选槽位的哈希不是一回事。输入可能是对齐过的线程 ID也可能把分类值放在高位——直接拿低位去 mask某些输入会成片地挤在同一小片区域里。如果许多 key 都从相邻的几个槽开始探测段就会越来越长一次查找要多做许多次读取和比较。选槽前先经过一个常见的位混合函数Murmur3 的 fmix64 finalizer再取混合后的高位让原 key 的各位都影响槽位尽量把起点分散开。这里有两种碰撞不同 key 选中同一槽表会继续比对和探测不同格式得到完全相同的 64 位 key。对后一种当前快路径不做原始字符串复核——如果它真的发生writer 会把两种格式当成同一个模板。这是完全依赖 64 位 key 区分格式的代价。7. 缓存满后的接纳策略前面希望常用模板留在缓存里但运行越久见过的格式可能越多。L2 的容量上限控制了内存占用满了之后还需要决定是否接纳新格式。假设缓存容纳 100 个模板输入却循环访问 101 个。若每次 miss 都淘汰旧项下一轮要用的模板可能刚被换走。大量只出现一次的动态格式也会挤掉原有热点。当前 L2 满后每 64 次新插入尝试接纳一次用轮转位置选择被替换项。实现无需记录每个格式的访问频率只是减慢替换速度降低大量新格式对已有缓存的冲击也少做一些淘汰和插入操作。“不接纳”只表示不留在 L2。模板和日志仍写入文件L1 也可能保留最近映射。如果下次在 L1、L2 都找不到这个格式就再写一份模板让这条日志引用新索引。文件会变大一些日志仍能正常解码——这正是第一篇第 4 节预告过的取舍缓存淘汰只影响复用率和文件大小。采样接纳在缓存稳定性和模板重复之间做取舍少换入一次性格式但重复出现且未被接纳的格式可能再次写入文件。扩容时先分配新数组迁移已有项再释放旧数组。若分配失败就继续使用旧表。估算内存峰值时需要计入迁移期间同时存在的两份数组。8. UTF-16 优化别把每个字符都当成最复杂的那种第一篇讲过 UTF-Mixed 的格式先把 ASCII 收窄遇到不适合快速处理的内容就存 UTF-16 后缀。这一节看它具体怎么跑。UTF-16 中的 ASCII 编码值在0x0000..0x007F范围内高 9 位全是零。因此只要确认一个字符属于这个范围就能去掉高字节直接保留低字节。标量版本一次把四个 UTF-16 编码单元读进一个 64 位值用下面的掩码同时检查四组高 9 位v 0xFF80FF80FF80FF80结果为零时四个字符都在 ASCII 范围直接抽取低字节打包。一次载入同时完成检查与编码。SIMD 把这项检查扩展到一批字符确认整块都是 ASCII 后一起收窄并存储。遇到含非 ASCII 的块就结束快速转换写入FF标记将剩余内容按 UTF-16 原样保存。这样不用逐个判断后续字符需要几个 UTF-8 字节也不用生成那些多字节序列。SIMD 按块检查分界处可能连同几个本来可以收窄的 ASCII 一起保留为 UTF-16。多保留少量字节换来的是省去逐字符寻找精确分界的工作。图 4值不值得完整转码要同时看结果字节数和当下的转换工作量不能只盯压缩率。日志格式串若有较长的 ASCII 前缀这条快速路径就能处理更多字符。非 ASCII 字符出现较早时UTF-Mixed 会留下较长的 UTF-16 后缀以减少后续转换工作。模板命中后相同格式不必每条日志都重新转换每次变化的参数字符串仍需单独处理。续写旧文件时需要先把 UTF-Mixed 还原到临时缓冲才能恢复原始 UTF-16 格式的哈希。这次额外读取发生在打开和扫描文件时不在逐条写入路径上。9. 先写正文再回填长度总线已经告诉消费者每个参数在哪里但整数编码成 VLQ、UTF-16 转成 UTF-Mixed 后文件中的长度还会变化。先测量再编码会重复执行同一套转换和判断。Appender 从写缓存预留空间留出头部后直接写 body。编码结束用写指针之差得到实际长度再回填。这里有两个不同位置的长度字段。9.1 整条 item 的头部第一篇介绍过item 类型可以单独占一个字节也可以合入多字节 VLQ 的首字节。BqLog 用这两种布局配合预留空间长度编码仍占预计字节数时类型单独保存长度编码多用一个字节时将类型合入首字节。两种情况下头部总宽度相同刚写好的 body 都不用移动。例如预留了 2 字节头部。body 长 5 时写80 85类型和长度各一字节body 长 128 时写C0 00类型合入两字节长度。头部都占 2 字节。为什么不一律预留 5 字节上限因为短 item 占多数头部宽度本身也是要省的空间。格式模板有自己的预留检查条件不满足时会撤销预留并按已测得的长度重试。9.2 UTF-Mixed 参数内部的字符串长度一个字符串参数还有自己的长度字段。若为它预留了两字节长度实际编码只需一字节就在长度后留下一个00占位字符串内容保持原位。这个字节计入外层 body但不计入字符串长度由 UTF-Mixed 参数的 decoder 跳过。图 5上半部分用类型位复用保持 item 头宽度下半部分的 00 位于字符串参数内部两个位置不能混用。当前 decoder 按长度后的首字节是否为00来识别占位。如果字符串内容真的以 0x00 开头就会被误判为占位字节。这类字符串属于需要另行验证的边界其他二进制内容是否安全这条规则保证不了。一条日志及其需要的新模板全部编码完成后mark_write_finished()才推进完整记录边界。写指针可以先走在前面flush 和恢复只处理完成边界之前的数据。10. 写缓存的 SIMD 对齐与批量 I/O接下来三节是同一手法的三个应用找到带固定成本的操作让一批数据分摊它。先看批量写出。若一条日志只有几十字节逐条调用文件写入接口会反复支付固定开销。Appender 把记录连续编码到写缓存按批次输出。write_cache_size的目标范围为 64 KiB–4 MiB按 2 的幂规整。较大的缓存可减少写调用次数同时增加内存占用和待刷出的数据量。特别大的记录可能让临时分配超过目标值因此该配置并非绝对内存上限。它与第二篇的 LP/HP 队列容量分属不同层次。这里的写缓存是 Appender 的输出缓冲和第 5 节的 CPU 缓存是两回事。批量 XOR 要同时载入数据与掩码所以还要让两个地址便于一起使用 SIMD。以 32 字节对齐为例若数据地址和掩码位置除以 32 都余 5先处理 27 字节后两边就同时到达整块边界后面可以连续运行 AVX2 循环。如果两边余数不同只向前推进相同字节数不能让它们同时对齐。Appender 按文件偏移调整写缓存前面的 padding维持这份相对对齐关系。调整可能需要memmove因此集中在创建段、刷新等边界处理让后面的一批记录共用结果。11. 加密成本由段和批次共同决定第一篇介绍了分段加密它是“固定成本按批摊薄”的第二处应用RSA 和 AES 的准备工作只在段创建时做一次payload 按批处理。可以把成本近似拆成总成本 ≈ 段数 × 每段初始化成本 payload 字节数 × 稳定变换成本 批次数 × I/O 固定成本段初始化包括用 RSA 包装 AES 密钥以及用 AES 处理 32 KiB 掩码后续 payload 在原地按批循环 XOR。段越长初始化成本被越多日志分摊。短段则要更频繁地支付这部分成本。图 6把算法名称换成“执行次数 × 每次工作量”就能看出为什么分段粒度和批次大小会影响结果。第二篇的公开用例每线程写 200 万条因此单线程共 200 万条10 线程共 2000 万条。单线程压缩与加密压缩分别耗时 95 ms、102 ms10 线程时分别是 507 ms、493 ms。两组数字的差异说明加密开销在总耗时中的占比会随并发和写入量变化。该方案使用 RSA、AES 和循环掩码没有 AEAD 认证标签。若应用需要验证日志未被篡改还需要额外的认证机制。12. 合并消费者唤醒请求第三处应用轮到通知的固定成本。如果每个生产者提交一条日志就通知一次消费者多个线程会反复争用通知状态和 mutex。消费者若正在处理数据这些通知也没有必要。当前 API 在空间不足或剩余空间偏低时请求唤醒。消费者准备等待时将awake_flag设为 true第一个请求用原子 exchange 将它改为 false再进入通知所需的 mutex 和条件变量路径。后续请求读到 false就直接返回省去重复通知。后台线程有数据就继续消费空闲时才等待通知或超时。这个标志管理的是“要不要通知”记录本身的可见性仍由第二篇的提交操作和 release/acquire 保证。减少通知有利于高频写入但低频日志可能要等后台线程醒来随后还要等待 Appender 刷新。评估延迟时要把这两个等待阶段一起计算。13. 文本路径上的缓存前面的优化都围绕压缩路径。文本 Appenderbenchmark 里的 BqLog Text 一行不写 VLQ 和模板却也有自己的重复工作要省方式同样是缓存时间字符串按毫秒缓存同一毫秒内的日志直接 memcpy 上一份格式化结果毫秒部分的三位数字用一张 1000 项的查表拼出不做除法。线程信息按线程 ID 缓存第一次拼好[tid-xxx 名称]存起来之后直接复用。10 线程用例里文本模式耗时 2811 msspdlog 的异步模式是 32939 ms。这条路径上的缓存是差距的来源之一哪怕不走压缩格式消费端也不该每条日志都从头格式化一遍。14. 怎样测出各项优化的收益这些改动降低了部分工作量但不能直接从少一次遍历或更宽的 SIMD 指令推算总耗时。要测单项收益需要让对照组只改变一个因素。可以按下面的方式设计对照想验证的变化对照方式同时检查融合拷贝哈希同一哈希算法融合 vs 拷贝后独立 hash数据相同、长度、对齐、冷热缓存四条 CRC 链固定输入长度和 CRC 更新次数只改变状态依赖关系查看汇编和每字节周期这是指令依赖实验两者哈希定义不同软件 L1保持 L2 和输入不变比较启用与旁路 L1软件命中率、CPU 缓存未命中、查找耗时L2 数组布局相同容量、探测与接纳策略比较 struct 数组和分离数组探测次数、CPU 缓存未命中、内存占用token 复用相同 miss 序列是否复用探测位置扩容和修改后是否正确失效UTF-Mixed 编码取舍相同 UTF-16 输入比较完整转 UTF-8 与 UTF-Mixed英文、中文、混合内容的耗时和体积解码后内容一致ASCII 快速转换相同输入比较标量和 SIMD 实现转换耗时两者切分位置可不同解码内容应一致批量写出相同输出和满缓冲策略第二篇 §8.2 的 discard/block/expand调整缓存大小I/O、RSS、调用时延与 flush 时间整体压测还要统计最终能解码多少条日志。若一组配置在缓冲满时丢弃另一组阻塞仅比较调用速度会高估前者。test_utils.h检查不同长度和偏移下的拷贝/哈希一致性test_bounded_hash_cache.h覆盖 token 失效、容量和分配失败test_compressed_cache.h检查缓存行为与最终可解码输出。仓库的 benchmark 自带多格式模板用例benchmark/cpp/main.cpp 里的test_compress_multi_format_*可直接用于模板缓存的对照测量。性能贡献仍需通过上面的单项对照测量。15. 把结果留给下一步沿写入路径看许多优化都发生在两个相邻步骤之间长度信息传给序列化拷贝时的载入值交给哈希查找失败的位置交给插入编码完成后再回填长度。批量输出则让多条记录共用一次 I/O 调用。必须执行的工作也能换一种安排CRC 多链让独立指令进入流水线小表和连续数组让常用数据更集中批读又让上一条记录的线程索引更可能继续有用。三篇到这里接在了一起格式把重复内容抽出来总线按写入频率安排共享与专用队列执行路径继续复用已有数据和中间结果。省下的工作来自这些具体的配合。对照源码继续看Java log_context、Java param、C# log_context总线直写、参数复用与提交。bq_log_impl.h、bq_log_wrapper_tools.h提前过滤、长度与参数布局。layout.cpp、time_zone.h文本路径的时间字符串与线程名缓存。util.cpp融合拷贝哈希、CRC 多链与 UTF-Mixed。bq_log_api.cpp、bq_log_go.cppformat_hash、native 调用与总线提交。appender_file_compressed.cpp、bounded_hash_cache.h缓存、token、编码和回填。appender_file_base.cpp、appender_file_binary.cpp写缓存、相对对齐、分段与加密。test_utils.h、test_bounded_hash_cache.h、test_compressed_cache.h对应正确性检查。