1. 项目背景

1.1 业务场景

数据团队和运营团队对统计数据的实时性要求越来越高。产品总监每天早上 9 点要看昨天的核心数据报表,包括:

  1. 接口 UV:商品详情接口、下单接口、支付接口每天的独立访客数。
  2. 活动 UV:各个营销活动(618、双十一、周年庆)的独立访客数,还要分渠道——App、Web、小程序分别多少,合起来去重后总共有多少。
  3. 内容曝光去重:首页推荐流的每条内容曝光了多少个独立用户,运营要根据这个数据优化推荐策略。

第一版实现用的是 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 适用与不适用场景

适用场景:

  1. 接口/页面 UV 统计——每日独立访客数,误差在运营可接受范围内。
  2. 活动独立参与人数——多活动多日期多维度,内存固定。
  3. 广告曝光去重——每个广告位独立统计曝光独立用户数。
  4. 搜索词独立搜索人数——每个搜索词每天有多少独立用户搜索。
  5. 分服务器统计后汇总——每台服务器本地记录 HLL,定时 PFMERGE 汇总到中心。
  6. 独立设备数——按设备 ID 统计,适合 IoT/移动端。

不适用场景:

  1. 需要给用户发推送/优惠券的场景——必须拿到具体用户列表(用 Set 或数据库)。
  2. 风控黑名单——需要精确判断"这个用户是否在黑名单里"(用 Set 的 SISMEMBER)。
  3. 抽奖资格验证——"用户是否参与过活动"必须精确(用 Set 或 Bloom Filter)。
  4. 需要回溯修正的场景——HLL 不能删除单个成员,错误加入后无法修正。
  5. 结算/计费——涉及金钱的场景必须精确计数(用 INCR + 数据库对账)。
  6. 数据量很小(< 几百)——小数据下 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 思考题

  1. 场景题:运营团队想做一个"参与过 A 活动且参与过 B 活动的用户"的交集统计。现在两个活动的 UV 都用 HLL 记录了。你能用 HLL 完成这个需求吗?为什么?如果不能,应该怎么设计?

  2. 设计题:一个内容平台有 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的网络实战圣经

posted on 2026-11-03 10:42  一天不进步,就是退步  阅读(12)  评论(0)    收藏  举报