Skip to content
Charles Shao
Go back

Bidder 频次控制工程篇:多维度限频的存储选型与读写分离

Updated:
–views

在程序化广告交易中,频次控制(Frequency Capping)是保护用户体验与优化预算 ROI 的核心防线。然而,当运营在 DSP 后台轻松配置下「单设备每小时最多曝光 3 次」的规则时,底层系统却面临着严峻的工程挑战:如何在海量并发的竞价请求中,以极低的延迟精确判定每一次曝光是否超限?

本文是 买侧竞价链路 系列的第 7 篇(频控 · 工程)。 全系列 9 篇:

  1. Bidder 竞价服务架构
  2. OpenRTB 协议精讲
  3. Bidder 解析层
  4. Bidder 定向过滤
  5. RTA 竞价前过滤
  6. Bidder 频次控制:五维配置与身份降级
  7. Bidder 频次控制工程篇
  8. Bidder 预算 Pacing
  9. Pacing 目标曲线

一句话定位:本文从工程实现视角,剖析多维度频次控制在高并发场景下的读写分离架构,以及基于 String、Hash 与 ZSet 的 Redis 存储选型策略。

Bidder 频次控制配置篇讲的是配置面:运营能拧哪些旋钮(层级 × 窗口 × 事件 × 抑制 × 身份口径),以及无 ID 流量怎么降级。本篇补的是工程实现面,探讨这些旋钮拧下去之后,计数到底存在哪、怎么写、怎么读。同一个「过去 N 小时最多 M 次」,落到代码里是一次 Redis 结构选型、一条读写分离的链路,以及几个各管一维的拦截器。建议先读配置篇建立框架,再回到这里看它怎么跑起来。

虽然频次控制在配置面上仅仅体现为几个简单的下拉框,但在工程实现层面,它却是一道典型的高 QPS 读写分离难题。具体而言,写操作通常发生在广告曝光(Impression)或竞价成功之后,因此可以走旁路异步处理,并容忍一定的延迟;而读操作则处于竞价的关键路径上,必须对每条候选广告进行实时判定,任何毫秒级的延迟都会直接消耗宝贵的出价预算。为了解开这道难题,我们需要做出三个核心决策:一是确定限频维度以设计合理的键(Key);二是选择合适的 Redis 数据结构以平衡精度与存储成本;三是规划读写链路以兼顾数据一致性与系统延迟。

频次控制读写分离总览:上半部为写路径旁路——曝光/竞价成功事件经 回传服务 异步转发到 计数服务,计数服务 按维度写入 Redis 的三种结构(String 自然日计数器、Hash 小时桶、ZSet 事件流);下半部为读路径关键链——bidder 竞价实例在漏斗的定向过滤级由一组拦截器(Bundle/TagId/Device 自然日/Device 小时滚动/Device 滑动窗口)就近读计数,热点维度先查进程内本地缓存(定时从 Redis 刷新)命中则免网络、未命中再点查 Redis,device 维度直接点查 Redis;中间 Redis 作为两条链路的交汇点;底部旁注强调写在旁路可异步、读在关键路径带超时降级、热点走本地缓存 频控的工程骨架就是一张「写在旁路、读在关键路径」的读写分离图:写侧 回传服务→计数服务 异步落 Redis,读侧 bidder 就近读、热点维度走本地缓存兜住 QPS。Redis 是两条链路唯一的交汇点。

TL;DR

Table of contents

Open Table of contents

一、设计理念:读写分离 + 就近读 + 尽力而为

在深入探讨结构选型与链路设计之前,我们需要明确贯穿全文的三条核心取舍原则。

① 写在旁路,读在关键路径。 频次的「+1」操作发生在竞价赢价或广告曝光回传之后。此时,当前请求的竞价结果已成定局,写入速度的快慢并不会影响本次出价。正因如此,写操作完全可以采用异步、批量的方式,并容忍秒级延迟,将其交由旁路服务处理。相对而言,「读」操作则发生在下一次竞价的关键路径上。系统必须针对每条候选广告实时判定该维度是否超限,任何微小的延迟都会从极其有限的延迟预算中扣除。因此,读操作必须具备极高的性能,并辅以超时与降级机制。这种读写非对称性,正是整套架构设计的出发点。

