从Apache Doris 学习 HyperLogLog

Hll类实现了HyperLogLog算法,这是一种用于大数据集基数估算的概率数据结构。它能够在内存消耗很小的情况下,准确估算数据集中不同元素的数量。

Hll类的变量分析

静态常量变量

java 复制代码
// 数据状态标识
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;

实例变量

java 复制代码
private int type;              // 当前数据状态
private Set<Long> hashSet;     // 显式存储集合(HLL_DATA_EXPLICIT状态)
private byte[] registers;      // 寄存器数组(HLL_DATA_SPARSE/HLL_DATA_FULL状态)

HLL算法原理

HyperLogLog算法基于概率统计原理,核心思想是:

  1. 哈希函数:对输入元素进行哈希,得到均匀分布的哈希值
  2. 分桶统计:将哈希值分为桶索引和尾部数据
  3. 前导零统计:统计每个桶中哈希值尾部的前导零个数
  4. 调和平均:通过调和平均数估算基数

算法详细步骤

步骤1:哈希处理
java 复制代码
// 对输入值进行MurmurHash64哈希
public void updateWithHash(Object value) {
    byte[] v = StringUtils.getBytesUtf8(String.valueOf(value));
    update(hash64(v, v.length, SEED));
}
步骤2:桶索引与前导零计算
java 复制代码
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. 计算前导零个数+1
    byte firstOneBit = (byte) (getLongTailZeroNum(hashValue) + 1);
    
    // 4. 更新寄存器最大值
    registers[idx] = registers[idx] > firstOneBit ? registers[idx] : firstOneBit;
}
步骤3:前导零计算函数
java 复制代码
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:基数估算
java 复制代码
public strictfp long estimateCardinality() {
    // 显示存储
        if (type == HLL_DATA_EMPTY) {
            return 0;
        }
        if (type == HLL_DATA_EXPLICIT) {
            return hashSet.size();
        }
    // m
        int 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² * harmonicMean
    float 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);
}

状态转换机制

java 复制代码
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 through
        case HLL_DATA_FULL:
            updateRegisters(hashValue);  // 寄存器更新
            break;
    }
}

内存优化策略

显式存储(小数据)

  • 直接存储原始哈希值
  • 当元素数≤160时使用
  • 内存占用:160×8=1280字节

稀疏存储(中等数据)

  • 存储非零寄存器的索引和值
  • 当非零寄存器数≤4096时使用
  • 内存占用:1+(索引×3+值×1)×非零数

完整存储(大数据)

  • 存储所有16384个寄存器
  • 内存占用固定:1+16384=16385字节

full 和 sparse 区别只是序列化区别

序列化优化策略

自适应存储格式

java 复制代码
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字节块处理)

java 复制代码
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字节)

java 复制代码
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;

小端序读取函数

java 复制代码
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);
}

关键设计亮点

  1. 内存效率:通过4种状态自适应,小数据用显式存储,大数据用概率估算
  2. 精度控制:14位精度提供约1%的估算误差
  3. 序列化优化:稀疏格式vs完整格式的智能选择
  4. 数值稳定性 :使用strictfp确保浮点数计算的一致性
  5. 大数处理 :通过BigInteger正确处理无符号64位整数

这个实现很好地平衡了内存使用、计算精度和性能,适合在Spark加载过程中处理大规模数据的基数统计需求。

HLL 数学原理

为了降低方差,HLL 使用了​​分桶平均(Stochastic Averaging)​​ 的技巧。

  • ​分桶​​:将哈希空间划分为 m=2^p个桶(或称为寄存器)。对于一个 64 位哈希值,取前 p位作为桶的索引,用剩下的 64−p位来计算前导零的数量 ρ(ρ=前导零数+1)。

​目的​​:将原始的 n个元素分散到 m个桶中,每个桶平均来看只负责大约 n/m个元素。我们对每个桶 j独立地记录其观察到的最大 ρ值,记为 M[j]。

这里的系数可以通过对随机变量的严格积分计算

相关推荐
fire-flyer3 小时前
maven-jlink-plugin入门
java·maven
Knight_AL3 小时前
Java 单元测试全攻略:JUnit 生命周期、覆盖率提升、自动化框架与 Mock 技术
java·junit·单元测试
cominglately3 小时前
记录一次生产环境数据库死锁的处理过程
java·死锁
用户0332126663673 小时前
在 Word 文档中插入图片的 Java 指南
java
深圳蔓延科技3 小时前
单点登录到底是什么?
java·后端
SimonKing3 小时前
除了 ${},Thymeleaf 的这些用法让你直呼内行
java·后端·程序员
智驱力人工智能3 小时前
使用手机检测的智能视觉分析技术与应用 加油站使用手机 玩手机检测
深度学习·算法·目标检测·智能手机·视觉检测·边缘计算
科兴第一吴彦祖4 小时前
基于Spring Boot + Vue 3的乡村振兴综合服务平台
java·vue.js·人工智能·spring boot·推荐算法
ajassi20004 小时前
开源 java android app 开发(十八)最新编译器Android Studio 2025.1.3.7
android·java·开源