0%

实现频控

广告系统中的频控实现:从 Redis 到 HBase 的方案详解

在广告投放中,频控(频率控制) 是核心机制之一,用于限制同一用户对同一广告的曝光次数(如 “1 小时内最多看 3 次”),避免用户反感并优化广告资源利用率。本文将详细介绍如何基于 Redis 和 HBase 实现频控,并分析两种方案的适用场景。

频控的核心需求与设计原则

核心需求

  • 精准计数:准确记录用户对特定广告的曝光 / 点击时间,支持按周期(天 / 小时 / 分钟)统计。
  • 高效判断:快速判断当前请求是否超出频控限制(如 “用户 A 在 1 小时内已看广告 B 5 次,限制为 3 次则拒绝投放”)。
  • 高并发支持:广告系统峰值请求可达百万级 QPS,频控判断需在毫秒级完成。
  • 数据持久化:长期保留用户行为数据(如 90 天),用于数据分析和策略优化。

设计原则

  • key 设计:需唯一标识 “用户 - 广告” 组合,通常采用 uid:campaignId 作为键(uid 为用户唯一标识,campaignId 为广告计划 ID)。
  • 时间排序:存储的行为时间需按顺序排列,便于快速筛选指定周期内的记录。
  • 过期清理:自动删除超出统计周期的数据(如只保留 24 小时内的记录),减少存储压力。

基于 Redis 的频控实现(高并发场景首选)

Redis 凭借内存存储和丰富的数据结构,成为高频场景下频控的首选方案,尤其适合实时性要求高的场景(如信息流广告实时投放)。

数据结构选择

使用 Redis List 存储用户对广告的行为时间戳,原因如下:

  • 支持有序插入(按时间戳正序 / 倒序排列)。
  • 可通过 LRANGE 快速获取指定范围的记录。
  • 结合 LTRIM 可修剪过期数据,控制列表长度。

核心实现逻辑

(1)记录行为(曝光 / 点击)

每次用户触发广告行为时,将当前时间戳插入列表:

1
2
3
4
5
6
7
8
9
10
11
private void addAction(String uid, String campaignId) {
String key = "freq:" + uid + ":" + campaignId; // 键格式:freq:用户ID:广告ID
long currentTime = System.currentTimeMillis();
// 从左侧插入最新时间戳(保证列表按时间倒序排列,最新的在最前)
jedis.lpush(key, String.valueOf(currentTime));
// 修剪列表,只保留最近N条(如100条,避免内存溢出)
jedis.ltrim(key, 0, 99);
// 一般投放周期不超过三个月
// 设置过期时间(如90天,自动清理旧数据)
jedis.expire(key, 90 * 24 * 3600);
}
(2)判断是否超频

检查指定周期内的行为次数是否超过限制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
* 判断是否超频
* @param uid 用户ID
* @param campaignId 广告ID
* @param timeRange 时间范围(毫秒,如3600000表示1小时)
* @param limit 最大允许次数
* @return true=超频,false=未超频
*/
private boolean isOverLimit(String uid, String campaignId, long timeRange, int limit) {
String key = "freq:" + uid + ":" + campaignId;
long currentTime = System.currentTimeMillis();
// 获取所有记录(实际可限制获取数量,如最近20条)
List<String> timeList = jedis.lrange(key, 0, -1);

if (timeList == null || timeList.isEmpty()) {
return false; // 无记录,未超频
}

int count = 0;
for (String timeStr : timeList) {
long actionTime = Long.parseLong(timeStr);
// 只统计时间范围内的记录
if (actionTime > currentTime - timeRange) {
count++;
if (count > limit) {
return true; // 超出限制,返回超频
}
} else {
// 由于列表按时间倒序排列,一旦遇到过期记录,后续无需检查
break;
}
}
return false;
}

方案优势与局限

优势 局限
内存操作,响应速度极快(微秒级) 内存成本高,不适合存储大量历史数据(如 1 亿用户 ×100 条记录 = 100 亿条)
支持 LTRIM EXPIRE 等命令,便于数据清理 分布式环境下需考虑一致性(如使用 Redis Cluster)
适合高并发场景(支持 10 万级 QPS) 数据易丢失(需开启持久化配置如 AOF+RDB)

基于 HBase 的频控实现(海量数据场景)

当用户量和广告量达到亿级时,Redis 的内存成本过高,此时需采用 HBase 等分布式存储方案,适合存储长期数据(如 90 天内的行为记录)。