② 热点就近读,分散直接读。 频控维度的访问分布存在显著差异。例如,bundle 或 tagId 通常只有有限的热门值,却会被海量请求反复命中,极易引发深度爆炸。针对这类热点维度,最有效的策略是将计数全量加载至进程内存中并定时刷新,使得绝大多数请求能够在本地命中,从而实现零网络开销。相比之下,device 维度的请求高度分散,本地缓存不仅命中率低下,还会无谓消耗内存资源。因此,对于分散维度,直接点查 Redis 反而是更具性价比的选择。简而言之,同一个「读计数」动作,需要根据维度的访问分布特征采用不同的实现路径。

③ 频控是尽力而为,读侧向「放行」降级。 由于计数数据分散在多个实例中,且真实的广告曝光天然存在延迟,因此超投在程序化广告中属于固有现象而非系统 Bug。基于这一业务现实,工程实现上确立了明确的降级策略:当 Redis 读取失败或超时时,系统应一律予以放行(即不触发 no-bid)。我们宁可牺牲少量的限频精度,也绝不能因为计数存储的偶发抖动而拖垮整条竞价链路,甚至误杀正常流量。

综上所述,写走旁路异步、读就近并带超时降级、按维度选结构,构成了频控工程的三大支柱。其中,读操作的确定性始终处于最高优先级,因为它直接扼守着竞价的关键路径。

二、限频的多个维度与各自的拦截器

一次完整的广告曝光通常需要在多个维度上分别进行限频。由于每个维度的业务语义、Redis 键设计乃至底层存储结构都不尽相同,工程上将它们拆解为一组独立的拦截器。这些拦截器被挂载于竞价漏斗的定向过滤阶段,对每条候选广告进行逐一的布尔判定,任何一个维度超限都会直接导致该候选被 no-bid。

维度语义Redis key结构窗口读侧走哪
Bundle单 app 一段时间出价 ≤ Nfreq:{dim}:{cid}:{id}String / Hash 汇总自然日 / 小时本地缓存 + Redis 兜底
TagId单广告位一段时间出价 ≤ Nfreq:{dim}:{cid}:{id}String / Hash 汇总自然日 / 小时本地缓存 + Redis 兜底
Device(自然日)单设备当日胜出 ≤ Nfreq:{dim}:{cid}:{id}String + TTL自然日 tumbling直接点查 Redis
Device(小时滚动)单设备近 N 小时 ≤ Mhfreq:{dim}:{cid}:{id}Hash(field=hour)近似滑动点查 Redis(Lua 求和)
Device(滑动窗口)单设备每小时精确 ≤ M 次,且两次间隔 ≥ X 秒sfreq:{dim}:{cid}:{id}ZSet精确滑动 + 节奏点查 Redis(Lua)

在设计这些 Redis 键时,需要把握以下几个核心要点:

三、三种 Redis 数据结构的选型

窗口维度的工程分歧,正如配置篇所指出的,核心在于「如何实现这个窗口」。落实到 Redis 层面,这就演变为在 String 自然日、Hash 小时桶与 ZSet 精确滑动三种数据结构之间的权衡与取舍。

3.1 String + TTL:自然日 tumbling(最省)

这是最简单直接的方案:为每个计数器分配一个独立的键,通过 INCR 指令递增计数。在首次写入时,利用 EXPIRE 指令将其 TTL 设置为当天结束。如此一来,自然日 0 点一过,键便会自动过期清零。

-- 自然日计数:INCR,且首次创建时按「今天剩余秒数」设 TTL
local v = redis.call('INCR', KEYS[1])
if v == 1 then
  redis.call('EXPIRE', KEYS[1], tonumber(ARGV[1]))  -- ARGV[1] = 当天剩余秒数
