广告系统中的频控实现:从 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; long currentTime = System.currentTimeMillis(); jedis.lpush(key, String.valueOf(currentTime)); jedis.ltrim(key, 0, 99); 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
|
private boolean isOverLimit(String uid, String campaignId, long timeRange, int limit) { String key = "freq:" + uid + ":" + campaignId; long currentTime = System.currentTimeMillis(); 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:
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(); List<Long> timeList = getActionListFromHBase(uid, campaignId); if (timeList == null) { timeList = new ArrayList<>(); } timeList.add(currentTime); putActionListToHBase(uid, campaignId, timeList); }
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; } 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) {} } }
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)); String json = new Gson().toJson(timeList); put.addColumn( Bytes.toBytes("f"), Bytes.toBytes(campaignId), Bytes.toBytes(json) ); put.setTTL(90 * 24 * 3600 * 1000); 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 协同工作
实际系统中,通常采用 “多级缓存” 策略,结合两者优势:
- 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 时机。
- 频控策略优化:
- 支持多维度频控(如 “用户 - 广告”“用户 - 广告类型”“设备 - 广告”),需设计对应的 key 规则。
- 对新用户或低活跃用户放宽限制,提高曝光机会;对高活跃用户严格限制,避免疲劳。
v1.3.10