[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"$f3n8rpmtvs2w1x":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},"6abab9b7ca21c797c7e9a350","bqlog2-265e58d7","王者荣耀日志组件BqLog为什么这么快之2——从环形队列到自适应数据总线","在追求极致性能的系统中，减少一切不必要的计算是优化的核心。以手游为例，帧率和流畅度是其基础体验的关键，而游戏的发行版本往往被一个“不可能三角”所困扰：","news",[10,12,17,22,27,32,37,42,47,52],{"headline":6,"body":7,"imageUrl":11,"sourceImageUrl":11},"https:\u002F\u002Fp6-xtjj-sign.byteimg.com\u002Ftos-cn-i-73owjymdk6\u002F0eb29e208cd74027bb7802becefacb41~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgcGlwcG9jYW8=:q75.awebp?rk3s=f64ab15b&x-expires=1791188993&x-signature=A17HpMmlilVW68gzPSJCKgaCZhc%3D",{"headline":13,"body":14,"imageUrl":15,"images":16},"国内发行的手游通常优先选择保1和3，放弃2。而HOK作为《王者荣耀》的国际服，由于面向全球的发行背景，面临时差、语言和隐私观念等问题，在遇到疑难杂症时很难直接与…","国内发行的手游通常优先选择保1和3，放弃2。而HOK作为《王者荣耀》的国际服，由于面向全球的发行背景，面临时差、语言和隐私观念等问题，在遇到疑难杂症时很难直接与用户沟通。这种时候我们就需要一种产品，能帮助我们“既要又要”，打破这个不可能三角。BqLog就是在这样的背景下诞生的。 BqLog不仅适用于客户端，也适用于服务器，能用于多种编程语言，也能兼容多种操作系统，具体请见Github地址： github.com\u002FTencent\u002FBqL… 本篇是系列第二篇。第一篇定了文件格式；但日志进文件之前，还要从几十个业务线程交到一个后台线程手上。本篇讲这条内存通道怎么建：从普通环形队列出发，走到 BqLog 的自适应数据总线。 为何 BqLog 如此快之二：从环形队列到自适应数据总线 上一篇讨论了压缩文件如何减少格式化和写入量。但日志在进入文件之前，还要从业务线程交给后台线程。几十个线程同时写入时，即使每条记录都很小，共享队列的写游标也可能成为热点。 BqLog 的 log_buffer 为低频线程提供共享队列，为高频线程分配专用队列。下面从普通环形队列讲起，看看多生产者写入、变长记录、线程增删和崩溃恢复分别带来哪些麻烦。","\u002Fapi\u002Fmedia\u002Fposts\u002Fbqlog2-265e58d7\u002F1.webp",{"local":15},{"headline":18,"body":19,"imageUrl":20,"images":21},"本文对应 BqLog 2.5.0。MISO、SISO 是项目里的命名习惯：MISO 表示多生产者单消费者（Multi In, Single Out，也就是常说的","本文对应 BqLog 2.5.0。MISO、SISO 是项目里的命名习惯：MISO 表示多生产者单消费者（Multi In, Single Out，也就是常说的 MPSC），SISO 表示单生产者单消费者（Single In, Single Out，即 SPSC）。代码里的对齐常量 BQ_CACHE_LINE_SIZE 是 128 字节，下文简称 C。它是库自己选的布局单位，不代表所有 CPU 的硬件缓存行都是 128 字节。","\u002Fapi\u002Fmedia\u002Fposts\u002Fbqlog2-265e58d7\u002F2.webp",{"local":20},{"headline":23,"body":24,"imageUrl":25,"images":26},"参与比较的有 C++ 的 spdlog（异步模式）、glog、fmtlog、quill 和 Java 的 Log4j2，各组件的完整配置见","参与比较的有 C++ 的 spdlog（异步模式）、glog、fmtlog、quill 和 Java 的 Log4j2，各组件的完整配置见 BENCHMARK_CHS.md。下表是 1–10 个线程的结果：每线程写 200 万条日志，每条带 4 个格式化参数；机器为 PC（AMD Ryzen 9 9950X，16 核 32 线程，96 GB，Windows 11 Pro；Java 用 JBR 21.0.9）。 这里每增加一个线程，总日志量也增加 200 万条。例如 10 线程对应 2000 万条日志，比较吞吐时要用这份总量除以耗时。","\u002Fapi\u002Fmedia\u002Fposts\u002Fbqlog2-265e58d7\u002F3.webp",{"local":25},{"headline":28,"body":29,"imageUrl":30,"images":31},"10 线程时，BqLog 压缩模式用时 507 毫秒：约为 Log4j2 的 1\u002F14、异步","10 线程时，BqLog 压缩模式用时 507 毫秒：约为 Log4j2 的 1\u002F14、异步 spdlog 的 1\u002F65，对 fmtlog 和 quill 也快约 13.7 倍和 15.4 倍。加密模式 493 毫秒，与不加密持平（差异在测量噪声内）。这些是整条写入路径的测试结果，不能单独归因于队列。用例和配置见 Benchmark 与 BENCHMARK_CHS.md。 格式化、缓存访问、I\u002FO 和线程间通信都会影响总耗时。本文只讨论最后一项：共享 MISO 队列如何处理多生产者分配，以及高频线程如何转到各自的 SISO 队列。相关方案已在 BqLog 开源前申请专利。 熟悉单生产者环形队列和 Disruptor 的读者可以直接看 第 3 节。这里主要讨论并发控制，不展开内存模型的全部细节。 先记住队列要回答的两个问题：生产者能写哪段空间，消费者能读到哪里。前一个问题要防止覆盖未读数据，后一个问题要防止读到尚未写完的数据。单生产者时两个游标就能协调好；生产者一多，空间预留和写入完成就得分开处理。 kFifo 是 Linux 内核提供的循环队列（FIFO）实现。这里取其单生产者、单消费者用法：一个线程写，一个线程读。 图 1：kFifo 的环形结构——In、Out 两个游标各只有一个主人。","\u002Fapi\u002Fmedia\u002Fposts\u002Fbqlog2-265e58d7\u002F4.webp",{"local":30},{"headline":33,"body":34,"imageUrl":35,"images":36},"队列用 in 和 out 记录累计写入、读出的进度，只有访问内存时才取模映射到环上。例如容量为 16，out=12、in=20，就有 8…","队列用 in 和 out 记录累计写入、读出的进度，只有访问内存时才取模映射到环上。例如容量为 16，out=12、in=20，就有 8 个单位尚未读出，下一处写入的物理位置是 20 % 16 = 4。后文出现 1000、1030 这样的游标，也是在表示累计进度。 图中红色格子是尚未消费的数据，Out 对应最老的位置，In 对应下一处可写位置。容量取 2 的幂时，取模可以写成 cursor & (size - 1)。 用 acquire\u002Frelease 伪接口写出单生产者、单消费者的基本过程：","\u002Fapi\u002Fmedia\u002Fposts\u002Fbqlog2-265e58d7\u002F5.webp",{"local":35},{"headline":38,"body":39,"imageUrl":40,"images":41},"写线程独占修改 in，读线程独占修改 out，因此不需要用 CAS 争取游标。写线程先拷贝数据，再以 release 语义发布 in；读线程以","写线程独占修改 in，读线程独占修改 out，因此不需要用 CAS 争取游标。写线程先拷贝数据，再以 release 语义发布 in；读线程以 acquire 语义取得这个进度后，也能看到此前写好的数据。读完后的 out 则反向告诉生产者：这些空间已经可以覆盖。 这里的原子读写负责双方可见性，CAS 则是用来协调多个修改者。若两个生产者同时执行上述写入过程，它们可能读到相同的 in，随后向同一位置拷贝数据。要支持多生产者，首先得让每个线程取得不同的位置。 LMAX Disruptor 是 LMAX 开源的并发框架，Log4j2 的异步日志也使用它。代码和文档见 LMAX-Exchange\u002Fdisruptor。 它也使用环形结构。以下按日志场景示意：槽位（Slot）持有 Log Entry 的引用，实际数据在对象中。 图 2：Disruptor 的环——槽位里放的是 Log Entry 引用，不是数据本身。 多生产者可用 CAS（Compare-And-Swap） 争取写入位置：只有当前值与预期值相同才更新；失败的线程重新读取游标并重试。 我们试着用 CAS 改造 kfifo_in，让它支持并发写入： CAS 成功时，当前线程取得从 old_in 开始的区间；失败说明别人推进了游标，需要重新计算容量并重试。空间分开了，接下来还要解决完成状态的发布。 预留游标不能直接当作可读边界。单生产者可以先写数据、再发布 in；多生产者先各自取得位置，再写数据，完成顺序可能与预留顺序不同。 图 3：先申请后写入的麻烦：A 和 C 的块已经写完（阴影），B 还没写完，In 不再能当可读边界。 图中 A 和 C 已经写完，B 尚未写完。消费者不能越过 B 读取 C，因此需要独立的完成状态。Disruptor 通过槽位序号跟踪发布进度；BqLog 把变长数据直接放在环形内存里，采用后文的块状态。 3.","\u002Fapi\u002Fmedia\u002Fposts\u002Fbqlog2-265e58d7\u002F6.webp",{"local":40},{"headline":43,"body":44,"imageUrl":45,"images":46},"MISO 队列用 fetch_add 分配空间 多个线程同时用 CAS 更新同一个游标时，失败的一方必须重读再重试。线程一多，重试就频繁到不能忽略。MISO…","MISO 队列用 fetch_add 分配空间 多个线程同时用 CAS 更新同一个游标时，失败的一方必须重读再重试。线程一多，重试就频繁到不能忽略。MISO 队列在空间分配这条常用路径上改用 fetch_add。 fetch_add 原子地增加游标，并返回增加前的值。所有调用对这个变量仍有先后次序，但每个线程都能拿到自己的旧值，程序不必再写一轮“比较失败就重试”的循环。 假设 in 初始值是 0，三个线程 A、B、C 同时申请 10、5、15 字节，执行完后它们拿到的起始位置可能是： 线程A：0，线程B：10，线程C：15，in 最终是 30 线程B：0，线程C：5，线程A：20，in 最终还是 30 图 4：fetch_add 为不同线程分配不同区间；容量上限仍需单独检查。","\u002Fapi\u002Fmedia\u002Fposts\u002Fbqlog2-265e58d7\u002F7.webp",{"local":45},{"headline":48,"body":49,"imageUrl":50,"images":51},"顺序取决于线程的实际执行次序，但拿到的区间互不重叠。两种方式的脾气不一样：CAS 是“确认没人动过才更新，失败就重来”；fetch_add…","顺序取决于线程的实际执行次序，但拿到的区间互不重叠。两种方式的脾气不一样：CAS 是“确认没人动过才更新，失败就重来”；fetch_add 是“先把号拿到手，有问题再处理”。图中还画出了容量上限：fetch_add 不检查剩余空间，预留之后还要验证拿到的区间是否有效。 fetch_add 只保证游标增加是原子的，不能保证队列有足够容量。 图中容量上限 Limit 为 25，多个线程预留后，In 到了 40。C 的末尾和 D 的全部区间都超出当前可写范围，不能直接写入。 消费者把 Out 推进到 15 时，可写上限也移到 45。先前越界的区间此时可能重新变得有效；回滚判断要考虑这个变化。 CAS 不会有这个问题，因为最后一次更新时必须确认 in 和当初判断时一模一样，中间有人动过就失败重来。 bq::miso_ring_buffer 在预留后检查容量，必要时回滚。下面的伪码省略了环回绕等细节（伪码里的 in_、out_ 就是源码里的写游标 write_cursor_、读游标 read_cursor_，§4 起用源码的名字）：","\u002Fapi\u002Fmedia\u002Fposts\u002Fbqlog2-265e58d7\u002F8.webp",{"local":50},{"headline":53,"body":54,"imageUrl":55,"images":56},"回滚仍然需要 CAS，而且可能反复失败。BqLog 把这份协调工作放在预留越过容量上限时：空间充足的常用路径用一次 fetch_add…","回滚仍然需要 CAS，而且可能反复失败。BqLog 把这份协调工作放在预留越过容量上限时：空间充足的常用路径用一次 fetch_add 取得位置，容量不足时才等待后续预留撤销，或等消费者腾出空间。真实实现里恰好写满也按越界处理（判断用 ≥）；游标是 32 位无符号数，源码注释自己交代了边界——所有线程累计分配达到数百 GB 量级才可能出现环绕，离日志缓冲区的实际规模很远。 为什么不能直接执行 fetch_add(&this->in_, -len)？看下面的交错顺序：多个生产者已经预留空间，消费者又在推进 out，直接减去自己的长度可能让后续分配与仍有效的区间重叠。 图 5：A、B、C 预留空间后，B 和 C 的区间越过当前可写上限。 分配前 in 为 1000，可写上限为 1012。A、B、C 先后预留后，in 到了 1030，B 和 C 的区间越界。如果 B 直接减去自己的长度，可能出现以下顺序： B 执行 fetch_add(&in, -5)，把 in 减到 1025； B 再申请 5 字节，得到 1025–1030，此时 in 又到了 1030； C 发现自己的 1015–1030 区间已在容量内，也开始写入。 图 6：B 直接减去自己的长度并再次申请，结果与 C 的区间重叠。","\u002Fapi\u002Fmedia\u002Fposts\u002Fbqlog2-265e58d7\u002F9.webp",{"local":55},[],[],{"name":60,"url":61},"Juejin","https:\u002F\u002Fjuejin.cn\u002Fpost\u002F7690159620451811337","zh",{"handle":64,"displayName":65},"spots","Spots",null,{"views":68,"likes":69,"saves":69,"shares":69,"completions":69,"opens":69,"skips":69,"depthSum":69},3,0,"2026-09-28T19:02:15.373Z","local"]