end
return v

在读侧,只需执行一次 GET 操作,将结果解析为数字并与阈值进行比对即可。该方案的优点显而易见:内存占用极低(每个键仅存储一个整数),读写时间复杂度均为 O(1),且 TTL 机制自动完成清零,无需额外的扫描清理。然而,其缺点在于 tumbling 窗口固有的边界突刺问题。例如,若在 23:59 和次日 00:01 各产生一次曝光,虽然在用户体感上是「连续两次被广告打扰」,但由于分属两个自然日,系统并不会判定其超限。对于「当日胜出 ≤ N」这类粗粒度的日限频场景,这种突刺是可以接受的,因此自然日 device 限频通常采用此方案。

3.2 Hash 小时桶:小时粒度的近似滑动窗口

当业务需求升级为「过去 N 小时 ≤ M 次」而非简单的「今天 ≤ M 次」时,自然日键便捉襟见肘了。如果精确记录每个事件的时间戳,成本又过于高昂(详见 3.3 节)。此时,按小时分桶便成为一种理想的折中方案:为每个设备与 Campaign 的组合创建一个 Hash 结构,其中 field 为小时索引(hourIndex = epochSecond / 3600),value 则为该小时内的计数。

Key:   hfreq:{dim}:{cid}:{id}        # 单设备-campaign 一个 Hash
Field: 471820  → 12    # 第 471820 小时(UTC)内计了 12 次
Field: 471821  → 8
Field: 471822  → 3     # 当前小时

写侧(计数服务):执行 HINCRBY key hourIndex 1,并为整个键设置 PEXPIRE。需要注意的是,TTL 应取窗口的右边界尺寸(即 (windowHours + 1) 小时),以确保读侧在求和最近 N 个桶时,历史数据不会被提前清理。

读侧(bidder):通过一段 Lua 脚本,从当前小时向前回溯,求和最近 windowHours 个 field 的值:

-- 求和 [hour-windowHours+1 .. hour] 这 windowHours 个小时桶
local hour = tonumber(ARGV[1])
local win  = tonumber(ARGV[2])
local sum  = 0
for i = 0, win - 1 do
  local v = redis.call('HGET', KEYS[1], tostring(hour - i))
  if v then sum = sum + tonumber(v) end
end
return sum

对比每小时生成一个 String 键的方案,单 Hash 多 field 的设计优势显著。如果采用前者,一个设备与 Campaign 的组合会散落出 N 个键,不仅导致键数量爆炸,批量读取时还需要 N 次网络往返。而单 Hash 方案将它们收拢于一个键内,读侧只需在一次 Lua 脚本执行中进行多次 HGET(仅需一次网络往返),内存布局也更为紧凑。当然,这种设计的代价是 field 级别无法独立设置 TTL,只能依赖整个键的过期机制进行兜底,或者在写侧顺带使用 HDEL 清理过期的 field。

总体而言,小时桶通过将连续时间量化为小时网格,实现了一种近似的滑动窗口机制。对于「近 2 小时 ≤ 50 次」这种量级的限频需求,小时级近似已经完全足够;只有当精度要求苛刻到分钟甚至秒级时,才需要引入更为复杂的 ZSet。

3.3 ZSet:精确滑动窗口 +「每小时 N 次 + 最小间隔」(最准最贵)

在真实的广告投放中,设备级别最严格的限频往往是两条约束的叠加:「每小时最多 N 次」以及「两次之间至少间隔 X 秒」。这两条规则相互正交,缺一不可:

为了同时精确判定这两条约束,系统必须记录每次事件的具体时间点。此时,ZSet 结构便派上了用场:member 作为事件的唯一标识(如 uuid 或请求 ID),score 则记录事件的毫秒时间戳。我们可以编写一段整合了「清理窗口、判定间隔、判定总量、记录事件」的原子 Lua 脚本,并通过返回码明确区分拒绝原因,从而为后续的归因分析提供数据支持:

