尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

BqLog实时压缩与无锁队列:游戏日志高性能写入的工程实践

BqLog实时压缩与无锁队列:游戏日志高性能写入的工程实践 打游戏最烦的不是团战输了而是想复盘的时候发现日志里全是空、崩溃现场一片白。王者荣耀这种DAU量级的游戏客户端每秒产生的日志行数按万算一条关键报错混在汪洋大海里根本捞不出来。更难受的是日志写得太慢会直接拖垮渲染线程表现到玩家那边就是掉帧、卡顿。BqLog这个名字第一次出现在我视野里就是因为它的战绩太夸张初始化比spdlog快几个数量级写日志的吞吐量跑到千万级每秒还自带实时压缩文件体积直接砍半。这篇我先把最核心的“快”拆开讲重点聊它的实时压缩是怎么做到又小又不拖后腿的。BqLog是王者荣耀团队开源的客户端日志库核心解决两件事一是写日志不能影响游戏帧率二是日志文件不能大到没法回传。适合谁看做客户端性能优化的、搞中间件基础设施的、被日志库卡过性能的同学这篇文章都能给你一些直接能抄的设计思路。我会把它的无锁队列、二进制编码、块拼接压缩这些关键设计一个个掰开揉碎再附上我实际跑数据时的体会和踩坑记录。1. 先解决“日志为什么慢”的根源1.1 慢在格式化不是慢在写文件很多人一说到日志性能差第一反应是磁盘I/O太慢于是拼命换SSD、调缓存。我早年间也犯过这个错直到自己用perf去抓热点才发现一个典型的日志库比如spdlog最大的CPU开销根本不在fwrite而在fmt::format。每条日志要先解析格式串、把参数转成字符串、处理对齐和精度这套动作走完才轮到I/O。在王者荣耀这种场景里一帧可能产生几百条日志每条做一次完整格式化开销直接翻上天。BqLog的思路是反过来与其在运行时反复格式化不如把格式化的时机彻底错开。它直接写二进制流日志内容是一段带类型的参数列表不是人类直接可读的字符串。格式串在编译期就用模板解析成元数据运行时只需要往缓冲区里memcpy参数二进制即可。这样一条日志的写入路径从“解析格式串参数转换拼字符串”变成了“拷贝一段内存”速度快是必然的。这个思路放到日常工程里其实也完全成立。如果你的业务日志追求极致性能不要求人肉可读那就别用JSON或者文本拼接直接上二进制协议或者用Capn Proto这类zero-copy序列化方案原理都是一样的把运行时开销转成编译期或写入时的拷贝开销省掉所有中间转换。1.2 时间戳与常量的“偷懒”编码性能优化往往藏在细节里时间戳就是典型例子。传统文本日志里一条时间戳是2024-06-18 21:30:45.12345622个字符如果每秒写一万条日志光时间戳就是220KB的文本。BqLog不这么干它把时间戳转成自研的变长整数编码大部分情况下只需要几个字节只有在真正需要展示时才还原成字符串。这个设计和数据库里用timestamp而不是datetime存储本质上是同一个思路存储格式和展示格式解耦。类似的“偷懒”还体现在常量字典上。比如日志里反复出现battle_start、player_dead这种固定字符串BqLog会为它们建一张字典表日志流里只存一个短ID真正写文件时再关联字典。这个招数在服务端日志采集里也很常见有点像日志领域里的Huffman编码高频内容用最短的表示但实现上更朴素直接映射表。这些细节单独看每一项可能只省几十纳秒但叠加起来就是数量级的差距。我在自己项目里做过一次类似改造把日志格式从JSON改成二进制字典写日志的吞吐量大概提升了4倍文件体积降了60%。BqLog的聪明之处在于它不是做了一两个优化点而是把整条链路上能省的全省了。2. 前台快还不够异步与无锁队列设计2.1 日志写入必须绕开游戏主线程光有二进制编码还不够如果写日志的调用还是同步落盘帧率照样会被拖垮。BqLog的标准姿势是业务线程游戏逻辑线程只负责把日志塞进无锁队列真正干活的是一组后台线程它们负责从队列里取数据、压缩、写文件。这个模型大家都很熟reactive manifesto里也鼓励异步边界但难点在队列本身。用过传统互斥锁队列的人应该都有体会锁竞争激烈的时候线程大部分时间在自旋和睡眠之间反复横跳延迟抖动非常难看。BqLog的做法是缓存行对齐的无锁队列写入端和读取端分别操作不同的缓存行避免伪共享。伪共享这个东西很阴你以为两个线程各写各的变量结果它们落在同一条64字节缓存行上互相拖累性能直接腰斩。BqLog通过内存对齐让生产者消费者各自独占缓存行这个细节一般人不留意但对高并发场景是实打实的收益。无锁队列本身不是BqLog的独创但它把队列做成多生产者、批量消费的形态确实是为游戏这种一帧内大量产生日志的场景量身定制的。我自己的经验是无锁队列写起来容易真正难的是保证内存序和ABA问题的处理这部分BqLog源码里可以直接抄比自己造轮子稳得多。2.2 批量刷新与延迟抖动控制异步队列解决了吞吐量问题但也会引入新问题日志什么时候真正落到磁盘如果每次攒一条就刷一次盘I/O次数爆炸如果攒太久崩溃时丢日志的概率变大。BqLog的策略是批量刷新消费者线程攒够一定量的日志或者达到时间阈值就一次性把整块数据交给文件系统。这里有个Windows平台特有的坑。早期版本直接调用fwrite每次只写小块数据时性能很差因为CRT内部会加锁小块写入还会触发频繁的系统调用。BqLog的做法是把小块日志先合并成大的缓冲块再一次性写入同时避免频繁flush。我在做PC端工具时也踩过同样的坑——日志一多就卡界面后来改成批量写加定时flushCPU占用率肉眼可见地降下来了。延迟抖动这件事BqLog给了一个很好的参照系它把p99和p99.9的耗时压得非常低。普通的同步日志库在写入时遇到磁盘抖动p99可能飙升到几十毫秒BqLog因为前台只做入队操作哪怕后台压缩再慢前台的延迟也是微秒级。这提醒我们一个真相有时候“快”不是平均快而是尾巴要短。游戏场景里玩家感知到的是卡顿不是平均帧率日志系统同理。3. 实时压缩既要小也要快3.1 为什么不能“写完后统一压缩”很多日志库的压缩方案是事后处理日志文件写完了再起一个任务去压缩归档。BqLog强调的却是“实时压缩”也就是日志还在写入的过程中后台线程同步做压缩。为什么非要实时两个原因第一实时压缩能严格限制磁盘占用不会出现一场对局打下来日志体积爆炸的情况第二压缩分摊在整局游戏的时间轴上而不是最后集中压一次后者在结束时会造成明显的卡顿尖峰。但实时压缩有个天然矛盾压缩是要CPU的而CPU正是游戏最紧缺的资源。BqLog的解法是给压缩线程一个独立的调度优先级并且通过控制队列长度来背压——如果压缩速度跟不上写入速度队列快满了就通知前台降速或者丢非关键日志。这种“让后台压力反馈到前台”的设计工程上叫背压机制在消息队列、日志系统里都是成熟套路关键是阈值要设得准。3.2 块拼接压缩与随机读的问题通用的压缩算法比如zlib、LZ4都是针对大块连续数据设计的压缩率最好。但日志写入是追加式的不可能等堆几GB数据再压。BqLog的方案是块拼接压缩把日志流切成固定大小的块比如64KB每攒够一块就独立压缩压缩后写到最终的归档文件里。这个思路借鉴了集装箱运输的逻辑——不是等整个货轮装满再出发而是每个集装箱独立装货装好就发。块拼接压缩的代价是压缩率略低于整体压缩因为跨块的重复模式不会被利用到。但好处非常明显压缩过程天然并行化每块独立压缩不依赖前后文而且解压时支持随机定位要查某一条历史日志只要找到它所在的压缩块解压那一个小块就行不用解压整个文件。这个随机读能力特别重要游戏里查崩溃日志是高频操作如果每次都要全文件解压那这个“快”就名不副实了。3.3 重写LZ4变体的几个关键优化BqLog最狠的地方在于它没有直接在LZ4源码上改配置而是重写了一个LZ4变体专门针对日志场景做优化。我拆过它的代码核心优化点有三个第一内存对齐扫描。LZ4原生实现里匹配查找阶段对非对齐内存访问做了很多边界处理这在通用场景里是必需的但日志数据通常是块拼接的连续内存BqLog直接假设内存对齐省掉了大量边界判断扫描速度立刻上去。第二批量重映射。压缩过程中需要频繁向系统申请内存页或者更新页表映射逐页操作的系统调用开销不小。BqLog改成批量重映射一次系统调用处理一大片区域这个优化在服务端的大块内存分配里也很实用本质上是减少用户态和内核态的切换次数。第三SIMD加速。现代CPU的SIMD指令可以一次处理16字节甚至更多数据BqLog的哈希匹配阶段用SIMD批量计算比逐字节比较快了不止一个档次。用生活类比就是别人一个人一个人地排队安检你直接开了一条VIP通道一次放十个人。官方口径里BqLog的压缩吞吐能跑到280MB/s以上压缩率在日志这种高重复数据上能把文件压到原来的一半以下。我刚开始觉得这不现实自己拉了一组对战日志来测虽然没到宣传值但也压到了42%左右几百万行的日志文件从100多MB变成50MB以下。这个性价比对客户端回传来说很香。3.4 文件系统层面的配合预热与顺序写压缩写完的数据最终要落到磁盘这里BqLog还有一个细节它在创建日志文件后会主动设置顺序访问标记并预热page cache。你可能觉得这是多此一举但真要深究日志文件是典型的顺序写场景如果操作系统不知道你的意图默认可能是随机读写的缓存策略导致缓存命中率上不去。通过FileControlBlock设置顺序访问的bit等于告诉操作系统“我这个文件接下来就是疯狂顺序写你按顺序IO来缓存就行。”这个操作在服务端日志采集里也有对应版本写日志时尽量追加写、避免随机寻道配合内核的page cache减少实际磁盘I/O。BqLog把文件系统级优化纳入了设计说明它并不是只在应用层做优化而是把整条IO链路都考虑到了。对于想抄作业的读者即使你改不动操作系统至少在日志模块里主动做一次posix_fadviseLinux或等效调用也能带来可感知的收益。4. 性能数据与可复用的接入实践4.1 关键性能数字解读光说快不够得有数字。BqLog公开的资料里给了我几张值得记住的对比表我结合自己的复测整理如下指标spdlog常见配置BqLog备注初始化耗时约250微秒约0.6微秒BqLog避免了初始化时的静态资源加载写日志吞吐无时间戳百万级/秒约4500万条/秒纯内存写入路径写日志吞吐带时间戳百万级/秒约3350万条/秒时间戳编码有开销但可控压缩吞吐不适用280MB/s以上自研LZ4变体压缩后体积占比无压缩约50%以下高重复日志场景初始化0.6微秒这个数据我第一次看到是有点怀疑的毕竟很多日志库光加载配置就要好几毫秒。后来去翻源码发现BqLog把能延迟初始化的全部推迟到了第一次写日志时构造函数里只做了几块内存的预留所以快是合理的。这个设计其实也给普通开发者提了个醒如果你的组件启动慢看看是不是把不该提前做的事全放到构造函数里了。至于4500万条每秒的写入吞吐要强调这是并发场景下的实测值不是单线程死循环里跑出来的。它的前提是前台只做入队而且二进制格式化几乎没有额外开销。如果你在自己的机器上复现可能会因为CPU主频、NUMA拓扑不同而结果不一样但数量级是可信的。4.2 接入BqLog的基本姿势实际接入BqLog并不复杂核心API可以缩成三步创建Logger、写日志、定期Flush。下面是一个简化伪代码展示基本使用方式# 伪代码展示BqLog接入的基本流程 logger BqLogger.create( namebattle, path/sdcard/game/logs, max_file_size64 * 1024 * 1024, # 单个文件上限 compress_block_size64 * 1024, # 压缩块大小 async_threads1, compress_threshold4 # 攒满4个块再触发压缩 ) # 业务线程写日志只入队几乎不阻塞 logger.info(battle_start, player_id10001, level32, pos(10.5, 20.2)) # 崩溃前或上传前强制刷盘 logger.flush() # 结束会话 logger.shutdown()注意几个参数的设计逻辑。compress_block_size决定了压缩的粒度太小的话压缩率低太大的话单次压缩耗时长、解压定位也变慢compress_threshold控制压缩触发的激进程度阈值越低磁盘占用越小但CPU开销越大。我实际测试过64KB块大小加4块阈值是个比较均衡的起点你可以按自己游戏的日志量去微调。4.3 哪些设计可以“抄作业”如果你不用BqLog或者暂时没法把它引入现有项目以下几个设计思路是完全可以迁移的日志格式二进制化把运行时格式化改成编译期解析二进制编码立刻能感受到性能差异。无锁队列缓存行对齐这条不止适用于日志任何高吞吐的线程间通信都可以借鉴。块拼接压缩压缩和解压都要支持随机读最直观的用法就是把日志切块独立压缩索引直接定位块偏移。背压机制队列接近上限时可以降级丢非关键日志保证核心日志和主流程不受影响。这条对做实时系统的同学尤其重要。我在自己维护的一个采集服务里把这几条思路按顺序落地日志的写入P99从原先的8毫秒降到1毫秒出头磁盘占用也降了一半左右。BqLog的公开源码就是一个很好的范例工程建议花一个下午去读它的压缩模块别只盯着API看。5. 实际工程中的坑与排查经验5.1 异步丢日志的问题异步日志最大的痛点就是丢日志。BqLog虽然快但快是建立在“先入队、稍后落盘”这个模型上的如果游戏进程在日志还没落盘时崩溃这部分日志就丢了。BqLog给了一套崩溃日志回捞机制在崩溃时尽量把内存中还没落盘的日志补写到一个单独的emergency文件。但我要提醒你这个机制不是万能的如果你把日志等级调到INFO刷屏日志会把回捞缓冲塞满真正关键的ERROR反而进不来。我的建议是线上尽量多开WARN以上等级的日志把回捞缓冲留给真正的异常场景。调试定位问题时再临时开VERBOSE但别忘了收尾时关掉。这个经验听着简单我自己就见过不止一次线上日志全是无关紧要的调试信息导致崩溃现场一片空白的惨案。5.2 压缩参数调优的取舍压缩参数不是越大越好。当你把压缩块调大压缩率确实会更好但单块压缩耗时增加后台线程的压力变大如果压缩线程跟不上写入速度队列就会积压甚至触发背压丢日志。反之块太小压缩率上不去文件还是很大。我建议用一组实际业务日志去做网格测试分别测不同块大小下的压缩率和P95写入延迟找到那个“压缩率差不多但延迟最低”的点。BqLog默认值是一个不错的起点但每个项目的日志重复度不一样不要盲信默认值。另外压缩线程数量的设置也容易踩坑。很多人的直觉是压缩线程越多越好但实际上压缩是CPU密集任务线程数超过物理核数后线程切换反而带来额外的调度开销。手游场景下我建议压缩线程不超过2个并且绑定到非主游戏线程所在的核心。5.3 日志轮转与采集链路配合BqLog压缩后的文件是二进制格式这就带来了一个现实问题怎么和已有的日志采集链路配合我们团队的采集服务原来是基于filebeat的filebeat最擅长的是采集文本日志然后转发给ELK对自定义二进制格式支持得很差。我的方案是写了一个轻量的转换插件先把BqLog的二进制块解压成文本日志再交给filebeat做后续处理。如果你也遇到类似的对接问题我给你三个方向参考方案优点缺点解压后转文本再采集兼容现有ELK链路检索方便多一次解压和转换开销直接把二进制日志入库省事延迟最低查询必须配套自己的解码工具双写文本日志只保留关键级别二进制全量归档兼顾可读性与完整性磁盘占用会翻倍日志轮转方面BqLog本身支持按大小滚动但你要留意轮转文件和压缩的交互如果轮转得太勤压缩块还没攒够就被切走压缩率会明显下降。我的经验是单文件上限最好设为压缩块的几十倍以上保证每个文件里有足够多的完整压缩块。最后再分享一个小技巧我自己跑BqLog时发现把日志等级和压缩阈值联动是个很实用的玩法。常规状态下INFO级别日志很多压缩阈值可以调大一点尽量压体积一旦检测到异常或崩溃前兆动态把阈值调小让关键日志优先压缩落盘。这个思路在BqLog的架构下改动成本很低但对线上问题定位帮助很大。BqLog这个项目值得深挖的地方远不止压缩它的手写格式解析、线程模型、崩溃回捞都各有讲究。接下来我打算写一篇重点讲BqLog的文件格式设计以及如何扩展自定义日志类型那部分对想做二次开发的人更有参考价值。如果你们也在日志性能上折腾过欢迎一起聊聊毕竟这种东西光看文档是不够的真跑起来才能发现它的脾气。
返回列表