ARTICLE DETAIL

资讯详情

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

BqLog压缩日志执行路径优化:从攒批到算法选型的工程实践

BqLog压缩日志执行路径优化:从攒批到算法选型的工程实践 前两篇聊了BqLog的整体架构和基础优化这篇把压缩日志这条执行路径单独拎出来拆。可能有人觉得日志组件嘛无非就是收集、格式化、写文件压缩不过是多调一个库能有什么好讲的。但王者荣耀这种体量的客户端一场对局下来日志量是以几十上百MB记的压缩这条路径要是优化不到位日志组件反而会成为游戏帧率的隐形杀手。这篇聊的东西适合三类人正在给游戏或App做日志系统的客户端开发想优化自家日志写入性能的后端工程师以及纯粹对BqLog这类高性能组件内部实现好奇的中间件爱好者。我会先讲清楚“执行路径优化”到底优化的是哪条路径、怎么拆的三段式结构再把攒批压缩、分块格式、算法选型这些核心细节展开最后分享我自己压测和排障时踩过的一些坑。内容会偏实操不会有太多源码逐行分析但思路和参数都是可以直接拿去做参考的。1. 压缩日志执行路径到底在优化什么1.1 “执行路径”指的是从日志产生到落盘的整条链路先说一个容易被误解的地方标题里的“执行路径”不是指某个压缩函数内部的循环优化而是指一条日志从业务代码里调用写日志接口开始到最终被写入磁盘文件为止整个数据链路是怎么走的。在这个链路里日志数据要经过序列化、入队、搬运、压缩、封装、写入等多个环节任何一个环节堵住整条链路就会变慢。我打个比方压缩日志的执行路径有点像一条生产流水线。工人把零件放到传送带上传送带把零件送到质检工位质检完再送包装工位。流水线快不快看的不是包装工位那个工人手速有多快而是传送带有没有频繁停线、工位之间有没有堆积、每个工位处理是不是重复劳动。BqLog这类高性能日志组件做的事情本质上就是让这条流水线每个工位都尽量不停顿、不空转压缩只是其中比较重的一个工位而已。所以当我们在讨论“压缩日志执行路径优化”时真正要回答的问题有三个第一压缩放在链路的哪个位置最合理第二压缩操作怎么设计才能不拖累其他环节第三整条链路的容量和瓶颈在哪里别让压缩成了最慢的那堵墙。1.2 三个躲不开的成本格式化、拷贝、IO日志链路里最耗时的三个成本依次是格式化、内存拷贝、IO写入。理解了这三样你就理解了BqLog为什么要把路径大改。格式化成本出现在日志产生端。传统日志组件拿到一条日志第一步就是调用snprintf之类的方法把参数拼成字符串。整型转字符串、浮点格式化、时间戳格式化、可变参数解析这些都是CPU密集操作。一条日志看起来没多少但一秒几十万条累积起来就很夸张尤其浮点格式化和可变参数解析在ARM处理器上开销比很多人想象的大得多。内存拷贝成本出现在数据搬运端。日志从业务线程的缓冲区拷到队列压缩线程再从队列拷到压缩输入缓冲压缩完拷到输出缓冲IO线程再拷到文件缓冲每多一次拷贝就多一次内存带宽消耗和CPU cache污染。普通日志组件在小日志量时感受不明显日志量一上来拷贝次数可能就是压垮性能的最后一根稻草。IO写入成本出现在落盘端。这里最大的问题是写放大一条日志可能只有几十字节如果每条都触发一次write系统调用系统调用本身的消耗就比数据内容还大。再加上磁盘的寻道时间、文件系统元数据更新日志量一大磁盘很快就会被写爆。1.3 压缩日志解决的是IO带宽问题不是计算问题把压缩加进来之后有人第一反应是“又多了一道计算那不是更慢吗”。这个想法恰恰没抓住重点。压缩确实引入了额外的CPU开销但它换来的是IO写入量的成倍下降。文本日志的压缩比通常在5:1到10:1之间也就是说原来要写100MB的日志压缩后只需要写十几二十MB磁盘压力瞬间小了一个量级。对于王者荣耀这类客户端产品这个交换非常划算。一方面玩家设备上的闪存写入速度和耐用性都有限能少写就少写另一方面对局日志需要回传服务器做复盘分析压缩后上传流量也省了一大截。所以压缩日志的核心逻辑不是“让压缩跑得快”而是“用可控的CPU开销换来IO瓶颈的解除”。这也是BqLog这类组件和普通日志库拉开差距的地方。普通日志库压缩功能往往是事后加上的直接在每个日志写入点同步调压缩接口等于把流水线上每个工人旁边都塞了一台压缩机。而BqLog是把压缩当作路径上的一个独立工位来设计让整条路径的流量变得可控。2. 路径拆分采集、压缩、落盘三段式2.1 压缩必须放在异步线程里这件事没得商量日志组件做得再快也不能忘了它的第一原则不能干扰业务主流程。在游戏客户端里这意味着日志写入绝对不能阻塞渲染线程或者玩法逻辑线程。压缩算法不管选多快的实现它终归是计算密集型的如果放在调用日志的线程里同步执行日志量一爆游戏帧率就会跟着一起爆。所以BqLog这类组件的路径设计第一步就是把链路拆成三段采集段在业务线程完成只做序列化和入队压缩段在独立线程完成专门处理压缩和封装落盘段在IO线程完成负责真正写文件。三段之间用队列连接数据由业务线程流向压缩线程再由压缩线程流向IO线程。这样做还有个额外的好处就是可以把压缩的“毛刺”吸收掉。业务线程产生日志的速度是不均匀的战斗激烈的时候日志密集平时可能很长时间才几条。异步压缩相当于在中间放了一个缓冲池日志密集的时候先攒在队列里压缩线程匀速消化日志稀疏的时候压缩线程可以歇着不会白白占用CPU。如果没有这层缓冲压缩日志的CPU消耗就会直接叠加在业务线程的时间线上用户感受到的就是帧率抖动。队列选择上也要讲究。日志组件里最常见的做法是采用SPSC单生产者单消费者无锁队列或者给每个业务线程分配独立的环形缓冲避免多线程同时写一个队列造成的锁竞争。多生产者场景下如果非要共享队列就一定得考虑用CAS实现的无锁队列并配合cacheline对齐防止伪共享否则队列本身的竞争开销会抵消掉异步化带来的收益。2.2 先做二进制序列化再谈压缩效率压缩路径上第二个关键决策是日志数据用什么形式进入压缩器。传统方案是先把日志格式化成人类可读的文本字符串然后原样压缩。文本的好处是可以用文本编辑器直接打开查看坏处是格式化的CPU开销全部保留在了路径上而且文本字符串往往带有大量重复的固定前缀压缩器虽然能把这些重复内容处理掉但前置的格式化成本已经花出去了。BqLog走的是另一条路先把日志结构化地序列化成二进制再对这个二进制流做压缩。每一条日志在序列化阶段就按照预定义的格式把参数类型、长度、值这些信息直接写进紧凑的结构里。业务线程在这里做的事情不是拼字符串而是把内存里的数据按固定布局拷贝到一个连续缓冲区速度比snprintf快一个量级都不止。有人可能会问二进制格式的日志排障的时候怎么看。这个问题BqLog用配套的解析工具来解决排查问题的时候把日志文件交给解析工具还原成可读文本而不是让运行时的日志组件去做这个还原工作。换句话说把“可读性”从热路径上挪走运行时只追求效率和体积可读性留给离线分析阶段这就是压缩日志执行路径优化里很核心的思路。序列化之后的二进制数据还有个好处它的字段排布是确定的压缩器可以拿到更规整的数据模式。同样的日志内容二进制格式比ASCII文本格式的压缩效率通常更好因为不会有数字字符和分隔符的冗余表达。这一点在日志量特别大的时候连带着文件体积和上传流量都能再省一截。2.3 队列水位管理日志可以丢业务线程不能卡异步化带来一个躲不开的问题生产者太快、消费者太慢时怎么办。如果压缩线程跟不上业务线程产生日志的速度队列里的数据会越堆越多内存占用涨上去延迟也会越来越高。这里要做的是水位管理和背压控制。BqLog这类组件的典型做法是给队列设置一个高水位阈值队列里的日志堆积超过阈值时触发两种策略中的一种。第一种是阻塞策略让产生日志的业务线程停下来等消费端处理完好处是日志一条不丢坏处是业务线程可能被拖住第二种是丢弃策略直接丢掉新产生的日志并在内部记录丢弃次数好处是业务线程永远不卡坏处是日志会有缺口。游戏场景下选哪种我的经验是选丢弃策略为主。玩家对局日志追求的是采样价值不是审计级别的完整性缺几条日志远比帧率掉几毫秒好处理。但丢弃策略必须配套两个细节一是要在日志文件里写入丢弃计数的标记事后分析时看到日志有跳号就知道这里丢了多少二是可以按日志级别做差异化处理Error级别的日志提升处理优先级战斗关键帧的日志不走队列直接同步压缩写盘保证最关键的信息尽量不丢。3. 攒批压缩与分块边界的细节解析3.1 压缩批不够大压了等于白压真正决定压缩路径快不快的一个关键参数是每次压缩的数据块有多大。很多自己写过日志压缩功能的人都有一个体会单条日志压缩耗时高得离谱压缩比也不理想。原因很简单压缩算法需要积累足够多的数据和上下文才能找到重复模式数据越少压缩算法的固定开销占比就越高。这里有个数据可以参考。LZ4在处理几KB级别的小块数据时压缩速度可能只有几十MB/s但处理几十KB到几百KB的块时轻松跑上几百MB/s。Zstd也类似它的重复模式搜索需要滑动窗口里有足够多的历史数据小块数据喂进去窗口几乎是空的压缩效率直接打折。所以在BqLog这类组件的设计里压缩不是来一条压一条而是攒批压缩。业务线程序列化好的日志先写进固定大小的accumulation buffer积累到一定容量后整块交给压缩线程处理。我实际用过比较顺手的配置是accumulation buffer设64KB压缩线程拿到的是连续完整的64KB日志数据一次性压缩成一个压缩块。这个容量下LZ4的压缩速度非常可观压缩比也基本能压到文本日志的1/5到1/8。积攒的触发条件一般是双重的容量阈值优先缓冲区满了立刻触发一次压缩同时配一个时间阈值比如100毫秒防止日志流量很小的时候数据在缓冲区里滞留太久。时间阈值设得太短会导致压缩块太小太长会让日志落盘延迟变大这个需要在延迟和压缩效率之间自己权衡。3.2 按块压缩而不是整个文件压缩方便排查也方便传输压成一个整体文件不行吗为什么非要分块这个问题我一开始也没想明白直到我尝试在压缩后的日志文件里定位某一条日志才发现按块压缩的必要性。如果整个文件作为一个压缩流处理那么想要读取中间某个时间段的日志就必须从文件头开始解压到目标位置中间的解压开销一点省不了。而且压缩流一旦有一处数据损坏从损坏位置到文件末尾的内容全部报废诊断问题的成本直线上升。按块压缩之后每个压缩块都是独立单元解压时可以先通过块索引跳过不相关的块定位到目标块再解压数据损坏也只会影响单个块其他日志依然可用。BqLog的日志块通常按固定容量分块比如64KB原始日志对应一个块。每个块的结构大致包含块头和数据区块头里记录魔数、原始数据长度、压缩后数据长度、压缩算法ID、块序号和校验值。块头的作用是让解析工具能快速遍历日志文件顺序读块头拿到各块的位置和长度需要哪块解哪块。校验值一般用CRC32或Adler32成本不高又能发现数据损坏。这个设计对日志回传的场景也很友好。客户端日志回传服务器时如果连接中断已经传完的块可以直接复用重新传的时候只需要从断点所在的块开始不需要整个文件重新传输。我在做日志上传工具时就因为这个分块设计少了很多麻烦按块记录传输进度简单粗暴又好使。3.3 延迟和压缩效率的平衡取决于业务场景攒批越大压缩效率越高但延迟也跟着变大这个矛盾在日志组件里必须面对。对局中的日志不像数据库事务日志那样强调实时性晚几百毫秒落盘完全不影响使用所以可以放心地把批调大。但有些场景比如App崩溃前的现场日志如果日志还积压在缓冲区里没来得及压缩落盘崩溃一来数据就全没了这种情况就要求批尽量小、落盘尽量勤快。我自己在两种场景之间切换过配置最后落地的方案是给不同日志类型设置不同的路径。核心性能日志走快速路径日志量更大但单个日志很小攒批按128KB触发追求最高吞吐崩溃定点日志和Error级别日志走短批路径攒够8KB或者50毫秒就触发一次压缩提交保证崩溃发生时已落盘日志尽量完整。压缩执行路径优化的意义就在这里不是选一套参数然后用到底而是让路径能够针对不同日志的重要程度提供不同服务等级。4. 压缩算法选型与BqLog的取舍逻辑4.1 zlib被排除的原因很简单CPU预算不够聊到日志压缩很多人第一反应是zlib因为它是系统自带的接口也成熟。但真正放到游戏客户端日志路径上zlib几乎是不合格的。不是它压缩效果不好而是它的CPU开销对于“给日志做个后台处理”这件事来说太高了默认级别的压缩率不错但吞吐量普遍只有几十MB/s还经常出现CPU占用尖刺。我打个比方zlib像是用精工慢炖的方式处理食材做出来的成品确实好但你的厨房里还等着做几百道其他菜炉灶根本不够用。游戏客户端每一帧的CPU时间预算都是算好的渲染、动画、物理、网络各拿各的预算日志压缩想分走一大块别说性能团队不答应玩家手里的发热问题也先不答应。所以BqLog这类组件做压缩算法选型时首先看的是CPU成本预算其次才是压缩比。算法压缩比再高如果CPU扛不住那也是负优化。4.2 LZ4和Zstd的实际差异现在日志压缩领域主流的选择基本就是LZ4和Zstd两个。LZ4走的是极致速度路线压缩和解压速度都非常夸张在典型移动设备上可以跑出几百MB/s的压缩吞吐代价是压缩比一般通常比Zstd低一些。Zstd是Facebook开源的高压缩比算法最厉害的地方是它提供了从1到22的压缩级别调节低级别时速度可以和LZ4打平高级别时压缩比能逼近甚至超过zlib最好水平。我这几年做中间件压测对比过LZ4和Zstd在日志数据上的实际表现给出一个参考算法配置压缩吞吐解压吞吐日志文本压缩比CPU开销适用场景LZ4 默认快速模式极高数百MB/s级别极高3:1到6:1很低实时日志量巨大、设备性能受限Zstd level 1接近LZ4很高4:1到8:1低平衡场景兼顾速度与体积Zstd level 3中等很高5:1到10:1中等日志需要长期留存、上传流量敏感Zstd level 19低高6:1到12:1很高离线日志归档不在运行时使用这些数据会随日志内容重复程度浮动但趋势是稳定的。日志数据的特点是重复内容多时间戳前缀、固定字段名、常见错误码反复出现所以压缩比天然比普通文本数据好。BqLog在做压缩日志路径的时候压缩算法模块被设计成可插拔的运行时通过压缩块头里的算法ID来标识。这样做的好处是客户端可以按设备等级选择不同的算法中低端机用LZ4保证吞吐优先高端机和PC端用Zstd level 3压出更小的日志文件解析工具读取块头后自动选择对应算法解压完全不感知实际用的哪种。4.3 压缩别忘了解压场景选压缩算法时压缩速度经常被关注解压速度和随机定位能力反而容易被忽略。但实际使用中日志的读取次数远比写入次数多。对局日志要回放分析玩家上报的异常日志要被自动解析每一步都需要解压。如果选的算法解压奇慢那等于把性能问题从写入端搬到了读取端。LZ4的解压速度是出了名的快几乎是内存拷贝级别的带宽而Zstd的解压速度也相当出色即使它的压缩级别设得再高解压仍然能保持很高吞吐。这两个算法在这一点上和zlib形成鲜明对比zlib的解压性能远不如它的压缩口碑。另一点容易踩坑的是压缩块的随机定位能力前面说过的分块设计在这里发挥作用只能从块边界开始解压不能往一个超大压缩流中间塞一个读请求所以块的大小设置多少直接决定了日志检索的精度和效率64KB的块配合块索引表基本可以在毫秒级定位到目标时间范围。5. 实操压测与问题排查实录5.1 怎么确认压缩路径是不是瓶颈做日志组件优化最忌讳的就是靠感觉。我见过好几个项目日志线上出问题业务线程卡了不问青红皂白先怀疑压缩算法折腾半天换算法问题一点没好。所以拿到一个卡顿问题第一件事不是猜而是用工具量化确认时间到底花在哪一段。我自己的工具组合是perf和Tracy搭配着用。perf负责火焰图采集Tracy负责打点统计各段的耗时分布。线程结构上我把日志采集线程标记为Producer压缩线程标记为CompressorIO线程标记为Writer这样火焰图一出来哪个线程CPU占用异常高哪一段调用栈消耗最大一眼就能定位到。定位之后重点看三个指标第一个是采集线程的单条日志平均耗时这个反映格式化成本超过1微秒就要怀疑序列化路径有问题第二个是压缩线程的CPU占用率如果持续超过一个核心的70%说明压缩频率或算法级别太高第三个是队列的堆积深度如果长期保持在容量的80%以上说明消费速度跟不上生产速度瓶颈在后面而不是压缩本身。压测的核心原则是只改一个变量然后把其他两个指标重新跑一遍多轮对比后才能确定问题的根源。5.2 压缩日志路径的常见问题速查表把我在实际开发和维护类似日志组件时遇到的典型问题整理成一张速查表按现象分类每个问题给排查方向和处理建议现象可能原因排查方向处理方式业务线程偶尔卡顿队列锁竞争、缓冲区分配频繁火焰图看锁等待和分配调用栈改成per-thread buffer或无锁队列预分配内存池压缩比远低于预期压缩块太小、算法级别不够、缓冲区未清空检查块大小、算法参数对照原始数据看重复度调大accumulation buffer换Zstd高level清理脏数据压缩线程CPU占用过高压缩级别过高、输入数据反复拷贝看压缩线程火焰图、检查输入缓冲复用情况降算法级别减少输入输出内存拷贝次数日志落后时间越来越长压缩吞吐跟不上生产速度看队列堆积深度和压缩线程利用率加压缩线程数量、调整攒批策略、必要时丢弃低优日志日志文件里出现大量跳号高水位丢弃生效查看丢弃计数标记确认丢弃的是低级别日志保证Error日志不丢磁盘IO吞吐上去了但文件写入还是很慢flush过于频繁、IO调度问题看写入模式下是否每次都fsync调整flush策略利用页面缓存攒批写入这张表里的每一条我都实际遇到过其中最典型的一个坑是压缩比偏低排查到最后发现压缩线程复用缓冲区时没有清空残留数据导致压缩器把上一轮的垃圾数据也算进去当作日志内容。这个问题不算难但肉眼很难发现必须用解析工具逐个字段校验才能看出来。5.3 关于优化顺序的一点个人体会最后聊一下我踩过多次坑之后总结出来的优化顺序。压缩日志执行路径的优化最忌讳一开始就抠压缩算法的参数。算法级别从3调成1速度确实上去了但压缩比下来了IO写放大的问题又回来了等于在一个局部优化里转圈圈。我建议的顺序是先去掉格式化热点把日志序列化成二进制这一步就能去掉最大的CPU开销再确认攒批容量足够大把压缩器每次处理的块调到64KB以上这一步能同时改善压缩速度和压缩比然后做内存复用把缓冲区的分配次数降下来这一步收益在长时间运行时非常明显最后才是调压缩算法参数根据剩余CPU预算选择LZ4或者Zstd的具体配置。按照这个顺序走下来绝大多数日志性能问题都能在最后一步之前解决掉而且每一步改动的风险都可控出了问题也容易定位。我在实际项目里的体会是BqLog这类组件真正快的原因不在于某一行代码写得有多巧妙而在于它把整个执行路径的设计目标定得很清楚业务线程少做事数据少拷贝CPU花在刀刃上。压缩日志执行路径优化只是这条设计思路在“压缩”这个环节上的具体落地理解了这条路径的取舍逻辑你再去读它的源码就不会被零散的优化技巧带偏也更容易在自己的项目里复现出接近的效果。
返回列表