ClickHouse VarUInt编码原理与高性能实现解析
1. 项目概述从一个字节序列说起为什么ClickHouse要自己造轮子写VarUInt你有没有在调试ClickHouse性能时突然发现某个MergeTree表的parts目录里.bin文件体积比预估小了30%或者在用clickhouse-client执行SELECT * FROM system.parts FORMAT Vertical时注意到marks_bytes字段的值总是一串看似随机的数字比如127、3456、16843这些数字背后藏着ClickHouse最底层的数据编码逻辑——而readVarUInt和writeVarUInt就是撬动整个数据读写链条的第一根杠杆。这两个方法名字里带“Var”不是“变量”variable的意思而是“变长”variable-length的缩写。它解决的是一个非常古老但至今无解的工程问题如何用尽可能少的字节安全、无歧义地表达一个可能很小也可能极大的整数比如存储用户ID7用uint8_t1字节就够了但存储某张日志表的行号12,345,678,901就得上uint64_t8字节。如果全按8字节存那99%的ID都浪费了7字节如果全按1字节存大数直接溢出。标准方案是Protocol Buffers里的varintPostgreSQL用pg_lsn做类似压缩Java的DataOutputStream.writeVarInt也干这事。但ClickHouse没抄作业它写了自己的一套——readVarUInt/writeVarUInt而且不止一套是四套并行readVarUInt64、readVarUInt32、readVarUInt16、readVarUInt8对应不同位宽的整数类型。这不是炫技是硬生生被吞吐量和延迟逼出来的选择。我第一次在源码里撞见readVarUInt64是在src/IO/ReadBuffer.h第217行。当时正跟踪一条SELECT count() FROM hits_100m查询的执行路径发现它从磁盘读取marks标记文件时每读一个mark都要调一次这个函数。我下断点单步进去看到它用一个while循环每次从buffer里取1个字节检查最高位是否为1如果是说明还有后续字节把低7位左移7位后累加如果不是说明这是最后一个字节把低7位直接加进去然后跳出循环。整个过程不依赖任何外部库不分配堆内存纯栈上位运算。那一刻我明白了ClickHouse不是在“实现一个功能”它是在给每纳秒的CPU周期签生死状。这篇文章我们就从这行代码开始一层层剥开ClickHouse数据读写的物理真相——它怎么把一行行结构化数据变成磁盘上紧凑的二进制流又怎么在毫秒级响应里把那串字节精准还原成你想要的数字。适合正在啃ClickHouse源码的后端工程师、想搞懂列式存储底层的DBA以及所有对“数据到底长什么样”有强迫症的开发者。2. 核心设计思路为什么不用现成的varint而要重写四套2.1 现成方案的三个致命软肋先说结论ClickHouse不是不能用Google Protocol Buffers的WriteVarint64或ReadVarint64而是实测下来在它的场景里这俩函数会成为性能瓶颈。我拿clickhouse-benchmark跑过一组对照实验对1亿行UInt64类型数据全为1到1000之间的随机数分别用PB的varint和ClickHouse原生writeVarUInt64写入内存buffer结果如下方案写入1亿个数耗时ms内存buffer大小MBCPU缓存命中率L1dProtobufWriteVarint641842128.563.2%ClickHousewriteVarUInt64967128.589.7%差距近一倍。原因不在算法本身都是7-bit分组而在三处细节提示ClickHouse的优化全部围绕“CPU缓存友好性”展开而不是“代码简洁性”。第一分支预测失败惩罚太高。Protobuf的WriteVarint64核心循环长这样简化版while (value 0x7F) { *ptr (value 0x7F) | 0x80; value 7; } *ptr value 0x7F;问题在while (value 0x7F)这个条件跳转。当value大部分是小整数比如1、7、42CPU分支预测器会坚定认为“下次还走if分支”但实际95%的case只进1次循环就跳出导致大量预测失败每次失败损失15-20个CPU周期。ClickHouse的writeVarUInt64直接展开为4个if判断if (v 0x80) { *buf v; return 1; } if (v 0x4000) { *buf (v 0x7F) | 0x80; *buf v 7; return 2; } if (v 0x200000) { /* 3字节 */ return 3; } if (v 0x10000000) { /* 4字节 */ return 4; } /* ... up to 10 bytes for uint64 */没有循环全是顺序执行条件跳转现代CPU的静态分支预测器能100%猜中前两个if因为小整数占比超高彻底消灭惩罚。第二内存访问模式不连续。Protobuf版本在写入时先计算需要多少字节再分配buffer最后倒序写入因为高位字节在后。这导致两次内存访问一次算长度一次写数据。ClickHouse版本是“边算边写”指针buf一路递增CPU预取器能完美跟上L1d缓存行利用率拉满。第三类型特化缺失。Protobuf只提供int64_t接口但ClickHouse知道marks文件里存的永远是UInt32因为mark数量不会超42亿primary.idx里存的是UInt64因为offset可能极大count.txt里存的是UInt8因为分区数很少。如果统一用int64_t处理UInt8等于强制用8字节逻辑处理1字节数据多出7倍无谓计算。所以ClickHouse必须拆成writeVarUInt8、writeVarUInt16等四套每套针对位宽做极致裁剪。2.2 四套方法的分工逻辑与位宽边界ClickHouse不是拍脑袋定四套而是严格按数据在存储层的实际分布来划分。我们以MergeTree表为例看哪些地方用哪套文件/结构存储内容典型值范围选用方法为什么选它marks.mrk2mark的偏移量相对于.bin文件起始0~2^32-1因.bin最大4GBwriteVarUInt32超过4GB的part会被自动切分所以32位足够省1字节primary.idx主键索引的offset相对于.bin0~2^64-1大表可能超4GBwriteVarUInt64安全起见用64位兜底避免未来扩容风险count.txt分区数1~100极少超1000writeVarUInt81000以内只需2字节但用8位版可压到1字节128checksums.txt块校验和长度16MD5或32SHA256writeVarUInt16长度固定但为兼容未来算法留到65535关键点在于ClickHouse把“类型”和“语义”绑定了。UInt8不只是一个8位整数它代表“绝对不超过127的计数器”UInt32代表“一个文件内偏移绝不会跨文件”。这种语义绑定让编译器能在-O3下做激进优化。比如writeVarUInt8的汇编输出里cmp $0x7f, %al之后直接跟jbe .L1小于等于127就跳连mov指令都省了——因为输入值就在%al寄存器里根本不用从内存load。注意readVarUInt系列必须和writeVarUInt严格配对。如果你用writeVarUInt32写了一个0x12345678却用readVarUInt64去读结果会是0x7856341200000000字节序错乱高位补零数据彻底损坏。ClickHouse在IMergeTreeDataPart::loadIndexGranularity()里做了断言校验一旦发现读出的mark数量超过max_parts_per_partition默认1000立刻抛LOG_ERROR并abort。2.3 与ZSTD/LZ4压缩的协同设计VarUInt本身不是压缩算法但它和ClickHouse的压缩层是深度耦合的。这里有个反直觉的事实VarUInt编码后的数据比原始二进制更利于后续压缩。我用zstd -19压过两组数据原始UInt64[1e7]数组全为1到1000压缩后2.1 MB同样数组经writeVarUInt64编码后压缩后1.3 MB省了38%原理很简单原始UInt64里每个数占8字节高4字节全是0x00因为1000的十六进制是0x3E8这产生了大量重复的0x00字节ZSTD能高效建模但VarUInt把1000编码成0xE8 0x07两个字节消除了冗余0x00反而让字节分布更均匀压缩率下降错。真实原因是VarUInt把“小整数高频出现”的统计特性转化成了“字节值高频集中在0x00-0x7F区间”的局部规律。ZSTD的熵编码器HuffmanANS对这种偏态分布极其敏感。我抓包看过ZSTD的symbol table0x00~0x7F的code length平均只有3bit而0x80~0xFF要12bit。VarUInt输出里95%的字节都落在前者等于给压缩器喂了最易消化的“软饭”。所以ClickHouse的存储格式是三层嵌套业务数据→列式编码如Delta、DoubleDelta→整数序列→VarUInt编码→字节流→ZSTD/LZ4压缩→磁盘文件VarUInt在这里不是终点而是承上启下的“适配器”。它不追求单点最优而是让上层编码和下层压缩形成合力。这也是为什么你不能简单用snappy替代ZSTD——Snappy的熵编码弱吃不下VarUInt制造的这种精细分布。3. 核心细节解析readVarUInt64的逐行手术刀式拆解3.1 源码定位与调用链全景图别急着看函数体先搞清它在ClickHouse宇宙里的坐标。readVarUInt64定义在src/IO/ReadBuffer.h第217行截至v23.8.3.23-lts但它的生命始于src/Storages/MergeTree/MergeTreeDataPart.cpp的loadIndexGranularity()方法。完整调用链如下loadIndexGranularity() └── loadIndexGranularityImpl() └── readVarUInt64(*data_buffer) // data_buffer指向marks.mrk2文件 └── ReadBuffer::readStrict() // 从文件读1字节到栈变量 └── memcpy() or read() syscall关键点readVarUInt64接收的不是FILE*而是一个ReadBuffer引用。ReadBuffer是ClickHouse的IO抽象基类它把磁盘、内存、网络、甚至/dev/zero都统一封装成“可读的字节流”。这意味着readVarUInt64完全不关心数据从哪来——可以是SSD上的.mrk2也可以是ZK里存的元数据甚至是测试用的std::string。这种解耦让单元测试变得极其简单你只需构造一个std::string塞进ReadBufferFromMemory就能100%复现线上行为。3.2 函数体逐行精读含汇编级注释现在看readVarUInt64本体已删减错误处理保留主干// src/IO/ReadBuffer.h:217 inline UInt64 readVarUInt64(ReadBuffer buf) { UInt64 res 0; for (size_t i 0; i 10; i) // 最多10字节64/7≈9.14向上取整 { UInt8 byte *buf.position(); // 1. 直接解引用指针无函数调用开销 buf.position(); // 2. 指针自增非原子操作极快 res | static_castUInt64(byte 0x7F) (7 * i); // 3. 取低7位左移后或入结果 if ((byte 0x80) 0) // 4. 检查最高位是否为0 return res; // 5. 是则结束否则继续 } throw Exception(Cannot read VarUInt64: size is too big, ErrorCodes::CANNOT_READ_ALL_DATA); }这段代码短得像伪代码但每一行都经过千锤百炼。我们逐行深挖第1行UInt64 res 0;看似普通实则暗藏玄机。res声明在栈上且是UInt648字节。Clang 15在-O3下会把它分配到%rax寄存器x86-64的通用寄存器后续所有和|操作都在寄存器内完成全程不碰内存。如果你改成uint64_t res 0;某些老编译器可能因ABI差异把它压栈性能掉10%。第2行UInt8 byte *buf.position();buf.position()返回的是char*类型的指针ReadBuffer内部维护一个working_buffer和position指针。*buf.position()就是一次内存load。这里没用buf.readStrict(byte, 1)因为后者要检查buffer剩余长度、触发refill逻辑开销太大。ClickHouse假设marks.mrk2文件是预加载到内存的MMAP或mmap且position指针绝不会越界——这个假设由MergeTreeDataPart::loadIndexGranularityImpl()在调用前用assert(buf.available() 10)保证。敢这么写是因为ClickHouse把“正确性”和“性能”做了硬性切割正确性由上层调用者保证底层只做极速执行。第3行buf.position();注意是前缀不是buf.position()。前者生成inc %rdx寄存器自增后者要先mov %rdx, %rax再inc %rdx多一条指令。这种微优化在每行数据都要调10次的场景里积少成多。第4行res | static_castUInt64(byte 0x7F) (7 * i);这是最烧脑的一行。byte 0x7F是位与取低7位 (7 * i)是左移。i从0开始所以第一字节左移0位即不移第二字节左移7位第三字节左移14位……这正是varint的标准解码低位字节在前高位字节在后每个字节贡献7位有效信息。static_castUInt64强制类型转换防止byteUInt8左移时发生符号扩展虽然byte是无符号但编译器可能保守处理。实测去掉castGCC 12会生成额外的movzbl指令多1个cycle。第5行if ((byte 0x80) 0)0x80是128的十六进制二进制10000000。byte 0x80的结果要么是0x00最高位为0要么是0x80最高位为1。 0就是判断是否为终结字节。这里用 0而非! 0x80是因为CPU的test指令对0有特殊优化test %al, %al比cmp $0x80, %al快半个周期。第6行return res;函数在此结束。注意res是寄存器值return只是把%rax的内容传给调用者无内存拷贝。整个函数的stack frame大小为0所有变量都在寄存器符合Leaf Function规范调用开销趋近于零。3.3 边界案例的魔鬼细节0x00、0xFF、0x80的实测行为理论终归要落地。我用GDB在readVarUInt64里下断点手工构造了三组输入字节流观察输出Case 1输入0x00单字节byte 0x00→byte 0x7F 0x00→res 0x00 0 0x00byte 0x80 0x00→ 条件成立return 0✅ 正确0x00解码为0符合预期。Case 2输入0xFF单字节byte 0xFF→byte 0x7F 0x7F→res 0x7F 0 0x7Fbyte 0x80 0x80→ 条件不成立进入下一轮第二轮buf.position()已移到下一个字节但输入只有1字节此时*buf.position()读到的是buffer外的垃圾值比如0xAB❌ 危险未检查buf.available()就解引用必然崩溃。实操心得ClickHouse的readVarUInt系列绝不做越界防护。防护必须由调用者完成。MergeTreeDataPart::loadIndexGranularityImpl()里有一段关键代码if (buf.available() 10) // 预留10字节安全空间 throw Exception(Not enough data in marks file, ErrorCodes::CORRUPTED_DATA);这就是为什么你看到的报错永远是CORRUPTED_DATA而不是SEGFAULT——防护前置了。Case 3输入0x80 0x01两字节第一轮byte 0x80→0x80 0x7F 0x00→res 0x00 0 0x000x80 0x80 ! 0→ 继续第二轮byte 0x01→0x01 0x7F 0x01→res 0x00 | (0x01 7) 0x800x01 0x80 0→ 返回✅ 正确0x80 0x01解码为128十进制即1 7标准varint。这三个案例揭示了一个铁律readVarUInt的健壮性100%依赖上层调用者的输入质量。ClickHouse把“防御性编程”的成本从热路径转移到冷路径文件加载时的完整性校验这是它敢宣称“亚毫秒延迟”的底层逻辑。4. 实操过程手写一个兼容ClickHouse的VarUInt编码器4.1 为什么需要手写线上故障排查的真实场景去年某次大促某核心报表服务突然SELECT超时system.processes显示大量查询卡在Reading from buffer状态。运维同学紧急strace发现read()系统调用返回EAGAIN但/proc/pid/fdinfo/fd显示文件offset没动。最终定位到marks.mrk2文件被上游ETL任务错误截断了最后几个字节。readVarUInt64在读到最后一个mark时buf.available()只剩1字节但readVarUInt64期望至少2字节因为上一个mark是0x80 0x01格式于是无限循环在for (i0; i10; i)里直到i10抛异常。而异常处理逻辑里throw Exception会触发std::terminate进程直接退出。如果当时有手写的、带详细诊断信息的VarUInt解码器我们就能在日志里看到“readVarUInt64 failed at offset 0x1A2F3: expected 2 bytes, got 1”5分钟内定位而不是花2小时翻core dump。所以手写一个兼容版不是为了造轮子而是为了掌控诊断权。4.2 C实现100%兼容ClickHouse ABI以下代码可直接编译进你的诊断工具需C17#include cstdint #include stdexcept #include vector // 完全复刻ClickHouse的readVarUInt64行为 inline uint64_t clickhouse_read_varuint64(const uint8_t* ptr, const uint8_t* end) { if (ptr end) { throw std::runtime_error(Buffer underflow: no bytes available); } uint64_t res 0; for (size_t i 0; i 10; i) { if (ptr end) { throw std::runtime_error(Buffer underflow: need std::to_string(i1) th byte but reached end); } uint8_t byte *ptr; ptr; // Check for overflow: if were at the last iteration (i9), // and byte has high bit set, its invalid (would need 11 bytes) if (i 9 (byte 0x80)) { throw std::runtime_error(VarUInt64 overflow: 10-byte sequence ended with continuation byte); } res | static_castuint64_t(byte 0x7F) (7 * i); if ((byte 0x80) 0) { return res; } } throw std::runtime_error(VarUInt64 internal error: loop completed without break); } // writeVarUInt64的兼容实现 inline void clickhouse_write_varuint64(uint64_t value, std::vectoruint8_t out) { // Unrolled loop for max performance and clarity if (value 0x80) { out.push_back(static_castuint8_t(value)); return; } if (value 0x4000) { out.push_back(static_castuint8_t(value | 0x80)); out.push_back(static_castuint8_t(value 7)); return; } if (value 0x200000) { out.push_back(static_castuint8_t(value | 0x80)); out.push_back(static_castuint8_t((value 7) | 0x80)); out.push_back(static_castuint8_t(value 14)); return; } if (value 0x10000000) { out.push_back(static_castuint8_t(value | 0x80)); out.push_back(static_castuint8_t((value 7) | 0x80)); out.push_back(static_castuint8_t((value 14) | 0x80)); out.push_back(static_castuint8_t(value 21)); return; } if (value 0x800000000LL) { out.push_back(static_castuint8_t(value | 0x80)); out.push_back(static_castuint8_t((value 7) | 0x80)); out.push_back(static_castuint8_t((value 14) | 0x80)); out.push_back(static_castuint8_t((value 21) | 0x80)); out.push_back(static_castuint8_t(value 28)); return; } // ... continue up to 10 bytes (omitted for brevity) // Full version handles all 10 cases }关键增强点双层越界检查ptr end在循环内外都检查确保任何情况下都不解引用非法地址。精确错误定位报错信息包含offset通过ptr - start可算和期望字节数方便xxd -g1 -c16 marks.mrk2 | head -n 20快速定位。100% ABI兼容输出字节流与ClickHouse原生writeVarUInt64完全一致可用hexdump -C验证。4.3 Python快速验证脚本三行搞定编解码测试对于不想编译C的同学Python版够用性能不重要诊断才重要def read_varuint64(data: bytes, offset: int 0) - tuple[int, int]: Return (value, new_offset) res 0 for i in range(10): if offset i len(data): raise ValueError(fBuffer underflow at offset {offset}, need byte {i}) byte data[offset i] res | (byte 0x7F) (7 * i) if not (byte 0x80): return res, offset i 1 raise ValueError(fVarUInt64 overflow at offset {offset}: 10 bytes consumed) def write_varuint64(value: int) - bytes: Encode uint64 to varint bytes if value 0: raise ValueError(Only unsigned integers supported) out bytearray() while value 0x80: out.append((value 0x7F) | 0x80) value 7 out.append(value 0x7F) return bytes(out) # 测试ClickHouse官方test case test_cases [ (0, b\x00), (1, b\x01), (127, b\x7f), (128, b\x80\x01), (16384, b\x80\x80\x01), ] for val, expected in test_cases: encoded write_varuint64(val) assert encoded expected, fEncode fail: {val} - {encoded.hex()} ! {expected.hex()} decoded, _ read_varuint64(encoded) assert decoded val, fDecode fail: {encoded.hex()} - {decoded} print(All tests passed!)运行它你会看到All tests passed!。这就是ClickHouse的VarUInt——没有魔法只有对每一个bit的绝对掌控。5. 常见问题与排查技巧实录来自生产环境的12个血泪教训5.1 问题速查表症状、根因、修复命令症状可能根因快速验证命令修复方案DB::Exception: Cannot read VarUInt64: size is too bigmarks.mrk2文件被截断或损坏ls -l parts/*/marks.mrk2 | tail -n 20查看文件大小是否突降从备份恢复该part或用clickhouse-client --queryDROP PARTITION ...删除坏part查询延迟突增perf top显示readVarUInt64占CPU 40%marks文件未mmap频繁read()系统调用cat /proc/$(pidof clickhouse-server)/maps | grep marks看是否有mmap标记在config.xml中设置use_mmap_if_have_mmap1/use_mmap_if_have_mmapSELECT * FROM system.parts显示marks_bytes0marks.mrk2为空文件ETL写入失败stat parts/*/marks.mrk2 | grep Size|Blocks手动删除该part触发后台merge重建clickhouse-copier同步失败报CORRUPTED_DATA源集群和目标集群ClickHouse版本不一致VarUInt实现有微小差异clickhouse-client --version对比两端升级到同一小版本如都到23.8.3.23INSERT后SELECT count()返回0primary.idx文件损坏readVarUInt64读出0作为第一个offsethexdump -C parts/*/primary.idx | head -n 5看前几字节是否为00用clickhouse-client --queryOPTIMIZE TABLE ... FINAL强制重写5.2 独家避坑技巧那些文档里不会写的细节技巧1用hexdump直接读marks.mrk2比看日志快10倍marks.mrk2是纯VarUInt序列每4个VarUInt组成一个markoffset_in_compressed_file,offset_in_decompressed_block,rows_in_block,skip_index_granularity。用以下命令可直观看到# 读前10个mark每个mark最多40字节取10*40400字节 hexdump -C parts/202310_1_1_0/marks.mrk2 | head -n 20 # 输出类似00000000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| # 这表示第一个mark的4个字段全为0说明该part刚创建还没写入数据技巧2readVarUInt的“静默失败”陷阱ClickHouse的readVarUInt在遇到0x00字节时会立即返回0不报错。但如果0x00出现在不该出现的位置比如primary.idx的第二个字段会导致后续所有offset错位。此时system.part_log里没有任何ERROR只有WARN“Part loading took more than 10 seconds”。诊断口诀只要part_log里WARN时间异常长立刻hexdump查primary.idx前100字节。技巧3writeVarUInt的线程安全真相writeVarUInt64函数本身是线程安全的无全局状态但它的参数ReadBuffer buf不是。ReadBuffer内部的position指针是可变的。所以绝对不要在多线程里共享同一个ReadBuffer实例调用writeVarUInt64。正确做法是每个线程持有一个独立的ReadBuffer或用std::mutex保护buf.position()的访问。我在某次压测中就因此出现marks文件内容错乱花了3天才复现。技巧4VarUInt不是万能的警惕“假压缩”VarUInt对1,2,3,...,N这种等差序列效果极好