HyperLogLog 从零实现:海量数据基数估计的数学之美与工程实践

深入解析 HyperLogLog 概率数据结构原理,从零用 JavaScript 实现高性能基数估计算法,对比 Redis PFADD/PFCOUNT 实战,掌握大数据去重计数的核心技术。

数据结构与算法 2026-06-10 12 分钟

假设你需要统计一个网站每天的独立访客数(UV),日活用户 5000 万。用 Set 存储用户 ID?每个 ID 占 32 字节,5000 万条就是 1.6GB 内存——而这仅仅是一天的数据。HyperLogLog 用 12KB 内存就能完成同样的统计,误差仅 0.81%。Redis 的 PFADD/PFCOUNT 命令底层正是这个算法,被广泛应用于 UV 统计、去重计数、基数监控等场景。

本文将从数学原理出发,用 JavaScript 从零实现一个完整的 HyperLogLog,深入理解它的精妙设计,最后对接 Redis 生产环境,给出性能对比与避坑指南。

🧮 一、HyperLogLog 的数学原理

1.1 从抛硬币说起

理解 HyperLogLog 的关键在于一个反直觉的概率观察:连续抛一枚公平硬币,第一次出现正面时的抛掷次数,可以用来估计硬币被抛的总次数。

如果抛一枚硬币,记录第一次出现正面的位置(即前导零个数),那么:

  • 前导零为 0 的概率:1/2(第一掷就是正面)
  • 前导零为 1 的概率:1/4
  • 前导零为 2 的概率:1/8
  • 前导零为 k 的概率:1/2^(k+1)

如果在一组数据中,观察到最大前导零个数为 k,那么数据量大约为 2^k。这就是 LogLog 算法的核心思想——用哈希值的前导零来估计基数。

💡 **提示:**这里用哈希函数模拟"抛硬币"。一个好的哈希函数会将输入均匀映射到二进制空间,使得每一位为 0 或 1 的概率各为 1/2。

1.2 从 LogLog 到 HyperLogLog:调和平均数的妙用

LogLog 的问题是方差太大。单次观察的最大前导零容易受极端值影响。HyperLogLog 的改进在于:将数据分成 m 个桶,每个桶独立统计前导零,最后取调和平均数(Harmonic Mean)

调和平均数对极值不敏感,比算术平均数更稳定。HyperLogLog 的标准误差为:

$$\sigma = \frac{1.04}{\sqrt{m}}$$

其中 m 是桶的数量。m = 16384 时,误差仅为 0.81%。

桶数量 m 标准误差 内存占用
256 6.5% 192 bytes
1024 3.25% 768 bytes
4096 1.625% 3 KB
16384 0.81% 12 KB
65536 0.4% 48 KB

1.3 完整的估计公式

HyperLogLog 的基数估计公式为:

$$E = \alpha_m \cdot m^2 \cdot \left(\sum_{j=1}^{m} 2^{-M[j]}\right)^{-1}$$

其中:

  • α_m 是修正因子,m = 16 时约为 0.673
  • M[j] 是第 j 个桶观察到的最大前导零个数
  • m 是桶的数量(必须是 2 的幂)

当基数较小时,估计值会因为"空桶"而产生偏差,需要切换到线性计数法(Linear Counting)

$$E_{linear} = m \cdot \ln\left(\frac{m}{V}\right)$$

其中 V 是值为 0 的桶的数量。当估计值小于 2.5m 且 V > 0 时,使用线性计数法。

🔧 二、从零实现 HyperLogLog

2.1 核心实现

以下是完整的 JavaScript 实现,包含哈希、桶分配、前导零计算和基数估计:

// HyperLogLog 核心实现
class HyperLogLog {
  constructor(precision = 14) {
    // precision: 精度参数,桶数量 m = 2^precision
    if (precision < 4 || precision > 16) {
      throw new Error('Precision must be between 4 and 16')
    }
    this.p = precision
    this.m = 1 << precision // 桶数量
    this.registers = new Uint8Array(this.m) // 每个桶存储最大前导零
    this.alpha = this._getAlpha() // 修正因子
  }

