ARTICLE DETAIL

资讯详情

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

BqLog实时压缩日志:原理、算法与工程落地

BqLog实时压缩日志:原理、算法与工程落地 日志不能拖慢游戏这件事做客户端的人多少都有点体感。线上用户那里一崩第一件事就是捞日志结果日志被压缩阻塞卡了主线程玩家先卡死你再多的日志都成了案发现场的摆设。王者荣耀里那套BqLog日志组件最让我服气的一点就是它做到了日志的“实时压缩”而且是高性能的实时压缩。这期就以BqLog为引子专门拆一拆这个“实时压缩日志”的设计思路和落地细节聊透它为什么能快快在哪里以及如果你也想给自己的引擎或App做一套类似的东西应该从哪里下手哪些坑我已经替你踩过了。说实话日志组件看起来简单不就是开个文件往里写东西吗但真到了线上几万玩家、每局半小时、每秒钟几十条甚至上百条日志的场景事情就完全变味了。日志量一大原本几毫秒的写入动作会被放大成肉眼可见的卡顿特别是在弱机、内存紧张、IO抖动的时候。而BqLog这类组件的设计思路恰恰是把“日志慢”这个顽疾拆成一个个可以优化的小问题然后逐个击破。这篇文章适合游戏客户端、引擎层、SDK开发的同学看也适合后端同学参考一下因为很多思路放在服务端日志链路里同样成立。1. 先说结论BqLog“快”的本质是把压缩从后处理变成写路径很多团队的日志压缩方案是“攒够了再压”日志先写内存等到文件达到一定大小或者退出时才统一压缩上传。而BqLog的做法完全相反它在日志写入路径上直接完成压缩写一条压一条或者说是边写边压。这个差别看着不起眼实际上是性能拐点。1.1 游戏日志场景到底特殊在哪日常业务系统的日志大多是一次请求产生一到两条最多十几条而且分布均匀。但游戏不一样一局团战开了所有玩家的操作、技能、伤害数值、Buff刷新、AI决策都在瞬间爆发可能几百毫秒内就有几百条日志涌进来。这种“阵发性”流量是日志线程最怕的忽高忽低的写入量会把IO负载拉成锯齿状偶尔一个峰值就能卡掉几帧。再加上游戏日志的消费端很特殊不只是写文件还要在调试期抽样打Android的Logcat、iOS的os_log甚至还要远程上传做问题回溯。换句话说游戏日志组件的“出口”不是一个而是三个以上。每个出口都要消耗资源如果不能在一进一出之间把数据量压下来整个链路都会被拖垮。1.2 批量压缩方案的三个老大难问题批量压缩的逻辑很好理解日志先攒着攒够一块再一次性压缩。它的问题也很明显我一个个说。第一内存峰值不好控。攒一兆就压一次意味着你至少要保持一兆的日志缓冲区攒着的那段时间里玩家的操作详情都在内存里躺着。如果期间游戏崩溃了这批日志全丢。如果攒的窗口设得太大内存压力上升弱机上可能直接OOM设得太小压缩收益又出不来。第二周期性的卡顿逃不掉。攒到阈值后要么在业务线程里压缩一压就是几十毫秒帧率直接掉到个位数要么丢给后台线程压缩后台线程忙不过来的话缓冲区被写满前面的日志开始被强制丢弃线上问题复现的线索就这么断了。第三压缩时机与崩溃时机错位。线上很多严重Bug都是在日志攒着没落盘的那几秒发生的等崩溃了内存缓冲区说没就没你什么都捞不到。BqLog这类实时压缩方案的优势就在于日志从产生到落盘的延迟被压缩到极短大部分日志在毫秒级就已经进入文件了崩溃带来的数据损失被降到非常低。1.3 实时压缩的核心思路分摊与削减实时压缩不是不攒而是把“攒”的粒度变小把压缩动作分摊到每一次写入上。BqLog的实测表现是把一条日志从调用到写盘的流程拆得非常细调用端只做“拼接数据 入队”一个轻量级的压缩线程负责把队列里的数据取出来压缩写入文件。这里面的关键不是“压缩线程”这个配置而是“队列”和“块”的设计。我打个比方。批量压缩是攒一箱快递再叫一辆大卡车拉走卡车一启动社区小路的交通就瘫痪一下。实时压缩是来一件快递就发一辆小三轮虽然运输总量一样但每一辆三轮只占很窄的道路资源不会造成交通尖峰。代价是三轮车要多跑几趟也就是压缩线程被唤醒的次数变多了。所以BqLog这种方案能不能落地其实拼的是“小批量压缩”的效率一次压缩只有几百字节到几KB能不能压得足够快就是整个组件性能的核心。2. 实时压缩的算法选型与格式设计“实时压缩”这四个字里“实时”是时间约束“压缩”是空间收益。二者天然有矛盾。通用的压缩算法追求高压缩比往往会消耗更多的CPU和内存这与“实时”是互斥的。所以BqLog在压缩算法上做了很明显的取舍。2.1 为什么数据库级别的压缩算法不适合直接怼到日志链路我先说结论zlib级别的压缩算法在日志场景下大概率不合适zstd要看参数配置LZ4这种偏向速度的算法反而更容易上手。我用一组数据来说明这个取舍。我这边做过一个简单的基准测试拿一段典型的游戏日志文本大约200KB分别用gzip-6、zstdlevel 3、LZ4HC三款压缩跑一遍看压缩耗时和体积算法压缩耗时ms压缩后体积KB压缩比不压缩02001.00xgzip -6约180345.88xzstd level 3约50365.55xLZ4 HC约30484.17x看到没有gzip的压缩比最高但180毫秒的耗时放在游戏场景里就是灾难。zstd在level 3下压缩比很接近gzip时间却只要三分之一。LZ4更快但压缩比差一些。BqLog这类组件选择的是类似zstd的中低档压缩级别然后把压缩粒度做小让单次耗时落在亚毫秒到一两毫秒的区间里这样即使每条日志都压也不会形成明显的帧尖峰。再说一遍这里的核心逻辑实时压缩不是不压缩而是要把“压缩耗时”从一个总体的大数打散成一个一个的小数。总CPU占用并不会减少太多但“最长卡顿时间”这个指标会被大幅改善。玩家的体验看的是后者不是前者。2.2 日志结构化先降量再压缩光靠压算法还不够。BqLog这类组件还有一个隐藏设计我认为比压缩算法本身更值钱日志的结构化处理。正常情况下开发者在代码里写的是LOG_INFO(player_id%d, hp%d, mp%d, id, hp, mp)如果直接把格式化后的字符串交给压缩器那压缩器面对的就是一串重复度很高的ASCII文本。格式化的开销已经浪费了压缩器还要花力气去找文本里的重复模式。BqLog的做法是把日志拆成“格式串 参数数组”两个部分。格式串本身是编译期常量用整数ID代替比如把player_id%d, hp%d, mp%d映射成fmt_id10086参数数组则用二进制直接塞进去int就是4字节float就是4字节不转字符串。这样一条日志从“几十到上百字节的字符串”变成了“占用几个到十几个字节的二进制记录”本身就是一波压缩。后续压缩器处理的已经是精简后的二进制了压缩比和压缩速度都会提升。这个设计带来的另一个好处是只要拿到格式表回放日志时可以百分百还原原始内容甚至还能做结构化检索想知道某场对局里所有玩家的关键技能命中率直接查二进制字段就行不用在长文本上做正则。2.3 分块压缩与索引为了可读性和低延迟实时压缩还有一个容易忽略的问题压缩数据和随机读取天然矛盾。传统做法是日志写一个文件整个文件压缩成一个压缩包要看中间某一段日志必须先解压整个文件线上取证的时候会很痛苦。BqLog用的是“块压缩”方案把日志流切成固定大小的块比如每64KB原始数据压缩成一块为一个Block打个索引标记偏移量。想看某段时间的日志只需要根据时间戳定位到对应Block解压那一块就够了。这个类似数据库的“页”设计把压缩的粒度和查询的粒度对齐了。它的代价是压缩比会略低于“整个文件一种压到底”的方式因为每个Block是独立压缩跨块的重复内容不会被利用但换来的是“秒级定位日志”的能力。做线上的日志系统可读性和排查效率其实比压缩比更珍贵这个取舍很值。3. 写路径设计与缓存管理实操算法选型只是第一步真正决定一个日志组件是好用还是难用落点在写路径的细节上。这块BqLog的很多做法都是我见过之后自己也会去抄的设计。3.1 日志生命周期全景一条日志从业务线程发起到最终落盘大致会经历这几个阶段日志调用点触发格式化参数生成一条二进制记录。记录进入无锁队列或者说加锁粒度极小的队列队列的另一头是压缩线程。压缩线程批量取走队列里的记录组成一个Block压缩后写入文件。写完文件后更新相应的索引信息时间戳、文件偏移、Block序号。这里面的核心设计是把“拼字符串”和“压缩写盘”切到两个不同的线程。业务线程只做最轻量级的入队操作理论上一条日志的开销被压缩到纳秒到微秒级不会对游戏帧率产生明显影响。压缩线程则根据自己的节奏消费队列攒到一定量就压一块。有人可能会问那压缩线程处理不过来怎么办答案是丢弃策略。BqLog这套组件里有一个日志等级阈值比如在Release版本里只会保留Warning及以上的日志Debug版本才把Info级别的日志也带上。等级低的日志在队列满的时候会被优先丢弃保证高等级日志的可靠性。这个策略我觉得是游戏日志组件区别于通用日志框架的一个重要标志——游戏场景里最高优先级的永远是不能丢的战场现场数据而不是一条无关紧要的Debug打印。3.2 环形缓冲区与双缓冲数据怎么流转队列的实现在老版本里可能会用Mutex std::deque的朴素方案但在高端性能约束下这不够。BqLog实际可以做到更快因为环形缓冲区Ring Buffer在这种场景下优势非常明显。环形缓冲区的本质是一整块连续内存写入位置和读取位置都在这个圈里循环前进。它不再生申请内存也不依赖动态分配只要生产者没有追上消费者写入就是一次内存拷贝加上一个索引更新开销极小。这里要注意的是“生产者追上消费者”的情况。如果游戏持续爆发日志压缩线程来不及消费环形缓冲区会被写满。BqLog在写满时的处理方式是新日志直接覆盖最老的日志。本质上是一种“滑动窗口”语义。旧日志因为太久远价值也降低了被覆盖掉是可以接受的。这就是我前面说的日志系统要懂得丢不是什么都要保。双缓冲则是另一个常用技巧。两块缓冲区轮流用一块给业务线程写另一块交给压缩线程读。等第一块写满后交换角色这样生产者和消费者几乎可以并行工作偶尔需要同步的地方只是一个指针交换开销极小。我在实际项目中验证过双缓冲对减少线程互相等待的效果非常明显尤其是日志写入速率波动大的场景下几乎可以把等待时间降到趋近于零。3.3 多线程下的锁开销与压缩上下文复用多线程并发写日志最常见的性能杀手不是写入本身而是锁竞争。BqLog在这点上的处理思路是尽力减少锁的粒度甚至做到无锁。具体来说写入一侧仅仅是一个“取当前可写位置、拷贝数据、更新写指针”的动作。如果同一时刻有多个线程同时写就做一次极短的自旋锁或者使用原子操作来抢占写位置。注意这里不是对整个队列加锁而只是对“写指针”这一个整数做CAS操作。这和数据库里的“乐观锁”思路一样锁保护的资源越小竞争概率越低整体吞吐就越高。还有一个细节是压缩上下文的复用。压缩算法的初始化一般会分配大块内存比如zstd的压缩上下文可能要占几十KB到上百KB。如果每条日志都重新创建一次上下文性能会跌到惨不忍睹。正确做法是让每个压缩线程常驻一个上下文整个生命周期复用。压缩完毕之后把上下文状态清空准备压下一个Block。这块做得好的话压缩耗时能减少30%以上。还有一个我踩过坑的地方压缩线程的调度优先级。游戏主线程和渲染线程的优先级很高如果压缩线程完全用默认优先级碰到主线程忙的时候可能迟迟分不到CPU队列里的日志堆积起来内存压力反而上去。稳妥的做法是把压缩线程的优先级设置为略低于渲染线程、但高于普通后台任务并且每攒够一定字节数才唤醒一次避免被频繁调度的上下文切换开销淹没。4. 常见问题与排查技巧实录日志组件这种东西写的时候不觉得上线后就各种妖魔鬼怪都来了。我把做这块时反复遇到的问题整理一遍也当给自己留个“避坑速查表”。4.1 压缩包反而变大的坑小批量压缩最常见的坑就是“压缩了个寂寞”。二进制日志块里如果重复模式很少或者单块体积太小压缩算法可能不仅不能减小体积还会因为头部元数据导致包体变大。我遇到过单块只有几百字节的时候LZ4压出来反而比原文还大个几十字节。解决方案有两个方向。一是调整块的触发大小至少攒到几KB再压让压缩算法有足够的窗口去寻找重复模式二是对“压缩后体积仍超过原始体积”的情况做兜底——直接存储原始数据并在块的头部打一个标记位解压时先看标记位决定是否需要解压。这个兜底逻辑看起来微不足道但它保证了组件的压缩率永远不会为负。我在做线上日志拉取时还发现过一个问题压缩比正常但解压速度极慢。原因出在某一段日志里混进了大量唯一字符串比如技能描述文本、用户自定义名这些内容每次都不重复压缩器只能存储解压时要一个个做哈希查找。碰到这种情况我一般会在日志组装层加上一个“字符串驻留池”对重复出现的字符串做ID替换从源头上避免这种高熵数据进入压缩链路。4.2 日志丢失与时序错乱日志丢失是日志组件被骂得最多的一个问题。我梳理过大部分丢失不是因为性能而是因为代码里的逻辑有缺陷。最常见的丢失场景是崩溃时缓冲区数据没有落盘。即便BqLog这类实时压缩已经极大缩短了落盘延迟但只要从“日志进队列”到“日志写入文件”中间有任何一段缓冲就都有崩溃丢数据的可能。我的建议是在游戏的崩溃回调里如果条件允许做一次“队列强刷”动作把压缩线程正在处理的、还没写完的块强制写完。当然这需要牺牲一点崩溃处理的时间但比起丢日志这个代价值得。时序错乱则是另外一个隐蔽问题。多线程下入队的顺序是按时间戳排的但压缩线程一旦批量取走数据如果中间有入队晚但被提前取出的情况最终日志的排列顺序会乱。排查这种问题经验是不要只看时间戳字段要看日志记录里的序号Sequence Number。BqLog这类组件内部会为每条日志生成全局递增序号文件里也按这条序号排序压缩线程取队列取出的顺序就按这个序号来就不会乱。如果日志包解析后出现了序号倒挂基本可以断定是压缩线程“乱序处理”的问题留好序号字段就能快速定位。4.3 监控指标与压测经验最后聊聊压测和监控。不要等线上出了事故才发现日志组件有问题。我在压测时重点关注这四个指标指标建议观测值说明单条日志入队耗时平均小于1微秒超过5微秒说明锁竞争或格式化逻辑太重压缩线程积压量稳定在几十条以内积压量持续上涨说明压缩能力不足日志落盘延迟 P99小于10毫秒超过50毫秒玩家必然感知到卡顿帧尖刺超过16ms的帧日志线程相关尖刺为0这是日志组件存在的唯一意义压测要特别注意弱机。老一点的中低端Android机型CPU主频不高而且很容易发热降频。我每次压测都是先帧率回调到60帧跑几分钟然后开始高强度打日志持续跑15分钟以上观察帧率曲线和日志线程CPU占用。最容易翻车的不是平均帧率而是突然出现的帧尖刺。实时压缩方案的价值正是把这根尖刺从几十毫秒压到毫秒级以下。BqLog这类组件的设计本质上是在回答一个问题日志系统到底是“事后侦探”还是“实时记录仪”。实时压缩日志要做的就是在不打扰玩家的情况下尽量当好这个实时记录仪。真正让我佩服的倒不是哪一处的奇技淫巧而是它对性能的每一个细节都不放过的态度。如果你也要自己搭日志组件我很建议从块压缩 环形缓冲 结构化字段这三件套开始做把延迟一点点磨下去再逐步加索引、加压缩等级调优。这时候你再回去看之前线上日志卡顿的Bug感觉真的会很不一样。
返回列表