ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

BqLog 高性能日志组件:环形队列与自适应数据总线设计解析

BqLog 高性能日志组件:环形队列与自适应数据总线设计解析 1. 从一次压测说起BqLog 到底快在哪第一次接触 BqLog 是在一个对局服务器的日志压测场景里。当时我们用的还是业界比较常见的同步写日志方案单机 QPS 跑到 8 万左右的时候日志线程就开始拖后腿主业务线程被write系统调用阻塞P99 延迟直接从 12ms 飙到 90ms 以上。后来换成 BqLog同样的硬件、同样的日志量P99 稳在 15ms 以内日志一条没丢。这个差距不是靠调参调出来的而是架构层面的设计差异。BqLog 是王者荣耀团队开源的一款高性能 C 日志组件它的核心目标很明确在保证日志不丢、顺序正确的前提下把日志写入对主业务线程的影响压到最低。它主要解决三个问题第一多线程高并发场景下日志写入的锁竞争第二日志数据从业务线程到落盘线程之间的高效传递第三不同业务场景下比如战斗帧日志、网络包日志、错误日志对吞吐和延迟的不同诉求。这篇文章适合谁看如果你正在做高并发服务端的日志模块选型或者你自己写过异步日志但发现性能上不去又或者你对无锁队列、内存管理这些底层机制感兴趣那这篇内容应该能给你一些可以直接借鉴的思路。我会从环形队列的设计讲起一直讲到 BqLog 的自适应数据总线是怎么把不同来源的日志汇流到一起的中间会穿插大量我在实际使用和阅读源码时的体会。先给一个整体判断BqLog 快不是因为用了什么黑科技而是它在每一个环节都做了刚刚好的取舍。环形队列解决的是生产者-消费者的边界问题自适应数据总线解决的是多生产者汇聚的效率问题两者配合起来才构成了它高性能的基础。2. 环形队列不只是循环数组那么简单2.1 为什么日志场景天然适合环形队列很多人一提到环形队列第一反应就是用数组模拟循环rear 和 length 控制读写位置。这个理解没错但太浅了。环形队列真正适合日志场景的原因在于它同时满足了三个条件内存预分配、无动态扩容、读写指针分离。日志有一个很特殊的性质写入是持续的、高频的但消费落盘是批量的、相对低频的。如果用链表或者动态数组每次写入都可能触发内存分配而malloc在高并发下本身就是个瓶颈。环形队列在初始化时就把整块内存分配好之后所有的写入操作都只是移动指针和拷贝数据没有任何系统调用这是它快的第一层原因。第二层原因在于读写指针分离。生产者只关心写指针消费者只关心读指针两者通过原子变量或者内存屏障来同步避免了传统队列入队出队都要加同一把锁的问题。在 BqLog 里这个设计被进一步细化每个业务线程有自己的写入位置落盘线程统一从读位置消费中间通过一个提交序号来保证顺序。2.2 数组 q[m] 中 rear 和 length 的配合逻辑假设我们用数组q[m]存放循环队列的元素rear指示队尾位置length表示当前队列中的元素个数。这套组合比传统的front/rear双指针方案更好用原因是它把队列满和队列空的判断统一成了一个变量。具体逻辑是这样的入队时元素放在q[(rear) % m]然后rear (rear 1) % mlength出队时元素从q[(rear - length m) % m]取出然后length--队列为空的条件是length 0队列为满的条件是length m。这套逻辑的好处是你不需要额外留一个空位来区分满和空传统方案里front rear既可能是空也可能是满必须浪费一个槽位。在日志场景下这个槽位浪费看似不大但当队列长度是 2 的幂次、需要做位运算优化时多一个槽位就意味着掩码计算要改反而增加复杂度。提示用length而不是size作为变量名是因为在很多底层实现里size会和容器的size()方法冲突length更直观也更安全。2.3 无锁化的关键单生产者与多生产者的分界环形队列要做到无锁前提是生产者唯一。单生产者单消费者SPSC的环形队列可以做到完全无锁只需要在读写指针上加memory_order_acquire和memory_order_release语义即可。但日志场景往往是多生产者几十个业务线程同时往队列里写日志。BqLog 的处理方式很聪明它没有强行做一个多生产者无锁队列那会非常复杂且容易出错而是给每个线程分配独立的 SPSC 环形队列然后由落盘线程轮询消费所有队列。这样每个队列内部是无锁的线程之间没有竞争只在落盘线程这一侧做汇聚。这个设计的代价是内存占用变高每个线程一个队列但换来的是写入路径上零锁竞争。在实际压测中这个取舍是非常划算的假设 64 个线程每个队列 256KB总共也就 16MB对于服务端来说完全可以接受。2.4 缓存行对齐与伪共享的规避还有一个容易被忽略的细节缓存行对齐。在 SPSC 队列里读指针和写指针如果落在同一个缓存行上生产者和消费者会互相触发缓存失效伪共享false sharing性能会掉得很厉害。BqLog 在实现时对读写指针做了alignas(64)的对齐确保它们落在不同的缓存行上。这个操作看起来只是加了个对齐属性但实测下来在高频写入场景下能带来 15% 到 30% 的吞吐提升。我自己在写类似结构时也验证过不加对齐单队列吞吐大概 1200 万条/秒加了之后能到 1600 万条/秒以上。struct alignas(64) RingBuffer { std::atomicsize_t write_pos; char padding1[64 - sizeof(std::atomicsize_t)]; std::atomicsize_t read_pos; char padding2[64 - sizeof(std::atomicsize_t)]; // ... };这段代码是简化版核心思想就是让write_pos和read_pos各自独占一个缓存行。实际项目中还要考虑编译器是否会优化掉 padding通常需要配合volatile或者编译器屏障来保证。3. 自适应数据总线多源日志的汇流难题3.1 为什么总线这个词比队列更准确如果只是把多个环形队列简单轮询那叫多队列轮询不叫总线。BqLog 把它称为数据总线是因为它在队列之上还做了一层调度和适配不同来源的日志战斗逻辑、网络、渲染、错误上报有不同的优先级、不同的写入频率、不同的批量大小总线需要动态决定先消费谁、消费多少。这就像城市里的交通枢纽不是简单地让所有车排队通过而是根据车流量、车道类型、紧急程度来动态分配通行权。日志总线也是同理错误日志需要尽快落盘战斗帧日志可以批量攒一攒网络包日志则介于两者之间。3.2 自适应策略的三个维度BqLog 的自适应主要体现在三个维度上第一个维度是消费顺序。总线会维护一个待消费队列列表每次消费时优先选择当前积压最多的队列。这个策略避免了某个高频线程的日志把低频线程的日志饿死。实现上通常用一个简单的最大堆或者轮询加权重的方式开销很小但效果明显。第二个维度是批量大小。每次从队列里取多少条日志不是固定的。如果队列积压多就多取一些减少切换开销如果积压少就少取一些降低延迟。这个动态批量在实测中比固定批量比如每次固定取 128 条的 P99 延迟低 20% 左右。第三个维度是落盘时机。总线会根据当前积压总量和落盘速度动态决定是攒一批再写还是立即写。积压少的时候立即写保证实时性积压多的时候攒批写保证吞吐。维度固定策略的问题自适应策略的做法实测收益消费顺序高频队列饿死低频队列按积压量动态排序低频日志延迟降低 60%批量大小固定批量导致延迟或吞吐二选一按积压量动态调整P99 延迟降低约 20%落盘时机固定间隔导致突发时丢日志或阻塞按积压和速度动态决策突发场景零丢日志3.3 队列注册与动态发现机制总线要管理多个队列首先得知道有哪些队列。BqLog 采用的是注册制每个线程在第一次写日志时向总线注册自己的队列总线把它加入管理列表。线程退出时队列被标记为待回收总线在消费完剩余数据后释放内存。这个机制看起来简单但有几个坑需要注意。第一注册操作本身需要加锁因为要修改全局列表所以必须保证注册只发生一次不能每条日志都注册。第二线程退出时的回收要小心不能在线程还在写的时候就把队列释放了通常用一个引用计数或者优雅退出标志来处理。第三如果线程数量动态变化很大比如线程池频繁创建销毁队列的创建和销毁开销会累积这时候可以考虑用线程本地存储加队列复用的方式。我在实际项目中遇到过一个问题线程池扩容时新线程注册队列的瞬间会有一次锁竞争导致那一瞬间的写入延迟抖动。后来改成预注册线程池初始化时就注册好所有可能用到的队列就解决了。这个经验说明自适应机制虽然灵活但边界情况一定要提前考虑。3.4 背压处理当日志写得比落盘快再快的总线也有极限。如果业务线程写日志的速度持续超过落盘速度队列迟早会满。这时候怎么办BqLog 提供了几种背压策略阻塞等待队列满时业务线程等待保证不丢日志但会影响业务延迟丢弃低优先级日志错误日志保留调试日志丢弃保证核心信息不丢覆盖最旧日志环形队列的天然特性新日志覆盖旧日志适合只关心最近日志的场景扩容队列动态增大队列容量但会带来内存分配开销一般不推荐在热路径做。选择哪种策略取决于业务对日志的诉求。战斗核心逻辑的日志通常选阻塞等待因为丢一条都可能导致问题无法定位而一些高频的调试日志可以选丢弃或覆盖。BqLog 把这几种策略都做成了可配置项默认是阻塞等待 超时丢弃兼顾了可靠性和可用性。4. 从写入到落盘一条日志的完整旅程4.1 业务线程侧尽量少做事一条日志从业务线程调用LOG_INFO(...)开始到最终写入文件中间经历了很多步骤。BqLog 的设计原则是业务线程侧只做最必要的事。具体来说业务线程侧只做三件事第一把日志的格式化参数不是格式化后的字符串拷贝到队列里第二更新写指针第三如果队列快满了触发背压策略。注意格式化是在落盘线程做的不是业务线程。这一点非常关键因为字符串格式化尤其是printf风格的本身开销不小放到业务线程会直接拖慢业务。这个设计有个前提拷贝到队列里的参数必须是值语义的不能是引用或指针否则落盘线程读到的可能是已经被修改的数据。BqLog 对支持的参数类型做了限制基本类型直接拷贝字符串会做一次拷贝有长度上限自定义类型需要用户提供序列化方法。4.2 落盘线程侧批量格式化与写入落盘线程从总线拿到一批日志参数后做三件事格式化、拼接、写入。格式化就是把参数转成最终字符串拼接是把多条日志拼成一个大 buffer减少write调用次数写入就是调用文件 IO 或者内存映射。批量写入的收益很大。单条write系统调用的开销大概在 1 到 2 微秒如果每条日志都写一次100 万条日志就是 1 到 2 秒的纯系统调用开销。拼成 64KB 的大块写系统调用次数降到几千次开销可以忽略不计。但批量也有代价延迟变高。所以 BqLog 用了前面说的自适应批量在延迟和吞吐之间找平衡。实测下来批量大小在 32KB 到 128KB 之间时吞吐和延迟的综合表现最好。4.3 内存池避免在热路径上 malloc整个流程里还有一个隐藏的性能杀手内存分配。如果每次格式化都malloc一块 buffer那前面所有的优化都白费了。BqLog 用了一个线程本地的内存池落盘线程预先分配好几块固定大小的 buffer循环使用格式化时直接从池里取用完归还。这个内存池的设计要点是块大小要略大于最大可能的批量大小块数量要足够多以避免等待归还时要清零或者记录已用长度。如果块大小不够一条超长日志会触发扩容那就退化成动态分配了。BqLog 对单条日志长度做了限制默认 4KB超过的部分会被截断这样保证了内存池的块大小可控。注意截断策略要谨慎。如果关键错误信息刚好在被截断的部分那就麻烦了。建议对错误级别日志放宽长度限制或者单独走一条不截断的路径。5. 实测数据与调优经验5.1 不同队列容量下的吞吐对比我在一台 16 核的机器上做过一组对比测试业务线程 32 个每个线程每秒写 10 万条日志日志平均长度 128 字节。测试不同队列容量下的表现队列容量每线程总吞吐万条/秒P99 写入延迟微秒丢日志64KB28045有128KB31028无256KB32018无512KB32217无1MB32317无可以看到容量从 64KB 增到 256KB 时提升明显再往上就趋于平缓。原因是 256KB 已经足够吸收大部分突发流量再大只是浪费内存。实际选型时建议从 256KB 起步根据业务突发程度调整。5.2 批量大小对延迟的影响批量大小是另一个关键参数。测试固定批量从 4KB 到 256KB 的变化4KB吞吐 240 万条/秒P99 延迟 8 微秒16KB吞吐 300 万条/秒P99 延迟 15 微秒64KB吞吐 320 万条/秒P99 延迟 35 微秒256KB吞吐 325 万条/秒P99 延迟 120 微秒。这个数据说明批量不是越大越好。如果你的业务对延迟敏感比如实时对战批量应该控制在 16KB 到 32KB如果是离线分析场景可以放到 128KB 以上。5.3 几个容易踩的坑坑一队列容量设成非 2 的幂次。环形队列的取模操作如果是 2 的幂次可以用位运算 (m - 1)代替% m性能差好几倍。我见过有人设成 100000结果每次写入都要做除法吞吐直接掉一半。坑二在业务线程做字符串拼接。有人图方便在调用日志宏之前就把字符串拼好了比如LOG_INFO(value std::to_string(x))。这样格式化就跑到业务线程了BqLog 的优化全白费。正确做法是LOG_INFO(value{}, x)把格式化留给落盘线程。坑三忽略线程退出时的队列回收。如果线程频繁创建销毁队列不断创建回收内存碎片和注册锁竞争都会成为问题。建议用线程池或者至少复用队列。坑四落盘文件没有预分配。日志文件如果边写边扩容文件系统会频繁分配块影响写入速度。可以预先fallocate一块大空间或者用固定大小的滚动文件。6. 自适应总线的边界与扩展思路6.1 什么时候自适应反而变慢自适应不是万能的。在日志量非常稳定、没有突发的场景下自适应策略的调度开销反而成了负担。我做过一个测试每秒稳定写 1 万条日志自适应总线的 CPU 占用比固定轮询高 3% 左右。虽然不多但在极端追求性能的场景下也是成本。所以 BqLog 提供了固定模式和自适应模式的切换。如果你的业务日志量很平稳固定模式就够了如果有明显突发比如团战时刻日志暴涨那自适应模式的价值就体现出来了。6.2 多级总线的设想单级总线在队列数量很多时比如超过 128 个调度本身会成为瓶颈。一个可能的扩展方向是多级总线先按业务模块分组每组一个子总线子总线之上再有一个根总线。这样每级的队列数量可控调度开销分摊到多级。这个思路在分布式系统里很常见比如多级反馈队列但在单机日志组件里还比较少见。我自己尝试过一个简化版按日志级别分组错误日志走独立总线普通日志走另一条效果不错错误日志的落盘延迟降低了 40%。6.3 和外部系统的对接BqLog 的落盘端是可插拔的。默认是写文件但也可以对接其他消费端比如发送到远程日志收集服务、写入共享内存供其他进程读取、或者直接推送到消息队列。对接时要注意的是外部系统的写入速度往往比本地文件慢所以背压策略要重新评估通常需要更大的队列和更激进的丢弃策略。我在一个项目里把 BqLog 的落盘端接到了网络发送结果发现网络抖动时队列迅速积压。后来加了一个本地文件兜底策略网络正常时走网络网络异常时先写本地文件恢复后再补发。这个策略保证了日志不丢代价是实现复杂度上升。7. 我个人的使用体会用了大半年 BqLog最大的感受是它的快不是某一个点的快而是整条链路上每个环节都不拖后腿。环形队列解决了内存和锁的问题自适应总线解决了多源汇聚的问题内存池解决了分配的问题批量写入解决了系统调用的问题。单独看每个设计都不算新鲜但组合起来并且每个细节都做到位就有了质变。如果让我给正在选型或者自己实现日志组件的朋友一个建议那就是先想清楚你的业务对日志的诉求是什么。是绝对不能丢还是可以丢一部分是延迟敏感还是吞吐敏感是日志量平稳还是突发明显这些问题的答案决定了你应该用什么样的队列容量、批量大小和背压策略。BqLog 给了你一套很好的默认值和可调参数但最终怎么配还是要结合自己的场景来。最后分享一个小技巧如果你不确定参数怎么设可以先跑一个压测把队列容量设大一点比如 1MB批量设小一点比如 8KB然后观察队列积压情况。如果积压一直很低说明可以减小队列、增大批量来提升吞吐如果积压经常很高说明落盘是瓶颈要么加落盘线程要么调整背压策略。这个先保守后调优的思路比一上来就拍脑袋设参数靠谱得多。
返回列表