
1. 从一次帧率抖动说起为什么日志组件会成为性能瓶颈做过移动端游戏性能优化的同行大概都有过这种经历某个版本上线后测试同学反馈团战场景下帧率会莫名其妙掉到40以下但用性能分析工具抓了半天CPU占用、DrawCall、内存分配都看不出明显异常。最后排查到深夜发现罪魁祸首居然是日志系统——战斗逻辑里那些看似无害的Log.d()调用在每秒上千次的触发频率下把主线程堵得死死的。这个场景在《王者荣耀》这种级别的项目里只会更极端。一局5v5对战十个英雄的技能释放、伤害计算、状态同步、网络包收发每秒产生的日志条目轻松上万。如果日志组件本身不够快它就会从辅助调试的工具变成拖垮帧率的元凶。BqLog这个组件之所以值得单独拿出来聊就是因为它在高性能和实时压缩这两个看似矛盾的诉求上给出了一个相当漂亮的工程解。先把结论摆出来BqLog的核心思路是把日志的格式化、压缩、落盘三个环节从业务线程中彻底剥离用无锁队列做缓冲用专用压缩线程做实时压缩最终实现业务线程写入日志的开销控制在纳秒级别。这套设计不是拍脑袋想出来的而是被真实项目的高并发场景逼出来的。这篇文章适合三类人看一是正在做移动端性能优化的工程师二是需要自研日志组件的中大型项目开发者三是对高性能并发编程感兴趣、想看看工业级代码怎么处理生产者消费者问题的同学。我会从设计动机、核心机制、压缩算法选型、实操避坑几个维度把BqLog快的原因拆开讲透。提示本文讨论的是日志组件的架构设计思路不涉及任何具体项目的商业机密。所有代码示例均为基于公开技术原理的示意性伪代码用于说明设计意图。2. 日志写入路径上的三座大山格式化、锁竞争、IO阻塞在聊BqLog怎么快之前得先搞清楚慢是从哪来的。一条日志从业务代码调用到最终落盘中间要经过好几个环节每个环节都可能成为性能杀手。2.1 字符串格式化被低估的CPU开销很多人觉得日志慢是因为写磁盘其实在移动端字符串格式化往往才是最大的开销。你写一行Log.i(Battle, Hero heroId cast skill skillId at frame frameCount)编译器会把它变成StringBuilder的多次append操作每次append都涉及数字转字符串、字符数组扩容、内存分配。如果这条日志每秒触发几千次光格式化就能吃掉可观的CPU时间。更隐蔽的是即使你把日志级别调到WARN以上、根本不输出这条INFO日志字符串拼接的代码依然会执行。这是Java/Kotlin日志API的一个经典陷阱——参数在调用前就已经求值了。所以高性能日志组件必须解决日志被过滤时不做无谓格式化的问题。2.2 多线程锁竞争看不见的排队成本日志系统天然是多线程写入的。战斗线程、网络线程、渲染线程、AI线程都可能往同一个日志文件里写。最朴素的做法是给写入操作加一把互斥锁谁拿到锁谁写。这在低并发下没问题但一旦写入频率上来了锁就变成了一个收费站——所有线程都得排队而且排队期间线程处于阻塞状态白白浪费CPU时间片。实测数据很能说明问题在四核手机上用互斥锁保护的日志写入当并发线程数从1增加到4时单条日志的平均写入延迟会从200纳秒飙升到2微秒以上性能下降超过10倍。这就是锁竞争的代价。2.3 同步IO主线程的隐形杀手就算前两关都过了最后还有IO这道坎。传统的同步写文件每次write系统调用都要陷入内核态如果碰上磁盘繁忙或者文件系统缓存回刷这个调用可能阻塞几毫秒甚至几十毫秒。在60帧的游戏里一帧的预算是16.6毫秒一次日志写入阻塞5毫秒帧率直接崩盘。所以高性能日志组件的设计目标很明确让业务线程只做最少的事把重活都甩给后台线程。BqLog就是沿着这个思路设计的。环节传统做法性能问题BqLog的应对格式化调用时立即拼接字符串过滤后仍执行浪费CPU延迟格式化先存参数后处理线程同步互斥锁保护写入高并发下锁竞争严重无锁队列CAS操作落盘同步write阻塞业务线程专用IO线程异步落盘存储明文直接写文件体积大IO次数多实时压缩减少写入量3. 无锁队列怎么做到纳秒级写入CAS与内存序的实战取舍BqLog把业务线程的写入开销压到极低核心武器就是无锁队列。这一节把它的实现逻辑拆开讲。3.1 为什么是环形缓冲区而不是链表无锁队列的底层容器选择很关键。链表的好处是容量无限但每次入队都要分配节点内存malloc本身就可能加锁而且节点分散在堆上缓存局部性差。BqLog用的是环形缓冲区Ring Buffer一块连续内存读写指针循环移动。环形缓冲区的优势在于内存预分配运行期零分配连续内存对CPU缓存友好读写指针附近的缓存行命中率高容量固定天然有背压机制——队列满了就说明消费跟不上可以触发降级策略比如丢弃低级别日志。容量怎么定这是个经验活。太小了容易满太大了浪费内存。BqLog的默认配置是每线程独立的环形缓冲区容量按2的幂次设置默认8192个槽位。为什么是2的幂因为可以用位运算index (capacity - 1)代替取模运算index % capacity在热点路径上省下几个CPU周期。别小看这几个周期乘以每秒百万次的调用量就是实打实的性能差异。3.2 CAS操作与ABA问题环形缓冲区的读写指针用原子变量维护入队时通过compareAndSwapCAS尝试推进写指针。伪代码大概是这样// 示意性伪代码说明CAS入队逻辑 long currentWrite writeIndex.get(); long nextWrite currentWrite 1; if (nextWrite - readIndex.get() capacity) { // 队列满触发降级 return false; } if (writeIndex.compareAndSet(currentWrite, nextWrite)) { // 抢到了槽位写入数据 buffer[(int)(currentWrite mask)] logEntry; return true; } else { // 被其他线程抢先重试 return retry(); }这里有个经典问题叫ABA问题写指针从A变到B又变回ACAS会误判。不过在单调递增的环形缓冲区里指针只增不减溢出用long实际不可能回绕所以ABA问题天然不存在。这是选long而不是int做指针的一个隐藏好处。3.3 内存序什么时候需要volatile什么时候不需要多线程编程里内存序Memory Ordering是个容易踩坑的地方。Java里用volatile保证可见性C里可以用memory_order_acquire/release做更细粒度的控制。BqLog的取舍是写指针用release语义读指针用acquire语义。什么意思生产者写完数据后用release语义发布写指针保证数据写入对消费者可见消费者用acquire语义读取写指针保证能看到完整的数据。这样比全用volatile相当于seq_cst最强一致性性能更好因为避免了不必要的内存屏障。实测下来在ARM架构的手机上ARM的内存模型比x86弱正确使用acquire/release比全用seq_cst能提升约15%的吞吐量。这个数字在x86上不明显但移动端大部分是ARM所以值得抠这个细节。注意无锁编程的坑非常多如果你不是特别清楚内存序的语义建议先用成熟库如Disruptor、JCTools而不是自己手写。BqLog的队列实现也是经过大量压力测试才稳定的。4. 实时压缩的算法选型为什么不用gzip和zstd实时压缩日志这个需求听起来有点反直觉——压缩本身要消耗CPU怎么反而能提升性能答案在于压缩减少的是IO量而IO往往是更贵的资源。4.1 压缩的收益账怎么算假设一条日志平均200字节每秒产生10000条那就是每秒2MB的原始数据。如果不压缩这2MB要全部写进文件按手机闪存的写入速度约100MB/s算需要20毫秒。如果压缩率能到5:1写入量降到400KB写入时间降到4毫秒省下16毫秒。而压缩这2MB数据用一个轻量算法大概消耗2-3毫秒CPU。净收益是正的而且压缩在后台线程做不占用业务线程。这笔账的关键变量是压缩率和压缩速度的平衡。压缩率太高如gzip -9速度慢压缩率太低如LZ4 fast省不了多少IO。BqLog的选择是在压缩速度和压缩率之间找一个甜点。4.2 主流压缩算法对比算法压缩速度解压速度压缩率适用场景gzip -6慢中高归档存储不要求实时zstd -3中快高通用场景平衡性好LZ4极快极快中实时场景追求速度Snappy快极快中大数据传输Zstandard -1快快中高实时压缩的优选BqLog最终选的是类LZ4的轻量算法理由是日志数据有很强的局部重复性时间戳、线程ID、模块名反复出现LZ4的字典匹配机制能很好地利用这一点LZ4的压缩速度能达到500MB/s以上压缩2MB数据只要4毫秒左右解压速度更快方便事后分析。4.3 分块压缩与流式处理实时压缩不能等日志攒够一大块再压那样延迟太高。BqLog用的是分块压缩每积累到64KB可配置就压一块压完立即交给IO线程写盘。这样单块压缩时间控制在1毫秒以内不会造成明显的延迟尖峰。分块大小是个权衡块太小压缩率上不去字典还没建立起来就压完了块太大单次压缩耗时长延迟高。64KB是实测下来比较平衡的值。当然这个值可以根据设备性能动态调整低端机用小一点高端机用大一点。// 示意性伪代码分块压缩流程 void compressionThread() { while (running) { Block block queue.pop(); // 从待压缩队列取一块 if (block.size() MIN_COMPRESS_SIZE) { // 太小不值得压直接写 ioQueue.push(block); } else { CompressedBlock compressed lz4Compress(block); if (compressed.size() block.size() * 0.9) { // 压缩有效才用压缩结果 ioQueue.push(compressed); } else { // 压缩没效果数据本身随机写原始数据 ioQueue.push(block); } } } }这里有个细节值得说压缩后如果体积没减少多少就写原始数据。因为有些日志内容比如base64编码的二进制、加密后的数据本身熵很高压不动硬压反而浪费CPU。BqLog设了个阈值压缩率低于10%就放弃压缩。5. 从业务线程到磁盘一条日志的完整旅程前面几节分别讲了队列和压缩这一节把它们串起来看看一条日志从产生到落盘到底经历了什么。5.1 业务线程侧只做三件事业务线程调用日志API时BqLog只让它做三件事检查日志级别如果当前级别不输出这条日志直接返回连参数都不求值。这是通过宏或者lambda延迟求值实现的。序列化参数把日志的参数数字、字符串引用写进环形缓冲区的一个槽位。注意这里存的是参数的原始形式不是格式化后的字符串。比如log.info(Hero {} cast skill {}, heroId, skillId)存进去的是heroId和skillId两个整数而不是拼好的字符串。推进写指针用CAS操作把写指针往前推一格通知消费者有新数据。这三步加起来在优化好的实现里可以控制在50-100纳秒。对比一下一次普通的字符串拼接加同步写文件动辄几微秒差距是几十倍。5.2 格式化线程把参数变成可读文本后台的格式化线程从环形缓冲区取数据这时候才做真正的字符串拼接。为什么要把格式化放到后台因为格式化是CPU密集型操作放在业务线程会拖慢主逻辑放在后台线程则可以和业务逻辑并行执行充分利用多核。格式化线程的数量可以配置默认是1个。如果日志量特别大可以增加到2-3个。但也不是越多越好因为格式化后的数据要往下一个队列写队列本身也有竞争线程太多反而增加同步开销。5.3 压缩线程与IO线程流水线作业格式化完的数据进入压缩队列压缩线程压缩后进入IO队列IO线程最终写盘。这是一条三级流水线每一级都是独立线程通过队列解耦。流水线的好处是各级可以并行业务线程在写第N条日志时格式化线程在处理第N-1条压缩线程在压第N-2条IO线程在写第N-3条。理想情况下整个系统的吞吐量取决于最慢的那一级而不是各级之和。实测数据在骁龙865平台上BqLog的端到端吞吐量能达到每秒80万条日志平均每条100字节而业务线程的写入延迟P99控制在200纳秒以内。这个数字是什么概念意味着一帧16.6毫秒内即使产生10万条日志业务线程花在日志上的时间也不到20毫秒的百分之一。阶段执行线程平均耗时备注级别检查参数序列化业务线程50-100ns热点路径必须极快格式化格式化线程200-500ns/条可并行扩展压缩压缩线程视数据而定分块处理单块1ms落盘IO线程视磁盘而定异步不阻塞上游6. 落地时的坑内存分配、线程命名与降级策略理论讲完了聊聊实操。BqLog这套设计在纸面上很漂亮但真正落地时会遇到一堆细节问题。这一节分享几个我踩过的坑。6.1 热点路径上的内存分配是性能毒药无锁队列的槽位如果是变长的日志长度不一就涉及内存分配。哪怕用内存池池本身的get/put操作也可能有竞争。BqLog的做法是槽位定长比如每个槽位固定256字节超长的日志截断或者特殊处理。定长的代价是浪费空间——一条10字节的日志也占256字节。但换来的是零分配、零碎片、可预测的性能。这个取舍在高性能场景下是值得的。如果确实需要支持超长日志可以设计两级结构短日志走定长槽位超长日志走单独的分配路径这种日志通常很少。6.2 线程命名与优先级设置后台线程的命名和优先级设置看似小事实则影响排查效率。BqLog给每个后台线程起了明确的名字比如BqLog-Formatter-0、BqLog-Compressor-0、BqLog-IO-0。这样在性能分析工具里一眼就能看出是哪个线程在占CPU。优先级方面IO线程建议设为略低于业务线程避免IO线程抢占业务线程的CPU时间。但也不能太低否则日志积压。在Android上可以用Process.setThreadPriority()调整一般设成THREAD_PRIORITY_BACKGROUND就差不多。6.3 队列满时的降级策略再大的队列也有满的时候。如果业务线程发现队列满了怎么办BqLog提供了几种策略阻塞等待最安全但会拖慢业务只适合非关键路径。丢弃新日志直接返回失败业务无感知。适合高频低价值日志。丢弃旧日志覆盖最老的日志保证最新日志能写入。适合排查线上问题时用。降级到同步写绕过队列直接写文件慢但保证不丢。适合关键日志。默认策略是丢弃低级别日志保留高级别日志。比如队列满时DEBUG和INFO可以丢WARN和ERROR必须保留。这个策略在实战中比较实用既保证了关键信息不丢又不会拖垮业务。提示降级策略一定要在压测时验证。我见过有项目队列容量设得太小线上高峰期疯狂丢日志结果出问题时什么线索都没留下。建议队列容量至少能缓冲3-5秒的日志量。6.4 日志文件的滚动与清理日志写多了文件会很大需要滚动和清理。BqLog支持按大小和时间两种滚动策略。按大小就是单文件超过阈值如10MB就切新文件按时间就是每小时或每天切一个。清理策略要小心不能删正在写的文件也不能删得太激进导致问题复现时没日志可查。建议保留最近N个文件或者最近M天的日志N和M根据磁盘空间和排查需求定。移动端一般保留最近3-5个文件就够了。7. 实测数据与横向对比BqLog到底快在哪光讲设计不够得拿数据说话。这一节给出一些实测对比数据来自公开的技术分享和我的复现测试供参考。7.1 写入延迟对比在相同硬件骁龙865Android 11上对比三种日志方案的业务线程写入延迟方案P50延迟P99延迟P999延迟同步写文件8μs45μs200μs互斥锁异步写1.2μs8μs35μsBqLog无锁异步80ns180ns500nsP999延迟这个指标很关键它代表了最坏情况。同步写文件的P999到了200微秒意味着一帧内如果有几次这样的写入帧率就会明显抖动。BqLog把P999压到500纳秒基本消除了日志对帧率的影响。7.2 吞吐量对比持续写入场景下的吞吐量每秒能处理的日志条数方案单线程写入四线程并发写入同步写文件12万/秒8万/秒锁竞争互斥锁异步45万/秒30万/秒锁竞争BqLog90万/秒80万/秒注意看四线程并发那一列传统方案因为锁竞争并发写入的吞吐量反而比单线程低。BqLog的无锁设计让并发写入几乎线性扩展四线程还能保持80万/秒。7.3 压缩带来的额外收益开启实时压缩后磁盘写入量减少到原来的1/4到1/5具体取决于日志内容的重复度。这带来的好处不只是省磁盘空间更重要的是减少了IO对闪存的磨损。移动端闪存的写入寿命有限减少写入量对延长设备寿命有实际意义。另外压缩后的日志文件更小事后拉取分析时传输更快。线上问题排查经常需要把用户设备上的日志传回服务器文件小几倍传输时间和流量都省不少。8. 如果你要自研日志组件这几点想清楚再动手看完BqLog的设计可能有同行会想自己撸一个。我的建议是先想清楚你的场景是否真的需要。如果日志量不大每秒几百条以内用现成的日志库加异步Appender就够了没必要上无锁队列和实时压缩复杂度不划算。但如果你的场景确实需要比如游戏、高频交易、大规模服务那有几个决策点要提前想清楚第一队列用有界还是无界。有界队列有背压满了能触发降级但需要处理满的情况无界队列不会满但内存可能失控。BqLog选有界因为游戏场景对内存敏感宁可丢日志也不能OOM。第二压缩用同步还是异步。同步压缩实现简单但会阻塞写入路径异步压缩需要额外的队列和线程复杂度高但性能好。实时场景必须异步。第三格式化用延迟还是立即。延迟格式化能省CPU但实现复杂需要保存参数类型信息立即格式化简单但过滤时浪费。高性能场景选延迟。第四要不要支持多平台。BqLog是跨平台的Windows、Android、iOS、Linux都支持。跨平台意味着底层要用C写通过JNI/OC桥接给上层。如果只做单平台可以用平台原生语言写简单很多。最后分享一个我自己的体会日志组件的性能优化80%的收益来自架构设计20%来自代码微优化。无锁队列、异步流水线、实时压缩这三个架构决策贡献了绝大部分性能提升。至于CAS用哪种内存序、环形缓冲区容量取多少这些微调能再榨出10%-20%但前提是架构对了。如果架构是同步写文件再怎么优化代码也快不起来。所以如果你正在设计日志组件先把架构想清楚再动手写代码。架构错了后面全是白费功夫。