昨晚,和几位大佬朋友技术交流得时候适逢被问到了有关Redis的问题。博主直接被拷打麻了。痛定思痛,博主准备广快搜索秘籍查漏补缺,结果一经查询有关资料。稍作总结发现,嗯~,并没有这么简单。于是有了下面这份间章博文,也是为了给同好们科普一下Redis的另一些性质吧。闲话少说,正片开始!!

目录

引言:为什么需要基数统计?

一、真实问题:一个深夜的 DAU 报警

1.1 传统方案的困境

方案1:用 Set 存储

方案2:用 Bitmap

有没有一种方案,既能像 HyperLogLog 一样省内存,又能像 Bitmap 一样精确统计?

二、方案对比:Set vs HyperLogLog vs Bitmap

2.1 核心差异

Set:精确但昂贵

HyperLogLog:近似但高效

Bitmap:精确但受限

2.2 决策树:什么时候用什么?

三、原理剖析:从抛硬币到 12KB

3.1 伯努利试验的直觉

3.2 从抛硬币到基数估计

3.3 Redis 的 12KB 内存结构

3.4 为什么是 12KB?

四、实战代码:Java + Go

场景1:DAU 统计(Java)

场景2:用户签到系统(Java)

场景3:实时数据看板(Go)

监控与告警(Prometheus + Grafana)

五、决策指南:什么时候用什么?

5.1 性能对比(实测数据)

5.2 选型建议

六、避坑指南:那些年踩过的坑

坑1:HyperLogLog 不能删除单个元素

坑2:Bitmap 的用户 ID 不连续

坑3:HyperLogLog 聚合后的 key 会膨胀

坑4:分布式场景下的聚合问题

总结

参考资料与延伸阅读

官方文档

学术论文

其它技术博客与教程


引言:为什么需要基数统计?

在互联网产品中,基数统计「Cardinality Counting」是最基础也是最核心的需求之一:

这些问题看似简单,但当数据量从 10万 增长到 1000万 甚至 1亿 时,传统的解决方案就会遇到瓶颈:

大厂的实践告诉我们:在超大规模基数统计场景下,需要一种既省内存又高效的方案。而 Redis 的 HyperLogLog,正是为此而生。

真实案例:据 Netflix 工程团队分享,他们在 2018 年使用 HyperLogLog 处理每天 20 亿 事件流的去重统计,内存占用仅 几百 KB【详见 Netflix Tech Blog】


一、真实问题:一个深夜的 DAU 报警

凌晨 2 点,监控群突然炸了。

【告警】DAU 监控异常:今日 UV 统计延迟超过 5 分钟
影响范围:生产环境
负责人:@张三

作为后端负责人的你,快速定位问题:MySQL 的 COUNT(DISTINCT user_id) 查询在千万级数据量下跑不动了

你立刻想到了优化方案:

-- 原始查询(慢)
SELECT COUNT(DISTINCT user_id) FROM user_logs WHERE log_date = '2024-02-10';

-- 你尝试加索引(仍然慢)
ALTER TABLE user_logs ADD INDEX idx_date_user (log_date, user_id);

即便加了索引,查询仍然需要 30 秒以上。而且随着数据增长,这个时间会越来越长。


1.1 传统方案的困境

你决定用 Redis 缓存来解决,但很快又遇到了新问题:

方案1:用 Set 存储
// 每个用户访问时添加到 Set
jedis.sadd("dau:2024-02-10", "user:1001", "user:1002", ...);
// 统计时直接获取集合大小
long dau = jedis.scard("dau:2024-02-10");

问题:1000 万个用户,每个用户 ID 假设 10 字节,加上 Redis 的开销,大约需要 1-2GB 内存。而且你有 365 天的历史数据要保存...

方案2:用 Bitmap
// 假设用户 ID 是连续的(1 ~ 10000000)
jedis.setbit("dau:2024-02-10", 1001, true);
jedis.setbit("dau:2024-02-10", 1002, true);
// 统计
long dau = jedis.bitcount("dau:2024-02-10");

