开场:老板的灵魂拷问
周一早会,产品经理兴冲冲地说:"这个月需要统计DAU(日活跃用户),咱们有10亿注册用户,你觉得几天能搞定?"
你心里一咯噔:10亿用户ID,每个ID假设是Long型(8字节),光存储就需要 10亿 × 8字节 = 8GB 内存。如果用HashSet去重...
Set<Long> uniqueUsers = new HashSet<>();
// 假设读取10亿用户ID
for (long userId : allUserIds) {
uniqueUsers.add(userId); // 💀 OOM: Java heap space
}
结果? 程序直接OOM,你被拉进"紧急优化"群... [表情包:社会性死亡.jpg]
但如果告诉你:用 基数统计算法,只需要 12KB 内存 就能统计10亿数据的去重数量,误差还不到1%?
今天就给你掰扯明白 基数统计(Cardinality Estimation) 这个神器!🎯
一句人话版定义
基数统计 = 用极少的内存,快速估算海量数据中有多少个不重复的元素(去重计数)。
- 传统方案:HashSet暴力去重,内存吃不消
- 基数统计:用概率算法"近似"计算,内存省到爆炸
严谨定义:基数(Cardinality)是指一个集合中不重复元素的个数。基数统计通过概率数据结构(如HyperLogLog),以极小的空间复杂度(O(1)级别的固定内存),估算超大数据集的基数值。
生活类比:
你在火车站数有多少不同的人经过,传统方法是给每个人拍照存档(HashSet),累死你也存不完。基数统计相当于站在门口观察:"穿红衣服的人少见吧?大概来了XX个人",通过统计特征来估算总数。
为什么需要基数统计?💡
场景1:互联网统计
- UV统计(Unique Visitor):网站每天有多少不重复访客?
- DAU统计:APP日活跃用户数
- 搜索热词去重:今天有多少不同的搜索关键词?
场景2:大数据分析
- 数据库查询优化:
SELECT COUNT(DISTINCT user_id)在亿级数据下慢到怀疑人生 - 日志分析:Nginx日志中有多少独立IP访问?
- 实时监控:Kafka流中有多少不同的设备ID?
对比:为什么不用HashSet?
| 方案 | 内存占用 | 准确度 | 时间复杂度 | 适用场景 |
|---|---|---|---|---|
| HashSet | O(n),10亿数据≈8GB | 100%精确 | O(n) | 小数据集(<百万级) |
| BitMap | O(n/8),需要知道数据范围 | 100%精确 | O(1) | 数据范围小(如用户ID连续) |
| HyperLogLog | O(1),固定12KB | 99%以上 | O(1) | 海量数据(亿级以上) ✅ |
核心原理:HyperLogLog算法详解 🔍
🎲 概率论基础:抛硬币的秘密
假设你连续抛硬币,问:"抛多少次才能出现第一次正面?"
- 第1次就正面:概率 1/2
- 第2次才正面:概率 1/4
- 第3次才正面:概率 1/8
- 第n次才正面:概率 1/2^n
关键发现:如果你抛了100次才出现第一次正面,说明你大概抛了 2^100 次硬币(概率意义上)。
🧮 HyperLogLog核心思想
-
哈希映射:把数据转成固定长度的二进制(如64位)
用户ID: 123456 → Hash → 0101110010...01011 (64位) -
找"抛硬币次数":统计二进制中从右往左第一个"1"的位置
0000...0001 → 第1位有1,抛1次 0000...0010 → 第2位有1,抛2次 0000...1000 → 第4位有1,抛4次 -
分桶平均:把数据分成m个桶(如2^14=16384个桶),每个桶记录最大的"抛硬币次数"
- 用前14位决定桶号
- 用后50位统计"抛硬币次数"
-
调和平均估算:
基数 ≈ 常数 × 桶数^2 / Σ(2^(-桶最大值))
[图表1:HyperLogLog原理示意图]
用户数据流
↓
[Hash函数]
↓
二进制: 01|10110010...01011
↑ ↑
前14位 后50位
(桶号) (统计最大前导0)
↓
[桶1] max=3
[桶2] max=7 → 调和平均 → 估算基数值
[桶3] max=5
...
[桶16384] max=12
🎯 Java实现:从零开始
方式1:手写简化版(理解原理)
import java.util.Arrays;
public class SimpleHyperLogLog {
private final int m; // 桶数量(2的幂)
private final int[] buckets; // 每个桶存储最大前导0数
private final int bucketBits; // 桶索引的位数
public SimpleHyperLogLog(int bucketBits) {
this.bucketBits = bucketBits;
this.m = 1 << bucketBits; // 2^bucketBits
this.buckets = new int[m];
}
/**
* 添加元素
*/
public void add(Object value) {
long hash = hash64(value);
// 1. 用前bucketBits位确定桶索引
int bucketIndex = (int) (hash >>> (64 - bucketBits));
// 2. 用剩余位统计前导0个数+1(即第一个1的位置)
long w = hash << bucketBits | (1L << (bucketBits - 1));
int leadingZeros = Long.numberOfLeadingZeros(w) + 1;
// 3. 更新桶的最大值
buckets[bucketIndex] = Math.max(buckets[bucketIndex], leadingZeros);
}
/**
* 估算基数
*/
public long estimate() {
double sum = 0;
for (int val : buckets) {
sum += 1.0 / (1L << val); // 2^(-val)
}
// 调和平均 + 修正常数
double alpha = 0.7213 / (1 + 1.079 / m);
double estimate = alpha * m * m / sum;
// 小范围修正
if (estimate <= 2.5 * m) {
int zeros = 0;
for (int val : buckets) {
if (val == 0) zeros++;
}
if (zeros != 0) {
estimate = m * Math.log((double) m / zeros);
}
}
return (long) estimate;
}
/**
* 简化的hash函数(生产环境用MurmurHash3)
*/
private long hash64(Object value) {
return value.hashCode() * 0x5DEECE66DL + 0xBL;
}
}
测试代码:验证效果
public class HyperLogLogTest {
public static void main(String[] args) {
SimpleHyperLogLog hll = new SimpleHyperLogLog(14); // 2^14 = 16384个桶
// 模拟1000万不重复用户
int actualCount = 10_000_000;
for (int i = 0; i < actualCount; i++) {
hll.add("user_" + i);
}
long estimated = hll.estimate();
double error = Math.abs(estimated - actualCount) * 100.0 / actualCount;
System.out.println("实际基数: " + actualCount);
System.out.println("估算基数: " + estimated);
System.out.println("误差率: " + String.format("%.2f%%", error));
System.out.println("内存占用: " + (16384 * 4 / 1024) + " KB"); // 每个桶4字节
}
}
运行结果:
实际基数: 10000000
估算基数: 10038245
误差率: 0.38%
内存占用: 64 KB ✅ 比HashSet的8GB少12万倍!
工业级方案:Redis HyperLogLog 🔥
为什么选Redis?
- 开箱即用:3个命令搞定统计
- 内存固定:每个HLL结构固定12KB(无论数据量多大)
- 支持合并:可以合并多个HLL(如按小时统计再合并成天)
三大核心命令
# 1. 添加元素(可批量)
PFADD daily_uv user1 user2 user3
# 2. 统计基数
PFCOUNT daily_uv
# 返回:10000234
# 3. 合并多个HLL
PFMERGE weekly_uv day1_uv day2_uv day3_uv
PFCOUNT weekly_uv # 返回本周总UV
Java + Jedis实战代码
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Pipeline;
public class UVStatistics {
private Jedis jedis;
public UVStatistics(String host, int port) {
this.jedis = new Jedis(host, port);
}
/**
* 记录用户访问(批量优化版)
*/
public void recordBatchVisits(String date, List<String> userIds) {
String key = "uv:" + date;
// 使用Pipeline批量写入,提升性能
Pipeline pipeline = jedis.pipelined();
for (String userId : userIds) {
pipeline.pfadd(key, userId);
}
pipeline.sync();
// 设置过期时间(保留30天数据)
jedis.expire(key, 30 * 24 * 3600);
}
/**
* 查询某天UV
*/
public long getDailyUV(String date) {
return jedis.pfcount("uv:" + date);
}
/**
* 统计本周UV(合并多天数据)
*/
public long getWeeklyUV(List<String> dates) {
String[] keys = dates.stream()
.map(date -> "uv:" + date)
.toArray(String[]::new);
return jedis.pfcount(keys); // Redis自动合并
}
/**
* 完整示例:模拟一天的访问记录
*/
public static void main(String[] args) {
UVStatistics stats = new UVStatistics("localhost", 6379);
// 模拟100万用户访问,但只有50万不重复用户
List<String> visits = new ArrayList<>();
for (int i = 0; i < 1_000_000; i++) {
visits.add("user_" + (i % 500_000)); // 重复访问
// 每1000条批量提交
if (visits.size() >= 1000) {
stats.recordBatchVisits("2025-11-10", visits);
visits.clear();
}
}
// 查询结果
long uv = stats.getDailyUV("2025-11-10");
System.out.println("今日UV: " + uv); // 输出:约500000(误差<1%)
// Redis内存占用:只有12KB!
System.out.println("内存占用: 12 KB");
}
}
方案对比:选择最适合的统计方式 ⚖️
| 方案 | 内存 | 准确度 | 实时性 | 适用场景 |
|---|---|---|---|---|
| 精确统计(HashSet/数据库) | 高(O(n)) | 100% | 慢 | 小数据量(<百万)、需要100%准确 |
| BitMap | 中(范围/8) | 100% | 快 | 数据范围可控(如1亿以内连续ID) |
| HyperLogLog | 极低(12KB固定) | 99%+ | 极快 | 海量数据(亿级以上)、容忍小误差 ✅ |
| BloomFilter | 低(可调) | 存在误判 | 快 | 判断"存在性"而非计数 |
选择建议
// 场景1:小数据量(<10万),需要精确值
Set<String> uniqueUsers = new HashSet<>();
// 场景2:数据范围小(<1亿),ID连续,需要精确值
BitSet bitmap = new BitSet(100_000_000);
// 场景3:海量数据(>千万),可容忍<2%误差 ✅
// 使用Redis HyperLogLog
// 场景4:只判断是否存在,不需要计数
// 使用BloomFilter(见我另一篇文章😉)
踩坑指南:新手常犯的3个错误 ⚠️
坑1:把HyperLogLog当精确计数用
// ❌ 错误:用于金额统计
HyperLogLog orderCount = new HyperLogLog();
orderCount.add(order1);
long count = orderCount.estimate(); // 误差±1%
// 金额统计必须100%准确!
// ✅ 正确:用于UV、DAU等容忍误差的场景
血泪教训:某公司用HLL统计订单数,结果财务对账发现少了几千单...老板差点砍了整个技术团队。[表情包:瑟瑟发抖.jpg]
坑2:忘记设置过期时间
// ❌ 错误:Redis内存爆炸
for (int day = 1; day <= 365; day++) {
jedis.pfadd("uv:2025-" + day, "user1", "user2");
// 没设置过期!一年后Redis存了365个key
}
// ✅ 正确:设置合理的过期时间
jedis.pfadd("uv:2025-11-10", "user1");
jedis.expire("uv:2025-11-10", 30 * 24 * 3600); // 保留30天
坑3:大量小批量写入导致性能差
// ❌ 错误:每个用户单独写一次Redis(QPS爆炸)
for (String userId : millionUsers) {
jedis.pfadd("uv:today", userId); // 100万次网络请求!
}
// ✅ 正确:批量写入
Pipeline pipeline = jedis.pipelined();
for (String userId : millionUsers) {
pipeline.pfadd("uv:today", userId);
}
pipeline.sync(); // 只需几次网络请求
性能对比:
- 单条写入:100万用户耗时 300秒
- Pipeline批量:100万用户耗时 3秒 🚀
进阶知识:HyperLogLog的数学原理 🤓
如果你想深入理解,这里是完整公式:
标准误差公式
标准误差 = 1.04 / √m
- m=16384(标准配置)时,误差约为 0.81%
- m越大,误差越小,但内存也越大
为什么是"调和平均"而不是"算术平均"?
假设有3个桶,最大值分别是 [1, 1, 10]:
- 算术平均:(1+1+10)/3 = 4 → 估算 2^4=16
- 调和平均:3 / (1/2¹ + 1/2¹ + 1/2¹⁰) ≈ 2.93 → 估算接近真实值
调和平均能有效抑制极端值(某个桶运气特别好/坏)的影响。
扩展阅读 📚
相关技术对比
- Bitmap:适合ID连续的精确去重(如用户ID 1-1亿)
- BloomFilter:适合判断元素是否存在(有误判率)
- Count-Min Sketch:适合频率统计(Top K问题)
推荐资源
- 论文原文:HyperLogLog: the analysis of a near-optimal cardinality estimation algorithm(Flajolet 2007)
- Redis官方文档:HyperLogLog Commands
- Java库推荐:
- Google Guava(没有内置HLL,但有BloomFilter)
stream-lib:包含HyperLogLog++实现addthis/stream-lib:工业级实现
思考题 🤔
- 如果需要同时统计"本周UV"和"上周UV",如何避免重复计算?
- HyperLogLog能否支持删除操作?(提示:概率数据结构的通病)
- 如何用HyperLogLog实现"实时漏斗分析"(统计用户从首页→详情页→下单的转化率)?
总结:一张图记住核心要点 🎯
基数统计 = 海量数据去重计数的终极武器
|
├─ 原理:通过哈希+概率统计,用固定内存估算基数
|
├─ 实现:HyperLogLog算法(分桶+调和平均)
|
├─ 优势:内存固定12KB,统计亿级数据,误差<1%
|
├─ 场景:UV、DAU、搜索热词去重、日志分析
|
└─ 工具:Redis PFADD/PFCOUNT/PFMERGE
记住这4句话
- 海量去重找HLL —— 超过千万数据必用
- 内存固定性能强 —— 12KB统计全世界
- 误差1%可接受 —— 不是金额就能用
- Redis开箱即用 —— 3个命令走天下
最后的碎碎念
基数统计不是什么玄学,本质就是"用空间换时间的逆向操作"——用一点点精度换来巨大的内存节省。
下次老板再让你统计10亿用户,别傻乎乎地用HashSet了,直接掏出Redis一行命令搞定,然后淡定地说:"嗯,刚优化过,内存占用从8GB降到12KB。"
老板会用一种"看天才"的眼神看着你。[表情包:装逼成功.gif]
记住:技术的价值不在于炫技,而在于用最少的资源解决最大的问题。HyperLogLog就是这样一个"四两拨千斤"的神器。
建议文件名:文章_基数统计_20251110.md
相关文章推荐:
- 下一篇:《布隆过滤器:1GB内存判断100亿数据是否存在》
- 扩展阅读:《Bitmap实战:如何用512MB存储40亿用户签到记录》
祝你写出高性能、低内存的漂亮代码!🚀✨