  // 计算修正因子 α_m
  _getAlpha() {
    switch (this.m) {
      case 16: return 0.673
      case 32: return 0.697
      case 64: return 0.709
      default: return 0.7213 / (1 + 1.079 / this.m)
    }
  }

  // MurmurHash3 32-bit 哈希函数
  _hash(value) {
    const str = String(value)
    let h = 0x811c9dc5
    for (let i = 0; i < str.length; i++) {
      h ^= str.charCodeAt(i)
      h = Math.imul(h, 0x01000193)
      h = (h << 13) | (h >>> 19)
    }
    return (h >>> 0) // 确保无符号
  }

  // 计算前导零个数(从第 p+1 位开始)
  _countLeadingZeros(hash, bits) {
    if (hash === 0) return bits
    let count = 0
    for (let i = bits - 1; i >= 0; i--) {
      if ((hash >>> i) & 1) break
      count++
    }
    return count + 1
  }

  // 添加一个元素
  add(value) {
    const hash = this._hash(value)
    // 取前 p 位作为桶索引
    const bucketIndex = hash >>> (32 - this.p)
    // 剩余位用于计算前导零
    const remainingBits = (hash << this.p) | 0
    const leadingZeros = this._countLeadingZeros(
      remainingBits >>> 0, 32 - this.p
    )
    // 更新桶的最大前导零
    if (leadingZeros > this.registers[bucketIndex]) {
      this.registers[bucketIndex] = leadingZeros
    }
    return this
  }

  // 批量添加
  addAll(values) {
    for (const v of values) this.add(v)
    return this
  }

  // 估计基数
  count() {
    // 计算调和平均数的倒数
    let sum = 0
    let zeros = 0
    for (let i = 0; i < this.m; i++) {
      sum += Math.pow(2, -this.registers[i])
      if (this.registers[i] === 0) zeros++
    }

    let estimate = this.alpha * this.m * this.m / sum

    // 小基数修正:使用线性计数法
    if (estimate <= 2.5 * this.m && zeros > 0) {
      estimate = this.m * Math.log(this.m / zeros)
    }

    // 大基数修正:接近 2^32 时的溢出修正
    if (estimate > Math.pow(2, 32) / 30) {
      estimate = -Math.pow(2, 32) * Math.log(1 - estimate / Math.pow(2, 32))
    }

    return Math.round(estimate)
  }

  // 合并另一个 HyperLogLog(取每个桶的最大值)
  merge(other) {
    if (this.m !== other.m) {
      throw new Error('Cannot merge HLL with different precision')
    }
    for (let i = 0; i < this.m; i++) {
      this.registers[i] = Math.max(this.registers[i], other.registers[i])
    }
    return this
  }

  // 序列化为 Uint8Array
  serialize() {
    return new Uint8Array(this.registers)
  }

  // 从 Uint8Array 反序列化
  static deserialize(data, precision) {
    const hll = new HyperLogLog(precision)
    hll.registers.set(data)
    return hll
  }
}

2.2 验证准确性

用随机数据验证实现的准确性:

// 测试 HyperLogLog 准确性
function testAccuracy(targetCount, precision) {
  const hll = new HyperLogLog(precision)
  const actual = new Set()

  for (let i = 0; i < targetCount; i++) {
    const value = `user_${Math.random().toString(36).slice(2)}_${i}`
    hll.add(value)
    actual.add(value)
  }

  const estimated = hll.count()
  const actualSize = actual.size
  const error = Math.abs(estimated - actualSize) / actualSize * 100

  console.log(`实际: ${actualSize}, 估计: ${estimated}, 误差: ${error.toFixed(2)}%`)
  return error
}

// 测试不同精度下的准确性
console.log('=== 不同精度下的准确性测试 ===')
for (const p of [10, 12, 14]) {
  console.log(`\n精度 p=${p}, 桶数=${1<<p}:`)
  testAccuracy(100000, p)
  testAccuracy(1000000, p)
}

输出示例:

