1. 从一条日志的旅程说起:为什么压缩日志的执行路径值得单独优化
很多人第一次接触 BqLog,是因为它在高频战斗场景下依然能把日志写入的抖动压到极低。但真正让这套组件在同类方案里拉开差距的,不是"能压缩"这件事本身,而是压缩日志执行路径被拆得足够细、足够短。我最早做性能剖析时也走过弯路,以为瓶颈在磁盘 IO,结果火焰图一拉,发现大量时间耗在了压缩前的数据搬运和校验环节上。这个认知转变,是理解 BqLog 为什么快的起点。
先把场景说清楚。所谓压缩日志,指的是日志在写入落盘之前,先在内存里做一次批量压缩,把多条日志记录合并成一个压缩块再写出。这样做的好处很直接:同样的日志量,磁盘写入次数大幅减少,IO 压力下降,长期存储成本也跟着降。但代价是,压缩本身要消耗 CPU,而且压缩前的数据组织、校验、边界处理都会引入额外开销。如果这些环节设计得不好,压缩省下来的 IO 时间会被 CPU 开销吃掉,甚至倒亏。
BqLog 的思路是:既然压缩不可避免,那就把执行路径做到极致精简。所谓执行路径,就是从"一条日志产生"到"进入压缩器"之间,代码实际要走过的所有分支、函数调用、内存操作。路径越短、分支越少、缓存越友好,单位日志的处理成本就越低。这篇文章就围绕这条路径,把 BqLog 在压缩日志场景下的优化思路、关键细节、实操要点和踩坑经验完整拆一遍。
适合谁看?如果你正在做高吞吐日志组件、嵌入式埋点、游戏战斗日志,或者单纯对"高性能 C++ 组件怎么抠细节"感兴趣,这篇都能给你一些可以直接抄作业的东西。我会尽量把每个设计选择背后的"为什么"讲透,而不是只丢结论。
2. 压缩日志执行路径的整体设计与取舍逻辑
2.1 为什么要把"压缩"和"执行路径"分开看
很多人把压缩日志当成一个整体功能来优化,上来就换压缩算法、调压缩级别。我试过,收益有限。原因是压缩算法只占整条路径的一部分,真正拖后腿的往往是压缩之前的那段准备逻辑。BqLog 把这条路径拆成几个清晰的阶段:日志记录生成、暂存区写入、批量聚合、校验计算、压缩编码、落盘。每个阶段都有独立的开销,优化时必须分开度量。
这么拆的好处是,你能清楚知道每一纳秒花在哪。比如校验计算,如果放在压缩之后做,就要对整个压缩块算一次;如果放在压缩之前对原始数据算,虽然数据量大,但可以和写入暂存区合并做,反而更快。这种取舍只有把路径拆开才能看清楚。
2.2 核心设计目标:短路径、少分支、缓存友好
BqLog 在这条路径上的三个核心目标,我总结成一句话:让 CPU 尽量做顺序的、可预测的、贴着缓存走的事情。
- 短路径:减少函数调用层级,能内联的内联,避免为了"代码好看"而引入抽象层。日志这种热路径,一次虚函数调用的开销在百万级 QPS 下会被放大到不可忽视。
- 少分支:分支预测失败是性能杀手。路径上的条件判断要尽量可预测,比如把"是否达到压缩阈值"这种判断做成大概率走同一条分支。
- 缓存友好:日志数据在内存里的布局要连续,避免指针跳转。压缩器读数据时最好是顺序读,这样预取器能发挥作用。
这三个目标不是孤立的,它们互相牵制。比如为了少分支,你可能要牺牲一点内存;为了缓存友好,你可能要放弃某些灵活的数据结构。BqLog 的选择是优先保证热路径的确定性,把复杂性推到冷路径或者初始化阶段。
2.3 与常见方案的对比:为什么不用现成的日志库加压缩
市面上不少日志库的做法是:日志先格式化成字符串,写进一个缓冲区,缓冲区满了再整体压缩。这个方案实现简单,但有两个问题。第一,字符串格式化本身开销大,尤其是数字转字符串;第二,压缩器面对的是已经格式化的文本,压缩率不如面对结构化二进制数据。
BqLog 走的是另一条路:日志在内存里保持结构化或半结构化的紧凑表示,压缩器直接吃这种紧凑数据。这样既省了格式化开销,又提高了压缩率。代价是压缩器和日志格式耦合更紧,扩展性差一些。但对于追求极致性能的场景,这个取舍是值得的。
| 方案 | 格式化开销 | 压缩率 | 路径长度 | 扩展性 |
|---|---|---|---|---|
| 文本缓冲后压缩 | 高 | 中 | 长 | 好 |
| 结构化紧凑表示后压缩 | 低 | 高 | 短 | 一般 |
| 不压缩直接写 | 无 | 无 | 最短 | 好 |
这张表不是要证明谁绝对好,而是说明 BqLog 的选择是在特定目标下的最优解。如果你的场景日志量不大、对延迟不敏感,用现成方案完全没问题。
3. 核心细节解析:校验、哈希与数据组织的关键取舍
3.1 CRC 校验放在路径的哪个位置最划算
CRC 校验是压缩日志里绕不开的一环,用来保证压缩块在落盘和读取时的完整性。但 CRC 放在哪,直接影响路径长度。常见有三种放法:
- 每条日志记录单独算 CRC,写入时逐条校验。
- 整个压缩块算一次 CRC,落盘前算,读取时校验。
- 压缩前对原始数据算 CRC,压缩后不再算。
BqLog 选的是第二种为主、第一种为辅的混合策略。原因是:逐条算 CRC 会让热路径变长,每条日志都要走一遍 CRC 计算循环;而整块算一次,虽然单次计算量大,但可以放在压缩之后、落盘之前的相对冷的阶段,不阻塞日志生成。至于为什么还要保留逐条校验作为辅助,是为了在调试模式下快速定位是哪条日志损坏,生产环境则关闭逐条校验。
这里有个容易踩的坑:CRC 校验码计算的多项式选择。不是随便一个多项式都能用。业界有个共识,某些多项式无法保证检出全部奇数个比特错误,这类多项式不能作为 CRC 生成多项式。选型时一定要用经过验证的标准多项式,比如 CRC-32 常用的那个。自己拍脑袋定一个多项式,测试时可能碰巧没问题,上线后遇到特定错误模式就漏检了。
3.2 哈希在路径中扮演的角色:不只是查重
哈希在 BqLog 的压缩路径里有两个用途。一是日志模板去重,相同格式的日志只存一份模板,记录里只存参数;二是压缩字典的快速查找,压缩器需要快速判断某个片段是否出现过。
模板去重这个设计很关键。战斗日志里大量记录格式相同、只有数值不同,比如"玩家 X 对玩家 Y 造成 Z 点伤害"。如果每条都完整存,压缩率上不去。用哈希把模板映射成 ID,记录里只存模板 ID 加参数,数据量能降一个数量级。
但哈希表本身有开销。每次日志生成都要查一次哈希表,如果哈希函数慢或者冲突多,热路径就被拖长了。BqLog 的做法是用一个固定大小的开放寻址哈希表,哈希函数选的是计算极快的位运算混合,冲突用线性探测。这样查表基本是一次内存访问,缓存命中率高。
提示:哈希表大小要提前根据模板数量预估。太小冲突多,太大浪费缓存。我一般按预估模板数的 1.5 到 2 倍来定,并且用 2 的幂次方便做位运算取模。
3.3 数据在内存里的布局:连续比聪明更重要
压缩器读数据的速度,很大程度上取决于数据在内存里是否连续。BqLog 的暂存区是一块预分配的大缓冲区,日志记录按顺序追加,不做链表、不做树。这样压缩器可以顺序扫描,CPU 预取器能提前把数据拉进缓存。
我见过一些实现用链表串日志记录,每条记录单独分配。这种布局在压缩时就是灾难,指针跳来跳去,缓存命中率极低。BqLog 坚决避免这种设计,宁可牺牲一点灵活性,也要保证内存连续。
具体做法是:暂存区按固定块大小分配,比如 64KB 一块。日志记录追加到当前块,块满了就切下一块。压缩时按块处理,块内数据连续,压缩器读起来很顺。块大小也不是随便定的,64KB 大致能覆盖 L2 缓存的一部分,既不会太大导致缓存失效,也不会太小导致频繁切换。
4. 实操过程:从日志生成到压缩落盘的完整路径
4.1 日志记录生成阶段的精简写法
先看日志生成。这一步的目标是尽快把一条日志变成紧凑的字节序列,追加到暂存区。核心原则是避免任何不必要的分配和格式化。
// 简化示意,非 BqLog 原始代码 struct LogRecord { uint32_t templateId; // 模板哈希 ID uint16_t paramCount; uint8_t paramTypes[MAX_PARAMS]; uint64_t paramValues[MAX_PARAMS]; }; inline void appendLog(uint32_t templateId, const uint64_t* params, uint16_t count) { // 直接在当前块尾部构造,不做堆分配 LogRecord* rec = reinterpret_cast<LogRecord*>(currentBlock->tail); rec->templateId = templateId; rec->paramCount = count; for (uint16_t i = 0; i < count; ++i) { rec->paramValues[i] = params[i]; } currentBlock->tail += sizeof(LogRecord); if (currentBlock->tail >= currentBlock->end) { rotateBlock(); // 切块,大概率分支 } }这段代码有几个细节值得说。第一,参数用固定大小的数组而不是变长容器,避免动态分配。第二,rotateBlock的判断是大概率不触发的分支,分支预测器能很好处理。第三,整个追加过程没有函数调用开销,appendLog会被内联。
参数类型这里我简化了,实际实现里会用变体或者类型标记来支持不同类型。但核心思想不变:用固定布局换速度。
4.2 批量聚合与压缩触发时机的选择
暂存区攒够一定量之后,就要触发压缩。触发时机很讲究。触发太频繁,压缩器启动开销占比高;触发太晚,内存占用大,延迟抖动也大。
BqLog 用的是双阈值策略:块数达到阈值 A 触发压缩,或者距离上次压缩超过时间 T 也触发。阈值 A 保证吞吐,时间 T 保证延迟上限。两个条件哪个先满足走哪个。
bool shouldCompress() { return pendingBlocks >= COMPRESS_BLOCK_THRESHOLD || (now() - lastCompressTime) >= COMPRESS_INTERVAL_MS; }COMPRESS_BLOCK_THRESHOLD这个值需要根据实际压测调。我一般从 8 块开始试,观察压缩线程的 CPU 占用和日志延迟分布,再微调。太小了压缩线程忙不过来,太大了内存涨得快。
4.3 压缩前的数据整理与 CRC 计算
压缩前要把待压缩的块整理成压缩器能吃的连续输入。如果块本身连续,这一步几乎零开销;如果块之间有间隙,就要先拷贝合并。BqLog 通过块分配策略保证块之间尽量连续,减少拷贝。
CRC 计算放在这一步。对整个待压缩数据算一次 CRC,结果存在压缩块头部。
uint32_t crc = crc32(data, totalSize); compressBlockHeader.crc = crc; compressBlockHeader.rawSize = totalSize;CRC 计算本身可以用查表法加速,预生成 256 项的表,每次处理一个字节查一次表。现代 CPU 上还可以用硬件指令加速,但为了可移植性,BqLog 默认用查表法,检测到硬件支持时切换到硬件实现。
注意:CRC 表要在初始化阶段生成好,不要放在热路径里算。我见过有人在每次压缩时重新生成表,白白浪费几毫秒。
4.4 压缩编码与落盘
压缩算法选型上,BqLog 没有用通用压缩库,而是针对日志数据特点做了定制。日志数据的特点是重复模式多、数值局部性好,所以用了基于字典的轻量压缩,配合简单的熵编码。压缩级别可调,默认级别在压缩率和速度之间取平衡。
落盘用异步 IO,压缩线程把压缩块交给 IO 线程就返回,不阻塞。IO 线程负责实际的写操作和错误处理。这样压缩和 IO 可以并行,整体吞吐更高。
void compressAndSubmit() { auto data = gatherPendingBlocks(); uint32_t crc = crc32(data.data(), data.size()); auto compressed = compressor.compress(data); compressed.header.crc = crc; ioThread.submit(std::move(compressed)); // 异步提交 }整个路径到这里结束。从日志生成到落盘,热路径上只有追加、判断、偶尔的切块,压缩和 IO 都在相对冷的阶段完成。
5. 常见问题与排查技巧实录
5.1 压缩后日志读取校验失败怎么排查
这是最常见的问题。读取时 CRC 校验失败,说明数据在写入或存储过程中损坏。排查顺序建议这样:
- 先确认是不是 CRC 计算范围不一致。写入时算的范围和读取时算的范围必须完全一样,差一个字节都会失败。
- 检查压缩块头部是否被覆盖。有时候缓冲区越界会踩到头部。
- 确认存储介质没问题。偶发的校验失败可能是磁盘坏道。
- 如果只在特定数据上失败,检查压缩器是否有边界 bug。
我踩过一次坑:压缩块头部和压缩数据共用一个缓冲区,压缩器写数据时越界了一个字节,把头部的 CRC 字段覆盖了。这种问题很难查,最后是靠加边界检查断言定位的。
5.2 哈希冲突导致日志模板错乱
哈希冲突如果处理不当,两条不同的日志模板可能映射到同一个 ID,导致读取时模板对不上。开放寻址法要保证探测序列正确,删除操作要特别小心,不能简单置空,否则会打断探测链。
排查方法:在调试模式下记录每个模板的原始字符串,读取时对比。如果发现模板内容对不上,就是冲突处理有问题。解决方法是增大哈希表或者换更好的哈希函数。
5.3 压缩线程 CPU 占用过高
如果压缩线程持续跑满一个核,说明压缩触发太频繁或者压缩级别太高。先看触发阈值,把COMPRESS_BLOCK_THRESHOLD调大试试。如果还高,降低压缩级别。日志场景下,压缩率差几个百分点通常可以接受,换来的 CPU 节省更值。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 解决思路 |
|---|---|---|---|
| 读取 CRC 失败 | 计算范围不一致、缓冲区越界、介质损坏 | 对比写入读取范围、加边界断言 | 统一范围、修复越界、换介质 |
| 模板错乱 | 哈希冲突处理不当 | 调试模式对比模板原文 | 增大哈希表、换哈希函数 |
| 压缩线程 CPU 高 | 触发频繁、级别过高 | 看触发阈值和级别配置 | 调大阈值、降级别 |
| 日志延迟抖动大 | 压缩阻塞了写入路径 | 检查压缩是否异步 | 确保压缩和 IO 异步 |
| 内存增长快 | 触发太晚、块回收不及时 | 看 pending 块数 | 调小阈值、及时回收 |
5.5 几个我踩过的坑
第一个坑是 CRC 多项式选错。早期我用了一个自己定义的多项式,测试数据上没问题,后来遇到一种特定的位翻转模式,CRC 没检出来。查了资料才知道,某些多项式无法保证检出全部奇数个比特错误,这类多项式不能作为 CRC 生成多项式。换成标准多项式后问题消失。
第二个坑是哈希表在扩容时阻塞了热路径。早期实现里哈希表满了会触发扩容,扩容要重新哈希所有条目,耗时较长,正好发生在日志高峰期,导致明显卡顿。后来改成预分配足够大的表,并且扩容放到独立的维护线程做,热路径只读不写。
第三个坑是压缩块大小定得太大。一开始用 1MB 一块,想着减少块切换开销。结果压缩时单块处理时间长,延迟抖动明显。改成 64KB 后抖动小了很多,吞吐几乎没降。
6. 路径优化的度量方法与调优经验
6.1 怎么度量执行路径的开销
优化不能靠感觉,要有数据。BqLog 在调试模式下会统计每个阶段的耗时:日志生成、暂存追加、CRC 计算、压缩、落盘。这些统计用轻量的计数器实现,生产环境可以关闭。
度量时要注意,统计本身也有开销。我一般用采样而不是全量统计,比如每 1000 条日志统计一次,减少对热路径的干扰。
火焰图是另一个好工具。把日志高峰期的火焰图拉出来,看热路径上哪些函数占的时间多。我最初就是靠火焰图发现 CRC 计算占比比预期高,才决定把它挪到压缩之后。
6.2 调优的优先级顺序
调优要有优先级,不要眉毛胡子一把抓。我的经验顺序是:
- 先消除热路径上的动态分配。这是收益最大的一步。
- 再优化内存布局,保证连续。
- 然后减少分支,把可预测的判断前置。
- 最后才考虑压缩算法本身的调优。
很多人一上来就调压缩算法,其实前面几步的收益往往更大。压缩算法调优的边际收益递减很快,而消除一次动态分配可能直接省下几微秒。
6.3 不同负载下的参数调整
参数没有万能值,要根据负载调。低负载场景,压缩阈值可以调小,让日志尽快落盘,减少内存占用。高负载场景,阈值调大,让压缩批量更大,提高压缩率,减少 IO 次数。
我一般会准备几套预设参数,根据运行时的日志速率自动切换。速率低时用低延迟预设,速率高时用高吞吐预设。切换逻辑放在维护线程,不影响热路径。
提示:参数调整后一定要用真实负载压测,不要只看微基准。微基准和真实场景的差异可能很大,尤其是缓存行为和分支预测。
7. 写在最后的一点个人体会
这套压缩日志执行路径的优化,我前后迭代了挺多版本。最大的体会是:性能优化不是找一个大招,而是把一堆小事情做对。CRC 放对位置、哈希表预分配、内存布局连续、分支可预测,每一条单独看都不起眼,但叠在一起就是数量级的差距。
另外,度量永远比直觉可靠。我很多次以为瓶颈在某处,一测发现完全不是。火焰图和分阶段计时是必备工具,没有数据支撑的优化都是瞎猜。
最后分享一个小技巧:如果你也在做类似的日志组件,建议在早期就把调试模式的逐条校验和统计做进去。生产环境关掉,但开发和压测阶段开着,能帮你快速定位问题。等出了问题再补这些设施,成本高得多。