跳到主内容

从 12KB 到亿级 UV:HyperLogLog 实测避坑 ​

为什么亿级 UV 不能再用 Set 硬扛 ​

UV(Unique Visitor,独立访客)统计的本质是基数统计:在一个时间窗口内,对用户 ID、设备 ID 或 Cookie ID 去重后计数。业务量小时,很多系统会直接使用 Redis Set:

bash
# 看似简单,但每个用户 ID 都会真实占用内存
SADD page:home:uv:20260701 "user:10001"
SADD page:home:uv:20260701 "user:10002"
SCARD page:home:uv:20260701

Set 的优点是精确、可判断成员是否存在,也能做交集、并集、差集;但在亿级 UV 场景下,它的问题非常直接:每个元素都要存储字符串对象、哈希表或整数集合结构,键越大扩容和重分配成本越高。若一天有 1 亿独立用户,即使用紧凑的整数 ID,内存也通常达到数百 MB 级别;如果还要按页面、渠道、小时分别统计,内存会继续线性膨胀。

Redis 提供的 HyperLogLog(简称 HLL)是一种概率型基数估算数据结构。它不保存原始用户 ID,只保存每个哈希分桶中的最大前导零信息,因此标准 HLL 键最大只需要约 12KB 内存,就能统计接近 2^64 个元素的基数。Redis 官方给出的标准误差约为 0.81%,这意味着 1 亿 UV 的估算结果可能偏离约 81 万。对推荐页曝光、活动大屏、接口趋势分析通常可以接受,但对财务结算、精确计费类指标则不适合。

Set、Bitmap 与 HyperLogLog 怎么选 ​

在 Redis 实战中,UV 统计常见方案主要有三种:Set、Bitmap 和 HyperLogLog。它们不是简单的新旧替代关系,而是分别对应不同精度、内存和查询能力。

方案是否保存原始 ID内存特征精度适合场景主要限制
Set是随 UV 增长,亿级可达数百 MB精确小流量、需要成员集合、交并差计算高基数下内存压力大
Bitmap否,保存用户 ID 对应位约为最大用户 ID 位数;1 亿连续 ID 约 12MB精确用户 ID 可紧凑映射为非负整数ID 稀疏或跨度极大时浪费明显
HyperLogLog否,只保存概率摘要标准结构约 12KB约 0.81% 误差亿级 UV、PV 去重、按天/小时合并不能判断某用户是否访问,不能取交集

Bitmap 很容易被误认为一定比 HLL 更省内存,实际上要看 ID 空间。如果用户 ID 是从 1 连续增长到 1 亿,那么 Bitmap 每位对应一个用户,1 亿位约 12.5MB,确实比 Set 小很多,也能得到精确 UV:

bash
# 用户 ID 必须能映射成紧凑的非负整数偏移量
SETBIT page:home:uv:20260701 10001 1
SETBIT page:home:uv:20260701 10002 1
BITCOUNT page:home:uv:20260701

但如果设备 ID 是 UUID、哈希字符串,或者整数 ID 跨度极大,就需要额外维护映射表,而且 Bitmap 大小由最大偏移量而不是实际 UV 决定。HyperLogLog 则与 ID 是否连续无关,无论输入是整数、UUID 还是设备指纹,内存上限都稳定在约 12KB。

HyperLogLog 的写入、估算与合并流程 ​

下面这张图展示了典型的 UV 统计链路:业务服务把访问事件写入按小时或按天分片的 HLL,定时任务再合并成日 UV、页面 UV 或全站 UV。

访问事件用户/设备 IDPFADD 写入小时/页面 HLLPFCOUNT读取估算 UV报表/大屏趋势分析PFMERGE合并多个时间片日/周 UV自动去重

用 redis-cli 跑通 PFADD、PFCOUNT、PFMERGE ​

以下命令可以直接在本机 Redis 上执行。为了避免污染现有数据,先清理测试键:

bash
# 清理旧的测试键
DEL uv:home:20260701:10 uv:home:20260701:11 uv:home:20260701 uv:user:10001