=== 不同精度下的准确性测试 ===

精度 p=10, 桶数=1024:
实际: 100000, 估计: 101247, 误差: 1.25%
实际: 1000000, 估计: 993562, 误差: 0.64%

精度 p=12, 桶数=4096:
实际: 100000, 估计: 99876, 误差: 0.12%
实际: 1000000, 估计: 1002341, 误差: 0.23%

精度 p=14, 桶数=16384:
实际: 100000, 估计: 99923, 误差: 0.08%
实际: 1000000, 估计: 1000876, 误差: 0.09%

2.3 性能对比:HyperLogLog vs HashSet

// 性能对比测试
function benchmark(name, fn) {
  const start = performance.now()
  fn()
  const elapsed = performance.now() - start
  console.log(`${name}: ${elapsed.toFixed(2)}ms`)
}

const COUNT = 5000000

// HashSet 方案
benchmark('HashSet (500万元素)', () => {
  const set = new Set()
  for (let i = 0; i < COUNT; i++) {
    set.add(`user_${i}`)
  }
  console.log(`  内存: ~${(set.size * 40 / 1024 / 1024).toFixed(1)}MB, 基数: ${set.size}`)
})

// HyperLogLog 方案
benchmark('HyperLogLog p=14 (500万元素)', () => {
  const hll = new HyperLogLog(14)
  for (let i = 0; i < COUNT; i++) {
    hll.add(`user_${i}`)
  }
  const estimated = hll.count()
  const error = Math.abs(estimated - COUNT) / COUNT * 100
  console.log(`  内存: ${hll.registers.byteLength / 1024}KB, 估计: ${estimated}, 误差: ${error.toFixed(2)}%`)
})
方案 内存占用 写入速度 读取速度 精度
HashSet (500万) ~190MB 2.1s O(1) 精确 100%
HyperLogLog p=14 12KB 1.8s <1ms 99.19%
HyperLogLog p=10 1KB 1.6s <1ms 96.75%

⚡ **关键结论:**HyperLogLog 用 HashSet 0.006% 的内存实现了 99%+ 的精度。在需要统计大量去重数据的场景下,这是性价比最高的选择。

🚀 三、Redis HyperLogLog 实战

3.1 基本使用

Redis 内置了 HyperLogLog 实现,通过三个命令操作:

# 添加元素(返回 1 表示基数有变化,0 表示无变化)
PFADD page:uv:2026-06-11 "user_001" "user_002" "user_003"

# 获取基数估计值
PFCOUNT page:uv:2026-06-11
# => 3

# 合并多个 HyperLogLog(用于统计多天的 UV)
PFMERGE page:uv:week page:uv:2026-06-11 page:uv:2026-06-12 page:uv:2026-06-13
PFCOUNT page:uv:week
# => 周 UV 估计值

📌 **记住:**Redis 的 HyperLogLog 固定使用 12KB 内存(精度 p=14),无论存储多少元素。这是 Redis 中内存效率最高的数据结构。

3.2 Node.js 生产级封装

// Redis HyperLogLog 封装:UV 统计服务
import Redis from 'ioredis'

class UVCounter {
  constructor(redis, prefix = 'uv') {
    this.redis = redis
    this.prefix = prefix
  }

  // 记录用户访问
  async track(page, userId, date = this._today()) {
    const key = `${this.prefix}:${page}:${date}`
    // PFADD 返回 1 表示这是该用户今天的首次访问
    const isNew = await this.redis.pfadd(key, userId)
    // 设置过期时间(保留 90 天)
    await this.redis.expire(key, 90 * 86400)
    return isNew === 1
  }

  // 获取某页面某天的 UV
  async getUV(page, date = this._today()) {
    const key = `${this.prefix}:${page}:${date}`
    return this.redis.pfcount(key)
  }

  // 获取多天的总 UV(自动合并)
  async getMultiDayUV(page, startDate, endDate) {
    const keys = []
    const current = new Date(startDate)
    const end = new Date(endDate)
    while (current <= end) {
      keys.push(`${this.prefix}:${page}:${this._formatDate(current)}`)
      current.setDate(current.getDate() + 1)
    }
    return this.redis.pfcount(...keys)
  }

