1. 项目背景
1.1 业务场景
数据团队和运营团队对统计数据的实时性要求越来越高。产品总监每天早上 9 点要看昨天的核心数据报表,包括:
- 接口 UV:商品详情接口、下单接口、支付接口每天的独立访客数。
- 活动 UV:各个营销活动(618、双十一、周年庆)的独立访客数,还要分渠道——App、Web、小程序分别多少,合起来去重后总共有多少。
- 内容曝光去重:首页推荐流的每条内容曝光了多少个独立用户,运营要根据这个数据优化推荐策略。
第一版实现用的是 Redis Set。每个统计维度一个 Set key——uv:api:product:20260718,每次用户访问就 SADD uv:api:product:20260718 uid,统计时 SCARD。这个方案精准、易理解、能回答"某用户是否在集合中"。
问题在三个月后暴露。天天有运营和 BI 同事跑新的统计需求,Set key 越来越多。某个活动页同时配置了 20 个埋点,每个埋点一个 Set;商品详情接口每天 UV 达到 3000 万;首页 50 个内容位各需要独立 UV。到第三个月时,Redis 里积累了超过 8000 个 Set key,总内存从 8GB 涨到 45GB。运维开始频繁收到内存告警。
1.2 痛点
| 指标 | Set 方案 | HyperLogLog 方案 | 差异 |
|---|---|---|---|
| 3000 万 UV 的内存 | ≈ 3000万 × (8+16) 字节 ≈ 720MB | 约 12KB(固定) | 60000 倍 |
| 20 个埋点 × 3000 万 UV | 20 × 720MB ≈ 14.4GB | 20 × 12KB ≈ 240KB | 也是 60000 倍 |
| 准确性 | 100% 精确 | 标准误差 0.81%(Redis 实现) | 99.19% 正确 |
| 能否查询具体用户 | 可以(SMEMBERS/SISMEMBER) | 不可以(只存概率结构) | Set 胜 |
| 能否删除成员 | 可以(SREM) | 不可以(不可逆) | Set 胜 |
核心矛盾是:运营和 BI 要的是统计数字,不是精确名单。一个活动页 UV 是 10,000,000 还是 10,000,137,对决策几乎没有影响。但 720MB vs 12KB 的内存差异,直接决定了 Redis 能不能在高频统计场景下撑住。
HyperLogLog 就是为这种场景设计的——它用概率算法把基数估算的空间复杂度压缩到常数级,代价是约 0.81% 的标准误差和无法枚举成员。
1.3 本章目标
掌握 Redis HyperLogLog 的三种实战模式:
- 单一指标 UV:
PFADD+PFCOUNT统计某个接口/活动的独立访客数。 - 多维合并:
PFMERGE合并渠道/天/周的 UV,实现分片统计后汇总。 - Set vs HLL 对比实验:实际比较两种方案在十万、百万、千万规模下的内存和精度差异。
同时理解三个关键边界:
- HLL 不能取成员的代价——需要走名单的场景(发券、抽奖资格)仍要靠 Set 或数据库。
- 统计口径的一致性——
userId、deviceId、cookieId混用会导致数据失真。 PFCOUNT在小数据(<几千)时的行为——误差可能比 0.81% 大,但绝对误差很小。
2. 项目设计
2.1 第一幕:去重还要"近似"?
小胖:"去重还要近似?我卖了 100 个包子,少算一个都不行啊!UV 统计不就是要算清楚到底多少个不同用户访问了吗?"
小白:"但是你卖的包子是精确结算——少一个就少收钱。UV 统计不是结算数据,是运营看趋势用的。屏幕上写着'昨日 UV:10,237,189'和'昨日 UV:10,281,000',对运营做决策的影响几乎一样。"
大师:"我讲个类比。你去参加一场演唱会,主办方要知道今天来了多少人。第一种方案是每个人进门时登记身份证号——这能得到精确数字,但需要很大内存存所有人的身份证号。第二种方案是门口放一个计数器,有人进门就按一下——这样很省事,但计数器坏了就全错了。HyperLogLog 是第三种方案——它用一个聪明的数学算法,只需要一小块内存,就能估算出大概有多少人进了门。误差大约 0.81%,对于演唱会来说,3 万人还是 3 万零 240 人,主办方无所谓。"
技术映射:不是所有数据都需要精确。结算、库存、余额必须精确;UV、独立设备数、广告曝光人数通常是趋势指标。HyperLogLog 用 12KB 固定内存换 0.81% 误差。
小胖追问:"那 HLL 为什么不能像 Set 一样告诉我到底有哪些用户来过了?"
大师:"这是概率算法的本质决定的。HLL 不保存每个成员,只保存一些用于估算的'桶信息'。就像门口计数器只记录'有多少人经过',不记录'都是谁'。你加入一千万用户,HLL 的大小几乎不变——因为你只是更新了桶里的统计信息,不是把一千万个用户 ID 存下来。"
Set 的原理(类比):
门口工作人员举着本子登记:
张三来了 → 写"张三"
李四来了 → 写"李四"
张三又来了 → 翻本子发现"张三"已经有了,不重复写
→ 一天下来本子上有 10000 个名字,本子很厚,但能查"张三来没来过"
HLL 的原理(类比):
门口放了 16384 个小桶(=寄存器),有个数学规则:
张三来了 → 算出一个哈希值,比如 0x3A... → 根据规则更新某个桶
李四来了 → 再算哈希,更新另一个桶
张三又来了 → 哈希一样,同一桶不变化
→ 一天下来,根据 16384 个桶的值,用公式估算出大概来了 10000 人
→ 你不能查"张三来没来过",但你能知道"大概 10000 人"
2.2 第二幕:分渠道统计与合并
小胖:"那如果我 App、Web、小程序各一个 HLL,运营想把三个渠道合并看总 UV,怎么办?"
大师:"PFMERGE 就是做这个的。它的底层是把多个 HLL 的桶信息合并成一个新的 HLL——不需要访问原始数据,合并完 PFCOUNT 就能估算总 UV。"
# App 端 UV
PFADD uv:618:app:20260718 1001 1002 1003
PFADD uv:618:web:20260718 1003 1004 1005
PFADD uv:618:mini:20260718 1004 1005 1006
# 合并统计全渠道总 UV
PFMERGE uv:618:all:20260718 uv:618:app:20260718 uv:618:web:20260718 uv:618:mini:20260718
PFCOUNT uv:618:all:20260718
# 返回大约 6(去重后:1001,1002,1003,1004,1005,1006)
小白:"那如果我想看周 UV,是不是要把 7 天的 HLL 合并?"
大师:"对,但这里有一个重要的统计概念——如果你把每天的 PFCOUNT 直接相加,得到的是'7 天 UV 的算术和',不是'7 天独立 UV'。因为你无法排除同一个用户跨天重复访问。正确做法是用 PFMERGE 合并 7 天的 HLL,再 PFCOUNT。"
# 错误:直接加每天的 PFCOUNT
PFCOUNT uv:day1 → 1000
PFCOUNT uv:day2 → 1200
# 算术和: 2200 ← 这夸大了独立用户数(可能有 300 人两天都来了)
# 正确:合并 HLL 后再统计
PFMERGE uv:week1 uv:day1 uv:day2 uv:day3 ... uv:day7
PFCOUNT uv:week1 → 约 1900 ← 去重后的周独立用户数
2.3 第三幕:误差容忍的边界
小胖:"0.81% 的误差到底意味着什么?在多少数据量下这个误差是稳定的?"
大师:"Redis 的 HyperLogLog 使用的是 16384(2^14)个寄存器,理论标准误为 1.04/√16384 ≈ 0.81%。但有几个关键事实:"
数据规模与误差的关系:
┌────────────┬────────────┬──────────────┐
│ 基数规模 │ 理论误差 │ 实际表现 │
├────────────┼────────────┼──────────────┤
│ < 100 │ 误差可能 >5%│ 绝对误差很小 │
│ 100-1000 │ 1%-5% │ ±10 到 ±50 │
│ 1000-100万 │ ~0.81% │ 与理论吻合 │
│ > 100万 │ < 1% │ HLL 最擅长的区间│
└────────────┴────────────┴──────────────┘
关键理解:
1. 小基数时相对误差大,但绝对误差小
(实际 10 个用户,HLL 可能报 9 或 11 → 误差 10%,但只差 1 个)
2. 大基数时相对误差稳定在 0.81%
(实际 1000 万用户,HLL 报 991.9 万~1008.1 万)
3. Redis 对小基数有优化(稀疏编码),内存占用更少
技术映射:HLL 是一种"以相对误差换绝对空间"的算法。越大规模的数据,HLL 的相对误差越稳定,空间优势越明显。
2.4 第四幕:统计口径——一条被忽视的暗线
小白:"如果我今天用 userId 做去重,明天改用 deviceId,数据就完全不可比了。统计口径的不一致比 HLL 的误差更可怕。"
大师:"这是统计系统最容易掉进的坑。不是 Redis 的问题,而是业务建模的问题。几个常见陷阱:"
场景 1: 用户未登录
方案 A: 用设备 ID → 同一个人换设备被算成 2 个 UV
方案 B: 用 cookie → 同一个人清除 cookie 被算成新 UV
方案 C: 用 IP → 公司 NAT 下的 100 个人被算成 1 个 UV
场景 2: 跨端合并
App 用 deviceId, Web 用 cookieId, 小程序用 openId
三个 HLL 合并 → 数据完全不可比
场景 3: 统计口径变化
6 月之前用 userId, 7 月改用 deviceId
环比数据断崖 → 运营以为是流量暴增
大师:"所以团队的统计规范比数据结构选型更重要。确定一个统一口径——比如'已登录用户用 userId、未登录用户用设备 ID',所有人遵守,整个报表才有可能比性。"
技术映射:HLL 只管"估算有多少不同元素",不管"这些元素代表什么"。统计口径的规范性是数据质量的基石。
3. 项目实战
3.1 环境准备
docker exec -it redis-lab redis-cli -a redis123
3.2 实战一:接口 UV 统计
步骤 1:记录 UV
# 商品详情接口 UV — 用户访问即记录
127.0.0.1:6379> PFADD uv:api:product:20260718 1001 1002 1003
(integer) 1 # 返回 1 表示基数发生了变化
127.0.0.1:6379> PFADD uv:api:product:20260718 1002 1003 1004
(integer) 1 # 1002,1003 重复了,但 1004 是新的,基数仍变化
# 再次添加完全重复的成员
127.0.0.1:6379> PFADD uv:api:product:20260718 1001 1002 1003 1004
(integer) 0 # 返回 0 表示基数没有变化(没有新成员)
# 统计 UV
127.0.0.1:6379> PFCOUNT uv:api:product:20260718
(integer) 4 # 去重后共 4 个独立用户
步骤 2:Java 实现
public class UvStatsService {
// 记录单次访问
public void recordVisit(String business, String date, String visitorId) {
String key = String.format("uv:%s:%s", business, date);
redis.pfAdd(key, visitorId);
// 保留 90 天
redis.expire(key, Duration.ofDays(90));
}
// 查询 UV
public long getUv(String business, String date) {
String key = String.format("uv:%s:%s", business, date);
Long count = redis.pfCount(key);
return count == null ? 0 : count;
}
// 统一获取 visitorId(口径一致)
private String getVisitorId(HttpRequest request) {
// 已登录用户用 userId
if (request.getUserId() != null) {
return "uid:" + request.getUserId();
}
// 未登录用户用 deviceId
if (request.getDeviceId() != null) {
return "did:" + request.getDeviceId();
}
// 兜底:用 IP(不推荐,但胜于无)
return "ip:" + request.getClientIp();
}
}
3.3 实战二:分渠道统计与合并
步骤 1:分别记录各渠道
# App、Web、小程序分别记录
127.0.0.1:6379> PFADD uv:618:app:20260718 1001 1002 1003 1004
(integer) 1
127.0.0.1:6379> PFADD uv:618:web:20260718 1003 1004 1005 1006
(integer) 1
127.0.0.1:6379> PFADD uv:618:mini:20260718 1005 1006 1007 1008
(integer) 1
# 各渠道独立 UV
127.0.0.1:6379> PFCOUNT uv:618:app:20260718
(integer) 4
127.0.0.1:6379> PFCOUNT uv:618:web:20260718
(integer) 4
127.0.0.1:6379> PFCOUNT uv:618:mini:20260718
(integer) 4
步骤 2:合并全渠道 UV
# 合并三个 HLL
127.0.0.1:6379> PFMERGE uv:618:all:20260718 uv:618:app:20260718 uv:618:web:20260718 uv:618:mini:20260718
OK
# 全渠道去重 UV
127.0.0.1:6379> PFCOUNT uv:618:all:20260718
(integer) 8 # 8 个独立用户(1001~1008),各渠道重复的已去重
# 设置临时 key 的 TTL
127.0.0.1:6379> EXPIRE uv:618:all:20260718 86400
(integer) 1
步骤 3:跨天合并(周 UV)
# 假设已有 7 天的数据
127.0.0.1:6379> PFADD uv:api:product:20260712 1001 1002
127.0.0.1:6379> PFADD uv:api:product:20260713 1002 1003
127.0.0.1:6379> PFADD uv:api:product:20260714 1001 1004
# ... 省略中间 ...
# 合并成周 UV
127.0.0.1:6379> PFMERGE uv:api:product:2026W29 uv:api:product:20260712 uv:api:product:20260713 uv:api:product:20260714 uv:api:product:20260715 uv:api:product:20260716 uv:api:product:20260717 uv:api:product:20260718
OK
127.0.0.1:6379> PFCOUNT uv:api:product:2026W29
(integer) 4 #(以上示例数据共 4 个独立用户)
# 周 UV 保留更久
127.0.0.1:6379> EXPIRE uv:api:product:2026W29 864000
(integer) 1
步骤 4:Java 多维统计
public class MultiDimensionUvService {
// 记录单维 UV(如某活动某渠道某天)
public void record(String activity, String channel, String date, String visitorId) {
String key = String.format("uv:%s:%s:%s", activity, channel, date);
redis.pfAdd(key, visitorId);
redis.expire(key, Duration.ofDays(90));
}
// 单渠道 UV
public long getChannelUv(String activity, String channel, String date) {
return redis.pfCount(String.format("uv:%s:%s:%s", activity, channel, date));
}
// 全渠道合并 UV
public long getAllChannelUv(String activity, String date, String... channels) {
String dstKey = String.format("uv:%s:all:%s", activity, date);
String[] srcKeys = Arrays.stream(channels)
.map(ch -> String.format("uv:%s:%s:%s", activity, ch, date))
.toArray(String[]::new);
redis.pfMerge(dstKey, srcKeys);
redis.expire(dstKey, Duration.ofDays(7));
return redis.pfCount(dstKey);
}
// 周 UV(合并 7 天)
public long getWeeklyUv(String activity, String channel, List<String> dates) {
String dstKey = String.format("uv:%s:%s:weekly:%s", activity, channel, dates.get(0));
String[] srcKeys = dates.stream()
.map(d -> String.format("uv:%s:%s:%s", activity, channel, d))
.toArray(String[]::new);
redis.pfMerge(dstKey, srcKeys);
redis.expire(dstKey, Duration.ofDays(30));
return redis.pfCount(dstKey);
}
}
3.4 实战三:Set vs HLL 内存对比实验
这个实验直观展示 HLL 的空间优势:
# === Set 方案 ===
# 模拟 10 万个独立用户访问
# (实际生产中用脚本批量写入,这里展示概念)
127.0.0.1:6379> DEL uv:set:test
(integer) 1
# 写入 100000 个用户(略去批量写入脚本)
127.0.0.1:6379> SCARD uv:set:test
(integer) 100000
127.0.0.1:6379> MEMORY USAGE uv:set:test
(integer) 4288960 # ≈ 4.1 MB
# === HLL 方案 ===
127.0.0.1:6379> DEL uv:hll:test
(integer) 1
# 写入同样的 100000 个用户
127.0.0.1:6379> PFCOUNT uv:hll:test
(integer) 99872 # 约 99.87% 准确率(0.13% 误差)
127.0.0.1:6379> MEMORY USAGE uv:hll:test
(integer) 12876 # ≈ 12.6 KB
# 内存对比: 4.1 MB vs 12.6 KB → HLL 小约 326 倍!
关键观察:10 万用户时 HLL 内存固定约 12KB,而 Set 随着成员数量线性增长。到 1000 万用户时,Set 约需 400MB,HLL 仍只需 12KB。
3.5 实战四:HLL 的精度验证
# 验证 HLL 在小数据规模下的表现
# 1 个成员
127.0.0.1:6379> DEL hll:acc:test
127.0.0.1:6379> PFADD hll:acc:test 1001
(integer) 1
127.0.0.1:6379> PFCOUNT hll:acc:test
(integer) 1 # 精确
# 10 个不同成员
127.0.0.1:6379> PFADD hll:acc:test 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010
(integer) 1
127.0.0.1:6379> PFCOUNT hll:acc:test
(integer) 10 # 精确(小数据下常精确)
# 少量重复不影响
127.0.0.1:6379> PFADD hll:acc:test 1001 1001 1001
(integer) 0
127.0.0.1:6379> PFCOUNT hll:acc:test
(integer) 10 # 不变
3.6 实战五:HLL 合并的应用模式
# 场景:每台服务器独立统计,定时汇总到中心
# 服务器 1 上的 HLL
127.0.0.1:6379> PFADD uv:server:1:20260718 1001 1002 1003
(integer) 1
# 服务器 2 上的 HLL
127.0.0.1:6379> PFADD uv:server:2:20260718 1003 1004 1005
(integer) 1
# 服务器 3 上的 HLL
127.0.0.1:6379> PFADD uv:server:3:20260718 1002 1005 1006
(integer) 1
# 汇总:合并所有服务器的 HLL
127.0.0.1:6379> PFMERGE uv:global:20260718 uv:server:1:20260718 uv:server:2:20260718 uv:server:3:20260718
OK
127.0.0.1:6379> PFCOUNT uv:global:20260718
(integer) 6 # 去重后 6 个独立用户(1001~1006)
# 各服务器分别统计 + 汇总 = 全局去重 UV
# 这是 HLL 最经典的分治统计模式
3.7 实战六:HLL 在微服务架构中的落地模式
在微服务架构中,每个服务实例本地维护自己的 HLL,定时汇聚到中心 Redis 或者由专门的统计服务负责合并。这种模式避免了每次访问都跨网络写中心 Redis。
public class LocalHllCollector {
// 每个服务实例本地内存中的临时计数器(避免每次访问都写 Redis)
private final ConcurrentHashMap<String, LongAdder> localBuffer = new ConcurrentHashMap<>();
private static final int FLUSH_THRESHOLD = 100; // 攒够 100 条 flush 一次
private static final int FLUSH_INTERVAL_MS = 5000; // 或 5 秒 flush 一次
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
@PostConstruct
public void init() {
// 定时强制 flush
scheduler.scheduleAtFixedRate(this::flushToRedis,
FLUSH_INTERVAL_MS, FLUSH_INTERVAL_MS, TimeUnit.MILLISECONDS);
}
// 记录访问(先写本地内存,攒批后写 Redis)
public void record(String metric, String visitorId) {
String localKey = metric + ":" + visitorId;
LongAdder counter = localBuffer.computeIfAbsent(localKey, k -> new LongAdder());
counter.increment();
// 攒够阈值就 flush
if (localBuffer.size() >= FLUSH_THRESHOLD) {
flushToRedis();
}
}
private void flushToRedis() {
if (localBuffer.isEmpty()) return;
// 快照后再清空(避免持有锁过久)
Map<String, LongAdder> snapshot = new HashMap<>(localBuffer);
localBuffer.clear();
// 按指标分组,批量 PFADD
Map<String, List<String>> groupedByMetric = new HashMap<>();
for (Map.Entry<String, LongAdder> entry : snapshot.entrySet()) {
String[] parts = entry.getKey().split(":", 2);
String metric = parts[0]; // 如 "uv:api:product:20260718"
String visitorId = parts[1]; // 如 "uid:1001"
groupedByMetric.computeIfAbsent(metric, k -> new ArrayList<>()).add(visitorId);
}
// Pipeline 批量写入 Redis
redis.executePipelined((RedisCallback<Object>) connection -> {
for (Map.Entry<String, List<String>> entry : groupedByMetric.entrySet()) {
byte[] key = entry.getKey().getBytes();
byte[][] values = entry.getValue().stream()
.map(String::getBytes).toArray(byte[][]::new);
connection.pfAdd(key, values);
}
return null;
});
log.debug("Flushed {} metrics, {} unique ids to Redis",
groupedByMetric.size(), snapshot.size());
}
// 服务关闭时确保数据不丢失
@PreDestroy
public void shutdown() {
scheduler.shutdown();
flushToRedis();
}
}
关键设计要点:
- 本地内存 buffer 减少网络开销——每次访问不必写一次 Redis。
- 两阶段刷新(数量阈值 + 时间阈值)保证数据不丢失。
@PreDestroy确保优雅关闭时最后一批数据被刷入 Redis。- 这种模式也适用于 Set 的
SADD、Bitmap 的SETBIT等高频写入场景。
3.8 实战七:HLL 监控与告警
HLL 本身虽然省内存,但如果不加管理,key 数量也会无限膨胀——特别是按"天+渠道+活动"多维组合后,可能产生笛卡尔积爆炸。
public class HllMonitoringService {
// 监控 HLL key 的数量趋势
@Scheduled(cron = "0 0 * * * *") // 每小时执行
public void monitorHllKeys() {
// 扫描所有 HLL 相关的 key
long totalHllKeys = scanAndCount("uv:*");
long noTtlKeys = scanNoTtlKeys("uv:*");
long oldKeys = scanOldKeys("uv:*", 90); // 超过 90 天未更新的 key
// 上报监控指标
metricsCollector.gauge("redis.hll.total_keys", totalHllKeys);
metricsCollector.gauge("redis.hll.no_ttl_keys", noTtlKeys);
metricsCollector.gauge("redis.hll.stale_keys", oldKeys);
// 告警条件
if (noTtlKeys > 1000) {
alertService.send("HLL 无 TTL key 数量超过 1000,当前: " + noTtlKeys);
}
if (oldKeys > 500) {
alertService.send("HLL 过期 key 超过 500,建议清理,当前: " + oldKeys);
}
}
// 清理策略
@Scheduled(cron = "0 0 3 * * *") // 每天凌晨 3 点
public void cleanStaleHllKeys() {
// 清理 180 天前的日 UV key
String threshold = LocalDate.now().minusDays(180)
.format(DateTimeFormatter.BASIC_ISO_DATE);
// ... 扫描 + DEL 过期 key
log.info("HLL 过期 key 清理完成");
}
}
3.9 实战八:HLL 误差的业务影响评估
在实际业务落地 HLL 前,建议做一次"离线对比"——用同一天的真实数据,分别用 Set 和 HLL 统计,对比误差是否在可接受范围。
# 假设有一个脚本 batch_compare.sh:
# Step 1: 从日志中提取某天所有独立用户 ID
cat access_20260718.log | awk '{print $3}' | sort -u > users_20260718.txt
# Step 2: 写入 Set 并精确统计(真实值)
# (500 万用户,耗时约 2 分钟)
redis-cli -a redis123 --eval compare_set.lua , 20260718 < users_20260718.txt
# 结果: SCARD uv:exact:20260718 → 5,234,891
# Step 3: 写入 HLL 并估算
# (500 万用户,耗时约 30 秒)
redis-cli -a redis123 --eval compare_hll.lua , 20260718 < users_20260718.txt
# 结果: PFCOUNT uv:hll:20260718 → 5,215,000
# Step 4: 计算误差
# 误差 = (5,215,000 - 5,234,891) / 5,234,891 = -0.38%
# → 在 0.81% 的理论范围内,业务可接受
业务决策模板:
| 误差范围 | 评估 | 建议 |
|---|---|---|
| < 0.5% | 优秀 | 完全可用 |
| 0.5% - 1.0% | 标准范围 | 符合理论,可接受 |
| 1.0% - 2.0% | 偏高 | 检查数据量是否太小(< 1000);或考虑是否该用 Set |
| > 2.0% | 异常 | 检查 visitorId 的分布(极不均匀可能影响 HLL 精度) |
4. 项目总结
4.1 核心命令速查
| 命令 | 用途 | 复杂度 | 注意事项 |
|---|---|---|---|
PFADD key element... |
添加元素到 HLL | O(1) | 返回 1=基数变化, 0=无变化 |
PFCOUNT key... |
估算基数 | O(1) 单 key, O(N) 多 key | 多 key 时内部临时合并(不生成新 key) |
PFMERGE dst src1 src2... |
合并多个 HLL | O(N) | 生成新 key,需设 TTL |
4.2 HyperLogLog vs Set 对比
| 维度 | Set | HyperLogLog |
|---|---|---|
| 内存占用 | 与成员数线性增长 | 固定约 12KB |
| 精确度 | 100% 精确 | 标准误差 0.81% |
| 能否枚举成员 | 可以(SMEMBERS/SSCAN) | 不可以 |
| 能否判断存在 | 可以(SISMEMBER) | 不可以 |
| 能否删除成员 | 可以(SREM) | 不可以 |
| 能否合并去重 | SINTER/SUNION(生成新 Set) | PFMERGE(生成新 HLL) |
| 千万 UV 内存 | ≈ 400 MB | ≈ 12 KB |
| 适用规模 | 几千 ~ 几万 | 几十万 ~ 几亿 |
4.3 适用与不适用场景
适用场景:
- 接口/页面 UV 统计——每日独立访客数,误差在运营可接受范围内。
- 活动独立参与人数——多活动多日期多维度,内存固定。
- 广告曝光去重——每个广告位独立统计曝光独立用户数。
- 搜索词独立搜索人数——每个搜索词每天有多少独立用户搜索。
- 分服务器统计后汇总——每台服务器本地记录 HLL,定时
PFMERGE汇总到中心。 - 独立设备数——按设备 ID 统计,适合 IoT/移动端。
不适用场景:
- 需要给用户发推送/优惠券的场景——必须拿到具体用户列表(用 Set 或数据库)。
- 风控黑名单——需要精确判断"这个用户是否在黑名单里"(用 Set 的 SISMEMBER)。
- 抽奖资格验证——"用户是否参与过活动"必须精确(用 Set 或 Bloom Filter)。
- 需要回溯修正的场景——HLL 不能删除单个成员,错误加入后无法修正。
- 结算/计费——涉及金钱的场景必须精确计数(用 INCR + 数据库对账)。
- 数据量很小(< 几百)——小数据下 Set 的内存优势反而更明显(Set 可能更小)。
4.4 常见踩坑经验
| 案例 | 表象 | 根因 | 解法 |
|---|---|---|---|
| 日报 UV 直接加和 | 运营说"日 UV 加起来比周报 UV 多 30%" | 把每天 PFCOUNT 相加,没有去重跨天重复用户 | 用 PFMERGE 合并 7 天的 HLL 后再 PFCOUNT |
| 统计口径不一致 | 6 月日 UV 500 万,7 月突然 1200 万 | 6 月用 userId 去重,7 月改成了 deviceId | 统一统计规范文档,口径变更需团队评审 + 数据校准 |
| PFMERGE 临时 key 堆积 | keys 数量异常增长 | 每次 PFMERGE 生成新 key 忘记设 TTL | PFMERGE 后立即 EXPIRE;定期清理临时 key |
| 误以为 HLL 能存成员 | 运营想把 UV 前三天的用户导出来发优惠券 | 产品/运营不了解 HLL 不能取成员 | 需求评审时明确统计/触达的区别;触达类必须用 Set |
| HLL 误差在决策中被放大 | A/B 测试两组转化率差异仅 0.5%,但 HLL 误差 0.81% 覆盖了差异 | 用 HLL 统计 AB 实验的样本量,误差淹没了实验效果 | A/B 测试使用 Set 精确统计或数据库落表;HLL 只用于大盘趋势 |
4.5 思考题
-
场景题:运营团队想做一个"参与过 A 活动且参与过 B 活动的用户"的交集统计。现在两个活动的 UV 都用 HLL 记录了。你能用 HLL 完成这个需求吗?为什么?如果不能,应该怎么设计?
-
设计题:一个内容平台有 50 万个创作者,每个创作者的文章每天都有曝光 UV。运营想看"Top 100 高 UV 创作者榜单"。如果每个创作者用 HLL 记录 UV,你需要执行 50 万次
PFCOUNT——这在时间复杂度上是不可接受的。请设计一个更高效的方案。
(答案提示将在下一章末尾给出。)
上一章思考题答案提示(第8章):
第1题(30 天活跃标记):用 Bitmap,按日期维度记录每天活跃用户。30 天内是否活跃 = 对 30 天的 Bitmap 做
BITOP OR,然后看该用户的位是否为 1。用户 ID 不连续时用分片 Bitmap(分 100 片)。内存估算:1 亿用户 / 8 bit = 12.5MB(单天),30 天 = 375MB。优化:只保留最近 30 天的 Bitmap key,每天清理最早一天的 key。第2题(DAU BITOP 优化):高峰期每分钟做 5ms 的 BITOP 是可以接受的(不会阻塞)。但优化方案包括:① 降低频率——每分钟 DAU 改为每 5 分钟执行一次,实时看板用上一小时累计值;② 异步执行——BITOP 放在低峰期(如整点过 5 分钟的 cron 任务),在线看板读最近一次任务结果;③ 预合并——在数据写入时就同时更新"当日累计"BitMap,避免事后 BITOP OR 多个 key。
延伸阅读与资源
Redis 8 实战精讲:从 CRUD 到源码,构建高可用缓存系统
Java 工程师进阶:从 JVM 生产排障到OpenJDK原理
MySQL Server 9从入门到进阶的实战之旅
Nacos 3.x 注册配置中心实战修炼:从入门到源码扩展
Elasticsearch从入门到进阶的实战之旅
RabbitMQ从入门到进阶的实战之旅:从单机到大促高可用架构
Celery 入门到进阶之路:从异步任务到自研调度平台
LangGraph 生产级实战进阶:从零到生产级Agent工作流开发
Dify 从入门到源码:LLM 应用平台实战修炼
从零到生产级:FastAPI 异步高并发、源码与 SRE 实战
实战SQLAlchemy 2.0: 从 CRUD 到生产级架构
从零打造企业级 AI 助手:LangChain RAG、Agent 与生产实战
后端工程师 AI 转型课:Ollama 私有化大模型从入门到生产
MongoDB 实战进阶与内核修炼
NumPy 从入门到生产落地:全链路实战指南(科学计算/向量化/性能调优)
Milvus向量数据库实战修炼:从 0 到 1 精通向量检索与生产落地
Python 3实战精进:从脚本到高并发订单引擎
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经

微信公众号: 架构师日常笔记 欢迎关注!
浙公网安备 33010602011771号