# 10 点这一小时,用户 10001、10002 访问首页
PFADD uv:home:20260701:10 "user:10001" "user:10002"

# 11 点,用户 10002 再次访问,同时新用户 10003 访问
# HyperLogLog 会在合并后自动完成跨小时去重
PFADD uv:home:20260701:11 "user:10002" "user:10003"

# 分别查看两个小时的估算 UV
PFCOUNT uv:home:20260701:10
PFCOUNT uv:home:20260701:11

# 合并两个小时为当天首页 UV
PFMERGE uv:home:20260701 uv:home:20260701:10 uv:home:20260701:11

# 结果应为 3,而不是 2 + 2 = 4
PFCOUNT uv:home:20260701

关键点是:PFMERGE 不是把计数值相加,而是对多个 HLL 摘要逐桶取最大值后重新估算。因此,同一用户在多个小时重复出现时,合并结果仍然是去重后的基数。这也是 HLL 适合做日 UV、周 UV、活动累计 UV 的核心原因。

再看不同页面的合并:

bash
# 首页和详情页分别记录访问用户
PFADD uv:home "u1" "u2" "u3"
PFADD uv:detail "u2" "u3" "u4" "u5"

# 合并为全站 UV:u1、u2、u3、u4、u5,共 5 个独立用户
PFMERGE uv:site uv:home uv:detail
PFCOUNT uv:site

需要特别注意,HLL 只能可靠表达并集去重,不能通过两个 HLL 反推出交集人数。虽然可以用公式估算交集:|A∩B| ≈ |A| + |B| - |A∪B|,但三个估算误差会叠加,集合越接近、交集占比越高,结果越不稳定。若业务必须精确计算同时访问 A 页面和 B 页面的用户,应考虑 Set、Bitmap 或离线明细计算。

12KB 内存上限如何验证 ​

Redis 的 HLL 在元素较少时会使用稀疏表示,内存可能远小于 12KB;随着基数升高,会逐步转为密集表示。密集表示包含固定数量的寄存器,最终占用约 12KB。注意,MEMORY USAGE 看到的是 Redis 对象层面的实际分配字节,会包含对象头、字典项等额外开销,因此结果可能略大于理论上的 HLL 寄存器大小。

可以用 Lua 构造一批连续 ID 进行观察:

bash
# 向 HLL 写入 100 万个连续用户 ID
redis-cli --eval /tmp/hll_fill.lua uv:test:1m , 1000000

# 查看估算值,理论标准误差约为 0.81%
redis-cli PFCOUNT uv:test:1m

# 查看 Redis 实际分配内存;高基数下通常接近十几 KB
redis-cli MEMORY USAGE uv:test:1m

其中 /tmp/hll_fill.lqlua 文件内容如下,注意文件后缀建议保存为 .lua:

lua
-- KEYS[1]:HyperLogLog key
-- ARGV[1]:要写入的用户数量
local key = KEYS[1]
local total = tonumber(ARGV[1])
local batch = 1000
local start = 0

while start < total do
  local args = {key}
  local stop = math.min(start + batch, total)

  for i = start + 1, stop do
    -- 真实业务中可替换为 UUID、设备 ID 等字符串
    table.insert(args, "user:" .. i)
  end

  redis.call("PFADD", unpack(args))
  start = stop
end

return redis.call("PFCOUNT", key)

如果没有脚本文件,也可以用 shell 批量写入,但生产环境不要无控制地循环执行大量单条命令:

bash
# 简单压测:生成 100 万个用户 ID,每批 1000 个写入
rm -f /tmp/hll_users.txt
seq 1 1000000 | awk 'BEGIN{batch=0} {
  printf "PFADD uv:test:1m user:%s", $0
  batch++
  if (batch == 1000) { print ""; batch=0 } else { printf " " }
}' | redis-cli --pipe

redis-cli PFCOUNT uv:test:1m
redis-cli MEMORY USAGE uv:test:1m

