实现频控
广告系统中的频控实现:从 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)记录行为(曝光 / 点击)
每次用户触发广告行为时,将当前时间戳插入列表:
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)判断是否超频
检查指定周期内的行为次数是否超过限制:
/**
* 判断是否超频
* @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(广告频控表) - RowKey:
uid(用户 ID,便于按用户聚合查询) - 列族:
f(存储用户的广告行为) - 列名:
campaignId(广告 ID,每个广告对应一列) - 值:JSON 格式的时间戳列表(如
[1620000000000, 1620000100000]) - TTL:90 天(自动过期清理)
核心实现逻辑
(1)记录行为(曝光 / 点击)
读取历史记录,追加新时间戳后写回 HBase:
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)判断是否超频
筛选时间范围内的记录并统计次数:
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 协同工作
实际系统中,通常采用 “多级缓存” 策略,结合两者优势:
- Redis 缓存热点数据:存储最近 1 小时的行为记录,用于实时频控判断(高并发场景)。
- HBase 存储全量历史:保存 90 天内的完整记录,用于离线分析和冷数据查询。
- 异步同步:通过消息队列(如 Kafka)将 Redis 中的数据异步同步到 HBase,保证数据一致性。
流程示例:
- 实时判断:优先从 Redis 读取数据,若未超频则投放广告,并将行为记录写入 Redis。
- 数据同步:Redis 中的记录定期(如每 5 分钟)通过批处理同步到 HBase。
- 冷数据查询:当 Redis 中无记录时,从 HBase 加载并回填到 Redis,避免缓存穿透。
优化建议
- 时间戳压缩:
- Redis 中可存储相对时间(如相对于当天 0 点的秒数),减少存储占用。
- HBase 中使用 Protocol Buffers 替代 JSON 序列化,降低存储空间和解析耗时。
- 批量清理过期数据:
- Redis 依赖
EXPIRE自动清理,可结合SCAN命令主动删除长期无效的键。 - HBase 利用 TTL 自动过期,无需手动干预,但需注意 Major Compaction 时机。
- Redis 依赖
- 频控策略优化:
- 支持多维度频控(如 “用户 - 广告”“用户 - 广告类型”“设备 - 广告”),需设计对应的 key 规则。
- 对新用户或低活跃用户放宽限制,提高曝光机会;对高活跃用户严格限制,避免疲劳。