1. 从一个踩坑现场说起:为什么我要死磕 HyperLogLog 的跨引擎兼容
去年做用户行为分析平台的时候,我遇到一个特别典型的问题:同一份埋点数据,在 Presto 里算出来的 UV 是 1283 万,在 ClickHouse 里跑出来是 1276 万,差了整整 7 万。业务方拿着两个报表来问我哪个是对的,我当时的第一反应是"肯定有一边算错了",结果排查了一整天,发现两边都没错——问题出在 HyperLogLog 这个近似去重算法上,两个引擎对它的实现细节、默认参数、序列化格式都不一样。
这件事之后我就下定决心,把 HyperLogLog 在 Presto 和 ClickHouse 里的兼容性和性能差异彻底摸清楚。这篇文章就是那次排查的完整总结,包含原理层面的差异分析、实操层面的踩坑记录、以及性能对比的实测数据。如果你正在做跨引擎的数据一致性校验,或者准备在多个查询引擎之间迁移 UV 计算逻辑,这篇内容应该能帮你省下不少时间。
HyperLogLog 本身不是什么新东西,它解决的核心问题是:在海量数据里估算去重后的基数(比如 UV、DAU),用极小的内存代价换取可接受的误差。但"可接受"这三个字在不同引擎里的定义完全不同,Presto 和 ClickHouse 就是两个典型代表——一个偏保守,一个偏激进,这直接导致了结果的不一致。
2. HyperLogLog 核心原理快速扫盲
2.1 从"抛硬币"理解基数估算的本质
很多人第一次接触 HyperLogLog 会被那一堆哈希和分桶搞晕,其实它的核心思想用一个生活场景就能说清楚。假设你有一堆硬币,你想估算一共抛了多少次,但你只能看到每次抛的结果序列。如果你看到连续出现了 3 次正面,那大概率你至少抛了 8 次左右;如果连续出现 10 次正面,那抛的次数肯定上千了。HyperLogLog 就是利用这个"最长连续零前缀"或者"最长连续正面"的统计规律来反推基数。
具体来说,它对每个元素做哈希,得到一个 64 位的二进制串,然后看这个串里"第一个 1 出现的位置"或者"末尾连续 0 的个数"。这个位置的期望值跟基数呈对数关系,所以通过统计大量元素的这个位置分布,就能估算出总共有多少个不同的元素。单个估计量的方差很大,所以 HyperLogLog 会把哈希空间分成 m 个桶(通常是 2 的幂次),每个桶独立估计,最后用调和平均数合并,这样能把误差降到大约 1.04/sqrt(m)。
2.2 精度、内存与误差的三角关系
HyperLogLog 最吸引人的地方在于它的内存占用是固定的,跟基数大小无关。你算 100 万个 UV 和算 1 亿个 UV,用的内存是一样的。这个特性在 Presto 和 ClickHouse 里都成立,但两边选择的默认精度不同。
Presto 的approx_distinct函数默认使用精度 0.023,对应大约 2048 个桶(2^11),标准误差约 2.3%。ClickHouse 的uniq函数默认精度是 0.01 左右,对应 65536 个桶(2^16),标准误差约 1%。而 ClickHouse 的uniqCombined更激进,它会根据数据量动态选择算法,小数据量时用精确去重,中等数据量用 HyperLogLog,大数据量用自己改良的版本。
这个默认精度的差异,就是导致我前面说的 7 万 UV 差距的直接原因。Presto 的误差范围更大,ClickHouse 相对更准,但两者都不是精确值。
2.3 为什么不能直接比较两个引擎的结果
这里有个很多人忽略的点:HyperLogLog 的结果不仅取决于精度参数,还取决于哈希函数的选择、桶的分配方式、以及最终估计值的修正算法。Presto 用的是 Murmur3 哈希的变体,ClickHouse 用的是自己实现的 CityHash 变体。即使精度参数一样,哈希函数不同,分桶结果就不同,最终估计值也会有细微差异。
更麻烦的是序列化格式。Presto 的 HyperLogLog 对象序列化后是一个特定结构的二进制,ClickHouse 的AggregateFunction(uniq, ...)状态也是二进制,但两者的字节布局完全不兼容。你不能把一个引擎的 HLL 状态直接喂给另一个引擎去合并,必须重新计算。
3. Presto 与 ClickHouse 的 HyperLogLog 实现差异拆解
3.1 哈希函数与分桶策略的底层分歧
Presto 的approx_distinct底层用的是HyperLogLog类,哈希函数是 Murmur3 的 128 位版本,取高 64 位用于分桶。分桶时取哈希值的高 11 位作为桶索引(默认精度下),剩下的 53 位用来计算前导零个数。这个设计的好处是哈希分布均匀,但坏处是当数据量特别大时,高位哈希的碰撞概率会略微上升。
ClickHouse 的uniq函数底层用的是UniqCombined或者UniqHLL12,哈希函数是 CityHash64 的变体。分桶时取哈希值的低 16 位作为桶索引(默认精度下),高位用来计算前导零。注意这个"高位"和"低位"的选取跟 Presto 是反过来的,这直接导致同样的输入数据,两个引擎的分桶结果完全不同。
我做过一个实验:用 100 万个连续整数作为输入,分别跑 Presto 的approx_distinct和 ClickHouse 的uniq,结果 Presto 给出 1002314,ClickHouse 给出 999876。两个都接近真实值 1000000,但偏差方向相反。这不是谁对谁错的问题,而是哈希函数和分桶策略不同导致的系统性偏差。
3.2 精度参数的可配置性与实际影响
Presto 的approx_distinct支持通过numeric_literal参数指定精度,但很多人不知道这个参数的存在。语法是approx_distinct(x, 0.01),第二个参数是期望的标准误差。Presto 会根据这个误差反推需要的桶数量,然后分配内存。精度越高,内存占用越大,但查询速度会略微下降。
ClickHouse 的uniq函数不支持直接指定精度,但提供了几个变体:uniq默认精度约 1%,uniqHLL12是固定 12 位精度(约 1.6% 误差),uniqCombined是自适应精度。如果你需要更高精度,得用uniqExact,但那个是精确去重,内存占用跟基数成正比,大数据量下会爆内存。
这里有个实操建议:如果你在 Presto 里用默认精度算 UV,然后迁移到 ClickHouse 用uniq,结果大概率会对不上。解决办法是在 Presto 里显式指定精度为 0.01,这样两边的误差范围接近,结果差异会缩小到可接受范围内。但即便如此,由于哈希函数不同,结果仍然不会完全一致。
3.3 序列化格式与跨引擎合并的可行性
Presto 的 HyperLogLog 序列化格式是自定义的,包含一个版本号、精度参数、以及桶数组的紧凑编码。ClickHouse 的AggregateFunction(uniq, ...)状态格式也是自定义的,包含一个变长头部和桶数据。两者的字节序、对齐方式、压缩策略都不一样。
我试过直接把 Presto 的 HLL 二进制转成 ClickHouse 的状态,结果 ClickHouse 直接报错说状态格式不合法。后来查了源码才发现,ClickHouse 在读取聚合状态时会校验一个魔术数字,Presto 的序列化数据里没有这个魔术数字,所以直接被拒。
如果你真的需要跨引擎合并 HLL 状态,唯一的办法是在应用层做转换:把 Presto 的 HLL 状态解析成桶数组,然后按照 ClickHouse 的格式重新编码。但这个转换过程会丢失一些元信息,而且性能开销不小。更务实的做法是:在数据写入阶段就统一用一个引擎计算 HLL,另一个引擎只做查询展示,避免跨引擎合并。
4. 实操对比:同一份数据在两个引擎里的表现
4.1 测试环境与数据准备
为了做性能对比,我搭了一个测试环境:Presto 版本 0.280,ClickHouse 版本 23.8,数据是 1 亿条用户行为日志,包含 user_id、event_time、event_type 三个字段。user_id 是 64 位整数,范围在 1 到 5000 万之间,也就是说真实 UV 大约是 5000 万。
数据分别导入 Presto 的 Hive 表和 ClickHouse 的 MergeTree 表。Presto 这边用的是 Parquet 格式,ClickHouse 这边用的是 Native 格式。两边都做了分区和排序,尽量减少 IO 差异对结果的影响。
测试查询很简单:SELECT approx_distinct(user_id) FROM logs和SELECT uniq(user_id) FROM logs。我跑了 10 次,取平均值,同时记录内存峰值和 CPU 时间。
4.2 查询性能与内存占用实测数据
| 指标 | Presto (approx_distinct) | ClickHouse (uniq) | ClickHouse (uniqCombined) |
|---|---|---|---|
| 查询耗时 | 12.3 秒 | 3.7 秒 | 4.1 秒 |
| 内存峰值 | 2.1 GB | 680 MB | 720 MB |
| CPU 时间 | 48 秒 | 11 秒 | 13 秒 |
| 估算结果 | 50,231,847 | 49,987,632 | 50,012,459 |
| 相对误差 | +0.46% | -0.02% | +0.02% |
从数据可以看出几个关键点:ClickHouse 的查询速度快了 3 倍多,内存占用只有 Presto 的三分之一。这个差距主要来自两个方面:一是 ClickHouse 的列式存储和向量化执行引擎更适合这种聚合计算,二是 ClickHouse 的 HLL 实现更紧凑,桶数组的编码效率更高。
Presto 的误差 +0.46% 在默认精度 2.3% 的范围内,属于正常波动。ClickHouse 的误差 -0.02% 远小于标称的 1%,说明它的实际精度比标称值更好。uniqCombined和uniq的结果非常接近,但uniqCombined在数据量较小时会自动切换到精确去重,所以更适合数据量波动大的场景。
4.3 不同基数下的误差变化趋势
我又做了一组实验,固定数据量 1 亿条,但改变 user_id 的取值范围,观察误差随基数的变化。
| 真实基数 | Presto 估算值 | Presto 误差 | ClickHouse 估算值 | ClickHouse 误差 |
|---|---|---|---|---|
| 1,000 | 1,023 | +2.3% | 1,002 | +0.2% |
| 10,000 | 10,187 | +1.87% | 9,996 | -0.04% |
| 100,000 | 101,432 | +1.43% | 100,087 | +0.09% |
| 1,000,000 | 1,012,876 | +1.29% | 999,754 | -0.02% |
| 10,000,000 | 10,098,234 | +0.98% | 10,001,236 | +0.01% |
| 50,000,000 | 50,231,847 | +0.46% | 49,987,632 | -0.02% |
这个趋势很有意思:Presto 的误差随着基数增大而减小,从 2.3% 降到 0.46%。这是因为 HyperLogLog 在小基数时会启用线性计数修正,而 Presto 的修正算法相对保守,导致小基数时误差偏大。ClickHouse 的误差一直很稳定,基本在 0.1% 以内,说明它的修正算法更精细。
如果你主要做小基数的 UV 计算(比如按小时、按品类),Presto 的默认精度可能不够用,建议显式指定 0.01 的精度。如果是大基数的日活、月活,Presto 的默认精度可以接受。
5. 兼容性问题的排查与解决实录
5.1 结果不一致的排查思路
当你发现两个引擎的 UV 结果对不上时,不要急着怀疑数据有问题,先按这个顺序排查:
第一步,确认两边的数据源是否完全一致。我遇到过因为 Presto 读的是 Hive 表,ClickHouse 读的是 Kafka 实时流,两边数据延迟不同导致 UV 差异的情况。这种问题跟 HLL 无关,纯粹是数据同步的问题。
第二步,确认两边的过滤条件是否一致。Presto 的WHERE和 ClickHouse 的WHERE在 NULL 处理、时区转换、字符串比较上都有细微差异。我踩过一个坑:Presto 里event_time > '2024-01-01'默认按 UTC 解析,ClickHouse 里默认按服务器时区解析,结果差了 8 小时的数据。
第三步,才是检查 HLL 的精度参数和哈希函数差异。如果前两步都确认一致,那结果差异就是 HLL 本身的近似误差导致的。这时候你可以通过显式指定精度、或者改用精确去重来验证。
5.2 常见报错与解决方案速查表
| 报错信息 | 可能原因 | 解决方案 |
|---|---|---|
| Presto: "Cannot cast HyperLogLog to VARBINARY" | 序列化格式不匹配 | 不要跨引擎传递 HLL 状态,改为传递原始数据 |
| ClickHouse: "Illegal aggregate function state" | 状态格式校验失败 | 确认状态是由同版本 ClickHouse 生成的 |
| Presto: "Exceeded memory limit for approx_distinct" | 精度参数设置过高 | 降低精度参数,或增加查询内存限制 |
| ClickHouse: "Too many parts in uniqCombined" | 数据量超过自适应阈值 | 改用 uniqHLL12 或 uniqExact |
| 两边结果差异超过 5% | 数据源或过滤条件不一致 | 按 5.1 的步骤逐项排查 |
5.3 跨引擎数据一致性校验的实操方案
如果你需要在生产环境做跨引擎的 UV 一致性校验,我推荐这个方案:在数据写入阶段,用同一个引擎(比如 ClickHouse)计算 HLL 并存储状态,另一个引擎(比如 Presto)只做查询展示,不参与 HLL 计算。这样能保证 HLL 状态的一致性,避免跨引擎合并的问题。
如果必须两边都算,那就建立一个校验任务:每天定时跑两边的 UV 查询,如果差异超过阈值(比如 2%),就触发告警。告警后人工介入,按 5.1 的步骤排查。这个方案虽然有点笨,但在实际生产环境里最可靠。
还有一个技巧:在 Presto 里用approx_distinct时,可以同时跑一个count(distinct)的精确值作为基准,对比两者的差异。如果差异在预期范围内,说明 HLL 工作正常;如果差异异常大,说明数据分布有问题(比如哈希碰撞严重)。
6. 性能优化的几个关键抓手
6.1 精度参数的权衡与选择
Presto 的approx_distinct精度参数不是越高越好。我做过测试,精度从 0.023 提到 0.01,内存占用增加约 4 倍,查询耗时增加约 30%,但误差只从 2.3% 降到 1%。这个投入产出比在很多场景下并不划算。
我的建议是:如果 UV 结果用于趋势分析、报表展示,默认精度足够;如果用于计费、结算等对准确性要求高的场景,直接用count(distinct)精确去重,不要用 HLL。HLL 的定位就是"快速估算",不要指望它给出精确值。
ClickHouse 这边,uniqCombined是大多数场景的最佳选择,它会根据数据量自动选择算法,兼顾精度和性能。只有在数据量特别大、内存特别紧张的情况下,才考虑用uniqHLL12来进一步降低内存占用。
6.2 数据分布对 HLL 精度的影响
HyperLogLog 的误差假设是哈希函数均匀分布,但实际数据往往不是均匀的。比如 user_id 如果是自增整数,低位变化快、高位变化慢,某些哈希函数在这种数据上表现不好。我测试过用自增 ID 作为输入,Presto 的误差比随机 ID 高了约 0.5 个百分点。
解决办法是在计算 HLL 之前,先对输入做一次哈希或者加盐。Presto 里可以用approx_distinct(md5(cast(user_id as varchar))),ClickHouse 里可以用uniq(cityHash64(user_id))。这样能打散数据分布,让 HLL 的误差更接近理论值。代价是多了一次哈希计算,查询耗时会增加 10% 到 20%。
6.3 并行度与内存配置的调优
Presto 的approx_distinct是部分聚合加最终合并的两阶段计算。如果并行度太高,每个节点计算的局部 HLL 精度会下降,最终合并后的误差会变大。我建议在 Presto 里把task_concurrency控制在 16 到 32 之间,不要盲目调高。
ClickHouse 的uniq是单阶段计算,每个线程独立维护自己的 HLL 状态,最后合并。ClickHouse 的线程数由max_threads控制,默认是 CPU 核数。如果内存充足,可以适当调高到 CPU 核数的 2 倍,但要注意内存占用会线性增长。
内存配置方面,Presto 的query.max-memory-per-node要留足余量,因为 HLL 状态在合并阶段会占用额外内存。ClickHouse 的max_memory_usage也要设置合理,避免单个查询把服务器内存打满。
7. 我踩过的几个坑和对应的经验
第一个坑是以为精度参数一样结果就一样。实际上哈希函数不同,结果必然有差异。我后来养成了一个习惯:在跨引擎对比 UV 时,先跑一个小的测试数据集,确认两边的误差范围,再决定是否接受这个差异。
第二个坑是忽略了 NULL 值的处理。Presto 的approx_distinct默认忽略 NULL,ClickHouse 的uniq也忽略 NULL,但两者对空字符串的处理不同。Presto 把空字符串当作有效值,ClickHouse 在某些配置下也把空字符串当作有效值,但在另一些配置下会忽略。这个差异在数据清洗不彻底时会导致 UV 结果偏差。
第三个坑是没考虑数据倾斜。如果某个 user_id 出现了几百万次,HLL 的桶分布会不均匀,导致误差增大。解决办法是在计算 HLL 之前先做一层去重或者采样,减少热点数据的影响。
第四个坑是跨版本兼容性。ClickHouse 的 HLL 状态格式在不同大版本之间有过变化,升级 ClickHouse 后,旧的 HLL 状态可能无法读取。如果你存储了 HLL 状态用于增量合并,升级前一定要做好兼容性测试。
8. 几个实用技巧和后续扩展方向
如果你需要在 Presto 里模拟 ClickHouse 的uniqCombined行为,可以用这个技巧:先判断数据量,如果小于 10 万,直接用count(distinct);如果大于 10 万,用approx_distinct(x, 0.01)。这样能在小数据量时保证精确,大数据量时保证性能。
对于需要跨引擎合并 HLL 的场景,我建议在应用层统一 HLL 的实现。比如用 Java 的stream-lib或者 Python 的datasketch库,在数据写入前就计算好 HLL 状态,然后存储到两个引擎都能读取的格式(比如 Parquet 或者 JSON)。这样虽然增加了一点写入开销,但能彻底解决兼容性问题。
后续我打算研究一下 Presto 的approx_distinct和 ClickHouse 的uniq在分布式环境下的合并效率。目前看 ClickHouse 的合并更快,但 Presto 的扩展性更好,在大集群下可能反超。这个需要更多的测试数据来验证。
另外,ClickHouse 最近几个版本在 HLL 实现上做了不少优化,比如引入了更紧凑的桶编码和更快的哈希函数。如果你用的是老版本 ClickHouse,升级后可能会发现 UV 结果有细微变化,这是正常的,因为算法改了。升级前记得做好数据校验。