HOOOS

广告系统UV统计大杀器 Redis HyperLogLog 实战案例分享

0 40 不爱加班的阿强 RedisHyperLogLog广告系统UV统计性能优化
Apple

搞广告系统的兄弟们,肯定都为一件事情头疼过——**独立用户覆盖数(Unique Visitors, UV)**的统计。尤其是当你的系统需要处理海量曝光、点击数据,并且业务方还要求实时、多维度(跨广告、跨时间、跨地域等)查询UV时,那酸爽...简直不敢想象。

想象一下这个场景:

  • 一个大型广告活动,一天下来可能有几千万甚至上亿的曝光量。
  • 你需要快速知道这个活动今天覆盖了多少独立用户。
  • 还需要知道最近7天,这个活动总共覆盖了多少独立用户(注意,是去重后的总用户)。
  • 更变态的是,需要知道多个活动在某个时间段内,合并覆盖了多少独立用户。

传统的方案有哪些?

  1. 数据库 COUNT(DISTINCT user_id):数据量一大,直接把数据库拖垮,实时性?不存在的。
  2. Set 集合:用 Redis 的 Set 数据结构存储用户 ID。简单直接,SCARD 命令就能拿到 UV。但问题是,内存!一个用户 ID 假设是 64 位整数(8字节),一亿用户就是 800MB,这还只是一个广告一天的数据。如果维度再多点,时间再长点,内存消耗会爆炸式增长。
  3. 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 起来。

2. 数据写入

当用户曝光或点击了某个 Campaign 的广告时,我们的后端服务会收到一条日志,包含 user_idcampaign_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_iddate 的。无论用户通过哪个广告位接触到这个 Campaign,只要 user_idcampaign_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 慢查询或阻塞。
  • 优化后 (HLL)
    • 计算单个 Campaign UV (PFCOUNT):O(1),但内部有少量计算,通常在 1 毫秒内完成。
    • 计算跨天/跨 Campaign UV (PFMERGE + PFCOUNT):PFMERGE 时间复杂度 O(K),K 是合并的 HLL 数量,非常快。PFCOUNT 依然是 O(1)。整个过程通常在 几毫秒到几十毫秒 内完成,即使合并上百个 Key。

计算效率提升了几个数量级! 原先需要几十秒的复杂查询,现在毫秒级就能搞定。报表生成速度、实时看板的响应速度得到了质的飞跃。

3. 准确性:

我们对 HLL 的估算结果和精确值(通过离线计算或小规模抽样验证)进行了对比。在百万到千万级别的 UV 规模下,HLL 的误差率稳定在 0.5% - 0.8% 之间,完全满足广告业务对 UV 统计精度的要求。

总结与思考

Redis HyperLogLog 通过牺牲极小的精度,换来了巨大的内存节省和计算效率提升,特别适合处理海量数据的基数估算问题,如网站/APP 的 UV、DAU、MAU 统计,以及我们案例中的广告系统独立用户覆盖数统计。

关键在于:

  1. 理解业务场景:确认你的场景能否接受 HLL 的近似估算。
  2. 精心设计 Key:合理的 Key 结构是高效查询和合并的基础。
  3. 善用 PFMERGE:它是处理跨维度聚合统计的利器。

当然,HLL 也不是万能的。如果你需要绝对精确的计数,或者需要获取具体的成员列表(比如找出今天访问过 Campaign A 访问过 Campaign B 的具体用户列表),那么 HLL 就无能为力了,你可能还是需要 Set、Bitmap 或者其他更复杂的方案(如 Roaring Bitmap)。

但对于绝大多数只需要 UV 量级趋势的场景,HLL 提供了一个性价比极高的解决方案。如果你还在为海量 UV 统计的性能和成本发愁,不妨试试 Redis HyperLogLog,也许它就是你一直在寻找的那个“银弹”。

希望这个实战案例能给你带来一些启发!

点评评价

captcha
健康