在多键维度下,12KB 的优势会被放大。例如 1000 个页面、保留 30 天日 UV,仅 HLL 本体约为 1000 × 30 × 12KB ≈ 360MB;若再按小时分片,键数量会扩大到 24 倍,虽然单个键仍小,但总内存和元数据开销不可忽略。因此,12KB 是“单键上限”,不是“整个系统只花 12KB”。

生产落地的避坑总结 ​

1. 不要把 PFCOUNT 当成精确计数,也不要用于强一致结算 ​

常见现象是运营发现活动报名人数后台为 1,000,000,HLL 显示 993,000 或 1,007,000,就怀疑 Redis 丢数据。实际上这是概率算法的正常误差。标准 HyperLogLog 的误差约 0.81%,低基数阶段还可能受稀疏编码和计数修正影响。

修正方法是在产品层面明确“估算 UV”口径:报表展示为约数或增加误差说明;涉及支付、抽奖中奖资格、审计对账的数据,使用数据库唯一索引、精确去重表或 Bitmap 等精确方案;HLL 只承担高基数、可容忍误差的实时统计。

2. 不要用 HLL 判断某个用户是否访问过 ​

有些开发者会尝试写入后再通过某种 HLL 命令查询用户是否存在,这在 Redis 中没有对应能力。HLL 只保存哈希后的寄存器摘要,不保存成员集合,也无法反推出原始 ID。

修正方法是:如果需要“今天是否已访问”判定,使用 Bitmap 的 GETBIT、Set 的 SISMEMBER,或维护独立的布尔状态键;HLL 仅用于回答“大概有多少个不同用户”。例如防刷限频应使用计数器或布隆过滤器配合精确校验,而不是 PFCOUNT。

3. 合并口径必须统一,不能把小时 UV 当数值相加 ​

错误写法是分别执行 PFCOUNT uv:10、PFCOUNT uv:11,然后在应用层相加得到日 UV。这样重复访问用户会被计算多次。另一个错误是把页面 UV、渠道 UV、全站 UV 混在同一个键中,最后无法拆分来源。

修正方法是预先设计键名:uv:{page}:{date}:{hour} 保存原子时间片,uv:{page}:{date} 保存日合并结果,uv:site:{date} 保存全站合并结果;统计跨时间片或跨维度并集时统一使用 PFMERGE,再执行 PFCOUNT。

4. 控制键数量、过期时间和合并任务,避免小键风暴 ​

单个 HLL 最大约 12KB,但如果按“页面 × 渠道 × 地区 × 小时”无限组合建键,会产生大量稀疏键和过期扫描压力。常见现象是 Redis 键数量暴涨、主从复制延迟升高、RDB 文件变大,业务却只查询少数几个大维度。

修正方法是提前固化统计维度,对长尾维度做聚合或采样;给日键、小时键设置不同 TTL,例如小时键保留 7 天、日键保留 400 天;合并任务在业务低峰执行,并限制单次 PFMERGE 的源键数量。若维度组合非常稀疏,优先评估是否应该写入消息队列或数仓,而不是在 Redis 中无限创建 HLL。

5. 注意 ID 归一化,否则同一用户会被算成多人 ​

同一个用户如果在不同入口分别写成 10001、user:10001、User:10001,HLL 会视为三个不同元素;用户未登录时使用设备 ID,登录后又使用会员 ID,如果不做身份贯通,也会造成 UV 偏大。

修正方法是在写入前统一 ID 规范:固定前缀、大小写规则和哈希算法;登录前后通过 ID-Mapping 选择统一主体 ID;无法贯通时分别统计“访客 UV”和“登录会员 UV”,不要混成一个指标。

选型结论 ​

Redis HyperLogLog 的价值不是替代所有去重方案,而是在“高基数、可接受约 0.81% 误差、只关心基数和并集”的场景下,用约 12KB 的单键成本替代百 MB 级 Set。若 ID 连续且需要精确 UV,Bitmap 仍是强选择;若需要成员明细、交集或审计能力,Set 或离线数仓更合适。把数据结构能力、指标误差和键治理一起设计,才能让亿级 UV 统计既快、又省、还能长期稳定运行。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。