亿级数据下的基数统计:从 Set 到 HyperLogLog 的进阶实战
在移动互联网的业务场景中,我们经常面临"海量数据"统计的挑战。无论是统计 APP 的日活/月活 、页面的独立访客(UV) 、搜索词条数 还是 注册 IP 数 ,其核心都是一个典型的数学问题:基数统计(Cardinality Counting),即统计一个集合中不重复元素的个数。
当用户量达到千万甚至亿级时,如何在高并发、高吞吐的场景下,既保证性能又节省内存?本文将带你从最原始的方案开始,逐步引出 Redis 中的王者方案------HyperLogLog。
一、 基数统计方案的演进
1. Set 方案:最直观的去重
Redis 的 Set 集合天然去重。每当一个用户访问时,直接将用户 ID 丢进 Set。
- 操作 :
SADD page_uv:20260209 user123 - 统计 :
SCARD page_uv:20260209 - 瓶颈 :如果一个页面有 1000 万用户访问,假设 ID 是 64 位的长整型(8 字节),单个页面仅 ID 存储就要占用约 80MB 内存。如果有 1000 个这样的页面,内存直接爆掉。
2. Hash 方案:灵活但笨重
利用 Hash 结构,将用户 ID 作为 Field,Value 设为 1。
- 操作 :
HSET page_uv:hash user123 1 - 统计 :
HLEN page_uv:hash - 瓶颈:Hash 的内存消耗比 Set 还要大,因为除了存储元素本身,还需要额外的指针和哈希表结构开销。
3. Bitmap 方案:极致的位运算
Bitmap 像是一个以 Bit 为单位的数组。将用户 ID 映射为 Offset,如果是 1 则代表已访问。
- 优点 :极省空间。1 亿个 Bit 只需要约 12.5MB。
- 缺点:
- 偏移量限制:用户 ID 必须是整型且相对密集。如果 ID 离散(如 UUID),需要先转换成数字映射。
- 仍有上限:当页面极多时,每个页面维护一个千万级的 Bit 数组,累加起来的内存开销依然不可小觑。
二、 走进 HyperLogLog:用 12KB 解决亿级统计
HyperLogLog(简称 HLL)是一种概率性数据结构 。它的精髓在于:不保存原始数据,只记录特征。
核心特性
- 占用空间极小 :无论你存 10 个还是 1 亿个元素,每个键只需要花费 12KB 内存。
- 支持超大基数:可以计算接近 个不同元素的基数。
- 存在误差 :这是一个估算算法,标准误差约为 0.81%。对于 UV、日活这种对绝对精度要求没那么高(99% 准确度即可)的业务,它是完美选择。
Redis 命令行体验
- PFADD:添加元素
bash
PFADD course:redis:uv user1 user2 user3
- PFCOUNT:统计基数
bash
PFCOUNT course:redis:uv # 返回 3
- PFMERGE:合并统计(如:统计两个页面的联合 UV)
bash
PFADD page_a user1 user2
PFADD page_b user2 user3
PFMERGE page_combined page_a page_b
PFCOUNT page_combined # 返回 3,user2 被去重
三、 深入浅出:HyperLogLog 的数学原理
HyperLogLog 的基础是 伯努利过程(Bernoulli process)。
- 抛硬币实验:如果你一直在抛硬币,直到出现正面。如果抛了 次才出现第一个正面,那么我们可以粗略估算,你一共进行了 次实验。
- 比特位映射:HLL 将每个元素通过 Hash 函数转为 64 位的二进制串。从低位开始找第一个 "1" 出现的位置。
- 分桶平均:为了减少一次偶然性带来的巨大误差,Redis 将 12KB 空间分为 个桶(Buckets)。
- 每个桶记录该组内观测到的最大 值(即第一个 "1" 出现的位置)。
- 最终通过 调和平均数(Harmonic Mean) 来计算整体基数,有效修正了异常值的影响。
为什么是 12KB?
个桶即 。每个桶存储的最大 值不超过 64,只需 6 个 bit 即可表示()。
四、 Java 实战:Redisson 整合应用
在 Java 开发中,推荐使用 Redisson ,它封装了极其简便的 RHyperLogLog 对象。
1. Maven 依赖
xml
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.16.7</version>
</dependency>
2. 核心服务类实现
java
@Service
public class HyperLogLogService {
@Autowired
private RedissonClient redissonClient;
/**
* 添加访问记录
*/
public <T> void add(String logName, T item) {
RHyperLogLog<T> hll = redissonClient.getHyperLogLog(logName);
hll.add(item);
}
/**
* 合并多个 Log
*/
public <T> void merge(String destLogName, String... sourceLogNames) {
RHyperLogLog<T> hll = redissonClient.getHyperLogLog(destLogName);
hll.mergeWith(sourceLogNames);
}
/**
* 获取统计基数
*/
public long count(String logName) {
RHyperLogLog<Object> hll = redissonClient.getHyperLogLog(logName);
return hll.count();
}
}
3. 单元测试演示
java
@Test
public void testUVStatistics() {
String page1 = "uv:page:1";
String page2 = "uv:page:2";
// 模拟 Page1 访问
hyperLogLogService.add(page1, "user_A");
hyperLogLogService.add(page1, "user_B");
// 模拟 Page2 访问
hyperLogLogService.add(page2, "user_B"); // 重复用户
hyperLogLogService.add(page2, "user_C");
// 合并统计
String totalKey = "uv:total";
hyperLogLogService.merge(totalKey, page1, page2);
System.out.println("合并后的总 UV: " + hyperLogLogService.count(totalKey)); // 输出 3
}
五、 总结与对比
| 维度 | Set | Bitmap | HyperLogLog |
|---|---|---|---|
| 准确性 | 100% 准确 | 100% 准确 | 约 0.81% 误差 |
| 内存消耗 | 随数据量线性增长 (极大) | 随 Offset 范围增长 (中) | 固定 12KB (极小) |
| 存储内容 | 存储原始值 | 存储位状态 | 不存储原始值 |
| 适用场景 | 需要取出原始数据去重 | 二值状态统计(签到、状态位) | 海量基数统计(UV、日活) |
核心建议 :
在进行海量数据统计时,如果业务对误差不敏感(如向公众展示的阅读量、在线人数),HyperLogLog 是毫无疑问的最优解。它用极小的内存代价,解决了分布式环境下最棘手的统计问题。