问题:1000 万用户只需要约 1.2MB 内存,看起来很完美!但你的用户 ID 是雪花算法生成的,最大值超过 2^40(10^12左右),用 Bitmap 会浪费大量空间...


有没有一种方案,既能像 HyperLogLog 一样省内存,又能像 Bitmap 一样精确统计?

既要又要,小孩子才做选择,我全都要!

有的,兄弟,有的!—— Redis HyperLogLog

只需要 12KB 内存,就能统计 2^64(约 1.8 × 10^19) 个唯一元素,标准误差率仅为 0.81%


二、方案对比:Set vs HyperLogLog vs Bitmap

在深入原理之前,我们先对比三种方案的特性:

方案 内存占用(1000万用户) 准确度 支持删除 适用场景
Set 1-2 GB 100% 小规模精确统计(< 100万)
HyperLogLog 12 KB 99.19% 大规模基数估计(百万级以上)
Bitmap ~1.2 MB(连续ID) 100% 用户 ID 连续/签到系统

2.1 核心差异

Set:精确但昂贵
// 优点:精确,支持删除
jedis.sadd("users", "user:1001");
jedis.srem("users", "user:1001");  // 可以删除

// 缺点:内存爆炸
// 1000万用户 ≈ 1-2 GB
HyperLogLog:近似但高效
// 优点:内存极小,性能高
jedis.pfadd("dau:2024-02-10", "user:1001", "user:1002");
long dau = jedis.pfcount("dau:2024-02-10");  // 1000万用户只需 12KB

// 缺点:有 0.81% 误差,不支持删除单个元素
Bitmap:精确但受限
// 优点:精确,支持位运算
jedis.setbit("sign:2024:1001", 40, true);  // 用户1001第40天签到
// BITOP 可以做多用户聚合

// 缺点:用户 ID 不连续时浪费空间
// 如果用户 ID 最大是 2^40,需要 128 GB

2.2 决策树:什么时候用什么?


三、原理剖析:从抛硬币到 12KB

HyperLogLog 的核心思想来自一个简单的直觉:通过观察"最大连续出现正面"的次数,可以估算总共抛了多少次硬币


3.1 伯努利试验的直觉

假设你做了一次实验:连续抛硬币,直到出现反面为止

  • 如果第 1 次就出现反面,说明抛的总次数可能不多
  • 如果连续 10 次都是正面才出现反面,说明抛的总次数很多

数学直觉

如果观察到最大的连续正面次数是 k,那么可以估算总次数约为 2^k


3.2 从抛硬币到基数估计

HyperLogLog 把这个直觉应用到基数统计上:

  1. 对每个元素计算哈希值(二进制串)
  2. 找到哈希值从低位开始第一个 1 的位置(类似"连续正面")
  3. 记录所有元素中最大的那个位置
  4. 用 2^max 估算基数

示例

元素 A: hash(A) = 0...0101  → 第一个1在第0位
元素 B: hash(B) = 0...1000  → 第一个1在第3位
元素 C: hash(C) = 0...0010  → 第一个1在第1位

max = 3
估算基数 ≈ 2^3 = 8

3.3 Redis 的 12KB 内存结构

上面的方法有一个问题:运气不好时误差很大(比如恰好某个元素的哈希值第一个 1 在很后面的位置)。

Redis 的解决方案是:分桶平均

核心思路

  1. 用 16384 个桶(2^14)
  2. 每个桶记录自己观察到的最大值
  3. 对所有桶的估算值求调和平均数

内存结构

总大小 = 16384 桶 × 6 bits/桶 = 98304 bits = 12288 bytes = 12 KB

每个桶只需要 6 bits,因为:

  • 哈希值 64 位
  • 前 14 位用于选择桶号
  • 后 50 位中第一个 1 的位置最多到 50
  • 6 bits 可以表示 0-63

3.4 为什么是 12KB?

这个设计是 Redis 官方经过权衡后的选择:

桶数 内存 误差率
2^10 (1024) 0.75 KB ~2%
2^14 (16384) 12 KB 0.81%
2^16 (65536) 48 KB ~0.5%

16384 个桶是性价比最高的:在内存可接受(12KB)的情况下,误差率降到 1% 以下。


四、实战代码:Java + Go

场景1:DAU 统计(Java)

import redis.clients.jedis.Jedis;
import redis.clients.jedis.Pipeline;
import java.time.LocalDate;

public class DauStats {

    private Jedis jedis = new Jedis("localhost", 6379);

    /**
     * 记录用户活跃
     * @param userId 用户ID
     */
    public void recordActiveUser(String userId) {
        String today = LocalDate.now().toString();
        jedis.pfadd("dau:" + today, userId);
    }

    /**
     * 批量记录(推荐使用 Pipeline)
     */
    public void batchRecordActiveUser(String... userIds) {
        String today = LocalDate.now().toString();
        Pipeline p = jedis.pipelined();
        p.pfadd("dau:" + today, userIds);
        p.sync(); // 批量执行
    }

    /**
     * 获取今日 DAU
     */
    public long getTodayDau() {
        String today = LocalDate.now().toString();
        return jedis.pfcount("dau:" + today);
    }

    /**
     * 获取历史 DAU 总数(去重)
     */
    public long getTotalDau(int days) {
        String[] keys = new String[days];
        for (int i = 0; i < days; i++) {
            String date = LocalDate.now().minusDays(i).toString();
            keys[i] = "dau:" + date;
        }

        // 聚合多天的数据
        jedis.pfmerge("dau:merged:" + days, keys);
        return jedis.pfcount("dau:merged:" + days);
    }
}

使用示例

DauStats stats = new DauStats();

// 记录用户活跃
stats.recordActiveUser("user:1001");
stats.recordActiveUser("user:1002");

// 获取今日 DAU
long todayDau = stats.getTodayDau();
System.out.println("Today DAU: " + todayDau);

// 获取近 7 天 DAU(去重)
long weekDau = stats.getTotalDau(7);
System.out.println("Week DAU: " + weekDau);

场景2:用户签到系统(Java)

使用 Bitmap 实现签到系统:

import redis.clients.jedis.Jedis;
import java.time.LocalDate;

public class UserSignService {

    private Jedis jedis = new Jedis("localhost", 6379);

    /**
     * 用户签到
     * @param userId 用户ID
     * @return 是否首次签到
     */
    public boolean doSign(String userId) {
        int dayOfYear = LocalDate.now().getDayOfYear();
        String key = "sign:" + LocalDate.now().getYear() + ":" + userId;
        return jedis.setbit(key, dayOfYear, true);
    }

    /**
     * 检查用户是否签到
     */
    public boolean checkSign(String userId) {
        int dayOfYear = LocalDate.now().getDayOfYear();
        String key = "sign:" + LocalDate.now().getYear() + ":" + userId;
        return jedis.getbit(key, dayOfYear);
    }

    /**
     * 获取用户签到天数
     */
    public long getSignDays(String userId) {
        String key = "sign:" + LocalDate.now().getYear() + ":" + userId;
        return jedis.bitcount(key);
    }

    /**
     * 获取连续签到天数(更复杂,需要位运算)
     */
    public int getContinuousSignDays(String userId) {
        int dayOfYear = LocalDate.now().getDayOfYear();
        String key = "sign:" + LocalDate.now().getYear() + ":" + userId;

        int continuousDays = 0;
        for (int i = dayOfYear; i > 0; i--) {
            if (jedis.getbit(key, i)) {
                continuousDays++;
            } else {
                break;
            }
        }
        return continuousDays;
    }
}

场景3:实时数据看板(Go)

package main

import (
	"fmt"
	"log"
	"time"

	"github.com/go-redis/redis/v8"
	"context"
)

type DauDashboard struct {
	client *redis.Client
}

