实现频控

广告系统中的频控实现:从 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(广告频控表)
  • RowKeyuid(用户 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 协同工作

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

  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 规则。
    • 对新用户或低活跃用户放宽限制,提高曝光机会;对高活跃用户严格限制,避免疲劳。