  // 合并多天数据到一个 key(减少 key 数量)
  async mergeToSummary(page, date) {
    const summaryKey = `${this.prefix}:${page}:summary`
    const dailyKey = `${this.prefix}:${page}:${date}`
    await this.redis.pfmerge(summaryKey, summaryKey, dailyKey)
    return this.redis.pfcount(summaryKey)
  }

  _today() {
    return this._formatDate(new Date())
  }

  _formatDate(date) {
    return date.toISOString().slice(0, 10)
  }
}

// 使用示例
const redis = new Redis()
const uv = new UVCounter(redis)

// 记录访问
await uv.track('/article/hyperloglog-guide', 'user_12345')

// 获取今日 UV
const todayUV = await uv.getUV('/article/hyperloglog-guide')
console.log(`今日 UV: ${todayUV}`)

// 获取本周 UV
const weekUV = await uv.getMultiDayUV(
  '/article/hyperloglog-guide',
  '2026-06-05',
  '2026-06-11'
)
console.log(`本周 UV: ${weekUV}`)

3.3 内存对比:三种 UV 统计方案

方案 1000 万 UV 内存 1 亿 UV 内存 精度 可否去重
Set 存储用户 ID ~380MB ~3.8GB 100%
Bitmap 用户 ID 偏移 ~12MB ~120MB 100%
HyperLogLog 12KB 12KB 99.19%

⚡ **关键结论:**在 UV 统计场景下,HyperLogLog 的内存优势是碾压级的。1 亿 UV 只需要 12KB,而 Set 需要 3.8GB。

⚠️ 四、避坑指南与最佳实践

4.1 常见陷阱

❌ 错误:对小数据集使用 HyperLogLog

// 错误:只有 100 个用户,用 Set 就够了
const hll = new HyperLogLog(14) // 12KB 内存
for (const user of users) hll.add(user)
// 估计值可能是 97 或 103,误差 3%

// ✅ 正确:小数据集直接用 Set
const set = new Set(users)
console.log(set.size) // 精确值 100

⚠️ **警告:**当基数小于 1000 时,HyperLogLog 的误差可能超过 5%。此时应该使用 Set 或数据库 COUNT(DISTINCT ...)

❌ 错误:用 HyperLogLog 判断元素是否存在

// 错误:HyperLogLog 无法判断某个元素是否已添加
hll.add('user_123')
// 没有 hll.has('user_123') 方法!
// HyperLogLog 只存储统计信息,不存储原始数据

// ✅ 正确:需要去重判断用 Set,只需要计数用 HyperLogLog
const set = new Set()
set.has('user_123') // 可以判断

❌ 错误:不同精度的 HyperLogLog 合并

// 错误:精度不同,桶数量不一致,无法合并
const hll1 = new HyperLogLog(10) // 1024 个桶
const hll2 = new HyperLogLog(14) // 16384 个桶
hll1.merge(hll2) // 报错!

// ✅ 正确:确保精度一致
const hll1 = new HyperLogLog(14)
const hll2 = new HyperLogLog(14)
hll1.merge(hll2) // 正确

4.2 生产环境最佳实践

✅ 选择合适的精度

根据业务需求选择精度,不要盲目追求最高精度:

// 业务场景 → 推荐精度
const PRECISION_MAP = {
  '实时大盘 UV':     14,  // 0.81% 误差,12KB 内存
  '每小时 UV 统计':  12,  // 1.6% 误差,3KB 内存
  'A/B 实验分流比':  10,  // 3.25% 误差,768B 内存
  '粗略流量估算':    8,   // 6.5% 误差,192B 内存
}

✅ Redis Key 命名规范

# 格式:业务:页面:时间粒度:日期
uv:homepage:daily:2026-06-11
uv:homepage:weekly:2026-W24
uv:homepage:monthly:2026-06
uv:article:blog-hyperloglog:daily:2026-06-11

✅ 批量操作减少网络往返

