从Apache Doris 学习 HyperLogLog
Hll类实现了HyperLogLog算法,这是一种用于大数据集基数估算的概率数据结构。它能够在内存消耗很小的情况下,准确估算数据集中不同元素的数量。
Hll类的变量分析
静态常量变量
// 数据状态标识
public static final byte HLL_DATA_EMPTY = 0; // 空状态
public static final byte HLL_DATA_EXPLICIT = 1; // 显式存储状态
public static final byte HLL_DATA_SPARSE = 2; // 稀疏存储状态
public static final byte HLL_DATA_FULL = 3; // 完整存储状态// HLL算法核心参数
public static final int HLL_COLUMN_PRECISION = 14; // 精度参数p=14
public static final int HLL_ZERO_COUNT_BITS = (64 - HLL_COLUMN_PRECISION); // 50位用于前导零计算
public static final int HLL_EXPLICIT_INT64_NUM = 160; // 显式存储阈值
public static final int HLL_SPARSE_THRESHOLD = 4096; // 稀疏存储阈值
public static final int HLL_REGISTERS_COUNT = 16 * 1024; // 寄存器数量m=2^14=16384// MurmurHash64算法常量
public static final long M64 = 0xc6a4a7935bd1e995L;
public static final int R64 = 47;
public static final int SEED = 0xadc83b19;
实例变量
private int type; // 当前数据状态
private Set<Long> hashSet; // 显式存储集合(HLL_DATA_EXPLICIT状态)
private byte[] registers; // 寄存器数组(HLL_DATA_SPARSE/HLL_DATA_FULL状态)
HLL算法原理
HyperLogLog算法基于概率统计原理,核心思想是:
- 哈希函数:对输入元素进行哈希,得到均匀分布的哈希值
- 分桶统计:将哈希值分为桶索引和尾部数据
- 前导零统计:统计每个桶中哈希值尾部的前导零个数
- 调和平均:通过调和平均数估算基数
算法详细步骤
步骤1:哈希处理
// 对输入值进行MurmurHash64哈希
public void updateWithHash(Object value) {byte[] v = StringUtils.getBytesUtf8(String.valueOf(value));update(hash64(v, v.length, SEED));
}
步骤2:桶索引与前导零计算
private void updateRegisters(long hashValue) {// 1. 提取桶索引(前14位)int idx = (int) (hashValue % HLL_REGISTERS_COUNT); // 0-16383// 2. 提取尾部数据(后50位)用于前导零计算hashValue >>>= HLL_COLUMN_PRECISION; // 右移14位hashValue |= (1L << HLL_ZERO_COUNT_BITS); // 设置最高位为1// 3. 计算前导零个数+1byte firstOneBit = (byte) (getLongTailZeroNum(hashValue) + 1);// 4. 更新寄存器最大值registers[idx] = registers[idx] > firstOneBit ? registers[idx] : firstOneBit;
}
步骤3:前导零计算函数
public static byte getLongTailZeroNum(long hashValue) {if (hashValue == 0) {return 0;}long value = 1L;byte idx = 0;for (;; idx++) {if ((value & hashValue) != 0) { // 找到第一个1的位置return idx;}value = value << 1;if (idx == 62) {break;}}return idx;
}
步骤4:基数估算
public strictfp long estimateCardinality() {// 显示存储if (type == HLL_DATA_EMPTY) {return 0;}if (type == HLL_DATA_EXPLICIT) {return hashSet.size();}// mint numStreams = HLL_REGISTERS_COUNT;float alpha = 0;if (numStreams == 16) {alpha = 0.673f;} else if (numStreams == 32) {alpha = 0.697f;} else if (numStreams == 64) {alpha = 0.709f;} else {alpha = 0.7213f / (1 + 1.079f / numStreams);}// 1. 计算调和平均数float harmonicMean = 0;int numZeroRegisters = 0;for (int i = 0; i < HLL_REGISTERS_COUNT; i++) {harmonicMean += Math.pow(2.0f, -registers[i]); // 2^(-M[j])if (registers[i] == 0) {numZeroRegisters++; // 统计空桶数量}}harmonicMean = 1.0f / harmonicMean;// 2. 原始估算公式:α_m * m² * harmonicMeanfloat alpha = 0.7213f / (1 + 1.079f / numStreams);double estimate = alpha * numStreams * numStreams * harmonicMean;// 3. 小数据修正(线性计数)if (estimate <= numStreams * 2.5 && numZeroRegisters != 0) {estimate = numStreams * Math.log(((float) numStreams) / ((float) numZeroRegisters));}// 4. 大数据偏差修正else if (numStreams == 16384 && estimate < 72000) {// 多项式偏差修正double bias = 5.9119 * 1.0e-18 * (estimate * estimate * estimate * estimate)- 1.4253 * 1.0e-12 * (estimate * estimate * estimate)+ 1.2940 * 1.0e-7 * (estimate * estimate)- 5.2921 * 1.0e-3 * estimate+ 83.3216;estimate -= estimate * (bias / 100);}return (long) (estimate + 0.5);
}
状态转换机制
public void update(long hashValue) {switch (this.type) {case HLL_DATA_EMPTY:hashSet.add(hashValue);type = HLL_DATA_EXPLICIT; // 空→显式break;case HLL_DATA_EXPLICIT:if (hashSet.size() < HLL_EXPLICIT_INT64_NUM) {hashSet.add(hashValue); // 显式存储break;}convertExplicitToRegister(); // 显式→完整type = HLL_DATA_FULL;case HLL_DATA_SPARSE: // fall throughcase HLL_DATA_FULL:updateRegisters(hashValue); // 寄存器更新break;}
}
内存优化策略
显式存储(小数据)
- 直接存储原始哈希值
- 当元素数≤160时使用
- 内存占用:160×8=1280字节
稀疏存储(中等数据)
- 存储非零寄存器的索引和值
- 当非零寄存器数≤4096时使用
- 内存占用:1+(索引×3+值×1)×非零数
完整存储(大数据)
- 存储所有16384个寄存器
- 内存占用固定:1+16384=16385字节
full 和 sparse 区别只是序列化区别
序列化优化策略
自适应存储格式
public void serialize(DataOutput output) throws IOException {int nonZeroRegisterNum = 0;for (int i = 0; i < HLL_REGISTERS_COUNT; i++) {if (registers[i] != 0) {nonZeroRegisterNum++;}}if (nonZeroRegisterNum > HLL_SPARSE_THRESHOLD) {// 使用完整格式:直接存储所有16384个寄存器output.writeByte(HLL_DATA_FULL);for (byte value : registers) {output.writeByte(value);}} else {// 使用稀疏格式:只存储非零寄存器output.writeByte(HLL_DATA_SPARSE);output.writeInt(Integer.reverseBytes(nonZeroRegisterNum));for (int i = 0; i < HLL_REGISTERS_COUNT; i++) {if (registers[i] != 0) {output.writeShort(Short.reverseBytes((short) i));output.writeByte(registers[i]);}}}
}
哈希函数实现
hash64
函数实现了MurmurHash64算法,这是一种高性能、低碰撞率的非加密哈希函数,特别适合用于哈希表和概率数据结构。
public static final long M64 = 0xc6a4a7935bd1e995L; // 乘法常量
public static final int R64 = 47; // 右移位数
public static final int SEED = 0xadc83b19; // 种子值
初始化阶段
long h = (seed & 0xffffffffL) ^ (length * M64);
- 将种子值限制在32位范围内
- 与数据长度进行异或混合
- 乘以魔数M64进行扩散
主处理循环(8字节块处理)
final int nblocks = length >> 3; // 计算8字节块数量for (int i = 0; i < nblocks; i++) {final int index = (i << 3);long k = getLittleEndianLong(data, index); // 小端序读取8字节// 混合轮1:乘法+位移k *= M64;k ^= k >>> R64; // 右移47位k *= M64;// 混合轮2:与哈希值结合h ^= k;h *= M64;
}
尾部处理(剩余1-7字节)
final int index = (nblocks << 3);
switch (length - index) {case 7:h ^= ((long) data[index + 6] & 0xff) << 48;case 6:h ^= ((long) data[index + 5] & 0xff) << 40;case 5:h ^= ((long) data[index + 4] & 0xff) << 32;case 4:h ^= ((long) data[index + 3] & 0xff) << 24;case 3:h ^= ((long) data[index + 2] & 0xff) << 16;case 2:h ^= ((long) data[index + 1] & 0xff) << 8;case 1:h ^= ((long) data[index] & 0xff);h *= M64;
}
最终混合
h ^= h >>> R64; // 第一次混合
h *= M64; // 乘法扩散
h ^= h >>> R64; // 第二次混合
return h;
小端序读取函数
private static long getLittleEndianLong(final byte[] data, final int index) {return (((long) data[index ] & 0xff))| (((long) data[index + 1] & 0xff) << 8)| (((long) data[index + 2] & 0xff) << 16)| (((long) data[index + 3] & 0xff) << 24)| (((long) data[index + 4] & 0xff) << 32)| (((long) data[index + 5] & 0xff) << 40)| (((long) data[index + 6] & 0xff) << 48)| (((long) data[index + 7] & 0xff) << 56);
}
关键设计亮点
- 内存效率:通过4种状态自适应,小数据用显式存储,大数据用概率估算
- 精度控制:14位精度提供约1%的估算误差
- 序列化优化:稀疏格式vs完整格式的智能选择
- 数值稳定性:使用
strictfp
确保浮点数计算的一致性 - 大数处理:通过
BigInteger
正确处理无符号64位整数
这个实现很好地平衡了内存使用、计算精度和性能,适合在Spark加载过程中处理大规模数据的基数统计需求。
HLL 数学原理
为了降低方差,HLL 使用了分桶平均(Stochastic Averaging) 的技巧。
分桶:将哈希空间划分为 m=2^p个桶(或称为寄存器)。对于一个 64 位哈希值,取前 p位作为桶的索引,用剩下的 64−p位来计算前导零的数量 ρ(ρ=前导零数+1)。
目的:将原始的 n个元素分散到 m个桶中,每个桶平均来看只负责大约 n/m个元素。我们对每个桶 j独立地记录其观察到的最大 ρ值,记为 M[j]。
这里的系数可以通过对随机变量的严格积分计算