Skip to content
Charles Shao
Go back

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

views

本文属于 Bidder 竞价服务架构 系列。

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

频次控制在配置面看是几个下拉框,在工程面却是一道典型的高 QPS 读写分离题:发生在曝光/竞价成功之后(旁路、可异步、可容忍延迟),发生在竞价关键路径上(每条候选、带超时、慢一毫秒就吃掉出价预算)。把这道题拆开,核心只有三个决定:限哪些维度(决定 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」发生在竞价赢价 / 曝光回传之后,这时候这次请求的输赢已成定局,写快写慢不影响本次出价——所以写可以异步、批量、容忍秒级延迟,扔到旁路服务去做。而「读」发生在下一次竞价的关键路径上,每条候选都要问一句「这个维度超没超」,慢一毫秒就从 ~20ms 的延迟预算里扣一毫秒——所以读必须极快、带超时、能降级。这一读一写的非对称,是整套设计的出发点。

② 热点就近读,分散直接读。 频控维度的访问分布差异极大:bundle / tagId 只有有限个热门值,却被海量请求反复命中(深度爆炸),适合把计数全量灌进进程内存、定时刷新,绝大多数请求本地命中、零网络;device 维度请求高度分散(每个设备各不相同),本地缓存命中率低、还占内存,直接点查 Redis 更划算。同一个「读计数」,因维度的访问分布不同而走两条实现。

③ 频控是尽力而为,读侧永远向「放行」降级。 计数分散在多实例、真实曝光有延迟,超投是固有现象而非 bug。工程上据此定一条铁律:Redis 读失败/超时时,一律放行(不 no-bid),宁可少限一点频,也不能因为计数存储抖动而把整条竞价链路拖垮或误杀流量。

一句话理念

频控工程 = 「写得起(旁路异步)+ 读得快(就近 + 超时降级)+ 存得省(按维度选结构)」。三者里,读的确定性优先级最高——它在关键路径上。

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

一次曝光要在多个维度上分别限频,每个维度语义不同、key 不同、甚至存储结构都不同。工程上把它们拆成一组独立的拦截器,挂在竞价漏斗的定向过滤级,逐条候选做布尔判定,任一超限即对该候选 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)

几个 key 设计要点:

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

窗口维度的工程分歧,配置篇已点出是「怎么实现这个窗口」。落到 Redis,就是三种结构的取舍:String 自然日、Hash 小时桶、ZSet 精确滑动

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

最简单:一个计数器 key,INCR 递增、首次写时 EXPIRE 到当天结束,自然日 0 点随 key 过期而清零。

-- 自然日计数: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,parse 成数字比阈值。优点:内存极省(一个 key 一个整数)、读写都是 O(1)、TTL 自动清零无需扫描。缺点:tumbling 的边界突刺——23:59 和次日 00:01 各投一次,用户体感「连着两次」,但分属两天都不超限。对「当日胜出 ≤ N」这种粗粒度日限频,突刺可接受,所以自然日 device 限频就用它。

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

要做「过去 N 小时 ≤ M 次」而不是「今天 ≤ M 次」,自然日 key 就不够了。精确到每个事件太贵(见 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,并给整个 key 设 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

为什么用单 Hash 多 field,而不是每小时一个 String key? 每小时一个 String 会让一个设备-campaign 散出 N 个 key,key 数量爆炸、批量读要 N 次网络往返;单 Hash 把它们收拢成一个 key,读侧一次 Lua 内多次 HGET(一次往返)、内存也更紧凑。代价:field 级不能各自 TTL,只能靠整 key 的 TTL 兜底 + 读侧只求和窗口内的 field(窗口外的旧 field 靠整 key 过期一起清,或写侧顺带 HDEL 掉过期 field)。

小时桶本质是把「连续时间」量化成小时格子的近似滑动窗口:窗口边界以小时为最小移动步长。对「近 2 小时 ≤ 50 次」这种量级,小时级近似完全够用;要精确到分钟/秒,才需要下面的 ZSet。

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

真实投放里,设备级最严的一档限频往往是两条约束叠加:「每小时最多 N 次」(比如每小时 ≤ 3 次)两次之间至少间隔 X 秒」(比如两次曝光至少隔 10 分钟)。这两条正交、缺一不可:

要同时精确判这两条,就得把每次事件的时间点都记下来——用 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,热门 key 的 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,key 用 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 维度请求高度分散,本地缓存命中率低、还白占内存,直接点查 Redis:

无论哪种,Redis 异常一律 catch 住、返回 null、放行(第一节铁律③)——只记一行日志,不影响本次出价。

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

三种窗口对应三种「精度—存储—成本」三角,和配置篇的 tumbling vs 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 根本表达不了的约束硬塞进去。

七、速查表

配置篇把频控拆成五个维度的旋钮,本篇把这些旋钮拧下去之后的存储与链路补齐:一次限频判定,背后是一次按访问分布挑过的读(本地缓存或 Redis 点查)、一条旁路异步写、和一个按精度需求选定的 Redis 结构。理解了「写在旁路、读在关键路径、按维度选结构、按精度选窗口」这四句,也就掌握了把「运营配的一条频控」真正跑在十万级 QPS 上的工程要领。


延伸阅读


views
Share this post on:

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