bigywater.tech
ClickHouse 内部原理 · 47

Nullable:为什么 Nullable 会影响性能

真实对比Nullable(UInt64)列和普通UInt64列的物理磁盘文件,纠正存储膨胀的常见误解,说明Nullable真实的性能代价所在。

0.078%
真实的null-mask文件相对数据体量的额外存储代价
代价在计算不在存储
真实的性能影响来自每次访问的额外判断分支

做的事:真实对比Nullable列和普通列的物理文件

代码CREATE TABLE demo.nulltest (k UInt64, v_plain UInt64, v_nullable Nullable(UInt64)) ENGINE=MergeTree ORDER BY k; INSERT INTO demo.nulltest SELECT number, number, if(number%100=0, NULL, number) FROM numbers(20000000); -- 真实1%的行是NULL -- 真实合并成1个part之后,直接看磁盘上的.bin文件

验证:真实多出的存储代价,只有0.08%

实测 · 本机执行输出(2000万行,OPTIMIZE FINAL之后的part文件)
ls -la part目录/*.bin
→ 0f231cbc...bin   20460777 字节   ← k列
  258dee36...bin   20460777 字节   ← v_plain列
  796d0908...bin   20460777 字节   ← v_nullable的数据文件(跟普通UInt64一样大)
  d9779c7c...bin      15912 字节   ← v_nullable额外多出的null-mask文件

真实多出的代价:15912 / 20460777 ≈ 0.078%

Nullable(UInt64)真实在磁盘上被拆成了两个独立文件:一个跟普通UInt64列完全一样大的数据文件(NULL的位置也占了一个UInt64槽位,只是值不作数),加一个额外的null-mask文件——本质是一个每行1个bit、标记这一行是否为NULL的位图,本文1%的行是NULL,这个位图真实高度可压缩,只占了15,912字节,相对2000万行的数据体量几乎可以忽略。

真实的代价不在存储,在每次访问都要多一次判断

这个结果纠正了一个常见的直觉误解:以为Nullable会让存储体积大幅膨胀(比如误以为要多存一份可变长度的"NULL标记字符串"或者整列体积翻倍),本文真实测出来的代价只有不到0.1%,完全不是storage layer的问题。Nullable真实影响性能的地方,在于每一次读取、每一次参与计算,都要多一步判断"这一行是不是NULL"——普通UInt64列的值可以直接参与求和、比较等运算,而Nullable(UInt64)的每一次运算,理论上都要先查一下对应位置的null-mask,是NULL的话结果要按三值逻辑处理(比如NULL + 1的结果依然是NULL,不能直接当0处理)。这个额外判断本身的开销在向量化批处理下(本系列《Vectorized Execution》验证过)通常很小,本文用sum()做的简单对比测试(0.06~0.07s vs 0.06~0.07s)在2000万行的规模下也确实没有测出有意义的差异;但这个成本真实会在两类场景下被放大:一是极高频的简单运算(每次判断的固定开销占比更高,跟本系列《Small Part》验证过的"固定开销占比"是同一类道理);二是Nullable列参与索引/排序键时,因为NULL值在排序语义上需要真实特殊处理(ClickHouse里NULL被当作比任何值都小或都大,取决于排序方向设置),会让原本简单的比较操作变得更复杂。真实的建议是:能用默认值语义表达"没有值"这件事(比如用0、空字符串、或者一个业务上不可能出现的哨兵值)就尽量不用Nullable,只有真的需要区分"值是0"和"值不存在"这两种不同语义时,才值得为Nullable多付出这份代价。

分步演示:从物理文件对比到真实的性能代价所在

每一步都是本文上面用真实磁盘文件对比跑出来的原始结果。

Nullable真实多出的存储代价不到0.1%
这一步新验证的文件

    本文用到的工具

    • 本机 ClickHouse 26.9.4(单节点),真实直接检查Nullable列的物理.bin文件
    • 真实确认存储代价不到0.1%,纠正常见的"Nullable存储膨胀"误解
    京ICP备2026067502号-1京公网安备11010502063487号