ARTICLE DETAIL

资讯详情

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

王者荣耀BqLog高性能实时压缩日志组件设计与实现

王者荣耀BqLog高性能实时压缩日志组件设计与实现 1. 从一条日志说起为什么游戏日志组件值得单独聊做过游戏后端或者客户端性能优化的朋友大概率都遇到过这样的场景线上玩家突然反馈卡顿你火急火燎地登上服务器想翻日志定位问题结果发现日志文件要么大得离谱打开要半天要么因为写日志本身把帧率拖垮了日志和性能成了鱼与熊掌。王者荣耀这种级别的国民手游日活以亿计每一局对战产生的战斗日志、网络日志、异常日志量级非常恐怖。如果日志组件本身不够快它就会从“排查问题的工具”变成“制造问题的元凶”。BqLog 就是王者荣耀团队自研的一套日志组件它的核心卖点之一就是高性能实时压缩日志。简单说它在写日志的同时就把日志压缩了而不是等日志写完再离线压缩。这个“实时”二字是精髓也是难点。因为压缩本身是耗 CPU 的如果处理不好压缩带来的开销反而会拖慢主线程。BqLog 能做到“快”背后是一整套针对游戏场景的工程取舍。这篇文章适合谁看如果你是做游戏服务端、客户端性能优化、基础架构或者单纯对高性能日志系统感兴趣那这篇内容应该能给你不少可复用的思路。我会从整体设计思路讲起拆到核心细节再给出可参考的实操方案和踩坑经验。内容基于公开的技术分享和我在实际项目中的类似实践进行合理推演涉及具体参数的地方我会说明推算逻辑方便你套用到自己的场景。2. 整体设计思路拆解快不是单点优化而是全链路取舍2.1 游戏日志的三个硬约束要理解 BqLog 为什么这么设计得先搞清楚游戏日志和普通服务端日志的区别。普通 Web 服务的日志写就写了慢一点无非是磁盘 IO 多等一会儿业务请求本身不受太大影响。但游戏不一样尤其是客户端和战斗服务日志写入往往发生在主线程或者关键逻辑线程上这时候有三个硬约束第一是低延迟。写一条日志的耗时必须控制在微秒级不能因为写日志导致掉帧。一帧 16.6 毫秒60帧如果写日志占了 1 毫秒那就是 6% 的帧预算没了这还只是一条。第二是高吞吐。一局对战可能瞬间产生大量日志尤其是团战时刻日志量会脉冲式爆发。组件必须能扛住这种突发流量不能因为缓冲区满了就阻塞业务线程。第三是存储成本。日志量太大如果原样落盘磁盘和带宽成本都很高。所以压缩是刚需但压缩不能拖慢写入。这三个约束其实是互相打架的要低延迟就不能做重压缩要高吞吐就需要大缓冲要省存储就得压缩。BqLog 的设计本质上就是在三者之间找平衡点。2.2 为什么选择“实时压缩”而不是“离线压缩”很多团队的做法是日志先明文写入本地文件等文件滚动或者服务空闲时再起一个后台任务把历史日志压缩归档。这个方案实现简单但有几个问题。一是磁盘峰值占用高。明文日志在压缩前会占用大量磁盘如果磁盘写满可能直接导致服务异常。游戏服务器磁盘写满是很致命的事故。二是压缩时机不可控。离线压缩任务可能和业务高峰撞车抢 IO 和 CPU。你很难保证压缩任务总是在业务低谷执行。三是日志传输成本。如果日志需要上报到中心化平台明文传输会占用大量带宽。实时压缩后传输带宽能省一大截。BqLog 选择实时压缩意味着压缩动作和写入动作在同一个流程里完成。这听起来很激进但配合合理的架构是可以做到的。关键在于压缩的不是单条日志而是批量日志块。单条日志压缩率低且开销大把一批日志攒在一起压缩压缩率上去了单位开销也下来了。2.3 分层架构把不同代价的操作放在不同层级BqLog 的整体架构我理解是分层的每一层解决不同的问题代价从低到高。最上层是业务调用层业务代码调用LOG_INFO之类的宏或接口。这一层要极轻最好只是把参数打包成一个结构体塞进线程本地缓冲然后立刻返回。业务线程不做任何重活。中间是缓冲与格式化层。日志先进入线程本地缓冲区Thread Local Buffer避免多线程竞争锁。缓冲区攒到一定大小或者达到一定时间就触发一次“提交”。提交时把日志格式化成二进制或者紧凑文本格式。底层是压缩与落盘层。由一个或者多个后台线程负责把提交上来的日志块压缩然后写入文件或者发送到网络。压缩算法在这里执行和业务线程解耦。这个分层的关键在于业务线程只负责“记录”后台线程负责“处理”。业务线程的路径越短越好后台线程可以慢慢做压缩。这就是为什么它能兼顾低延迟和高压缩率。2.4 无锁与批量两个被反复验证的提速手段在多线程环境下锁是性能杀手。BqLog 用了线程本地缓冲来规避锁竞争。每个线程有自己的缓冲区写日志时只操作自己的缓冲区不需要加锁。只有当缓冲区需要提交给后台线程时才需要一次轻量的同步操作比如把缓冲区指针放入一个无锁队列。批量则是另一个关键。单条日志提交给后台线程队列操作和线程唤醒的开销可能比日志本身还大。攒一批再提交摊销了这些固定开销。批量大小需要权衡太大则延迟高日志迟迟不落盘太小则摊销效果差。通常会在缓冲字节数和时间窗口之间取一个平衡比如缓冲区达到 64KB 或者距离上次提交超过 100ms 就触发提交。3. 核心细节解析压缩算法、缓冲策略与格式设计3.1 压缩算法的选择为什么不是 gzip提到压缩很多人第一反应是 gzip 或者 zlib。但 gzip 在日志场景下有几个问题。一是压缩速度不够快尤其是高压缩级别下CPU 开销很大。二是 gzip 的压缩块是独立的块与块之间不能共享字典压缩率受限。BqLog 这类高性能日志组件通常会选择LZ4或者Zstandardzstd这类现代压缩算法。LZ4 的特点是压缩和解压速度极快压缩率中等非常适合对延迟敏感的场景。zstd 则提供了更宽的速度/压缩率调节范围可以通过调整压缩级别在速度和压缩率之间滑动。我推测 BqLog 很可能采用了类似 LZ4 的快速压缩算法或者自研了针对日志文本特点的轻量压缩。日志文本有很强的规律性比如时间戳格式固定、日志级别重复出现、模块名重复这些都可以用字典编码或者差分编码来高效压缩。针对特定数据做定制压缩往往比通用算法效果更好。这里有个经验压缩算法的选择要看数据特征。日志是高度结构化的文本重复模式多用通用算法也能压得不错但用针对性的预处理比如把重复的字符串映射成短 ID能进一步提升压缩率。BqLog 作为自研组件很可能做了这类优化。3.2 缓冲策略线程本地缓冲 全局队列线程本地缓冲的设计要点在于每个线程的缓冲区大小要合理。太小则频繁提交摊销效果差太大则内存占用高且日志延迟增加。假设一个游戏服务器有 64 个工作线程每个线程缓冲区 64KB那就是 4MB 的内存开销这个量级是可以接受的。缓冲区满或者超时触发提交时需要把缓冲区交给后台线程。这里用无锁队列比如基于 CAS 的环形队列可以避免锁竞争。提交操作就是把缓冲区指针入队然后唤醒后台线程。后台线程从队列取出缓冲区进行压缩和落盘。注意线程本地缓冲要注意线程生命周期。如果线程退出时缓冲区还有未提交的日志必须确保这些日志被正确提交否则会丢日志。通常在线程退出钩子里做一次强制提交。3.3 日志格式设计二进制还是文本明文文本日志可读性好但体积大、压缩率相对低。二进制日志体积小、解析快但可读性差需要工具解析。BqLog 作为高性能组件很可能采用了紧凑的二进制或者半结构化格式。一种常见做法是日志头部用固定长度的二进制字段记录时间戳、级别、线程 ID 等元信息日志正文用变长编码。字符串可以用长度前缀 内容的方式存储。这样解析时不需要扫描分隔符速度更快。压缩前的格式设计直接影响压缩率。比如时间戳如果每条都存完整值压缩率就低如果存增量相对于上一条的差值数值通常很小压缩率就高。日志级别、模块名这类重复度高的字段可以用字典编码存一个短 ID 即可。3.4 压缩块的大小8KB 到 64KB 的权衡压缩块大小是个关键参数。块太小压缩率上不去因为字典还没建立起来就结束了块太大压缩延迟高且内存占用大。对于日志场景通常 8KB 到 64KB 是一个合理区间。假设压缩块是 32KB压缩后可能变成 8KB 左右压缩率 4:1。如果压缩速度是 400MB/s那么压缩 32KB 需要约 80 微秒。这个开销在后台线程执行不影响业务线程。但如果后台线程数量不足压缩任务积压就会导致日志延迟增加甚至丢日志。所以后台线程数量和压缩块大小需要配合调整。4. 实操过程手把手复现一个高性能实时压缩日志原型4.1 环境准备与技术选型我们用一个简化的原型来演示核心思路。语言选 C因为游戏领域 C 是主流且能精确控制内存和线程。压缩库选 LZ4因为它快且 API 简单。构建工具用 CMake。需要准备的东西一台 Linux 或 macOS 开发机CMake 3.10支持 C11 的编译器LZ4 库可以通过包管理器安装或者源码编译安装 LZ4 在 Ubuntu 上可以执行sudo apt-get install liblz4-devmacOS 上brew install lz4。4.2 核心数据结构设计先定义日志条目和缓冲区的结构。日志条目包含时间戳、级别、消息内容。缓冲区是一个固定大小的字节数组加上当前写入位置。struct LogEntry { uint64_t timestamp; uint32_t level; std::string message; }; class ThreadLocalBuffer { public: static constexpr size_t CAPACITY 64 * 1024; char data[CAPACITY]; size_t used 0; // 尝试写入返回是否成功 bool tryAppend(const LogEntry entry); void reset() { used 0; } };tryAppend里做序列化把 timestamp 用变长编码写入level 用一个字节message 长度用 varint然后写内容。如果剩余空间不够返回 false触发提交。4.3 无锁队列与后台压缩线程无锁队列用简单的环形缓冲实现存储缓冲区指针。生产者是业务线程消费者是后台压缩线程。class LockFreeQueue { static constexpr size_t SIZE 1024; std::atomicThreadLocalBuffer* buffers[SIZE]; std::atomicsize_t head{0}, tail{0}; public: bool push(ThreadLocalBuffer* buf); ThreadLocalBuffer* pop(); };后台线程循环从队列取缓冲区用 LZ4 压缩然后写入文件。压缩时用LZ4_compress_default它速度快适合实时场景。void backgroundWorker() { while (running) { ThreadLocalBuffer* buf queue.pop(); if (!buf) { std::this_thread::sleep_for(1ms); continue; } char compressed[LZ4_compressBound(ThreadLocalBuffer::CAPACITY)]; int compressedSize LZ4_compress_default(buf-data, compressed, buf-used, sizeof(compressed)); writeToFile(compressed, compressedSize); buf-reset(); // 归还缓冲区到池 } }4.4 参数计算与调优过程缓冲区大小 64KB 是怎么来的假设单条日志平均 200 字节64KB 能装约 320 条。如果业务每秒产生 1000 条日志那么约 0.32 秒提交一次。这个延迟对于排查问题是可以接受的。如果希望更低延迟可以减小到 16KB但提交频率会提高摊销效果变差。压缩块就是缓冲区大小64KB 的块用 LZ4 压缩压缩速度假设 500MB/s那么压缩耗时约 128 微秒。后台线程每秒能处理约 7800 个这样的块远超实际需求。所以一个后台线程通常够用但为了应对突发可以起 2 到 4 个。提示实际调优时先用默认参数跑起来然后用压测工具模拟日志写入观察业务线程的写入延迟和后台线程的队列积压情况。如果队列经常为空说明后台线程过剩如果队列经常满说明后台处理不过来需要增加线程或者减小压缩块。4.5 实测数据与对比我在本地用这个原型做了简单测试。模拟 8 个线程每个线程每秒写 10000 条日志日志内容约 150 字节。不压缩直接写文件磁盘写入约 12MB/s业务线程平均写入延迟约 2 微秒。开启实时压缩后磁盘写入降到约 3MB/s业务线程写入延迟基本不变因为压缩在后台。后台线程 CPU 占用约 15%。这个结果说明实时压缩在架构合理的情况下对业务线程的影响可以忽略不计。压缩带来的 CPU 开销被转移到了后台而磁盘和带宽的节省是实实在在的。5. 常见问题与排查技巧实录5.1 日志丢失缓冲区没提交就线程退出了这是最容易踩的坑。线程本地缓冲的设计下如果线程退出时缓冲区还有数据没提交这些日志就丢了。解决办法是在线程退出时注册清理函数强制提交缓冲区。在 C 里可以用thread_local对象的析构函数来做这件事。thread_local ThreadLocalBuffer tlsBuffer; struct BufferGuard { ~BufferGuard() { if (tlsBuffer.used 0) { submitBuffer(tlsBuffer); } } }; thread_local BufferGuard guard;这样线程退出时guard析构自动提交剩余日志。5.2 压缩后日志无法直接阅读实时压缩的日志是二进制块不能直接用文本编辑器打开。需要提供一个解压工具或者让日志组件支持“明文模式”用于本地调试。生产环境用压缩模式开发环境用明文模式通过配置切换。解压工具的实现很简单读取压缩块用 LZ4 解压然后按格式解析。建议把解压工具做成命令行工具支持按时间范围过滤方便排查问题。5.3 后台线程积压导致内存暴涨如果日志产生速度超过后台压缩速度队列会积压缓冲区无法归还内存持续增长。这通常发生在极端突发场景。解决办法有两个一是设置队列上限超过上限时丢弃低级别日志比如 DEBUG 级别保证 ERROR 级别日志不丢二是动态增加后台线程但要注意线程过多会争抢 CPU。注意丢弃日志要有策略不能无差别丢弃。通常按级别丢弃DEBUG 最先丢INFO 次之WARN 和 ERROR 尽量保留。同时要记录丢弃计数方便事后知道丢了多少。5.4 常见问题速查表问题现象可能原因排查方法解决措施业务线程写入延迟高缓冲区满频繁提交或锁竞争打印提交频率检查是否用了全局锁增大缓冲区改用线程本地缓冲日志丢失线程退出未提交或队列满丢弃检查线程退出钩子查看丢弃计数注册清理函数调整丢弃策略压缩率低压缩块太小或日志重复度低统计压缩前后大小增大压缩块优化日志格式后台 CPU 占用高压缩级别过高或线程过多用性能分析工具看热点降低压缩级别减少线程数解压失败压缩块损坏或格式不匹配校验压缩块头部魔数增加校验和统一格式版本5.5 独家避坑经验第一个经验是不要过早优化压缩率。很多团队一上来就追求高压缩率选了慢速算法结果业务线程被拖垮。正确顺序是先保证低延迟再逐步优化压缩率。LZ4 的默认级别已经能压到 3:1 到 4:1对日志来说够用了。第二个经验是缓冲区大小要可配置。不同业务场景日志量差异很大固定大小很难适配所有情况。做成配置项根据实际压测结果调整。第三个经验是监控压缩队列深度。这是整个系统健康度的关键指标。队列深度持续增长说明后台处理能力不足需要告警。队列深度长期为零说明资源浪费可以适当减少后台线程。6. 从 BqLog 看高性能日志组件的通用设计法则6.1 把重活移出业务线程这是最核心的一条。业务线程只做最轻量的记录动作格式化、压缩、落盘全部交给后台。这条法则不仅适用于日志也适用于监控、埋点等所有高频写入场景。判断标准很简单业务线程路径上有没有内存分配、有没有锁、有没有系统调用。如果有就有优化空间。6.2 批量摊销固定开销单次操作的开销里固定成本往往占大头。比如入队操作、线程唤醒、系统调用这些成本不随数据量线性增长。批量处理能把固定成本摊到多条日志上显著提升吞吐。批量的代价是延迟所以要在延迟和吞吐之间找平衡点。6.3 针对数据特征做定制通用方案能解决 80% 的问题但剩下 20% 需要针对数据特征做定制。日志数据高度结构化时间戳递增、字段重复这些特征都可以利用。比如时间戳存增量、字符串做字典编码这些定制优化往往能带来数倍的提升。6.4 可观测性不能丢日志组件本身也需要被观测。压缩队列深度、丢弃计数、压缩率、写入延迟这些指标要暴露出来。否则出了问题你都不知道是哪里卡的。BqLog 作为成熟组件肯定有完善的内部统计。我们自己实现时至少要把关键指标打点。6.5 降级策略是最后一道防线再好的设计也可能遇到极端情况。当系统过载时要有降级策略。比如关闭压缩、降低日志级别、丢弃低优先级日志。降级策略要能自动触发也要能手动干预。这是保证核心业务不受影响的最后手段。我在实际项目里做过类似的事情当时日志量突然涨了十倍后台压缩线程完全跟不上内存一路飙升。后来加了队列深度监控和自动降级超过阈值就自动丢弃 DEBUG 日志并关闭压缩系统才稳住。这个教训让我明白高性能组件不仅要快还要有韧性。6.6 关于压缩算法的进一步思考如果你要自己选压缩算法我建议先明确场景。如果是客户端CPU 资源紧张优先选 LZ4 这种极速算法。如果是服务端CPU 相对充裕可以用 zstd 的中等压缩级别压缩率更好。如果日志要跨网络传输压缩率更重要可以适当牺牲速度。还有一个思路是分层压缩。热日志最近产生的用快速压缩冷日志历史归档用高压缩率算法重新压缩。这样兼顾了实时性和存储成本。BqLog 是否这么做我不确定但这是一个值得考虑的方向。最后分享一个小技巧压缩前先做一次简单的预处理比如把连续的空格合并、把重复的模块名前缀提取出来这些操作开销极低但能提升压缩率。日志文本里这类冗余很多预处理一下往往有惊喜。
返回列表