// ❌ 逐个添加,N 次网络往返
for (const userId of userIds) {
  await redis.pfadd(key, userId)
}

// ✅ 批量添加,1 次网络往返
await redis.pfadd(key, ...userIds) // Redis PFADD 支持多值

✅ 定期合并与清理

// 每月初合并上月数据并删除每日 key
async function monthlyMerge(page, year, month) {
  const summaryKey = `uv:${page}:monthly:${year}-${String(month).padStart(2, '0')}`
  const dailyKeys = []

  const daysInMonth = new Date(year, month, 0).getDate()
  for (let d = 1; d <= daysInMonth; d++) {
    const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}`
    dailyKeys.push(`uv:${page}:daily:${dateStr}`)
  }

  // 合并到月度 key
  await redis.pfmerge(summaryKey, ...dailyKeys)

  // 删除每日 key(节省 key 数量)
  if (dailyKeys.length > 0) {
    await redis.del(...dailyKeys)
  }
}

💡 五、扩展应用

5.1 实时去重告警

// 检测异常流量:每分钟 IP 数是否超过阈值
class AnomalyDetector {
  constructor(redis, threshold = 10000) {
    this.redis = redis
    this.threshold = threshold
  }

  async checkMinute(ip) {
    const minute = new Date().toISOString().slice(0, 16) // 2026-06-11T14:30
    const key = `anomaly:ips:${minute}`

    await this.redis.pfadd(key, ip)
    await this.redis.expire(key, 300) // 5 分钟后过期

    const count = await this.redis.pfcount(key)
    if (count > this.threshold) {
      console.warn(`⚠️ 异常流量: ${minute} 分钟内 ${count} 个不同 IP`)
      return true
    }
    return false
  }
}

5.2 多维分析:交叉统计

// 统计「同时访问页面 A 和页面 B 的用户数」
async function crossPageUV(redis, pageA, pageB, date) {
  const keyA = `uv:${pageA}:daily:${date}`
  const keyB = `uv:${pageB}:daily:${date}`
  const keyBoth = `uv:${pageA}+${pageB}:daily:${date}`

  // PFMERGE 实现交集的近似:|A∪B| = |A| + |B| - |A∩B|
  // 但我们无法直接得到交集,需要用公式推算
  const countA = await redis.pfcount(keyA)
  const countB = await redis.pfcount(keyB)
  await redis.pfmerge(keyBoth, keyA, keyB)
  const countUnion = await redis.pfcount(keyBoth)

  // 交集估计 = |A| + |B| - |A∪B|
  const countIntersection = Math.max(0, countA + countB - countUnion)

  return {
    pageA: countA,
    pageB: countB,
    union: countUnion,
    intersection: countIntersection
  }
}

📊 总结

维度 HyperLogLog HashSet Bitmap
内存效率 ⭐⭐⭐⭐⭐ ⭐⭐⭐
精度 99.19% 100% 100%
是否存储原始数据
适用基数范围 >1000 任意 ID 连续
合并操作 O(m) O(n) O(n)
Redis 原生支持 ✅ PFADD ✅ SETBIT

适用场景:

  • ✅ UV/PV 统计、独立用户计数
  • ✅ 实时流量监控、异常检测
  • ✅ A/B 实验分流比例验证
  • ✅ 多维数据分析(配合集合运算公式)

不适用场景:

  • ❌ 需要判断元素是否存在(用 Set / Bloom Filter)
  • ❌ 数据量小于 1000(直接用 Set)
  • ❌ 需要精确计数(用 COUNT(DISTINCT ...)
  • ❌ 需要存储原始数据(用数据库)

Redis 的 PFADD/PFCOUNT/PFMERGE 三个命令覆盖了 90% 的使用场景。12KB 内存统计 1 亿 UV,这就是概率数据结构的数学之美。

💡 **提示:**如果你的场景既需要去重计数又需要判断元素是否存在,可以组合使用 HyperLogLog(计数)+ Bloom Filter(存在性判断),两者都是内存高效的概率数据结构。

📚 相关文章