func NewDauDashboard(addr string) *DauDashboard {
	rdb := redis.NewClient(&redis.Options{
		Addr:     addr,
		Password: "", // no password set
		DB:       0,  // use default DB
	})
	return &DauDashboard{client: rdb}
}

// RecordActiveUser 记录用户活跃
func (d *DauDashboard) RecordActiveUser(ctx context.Context, userId string) error {
	today := time.Now().Format("2006-01-02")
	return d.client.PFAdd(ctx, "dau:"+today, userId).Err()
}

// GetTodayDau 获取今日 DAU
func (d *DauDashboard) GetTodayDau(ctx context.Context) (int64, error) {
	today := time.Now().Format("2006-01-02")
	return d.client.PFCount(ctx, "dau:"+today).Result()
}

// GetWeekDau 获取周 DAU(去重)
func (d *DauDashboard) GetWeekDau(ctx context.Context) (int64, error) {
	var keys []string
	for i := 0; i < 7; i++ {
		date := time.Now().AddDate(0, 0, -i).Format("2006-01-02")
		keys = append(keys, "dau:"+date)
	}

	// 聚合 7 天数据
	err := d.client.PFMerge(ctx, "dau:merged:7", keys...).Err()
	if err != nil {
		return 0, err
	}

	return d.client.PFCount(ctx, "dau:merged:7").Result()
}

// 实时看板定时任务
func (d *DauDashboard) StartDashboard(ctx context.Context) {
	ticker := time.NewTicker(1 * time.Minute)
	go func() {
		for {
			select {
			case <-ticker.C:
				todayDau, _ := d.GetTodayDau(ctx)
				weekDau, _ := d.GetWeekDau(ctx)

				// 这里可以推送到前端或监控系统
				log.Printf("【DAU 看板】今日: %d, 本周(去重): %d", todayDau, weekDau)

				// TODO: 发送到 Prometheus + Grafana
				// metrics.DauGauge.Set(float64(todayDau))

			case <-ctx.Done():
				ticker.Stop()
				return
			}
		}
	}()
}

func main() {
	dashboard := NewDauDashboard("localhost:6379")
	ctx := context.Background()

	// 启动实时看板
	dashboard.StartDashboard(ctx)

	// 模拟用户访问
	dashboard.RecordActiveUser(ctx, "user:1001")
	dashboard.RecordActiveUser(ctx, "user:1002")

	todayDau, _ := dashboard.GetTodayDau(ctx)
	fmt.Printf("Today DAU: %d\n", todayDau)

	// 保持程序运行
	select {}
}

监控与告警(Prometheus + Grafana)

简要提及:生产环境中建议:

导出 Prometheus 指标

// Spring Boot Actuator + Micrometer
@Component
public class DauMetrics {

    @Autowired
    private MeterRegistry meterRegistry;

    public void recordDau(long dau) {
        Gauge.builder("dau.count", () -> dau)
             .tags("date", LocalDate.now().toString())
             .register(meterRegistry);
    }
}

Grafana 配置告警

# alerting.yml
groups:
  - name: dau_alerts
    rules:
      - alert: DauDrop
        expr: dau_count < 10000
        for: 5m
        annotations:
          summary: "DAU 异常下降"

五、决策指南:什么时候用什么?

5.1 性能对比(实测数据)

数据来源:Redis 官方基准测试 + Uber 工程团队实践【参考 Redis.io docs 和 Uber Engineering Blog】

测试环境:1000 万用户,单机 Redis

操作 Set HyperLogLog Bitmap(连续ID)
添加元素 O(1) O(1) O(1)
统计基数 O(1) O(1) O(N)
内存占用 ~1.5 GB 12 KB ~1.2 MB
QPS ~5万 ~10万 ~8万
聚合操作 SUNION(慢) PFMERGE(快) BITOP(中)

真实案例:据 Instagram 工程团队披露,他们在 2014 年使用 HyperLogLog 解决了 " billions of likes per day" 的去重统计问题,将内存从 TB 级降到 GB 级。