HBase 表结构设计

  • 表名ad_frequency(广告频控表)
  • RowKeyuid(用户 ID,便于按用户聚合查询)
  • 列族f(存储用户的广告行为)
  • 列名campaignId(广告 ID,每个广告对应一列)
  • :JSON 格式的时间戳列表(如 [1620000000000, 1620000100000]
  • TTL:90 天(自动过期清理)

核心实现逻辑

(1)记录行为(曝光 / 点击)

读取历史记录,追加新时间戳后写回 HBase:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
public void addAction(String uid, String campaignId) {
long currentTime = System.currentTimeMillis();
// 1. 读取该用户对该广告的历史行为
List<Long> timeList = getActionListFromHBase(uid, campaignId);

// 2. 追加新时间戳(按时间正序排列)
if (timeList == null) {
timeList = new ArrayList<>();
}
timeList.add(currentTime);

// 3. 写入HBase(覆盖原记录)
putActionListToHBase(uid, campaignId, timeList);
}

// 从HBase读取历史行为
private List<Long> getActionListFromHBase(String uid, String campaignId) {
Table table = null;
try {
table = connection.getTable(TableName.valueOf("ad_frequency"));
Get get = new Get(Bytes.toBytes(uid));
get.addColumn(Bytes.toBytes("f"), Bytes.toBytes(campaignId));

Result result = table.get(get);
if (result.isEmpty()) {
return null;
}
// 解析JSON为时间戳列表
byte[] value = result.getValue(Bytes.toBytes("f"), Bytes.toBytes(campaignId));
return new Gson().fromJson(new String(value), new TypeToken<List<Long>>(){}.getType());
} catch (IOException e) {
log.error("读取HBase失败", e);
return null;
} finally {
if (table != null) try { table.close(); } catch (IOException e) {}
}
}

// 写入HBase
private void putActionListToHBase(String uid, String campaignId, List<Long> timeList) {
Table table = null;
try {
table = connection.getTable(TableName.valueOf("ad_frequency"));
Put put = new Put(Bytes.toBytes(uid));
// 序列化时间列表为JSON
String json = new Gson().toJson(timeList);
put.addColumn(
Bytes.toBytes("f"),
Bytes.toBytes(campaignId),
Bytes.toBytes(json)
);
put.setTTL(90 * 24 * 3600 * 1000); // 90天过期
table.put(put);
} catch (IOException e) {
log.error("写入HBase失败", e);
} finally {
if (table != null) try { table.close(); } catch (IOException e) {}
}
}
(2)判断是否超频

筛选时间范围内的记录并统计次数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public boolean isOverLimit(String uid, String campaignId, long timeRange, int limit) {
List<Long> timeList = getActionListFromHBase(uid, campaignId);
if (timeList == null || timeList.isEmpty()) {
return false;
}

long currentTime = System.currentTimeMillis();
int count = 0;
// 遍历时间列表(正序排列,需从后往前查最新记录)
for (int i = timeList.size() - 1; i >= 0; i--) {
long actionTime = timeList.get(i);
if (actionTime > currentTime - timeRange) {
count++;
if (count > limit) {
return true;
}
} else {
// 正序排列下,前面的记录更早,无需继续检查
break;
}
}
return false;
}

方案优势与局限

优势 局限
分布式存储,支持海量数据(PB 级) 磁盘 IO,响应速度较慢(毫秒级,比 Redis 慢 10-100 倍)
按用户 ID(RowKey)聚合,适合批量分析 不支持复杂查询,需在应用层筛选数据
TTL 自动过期,适合长期存储 写入需先读后写,高并发下易引发性能瓶颈

混合方案:Redis + HBase 协同工作

实际系统中,通常采用 “多级缓存” 策略,结合两者优势:

  1. Redis 缓存热点数据:存储最近 1 小时的行为记录,用于实时频控判断(高并发场景)。
  2. HBase 存储全量历史:保存 90 天内的完整记录,用于离线分析和冷数据查询。
  3. 异步同步:通过消息队列(如 Kafka)将 Redis 中的数据异步同步到 HBase,保证数据一致性。

流程示例:

  • 实时判断:优先从 Redis 读取数据,若未超频则投放广告,并将行为记录写入 Redis。
  • 数据同步:Redis 中的记录定期(如每 5 分钟)通过批处理同步到 HBase。
  • 冷数据查询:当 Redis 中无记录时,从 HBase 加载并回填到 Redis,避免缓存穿透。

优化建议

  1. 时间戳压缩
    • Redis 中可存储相对时间(如相对于当天 0 点的秒数),减少存储占用。
    • HBase 中使用 Protocol Buffers 替代 JSON 序列化,降低存储空间和解析耗时。
  2. 批量清理过期数据
    • Redis 依赖 EXPIRE 自动清理,可结合 SCAN 命令主动删除长期无效的键。
    • HBase 利用 TTL 自动过期,无需手动干预,但需注意 Major Compaction 时机。
  3. 频控策略优化
    • 支持多维度频控(如 “用户 - 广告”“用户 - 广告类型”“设备 - 广告”),需设计对应的 key 规则。
    • 对新用户或低活跃用户放宽限制,提高曝光机会;对高活跃用户严格限制,避免疲劳。

欢迎关注我的其它发布渠道

表情 | 预览
快来做第一个评论的人吧~
Powered By Valine
v1.3.10