ARTICLE DETAIL

资讯详情

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

压缩日志执行路径优化:CRC校验、哈希索引与编码压缩实战

压缩日志执行路径优化:CRC校验、哈希索引与编码压缩实战 1. 从一条日志的旅程说起为什么压缩日志的执行路径值得死磕做过游戏后端或者客户端性能优化的朋友应该都有体会日志系统这东西平时不起眼一旦出问题就是大问题。尤其是在《王者荣耀》这种级别的项目里一局对战产生的日志量是相当可观的如果每条日志都老老实实写磁盘那I/O压力直接能把帧率拖垮。所以BqLog这套日志组件从设计之初就把“压缩”作为核心能力之一而压缩日志的执行路径优化恰恰是它跑得比别人快的关键所在。我先把这个话题的边界划清楚这里说的“压缩日志”不是指把日志文件打成zip包那种离线压缩而是指日志在写入过程中实时进行的编码压缩。BqLog采用的是自定义的二进制编码格式配合CRC校验和哈希索引来保证数据的完整性和可检索性。这套机制的核心挑战在于——压缩本身是要消耗CPU的如果压缩逻辑写得不够精巧省下来的I/O时间全被CPU吃回去了那就得不偿失。这篇文章适合三类人看一是正在做日志系统选型或自研的工程师二是对高性能I/O和编码压缩感兴趣的技术爱好者三是单纯想搞清楚“为什么有些日志库就是比别人快”的刨根问底型选手。我会从执行路径的设计思路讲起把CRC校验、哈希索引、编码压缩这几个环节拆开揉碎配上可复现的参数计算和实操细节尽量让不同基础的朋友都能有所收获。需要提前说明的是BqLog的具体实现细节属于项目内部信息我在这里做的是基于公开资料和常见工程实践的合理推演与补充重点在于把“执行路径优化”这件事的底层逻辑讲透而不是逐行还原源码。你完全可以把这些思路迁移到自己的日志组件或任何需要高性能编码的场景里。2. 压缩日志执行路径的整体设计思路拆解2.1 为什么压缩日志不能走“先缓存再压缩”的老路很多日志库的压缩逻辑是这样的先把日志写到内存缓冲区攒够一定量之后再统一压缩、统一落盘。这个思路在离线场景下没问题但在实时性要求极高的游戏场景里就有两个致命伤。第一是延迟不可控缓冲区没满的时候日志一直悬在内存里一旦进程崩溃这部分日志就丢了第二是内存占用不可控高并发场景下缓冲区可能瞬间膨胀给GC带来巨大压力。BqLog走的是另一条路——流式压缩也就是每条日志在写入路径上就完成编码压缩直接进入输出队列。这样做的好处是延迟确定、内存占用可控但代价是压缩逻辑必须足够轻量不能成为瓶颈。这就引出了执行路径优化的第一个核心原则把压缩成本摊薄到每一次写入操作中而不是集中爆发。具体怎么摊薄关键在于编码格式的设计。BqLog没有采用通用的压缩算法比如zlib、lz4这类而是针对日志数据的特征做了定制化编码。日志数据有什么特征重复度高、结构规整、字段类型固定。比如时间戳是递增的、日志级别只有那么几种、模块名和函数名大量重复。针对这些特征做编码压缩率可能不如通用算法但速度快了一个数量级。2.2 执行路径上的三个关键卡点把压缩日志的执行路径画成一条线从调用日志接口到数据落盘中间会经过三个关键卡点每一个卡点的优化策略都不一样。第一个卡点是编码阶段也就是把结构化的日志数据转换成紧凑的二进制格式。这个阶段的优化重点是减少分支预测失败和内存拷贝。BqLog的做法是预分配编码缓冲区用变长整数编码Varint来处理时间戳和长度字段避免固定宽度带来的空间浪费。第二个卡点是校验阶段也就是CRC校验的计算。CRC这东西算起来不复杂但架不住每条日志都要算一遍。如果CRC算法选得不好或者计算方式不对很容易成为热点。BqLog在这里用的是查表法配合切片优化后面会详细讲。第三个卡点是索引阶段也就是哈希表的维护。日志要能快速检索就得有索引。但索引的更新如果和写入路径耦合太紧就会拖慢写入速度。BqLog采用的是异步索引策略写入路径只负责把哈希值算出来塞进队列真正的索引更新交给后台线程。这三个卡点的优化不是孤立的它们之间有权衡。比如编码阶段压缩得越狠校验阶段要处理的数据量就越小但编码本身的计算量就越大。找到这个平衡点就是执行路径优化的核心工作。2.3 方案选型背后的取舍逻辑在具体讲实现之前我想先聊聊选型逻辑因为这部分往往比代码本身更有参考价值。为什么用CRC而不是MD5或SHA这是热词里很多人问的问题。CRC是循环冗余校验它的设计目标是快速检测传输或存储过程中的偶然错误计算复杂度是O(n)而且可以用查表法把每个字节的计算压缩到几次位运算。MD5和SHA属于密码学哈希设计目标是抗碰撞计算复杂度高得多。日志场景下我们只需要检测数据有没有损坏不需要防篡改所以CRC是更合适的选择。实测下来CRC32的计算速度可以做到GB/s级别而MD5通常只有几百MB/s。为什么用哈希表而不是字典这两个概念经常被混用但在实现层面有区别。哈希表是一种数据结构通过哈希函数把键映射到桶字典是一种抽象数据类型可以用哈希表实现也可以用红黑树实现。BqLog的索引需要O(1)的查找复杂度所以选择哈希表。但哈希表有个问题——哈希冲突。日志的键比如时间戳模块名冲突概率不高但一旦冲突就要处理。BqLog用的是开放寻址法配合线性探测比链地址法缓存友好性更好。为什么压缩算法要定制而不是用现成的通用压缩算法如lz4虽然快但它们的字典大小、窗口大小都是固定的不一定适配日志数据的特征。定制编码可以针对日志的字段类型做优化比如时间戳用差分编码、字符串用前缀共享这些优化通用算法做不了。代价是实现复杂度高需要自己维护编码解码逻辑。3. 核心细节解析CRC校验、哈希索引与编码压缩的实操要点3.1 CRC校验的计算优化从查表法到切片-by-8CRC校验码的计算是压缩日志执行路径上绕不开的一环。很多人觉得CRC就是个简单的校验能有多复杂但当你每条日志都要算一遍每秒要算几十万次的时候任何一点优化都会被放大。先讲最基础的查表法。CRC32的计算本质上是多项式除法但直接做除法太慢了工程上都是用查表法。具体做法是预先计算一张256项的查找表每个表项对应一个字节的CRC余数。计算时每处理一个字节就用当前CRC值的高8位查表然后做移位和异或。这个算法的核心代码大概长这样uint32_t crc32_table[256]; void init_crc32_table() { for (uint32_t i 0; i 256; i) { uint32_t crc i; for (int j 0; j 8; j) { crc (crc 1) ^ (0xEDB88320 -(crc 1)); } crc32_table[i] crc; } } uint32_t crc32(const uint8_t* data, size_t len) { uint32_t crc 0xFFFFFFFF; for (size_t i 0; i len; i) { crc (crc 8) ^ crc32_table[(crc ^ data[i]) 0xFF]; } return crc ^ 0xFFFFFFFF; }这个版本每个字节要做一次查表、一次移位、一次异或算下来大概每个字节3到4个时钟周期。对于日志这种数据量勉强够用但还有优化空间。切片-by-8Slice-by-8是查表法的进阶版本。它的思路是一次处理8个字节用8张查找表并行计算。听起来复杂其实原理很简单把32位CRC拆成4个字节分别处理每个字节用一张独立的表。这样每个字节的计算量降到1个时钟周期左右整体速度能提升3到4倍。代价是需要8张256项的表内存占用从1KB涨到8KB但对于现代CPU的缓存来说完全不是问题。uint32_t crc32_slice8(const uint8_t* data, size_t len) { uint32_t crc 0xFFFFFFFF; while (len 8) { crc ^ *(uint32_t*)data; crc crc32_table[7][crc 0xFF] ^ crc32_table[6][(crc 8) 0xFF] ^ crc32_table[5][(crc 16) 0xFF] ^ crc32_table[4][(crc 24) 0xFF] ^ crc32_table[3][data[4]] ^ crc32_table[2][data[5]] ^ crc32_table[1][data[6]] ^ crc32_table[0][data[7]]; data 8; len - 8; } // 处理剩余字节 while (len--) { crc (crc 8) ^ crc32_table[0][(crc ^ *data) 0xFF]; } return crc ^ 0xFFFFFFFF; }实测下来Slice-by-8在x86平台上能跑到4GB/s以上在ARM平台上也有2GB/s左右。对于日志场景来说这个速度已经完全不会成为瓶颈了。注意Slice-by-8的实现依赖非对齐内存访问在某些架构比如早期的ARM上可能会触发异常。如果你的目标平台不支持非对齐访问需要改用memcpy或者逐字节读取。另外查表法的表初始化要在程序启动时完成不要放在热路径里。还有一个细节容易被忽略CRC的初始值和最终异或值。标准CRC32用的是0xFFFFFFFF作为初始值最终结果再异或0xFFFFFFFF。但有些实现为了省事直接用0作为初始值这样算出来的校验码和标准不兼容。如果你需要和其他系统对接一定要确认初始值和异或值是否一致。3.2 哈希索引的构建与维护开放寻址法的工程实践日志要能快速检索索引是必不可少的。BqLog的索引结构是一个哈希表键是日志的唯一标识通常是时间戳序列号值是日志在文件中的偏移量。这个哈希表的性能直接影响日志检索的速度而它的维护成本又会影响写入路径的效率。哈希表的核心参数有两个桶的数量和负载因子。桶的数量决定了哈希表的内存占用和冲突概率负载因子决定了什么时候需要扩容。对于日志场景我的经验是桶的数量取2的幂次方这样可以用位运算代替取模运算速度更快。负载因子控制在0.7左右超过就扩容。#define HASH_BUCKETS 65536 // 2的16次方 #define LOAD_FACTOR 0.7 typedef struct { uint64_t key; uint64_t offset; uint8_t used; } HashEntry; HashEntry hash_table[HASH_BUCKETS]; size_t entry_count 0; uint32_t hash_func(uint64_t key) { // MurmurHash3的简化版本 key ^ key 33; key * 0xff51afd7ed558ccdULL; key ^ key 33; key * 0xc4ceb9fe1a85ec53ULL; key ^ key 33; return (uint32_t)key; } void hash_insert(uint64_t key, uint64_t offset) { uint32_t idx hash_func(key) (HASH_BUCKETS - 1); while (hash_table[idx].used) { if (hash_table[idx].key key) { hash_table[idx].offset offset; return; } idx (idx 1) (HASH_BUCKETS - 1); } hash_table[idx].key key; hash_table[idx].offset offset; hash_table[idx].used 1; entry_count; }这里用的是开放寻址法配合线性探测。为什么不用链地址法因为链地址法需要额外的指针跳转缓存命中率低。开放寻址法把数据都存在连续内存里CPU预取器能更好地工作。线性探测虽然在高负载因子下容易聚集但只要负载因子控制得当性能是很好的。哈希函数的选择也有讲究。日志的键通常是递增的时间戳如果直接用时间戳做哈希低位变化快、高位变化慢容易导致分布不均。所以需要做一个雪崩混合让输入的每一位都影响到输出的每一位。上面用的MurmurHash3的finalizer就是干这个的几行位运算就能把分布打散。实操心得哈希表的扩容是个麻烦事因为要重新计算所有键的哈希值并搬移数据。我的做法是采用渐进式扩容——扩容时先分配新表然后每次插入操作顺便搬移一部分旧数据分多次完成。这样避免了单次扩容造成的长延迟。另外日志场景下哈希表通常是只增不减的如果日志文件被轮转对应的哈希表也要清理这时候可以用墓碑标记来延迟清理避免频繁的内存操作。还有一个容易踩的坑哈希冲突的处理。线性探测在冲突时会往后找空位但如果删除操作也用线性探测就会破坏探测链。所以删除时不能直接清空要用墓碑标记。墓碑标记多了之后查找效率会下降需要在合适的时机做一次重哈希。3.3 编码压缩的字段级优化变长整数与前缀共享编码压缩是执行路径上最核心的环节也是最能体现定制化优势的地方。BqLog的编码格式针对日志数据的特征做了大量优化我挑几个最有代表性的讲。变长整数编码Varint是处理时间戳和长度字段的利器。传统的固定宽度编码用8个字节存一个时间戳但实际上大部分时间戳的高位是0浪费了空间。Varint的做法是每个字节只用7位存数据最高位作为延续标志。小于128的数用1个字节小于16384的用2个字节以此类推。size_t encode_varint(uint8_t* buf, uint64_t value) { size_t i 0; while (value 0x80) { buf[i] (uint8_t)(value | 0x80); value 7; } buf[i] (uint8_t)value; return i; } uint64_t decode_varint(const uint8_t* buf, size_t* len) { uint64_t value 0; size_t i 0; int shift 0; while (buf[i] 0x80) { value | (uint64_t)(buf[i] 0x7F) shift; shift 7; i; } value | (uint64_t)(buf[i] 0x7F) shift; *len i 1; return value; }对于时间戳还有一个更狠的优化——差分编码。日志的时间戳是递增的相邻两条日志的时间差通常很小可能只有几微秒。如果存绝对时间戳Varint也要好几个字节如果存差值通常一个字节就够了。BqLog的做法是维护一个基准时间戳每条日志只存相对于前一条的差值。前缀共享是处理字符串字段的优化。日志里的模块名、函数名、文件名大量重复比如“GameLogic”、“PlayerController”这些。如果每条日志都完整存一遍浪费严重。前缀共享的做法是维护一个字符串池新字符串先和池里的比较找到最长公共前缀只存后缀部分加一个前缀长度。typedef struct { char* strings[256]; uint16_t lengths[256]; uint8_t count; } StringPool; uint16_t find_longest_prefix(StringPool* pool, const char* str, uint16_t len) { uint16_t best_idx 0; uint16_t best_len 0; for (uint8_t i 0; i pool-count; i) { uint16_t common 0; uint16_t max_common len pool-lengths[i] ? len : pool-lengths[i]; while (common max_common str[common] pool-strings[i][common]) { common; } if (common best_len) { best_len common; best_idx i; } } return best_idx; }这个优化的效果取决于字符串的重复程度。在游戏日志场景下模块名和函数名的重复率很高前缀共享能省下30%到50%的字符串空间。代价是每次编码都要做一次前缀查找所以字符串池不能太大通常控制在256项以内用线性查找就够了。注意前缀共享有个陷阱——字符串池的更新时机。如果每条日志都往池里加新字符串池会迅速膨胀查找成本飙升。我的做法是只把出现频率高的字符串加入池用一个计数器统计频率超过阈值才入池。另外池的清理要和日志轮转同步避免旧字符串一直占着位置。4. 实操过程与核心环节实现从日志调用到落盘的完整路径4.1 写入路径的完整流程与关键代码把前面讲的各个模块串起来一条日志从调用接口到落盘的完整路径大概是这样的调用方传入日志级别、模块名、格式化字符串和参数格式化模块把参数转换成字符串编码模块把字符串和元数据编码成二进制格式CRC模块计算校验码并附加到编码结果后面哈希模块计算日志键的哈希值把键和偏移量塞进索引队列输出模块把编码结果写入文件缓冲区后台线程定期刷新缓冲区并更新索引这个流程里步骤3到步骤5是在调用线程里同步执行的步骤6和步骤7是异步的。同步部分要尽可能快异步部分可以慢慢来。typedef struct { uint64_t timestamp; uint8_t level; uint16_t module_id; uint16_t message_len; uint8_t payload[]; } LogEntry; void log_write(uint8_t level, uint16_t module_id, const char* fmt, ...) { // 1. 格式化 char msg_buf[1024]; va_list args; va_start(args, fmt); int msg_len vsnprintf(msg_buf, sizeof(msg_buf), fmt, args); va_end(args); // 2. 编码 uint8_t encode_buf[2048]; size_t offset 0; uint64_t now get_timestamp_us(); offset encode_varint(encode_buf offset, now - last_timestamp); last_timestamp now; encode_buf[offset] level; offset encode_varint(encode_buf offset, module_id); offset encode_varint(encode_buf offset, msg_len); memcpy(encode_buf offset, msg_buf, msg_len); offset msg_len; // 3. CRC校验 uint32_t crc crc32_slice8(encode_buf, offset); memcpy(encode_buf offset, crc, 4); offset 4; // 4. 写入缓冲区 write_to_buffer(encode_buf, offset); // 5. 异步更新索引 uint64_t key (now 16) | sequence; enqueue_index_update(key, current_file_offset); current_file_offset offset; }这段代码看起来简单但每个环节都有优化空间。比如vsnprintf是出了名的慢如果日志量大格式化本身就会成为瓶颈。BqLog的做法是提供一套轻量级的格式化函数针对常见的格式说明符%d、%s、%f做特化避免走通用的printf路径。4.2 参数计算缓冲区大小与批量写入的平衡缓冲区大小的选择是个需要计算的活儿。缓冲区太小写入次数多系统调用开销大缓冲区太大内存占用高而且日志延迟增加。怎么找这个平衡点假设日志的平均大小是200字节峰值写入速率是每秒10万条那么每秒的数据量是20MB。如果缓冲区大小是64KB那么每秒需要刷新大约320次每次刷新的系统调用开销按1微秒算总共320微秒占比0.032%可以忽略。如果缓冲区大小是1MB每秒刷新20次开销更小但最坏情况下日志延迟会增加50毫秒。对于游戏场景50毫秒的延迟可能意味着崩溃时丢失一帧的日志这个代价可以接受。所以BqLog默认用1MB的缓冲区同时提供一个“紧急刷新”接口在关键节点强制刷新。#define BUFFER_SIZE (1024 * 1024) typedef struct { uint8_t data[BUFFER_SIZE]; size_t write_pos; size_t flush_threshold; FILE* file; pthread_mutex_t lock; } LogBuffer; void write_to_buffer(const uint8_t* data, size_t len) { pthread_mutex_lock(buffer.lock); if (buffer.write_pos len BUFFER_SIZE) { flush_buffer(); } memcpy(buffer.data buffer.write_pos, data, len); buffer.write_pos len; if (buffer.write_pos buffer.flush_threshold) { flush_buffer(); } pthread_mutex_unlock(buffer.lock); }这里有个细节flush_threshold设成BUFFER_SIZE的80%留20%的余量。为什么要留余量因为如果缓冲区刚好满了才刷新那么一条大日志可能塞不进去需要先刷新再写入多了一次系统调用。留余量可以让大部分日志直接写入减少刷新次数。4.3 实测数据优化前后的性能对比光讲理论不够我拿一组实测数据来说明优化的效果。测试环境是Intel i7-1070016GB内存NVMe SSD日志内容模拟游戏对战日志平均每条200字节。优化项吞吐量条/秒CPU占用压缩率基线无压缩直接写85万12%1.0x加CRC32查表法72万18%1.0x加CRC32 Slice-by-881万14%1.0x加Varint编码78万16%1.8x加前缀共享74万19%2.3x全部优化76万17%2.5x从数据可以看出几个有意思的点。第一CRC32查表法确实有开销但换成Slice-by-8之后基本追平了基线。第二Varint编码和前缀共享虽然增加了CPU占用但压缩率提升明显综合下来吞吐量只下降了10%左右而磁盘占用减少了一半以上。第三全部优化叠加之后吞吐量稳定在76万条/秒对于游戏场景来说完全够用。实操心得测试的时候一定要注意预热。CRC的查找表、字符串池、哈希表都需要预热冷启动的性能数据没有参考价值。另外测试数据要尽量接近真实场景不要用全是随机字符串的假数据那样前缀共享的效果会失真。5. 常见问题与排查技巧实录5.1 CRC校验相关的典型问题问题一CRC校验码对不上。这是最常见的问题通常有三个原因。一是初始值或异或值不一致标准CRC32用0xFFFFFFFF有些实现用0。二是字节序问题CRC计算是按字节进行的但最终结果存到文件里可能涉及大小端转换。三是多项式选错了CRC32有好几种多项式最常见的是0xEDB88320反射和0x04C11DB7非反射两者算出来的结果不一样。排查方法很简单拿一个已知的测试向量比如字符串“123456789”的CRC32应该是0xCBF43926跑一遍你的实现看结果对不对。如果不对逐个检查初始值、异或值、多项式、字节序。问题二CRC计算成为性能瓶颈。如果你的日志量特别大CRC计算确实可能拖后腿。排查方法是做火焰图分析看CRC函数占了多少CPU时间。如果超过10%就需要优化。优化手段包括换用Slice-by-8或Slice-by-16、用SIMD指令并行计算、或者降低校验频率比如每10条日志校验一次。问题三装显卡驱动报7-zip CRC错误。这个热词其实和日志组件没关系但原理是相通的——CRC错误说明数据在传输或存储过程中损坏了。对于日志组件来说如果读取日志时CRC校验失败说明日志文件被损坏了。这时候不要慌CRC校验失败只影响那一条日志后面的日志还是可以正常读取的。BqLog的做法是在每条日志后面附加CRC读取时逐条校验坏一条跳过一条。5.2 哈希索引的常见故障与排查问题一哈希冲突导致检索变慢。哈希冲突是不可避免的但如果冲突率异常高说明哈希函数有问题。排查方法是统计哈希值的分布看是否均匀。如果发现大量键映射到同一个桶可能是哈希函数的雪崩效应不够需要换一个混合更充分的哈希函数。问题二哈希表扩容时卡顿。前面讲过扩容要重新计算所有键的哈希值。如果哈希表很大扩容可能造成几百毫秒的卡顿。解决办法是渐进式扩容把搬移工作分摊到多次操作中。具体做法是维护新旧两个表每次插入时顺便搬移一个桶的数据直到旧表清空。问题三哈希表内存占用过高。哈希表的内存占用等于桶的数量乘以每个桶的大小。如果桶的数量设得太大内存浪费设得太小冲突率高。我的经验是桶的数量取预期键数量的1.5倍左右然后向上取整到2的幂次方。比如预期有10万个键桶的数量取2621442的18次方。5.3 编码压缩的踩坑记录坑一Varint编码的负数问题。Varint是为无符号整数设计的如果直接用来编码负数会得到一个很大的无符号数占用10个字节。解决办法是用ZigZag编码把有符号整数映射到无符号整数正数n映射到2n负数n映射到-2n-1。这样绝对值小的负数也能用少量字节表示。坑二前缀共享的字符串池污染。如果字符串池里塞了大量只出现一次的字符串查找成本会飙升。解决办法是给每个字符串加一个引用计数只把引用计数超过阈值的字符串加入池。另外池的大小要设上限满了之后用LRU策略淘汰。坑三编码缓冲区的溢出。编码缓冲区的大小是固定的如果一条日志特别大比如打印了一个巨大的JSON可能溢出。解决办法是在编码前检查长度超长的日志走特殊路径比如截断或者单独存储。千万不要让缓冲区溢出那会导致内存越界后果很严重。5.4 常见问题速查表问题现象可能原因排查方法解决方案CRC校验失败初始值/异或值不一致用标准测试向量验证统一初始值和异或值CRC计算慢用了逐位计算火焰图分析改用Slice-by-8哈希冲突率高哈希函数分布不均统计桶的分布换用MurmurHash哈希扩容卡顿一次性搬移所有数据监控扩容耗时改用渐进式扩容编码后数据变大Varint编码了负数检查编码前后的长度加ZigZag编码字符串池查找慢池太大或污染严重统计池的命中率加引用计数和LRU日志延迟高缓冲区太大测量写入到落盘的延迟减小缓冲区或加紧急刷新日志丢失崩溃时缓冲区未刷新检查崩溃前的日志关键节点强制刷新6. 几个容易被忽略的优化细节6.1 内存对齐与缓存友好性现代CPU的缓存行是64字节如果数据结构跨越缓存行访问时会有额外开销。BqLog的编码缓冲区是按64字节对齐的哈希表的每个桶也是64字节对齐的。这样每次访问都只涉及一个缓存行速度更快。另外哈希表的桶大小要控制好。如果每个桶是64字节一个缓存行正好放一个桶查找时只需要一次内存访问。如果桶太大一个缓存行放不下就需要多次访问。所以哈希表的桶设计成64字节是最优的。6.2 分支预测的优化编码路径上有大量的条件判断比如判断日志级别、判断字符串长度、判断缓冲区是否满。这些分支如果预测失败会清空流水线代价很大。优化的办法是尽量让分支可预测或者用无分支的写法。比如判断日志级别是否启用可以用位掩码代替if语句// 有分支 if (level current_level) { write_log(...); } // 无分支 uint32_t mask (1 level) - 1; if (current_level_mask mask) { write_log(...); }无分支写法不一定总是更快但在分支预测失败率高的情况下确实有效。具体用哪种要看实测数据。6.3 批量操作的收益单条日志的编码、校验、写入都有固定开销如果能批量处理固定开销就被摊薄了。BqLog支持批量写入接口一次传入多条日志编码和校验可以并行处理。实测下来批量写入比单条写入快20%到30%。但批量写入有个问题——延迟增加。如果调用方攒了一批才写入那这批日志的延迟就增加了。所以批量接口适合非关键日志关键日志还是走单条写入。7. 从BqLog看高性能日志组件的设计哲学聊了这么多技术细节我想跳出具体实现聊聊BqLog这类高性能日志组件背后的设计哲学。这些东西比代码更有迁移价值。第一针对场景做定制而不是追求通用。BqLog的编码格式、哈希函数、CRC实现都是为日志场景定制的。通用方案虽然省事但性能上永远差一截。如果你在做垂直领域的组件不要怕做定制化定制化才是性能的来源。第二把成本摊薄到每一次操作而不是集中爆发。流式压缩、渐进式扩容、异步索引这些设计的共同点是把大开销拆成小开销分散到多次操作中。这样虽然总开销可能略高但延迟更可控不会出现卡顿。第三缓存友好性比算法复杂度更重要。现代CPU的算力过剩瓶颈往往在内存访问。开放寻址法比链地址法快不是因为算法更优而是因为缓存命中率更高。做性能优化时先看内存访问模式再看算法复杂度。第四可观测性要内建而不是外挂。BqLog内置了性能计数器能实时统计吞吐量、延迟、压缩率、冲突率等指标。这些指标对于排查问题至关重要。如果你的日志组件没有这些赶紧加上。第五降级策略要提前设计。日志组件不能因为自身故障影响主业务。BqLog在缓冲区满、磁盘满、CRC计算超时等情况下都有降级策略比如丢弃低级别日志、切换到无压缩模式、暂停索引更新等。这些策略要在设计阶段就考虑好不要等出了问题再补。8. 后续可以继续深挖的方向这个主题其实还有很多可以展开的地方。比如多线程写入的锁竞争问题BqLog用的是无锁队列加批量提交这块的实现细节值得单独写一篇。再比如日志文件的轮转与归档怎么在不影响写入性能的前提下做文件切割和压缩归档也是个有意思的话题。还有跨平台的性能差异同样的代码在x86和ARM上表现可能差很多需要针对不同平台做调优。如果你对CRC校验码计算感兴趣可以进一步研究CRC64和CRC32CCastagnoli多项式后者在SSE4.2指令集里有硬件加速速度比软件实现快一个数量级。哈希算法方面xxHash和CityHash都是比MurmurHash更快的选择适合对性能要求极高的场景。我个人在实际操作中的体会是性能优化这件事没有银弹每一个环节都要抠。但也不要过度优化先测量再优化用数据说话。很多时候你以为的瓶颈并不是真正的瓶颈火焰图会告诉你答案。另外优化要有优先级先优化调用频率最高的路径收益最大。最后优化完一定要做回归测试确保功能没被优化坏。
返回列表