Redis 基数统计:从抛硬币到千万级 UV 的算法之旅
昨晚,和几位大佬朋友技术交流得时候适逢被问到了有关Redis的问题。博主直接被拷打麻了。痛定思痛,博主准备广快搜索秘籍查漏补缺,结果一经查询有关资料。稍作总结发现,嗯~,并没有这么简单。于是有了下面这份间章博文,也是为了给同好们科普一下Redis的另一些性质吧。闲话少说,正片开始!!
目录
有没有一种方案,既能像 HyperLogLog 一样省内存,又能像 Bitmap 一样精确统计?
二、方案对比:Set vs HyperLogLog vs Bitmap
引言:为什么需要基数统计?
在互联网产品中,基数统计「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^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 的解决方案是:分桶平均。
核心思路:
- 用 16384 个桶(2^14)
- 每个桶记录自己观察到的最大值
- 对所有桶的估算值求调和平均数
内存结构:
总大小 = 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:适用于小规模精确统计,支持删除
核心原则:在准确度和性能之间找到平衡,根据业务场景选择合适的方案。
参考资料与延伸阅读
官方文档
学术论文
- HyperLogLog: the analysis of a near-optimal cardinality estimation algorithm (Flajolet et al., 2007) - HLL 算法原始论文
其它技术博客与教程
- Redis HyperLogLog: Use Cases and Performance - DZone 深度解析
更多推荐




所有评论(0)