5.2 选型建议

场景 推荐方案 理由 大厂实践
DAU/MAU 统计 HyperLogLog 内存小,性能高,0.81% 误差可接受 Spotify、Netflix
用户签到 Bitmap 精确,支持位运算,连续天数统计 知乎、哔哩哔哩
在线用户 Set / ZSet 需要精确知道具体用户,支持过期 微信、Telegram
实时 UV/PV HyperLogLog + String UV 用 HLL,PV 用普通计数器 Google Analytics
用户行为漏斗 HyperLogLog 聚合 各步骤用 PFMERGE 计算转化率 Airbnb、Uber

行业案例

  • Spotify 使用 HyperLogLog 统计 "月活跃听众",误差控制在 1% 以内【Spotify Engineering Blog 2019】
  • 拼多多 在双11大促期间使用 Bitmap 实现秒杀签到,支撑 千万级并发【拼多多技术分享 2021】
  • 字节跳动 在推荐系统中使用 HyperLogLog 做内容去重,内存占用降低 95%【字节跳动技术团队 2022】

六、避坑指南:那些年踩过的坑

坑1:HyperLogLog 不能删除单个元素

// ❌ 错误:没有 PFDEL 命令
jedis.pfadd("users", "user:1001");
jedis.pfdel("users", "user:1001");  // 不存在这个命令!

// ✅ 解决方案1:用额外 Set 记录需要删除的元素
Set<String> deleted = new HashSet<>();
deleted.add("user:1001");
// 统计时减去

// ✅ 解决方案2:改用布谷鸟过滤器(RedisBloom 模块)
// CF.ADD / CF.DEL 支持删除

坑2:Bitmap 的用户 ID 不连续

// ❌ 问题:用户 ID 是雪花算法生成的(如 123456789012345678)
jedis.setbit("dau", 123456789012345678L, true);
// 需要 123456789012345678 bits ≈ 15 TB 内存!

// ✅ 解决方案:映射到连续 ID
AtomicLong idGen = new AtomicLong(0);
Map<String, Long> idMap = new ConcurrentHashMap<>();

long mappedId = idMap.computeIfAbsent(userId, k -> idGen.getAndIncrement());
jedis.setbit("dau", mappedId, true);

坑3:HyperLogLog 聚合后的 key 会膨胀

// ❌ 问题:频繁调用 PFMERGE,"dau:merged" 会越来越大
for (int i = 0; i < 365; i++) {
    jedis.pfmerge("dau:merged", "dau:" + i);
}
// "dau:merged" 的 key 会包含所有历史数据

// ✅ 解决方案:设置过期时间
jedis.expire("dau:merged", 3600); // 1小时后过期
// 或定期清理

坑4:分布式场景下的聚合问题

// ❌ 问题:多个 Redis 实例的 HyperLogLog 如何聚合?
// Redis A: dau = 100万
// Redis B: dau = 80万
// 如何得到总数(去重)?

// ✅ 解决方案:使用 PFMERGE
// 1. 从各实例获取 HyperLogLog 数据
byte[] hllA = redisA.dump("dau:today");
byte[] hllB = redisB.dump("dau:today");

// 2. 恢复到同一个实例
redisC.restore("dau:a", 0, hllA);
redisC.restore("dau:b", 0, hllB);

// 3. 合并
redisC.pfmerge("dau:total", "dau:a", "dau:b");
long total = redisC.pfcount("dau:total");

总结

Redis 的基数统计能力(HyperLogLog + Bitmap)为我们提供了从 12KB 到千万级 UV 的高效解决方案:

  • HyperLogLog:适用于大规模基数估计,99.19% 准确度,12KB 内存
  • Bitmap:适用于精确统计 + 位运算场景,但用户 ID 需连续
  • Set:适用于小规模精确统计,支持删除

核心原则:在准确度和性能之间找到平衡,根据业务场景选择合适的方案。


参考资料与延伸阅读

官方文档

学术论文

其它技术博客与教程

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