-- KEYS[1] = sfreq:{dim}:{cid}:{id}
-- ARGV: 1=now(ms)  2=windowMs(如 3600000)  3=limitN  4=minGapMs(如 600000)  5=member(uuid/reqId)
local now, win   = tonumber(ARGV[1]), tonumber(ARGV[2])
local limit, gap = tonumber(ARGV[3]), tonumber(ARGV[4])

redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - win)             -- 1. 清理窗口外的旧事件

local last = redis.call('ZRANGE', KEYS[1], -1, -1, 'WITHSCORES')  -- 2. 节奏判定:获取最近一次事件的 score
if last[2] and (now - tonumber(last[2])) < gap then
  return -1                                                        -- 间隔不足 → 拒绝(节奏约束)
end

if redis.call('ZCARD', KEYS[1]) >= limit then                     -- 3. 总量判定:统计窗口内事件数
  return 0                                                         -- 超过每小时 N 次 → 拒绝(总量约束)
end

redis.call('ZADD', KEYS[1], now, ARGV[5])                         -- 4. 记录本次事件
redis.call('PEXPIRE', KEYS[1], win)                               -- 设置整 key TTL = 窗口大小
return 1                                                          -- 判定通过,予以放行

该方案的优势在于窗口极其精确,彻底消除了边界突刺,并且能够一次性原子判定总量与间隔约束,同时利用 ZREMRANGEBYSCORE 顺手清理过期事件。然而,其代价也相当高昂:每个事件都需要占用一个 member,导致热门键的 ZSet 深度近似等于窗口内的事件总数,内存消耗以及 ZADD、ZCARD、ZRANGE 等指令的执行成本远高于 String 或 Hash。正因如此,ZSet 方案仅适用于必须精确控制节奏的少数高价值场景,绝不应作为默认配置。

需要特别澄清的是读写边界问题。上述 Lua 脚本将「检查」与「记录」合并为一次原子操作,这与本篇「读在关键路径、写在旁路」的核心理念存在一定张力。在实战中,我们通常会根据严格程度在两种模式间进行抉择:

总结这三种结构的选型逻辑:

仅需满足「今天 ≤ N」                  → 选用 String + TTL(成本最低,容忍边界突刺)
需满足「近 N 小时 ≤ M」且小时粒度足够     → 选用 Hash 小时桶(近似滑动,读侧求和)
需精确满足「每小时 ≤ N + 两次间隔 ≥ X」 → 选用 ZSet(精度最高,承担深度与成本开销)

四、写路径:回传服务 → 计数服务 双写旁路

正如前文所述,写操作并不在竞价的关键路径上。当一次竞价成功或广告曝光回传发生后,回传服务会将频次事件异步转发至计数服务,最终由计数服务完成 Redis 的落库操作。

竞价成功/曝光回传
      │  (旁路,异步)
      ▼
回传服务
   · 从 Campaign 取 windowHours = winLimitHours
   · buildUrl:windowHours>0 时附 &windowHours=N
      │  HTTP 转发
      ▼
计数服务
   · 始终写自然日计数:INCR freq:{dim}:{cid}:{id}  + 当天 TTL
   · 若 windowHours>0,另写小时桶:HINCRBY hfreq:{dim}:{cid}:{id} {hourIndex} 1 + PEXPIRE

这一链路中包含两个关键的设计考量:

双写是系统迁移过程中的常规手法:先开启双写,接着切换读路径,最后停止旧数据写入。在频控场景下,由于写操作处于旁路且成本较低,用多写一份数据的代价换取读侧切换的高可用性与可回滚能力,无疑是一笔极具性价比的架构交易。

五、读路径:bidder 就近读 + 本地缓存兜热点

读操作直接扼守着竞价的关键路径,系统必须针对每条候选广告的每个维度进行实时判定。这里的核心策略是根据维度的访问分布特征,为其量身定制读取实现。

5.1 热点维度:本地缓存 + Redis 兜底

