搞广告系统的兄弟们,肯定都为一件事情头疼过——**独立用户覆盖数(Unique Visitors, UV)**的统计。尤其是当你的系统需要处理海量曝光、点击数据,并且业务方还要求实时、多维度(跨广告、跨时间、跨地域等)查询UV时,那酸爽...简直不敢想象。
想象一下这个场景:
- 一个大型广告活动,一天下来可能有几千万甚至上亿的曝光量。
- 你需要快速知道这个活动今天覆盖了多少独立用户。
- 还需要知道最近7天,这个活动总共覆盖了多少独立用户(注意,是去重后的总用户)。
- 更变态的是,需要知道多个活动在某个时间段内,合并覆盖了多少独立用户。
传统的方案有哪些?
- 数据库
COUNT(DISTINCT user_id)
:数据量一大,直接把数据库拖垮,实时性?不存在的。 - Set 集合:用 Redis 的 Set 数据结构存储用户 ID。简单直接,
SCARD
命令就能拿到 UV。但问题是,内存!一个用户 ID 假设是 64 位整数(8字节),一亿用户就是 800MB,这还只是一个广告一天的数据。如果维度再多点,时间再长点,内存消耗会爆炸式增长。 - Bitmap 位图:用 Redis 的 Bitmap。给每个用户分配一个固定的 offset,用户来了就把对应 offset 置为 1。
BITCOUNT
统计 UV。相比 Set,内存效率高很多,尤其是在用户 ID 连续或接近连续的情况下。但如果用户 ID 是稀疏的、分布范围极广的字符串(比如设备 ID),Bitmap 的内存优势就没了,甚至可能比 Set 更差。而且 Bitmap 的 Key 也会很大。
这些方案在我们的广告投放系统中都遇到过瓶颈。要么是性能扛不住,要么是内存成本太高。直到我们发现了 Redis 的 HyperLogLog (HLL)。
HyperLogLog 是什么?(简单科普,不深入算法细节)
你可以把它理解为一种概率性数据结构。它牺牲了一点点准确性(官方说标准误差是 0.81%),换来了令人难以置信的低内存消耗。
关键点:
- 固定内存占用:无论你往 HLL 里塞多少个不同的元素(用户 ID),它占用的内存几乎是恒定的(Redis 实现是固定的 12KB)。对,你没看错,统计几千万、几亿甚至几十亿的 UV,内存占用都是 12KB 左右!
- 基数估算:它不能精确地告诉你 UV 是多少,但能给出一个非常接近的估算值。
- 合并计算:HLL 支持合并(Merge)操作,可以快速计算多个 HLL 结构合并后的基数估算值,而且合并过程不丢失精度(相对于 HLL 本身的精度而言)。
对于广告系统这种允许一定误差(毕竟 UV 主要看趋势和量级),但对性能和成本要求极高的场景,HLL 简直是量身定做的神器。
实战案例:用 HLL 优化广告 UV 统计
我们的广告系统需要按天统计每个广告活动(Campaign)的 UV。
优化前的痛点:
- 最初使用 Set 存储每天每个 Campaign 的
user_id
。 - 随着业务增长,Redis 内存占用飙升,成本压力巨大。
- 计算跨天、跨 Campaign 的 UV 需要对多个大 Set 做
SUNION
操作,计算量大,耗时长,高峰期甚至导致 Redis 阻塞。
引入 HLL 后的方案:
1. HLL Key 的设计
这是关键一步。我们需要设计一个 Key 结构,既能满足按天、按 Campaign 查询的需求,又能方便地进行合并。
我们最终确定的 Key 格式是: ad:uv:hll:{campaign_id}:{date}
ad:uv:hll
: 命名空间,清晰标识用途。{campaign_id}
: 广告活动 ID。{date}
: 日期,格式如YYYYMMDD
。
为什么这么设计?
- 维度清晰:每个 Key 天然代表了一个 Campaign 在某一天的 UV 数据。
- 查询方便:获取某 Campaign 某天的 UV,直接
PFCOUNT ad:uv:hll:{campaign_id}:{date}
。 - 易于合并:
- 计算某 Campaign 7 天的 UV:
PFMERGE temp_key ad:uv:hll:{campaign_id}:20231001 ad:uv:hll:{campaign_id}:20231002 ... ad:uv:hll:{campaign_id}:20231007
,然后PFCOUNT temp_key
。 - 计算多个 Campaign(如 campaign_A, campaign_B)在某天的总 UV:
PFMERGE temp_key ad:uv:hll:campaign_A:20231001 ad:uv:hll:campaign_B:20231001
,然后PFCOUNT temp_key
。 - 计算多个 Campaign 在一段时间内的总 UV:把所有相关的 Key 都
PFMERGE
起来。
- 计算某 Campaign 7 天的 UV:
2. 数据写入
当用户曝光或点击了某个 Campaign 的广告时,我们的后端服务会收到一条日志,包含 user_id
和 campaign_id
。
处理逻辑很简单:
import redis
from datetime import datetime
# 假设 redis_client 是已初始化的 Redis 连接
redis_client = redis.Redis(decode_responses=True)
def record_user_impression(user_id, campaign_id):
today_str = datetime.now().strftime('%Y%m%d')
hll_key = f"ad:uv:hll:{campaign_id}:{today_str}"
# PFADD 命令:将元素添加到 HLL 中。如果元素已存在,基数估算值不会显著增加。
# 返回值:如果 HLL 的内部寄存器至少被修改了一个,则返回 1,否则返回 0。
# 这个返回值通常不重要,我们关心的是副作用。
redis_client.pfadd(hll_key, user_id)
# 示例调用
# record_user_impression("user_12345", "campaign_A")
# record_user_impression("user_67890", "campaign_A")
# record_user_impression("user_12345", "campaign_A") # 重复添加,HLL 估算值基本不变
PFADD
命令的时间复杂度是 O(1),非常高效。
3. 处理不同广告位的用户去重
一个常见的疑问是:如果一个用户在同一天看到了同一个 Campaign 的不同广告位(Placement)的广告,会被重复计算吗?
不会!
因为我们的 Key 设计是基于 campaign_id
和 date
的。无论用户通过哪个广告位接触到这个 Campaign,只要 user_id
和 campaign_id
相同,在同一天内,他都会被 PFADD
到同一个 HLL Key (ad:uv:hll:{campaign_id}:{date}
) 中。
HLL 的特性决定了,向同一个 Key 多次添加相同的 user_id
,其基数估算值不会线性增长。第一次添加会影响估算值,后续重复添加几乎没有影响。
这就天然地解决了同一个 Campaign 内跨广告位的用户去重问题。
4. 使用 PFMERGE 计算跨维度 UV
PFMERGE
是 HLL 的另一个杀手锏。它可以将多个 HLL Key 合并成一个新的 HLL Key,这个新的 Key 近似包含了所有源 Key 的并集信息。
场景一:计算 Campaign A 最近 3 天的总 UV
# 假设今天是 20231003
redis-cli PFMERGE ad:uv:hll:campaign_A:3days ad:uv:hll:campaign_A:20231001 ad:uv:hll:campaign_A:20231002 ad:uv:hll:campaign_A:20231003
# PFMERGE 命令会创建一个新的 HLL Key 'ad:uv:hll:campaign_A:3days'
# 这个新 Key 包含了 campaign_A 在这三天内所有接触过的用户的近似并集
redis-cli PFCOUNT ad:uv:hll:campaign_A:3days
# (integer) 估算的 3 天总 UV 值
场景二:计算 Campaign A 和 Campaign B 在 20231001 这一天的总 UV
redis-cli PFMERGE ad:uv:hll:A_B:20231001 ad:uv:hll:campaign_A:20231001 ad:uv:hll:campaign_B:20231001
redis-cli PFCOUNT ad:uv:hll:A_B:20231001
# (integer) 估算的两个 Campaign 在当天的合并 UV 值
场景三:计算 Campaign A 和 Campaign B 最近 7 天的总 UV
这就需要合并更多的 Key 了。
# 生成需要合并的 Key 列表 (伪代码)
keys_to_merge = []
for day in last_7_days:
keys_to_merge.append(f"ad:uv:hll:campaign_A:{day}")
keys_to_merge.append(f"ad:uv:hll:campaign_B:{day}")
target_key = "ad:uv:hll:A_B:7days"
# 执行 PFMERGE
redis-cli PFMERGE {target_key} {keys_to_merge.join(' ')}
redis-cli PFCOUNT {target_key}
# (integer) 估算的两个 Campaign 最近 7 天的合并 UV 值
注意: PFMERGE
操作本身也是非常快的,它的时间复杂度是 O(N),其中 N 是被合并的 HLL Key 的数量,但实际开销非常小,因为合并的是 HLL 内部的稀疏表示(registers),而不是原始数据。
临时 Key 的管理:PFMERGE
会创建目标 Key。对于这种临时聚合结果,建议设置一个合理的过期时间(EXPIRE
),避免无效数据堆积。
优化效果展示:数据说话
我们切换到 HLL 方案后,效果立竿见影:
1. 内存占用对比:
- 优化前 (Set):
- 假设平均每个 Campaign 每天 UV 为 500 万。
- 每个
user_id
字符串平均长度 20 字节 (估算)。 - 单个 Key (Set) 内存占用 ≈ 5,000,000 * (20 + Redis对象开销) ≈ 几百 MB 到 1GB+。
- 如果有 1000 个活跃 Campaign,每天就需要 几百 GB 到 TB 级别 的内存来存储当天的 UV 数据。这还没算历史数据和聚合数据。
- 优化后 (HLL):
- 每个 HLL Key 固定占用 约 12KB。
- 1000 个活跃 Campaign 每天的 HLL Key 总内存占用 ≈ 1000 * 12KB ≈ 12MB。
内存占用从 TB 级别骤降到 MB 级别,降幅超过 99.9%! 这极大地降低了我们的 Redis 集群成本。
2. 计算效率对比:
- 优化前 (Set):
- 计算单个 Campaign UV (
SCARD
):O(1),很快。 - 计算跨天/跨 Campaign UV (
SUNION
+SCARD
):时间复杂度 O(N),N 是所有 Set 中元素的总数(未去重)。当 Set 很大时,SUNION
操作非常耗时且消耗 CPU,动辄几秒甚至几十秒,高峰期可能导致 Redis 慢查询或阻塞。
- 计算单个 Campaign UV (
- 优化后 (HLL):
- 计算单个 Campaign UV (
PFCOUNT
):O(1),但内部有少量计算,通常在 1 毫秒内完成。 - 计算跨天/跨 Campaign UV (
PFMERGE
+PFCOUNT
):PFMERGE
时间复杂度 O(K),K 是合并的 HLL 数量,非常快。PFCOUNT
依然是 O(1)。整个过程通常在 几毫秒到几十毫秒 内完成,即使合并上百个 Key。
- 计算单个 Campaign UV (
计算效率提升了几个数量级! 原先需要几十秒的复杂查询,现在毫秒级就能搞定。报表生成速度、实时看板的响应速度得到了质的飞跃。
3. 准确性:
我们对 HLL 的估算结果和精确值(通过离线计算或小规模抽样验证)进行了对比。在百万到千万级别的 UV 规模下,HLL 的误差率稳定在 0.5% - 0.8% 之间,完全满足广告业务对 UV 统计精度的要求。
总结与思考
Redis HyperLogLog 通过牺牲极小的精度,换来了巨大的内存节省和计算效率提升,特别适合处理海量数据的基数估算问题,如网站/APP 的 UV、DAU、MAU 统计,以及我们案例中的广告系统独立用户覆盖数统计。
关键在于:
- 理解业务场景:确认你的场景能否接受 HLL 的近似估算。
- 精心设计 Key:合理的 Key 结构是高效查询和合并的基础。
- 善用 PFMERGE:它是处理跨维度聚合统计的利器。
当然,HLL 也不是万能的。如果你需要绝对精确的计数,或者需要获取具体的成员列表(比如找出今天访问过 Campaign A 且访问过 Campaign B 的具体用户列表),那么 HLL 就无能为力了,你可能还是需要 Set、Bitmap 或者其他更复杂的方案(如 Roaring Bitmap)。
但对于绝大多数只需要 UV 量级和趋势的场景,HLL 提供了一个性价比极高的解决方案。如果你还在为海量 UV 统计的性能和成本发愁,不妨试试 Redis HyperLogLog,也许它就是你一直在寻找的那个“银弹”。
希望这个实战案例能给你带来一些启发!