对于 bundle 和 tagId 这类仅有有限热门值却被海量请求频繁命中的维度,bidder 采用进程内的本地缓存机制进行应对。底层实现通常选用 Long2IntOpenHashMap 等基于原始类型的 Map 结构,利用 hash64(redisKey) 生成的 long 值作为索引,从而有效节省对象开销。该缓存由一个定时任务从 Redis 全量拉取并刷新。在实际读取时,逻辑如下:

freqCount = localCache.get(hash64("freq:{dim}:{cid}:{id}"))
if (freqCount == 缺失) {                 # 本地缓存未命中(冷值或刚上线)
    counter = redis.GET("freq:{dim}:{cid}:{id}")   # 触发 Redis 兜底点查
    freqCount = parse(counter)
}
if (freqCount >= cap) → no-bid

通过这种机制,绝大多数请求都能在本地内存中直接命中,实现零网络开销;只有当遇到本地缺失的冷值时,才会回源查询 Redis。虽然本地缓存存在一定的刷新延迟(在定时任务周期内数据不会更新),但对于 bundle 或 tagId 这种「粗粒度、允许略微滞后」的限频场景而言,这种权衡是完全可以接受的。

5.2 分散维度:直接点查 Redis + 超时降级

与热点维度截然不同,device 维度的请求呈现出高度分散的特征。如果强行使用本地缓存,不仅命中率极低,还会白白消耗大量内存资源。因此,针对 device 维度,最直接且高效的方式是直接点查 Redis:

需要特别强调的是,无论采用哪种读取方式,一旦 Redis 发生异常或超时,系统必须一律予以 catch 并返回 null,进而放行当前请求(呼应第一节的第③条原则)。此时只需记录一行告警日志,绝不能让存储层的抖动影响本次出价。

六、自然日 / 小时滚动 / 滑动窗口:怎么选

三种窗口实现对应着三种截然不同的「精度—存储—成本」三角关系。这实际上正是配置篇中探讨的 tumbling 与 sliding 概念在工程落地层面的具体映射:

窗口实现结构精度存储读成本适用场景
自然日 tumblingString+TTL粗(存在边界突刺)最省一次 GET「当日 ≤ N」等粗粒度限频
小时滚动(近似)Hash 小时桶中(小时粒度)省(N 个 field)一次 Lua 求和「近 N 小时 ≤ M」的主力场景
精确滑动ZSet高(精确到事件)贵(每个事件占用一个 member)一次 Lua需满足每小时精确 ≤ N 且两次间隔 ≥ X

在实际业务中,一个经常被反复追问的细节是:「每小时最多 N 次」到底意味着什么?同一句话在不同的业务口径下,会导向完全不同的结构选型:

同理,对于「间隔 ≥ X 秒」这条节奏约束,只有 ZSet 能够提供精确的表达能力。因为 String 和 Hash 仅仅记录了「计数」,而丢失了具体的「时间点」信息,根本无法获取「最近一次事件发生在何时」。

综上所述,合理的选型顺序应当是:首先评估是否需要控制节奏(如果需要,则直接选用 ZSet);其次评估总量的精度要求(如果自然小时粒度足够,则选用单桶;如果滚动近似可以接受,则选用多桶求和;如果必须绝对精确,则选用 ZSet)。总而言之,精度需求是驱动结构选择的唯一标准。切勿为了用不上的精度去支付昂贵的存储与延迟成本,更不要为了节省一点存储空间,而将「间隔」这种 String/Hash 根本无法表达的约束硬塞进去。

频控工程调优口诀:写走旁路,读在关键;热点本地缓,分散直接查;降级保大盘,精度定选型;日限用 String,滚动用 Hash,节奏靠 ZSet。

如果你对竞价链路的其他环节感兴趣,欢迎继续阅读 Bidder 竞价服务架构(总览) 了解全貌。


延伸阅读


–views
Share this post on:

Previous Post
Bidder 预算 Pacing:PID 控制与消耗平滑
Next Post
Bidder 频次控制:五维配置与身份降级