本文属于 Bidder 竞价服务架构 系列。
Bidder 频次控制配置篇讲的是配置面——运营能拧哪些旋钮(层级 × 窗口 × 事件 × 抑制 × 身份口径),以及无 ID 流量怎么降级。本篇补的是工程实现面:这些旋钮拧下去之后,计数到底存在哪、怎么写、怎么读。同一个「过去 N 小时最多 M 次」,落到代码里是一次 Redis 结构选型、一条读写分离的链路、和几个各管一维的拦截器。建议先读配置篇建立框架,再回到这里看它怎么跑起来。
频次控制在配置面看是几个下拉框,在工程面却是一道典型的高 QPS 读写分离题:写发生在曝光/竞价成功之后(旁路、可异步、可容忍延迟),读发生在竞价关键路径上(每条候选、带超时、慢一毫秒就吃掉出价预算)。把这道题拆开,核心只有三个决定:限哪些维度(决定 key 怎么设计)、用什么 Redis 结构(决定精度与存储成本)、读写各走哪条链路(决定一致性与延迟)。本篇就按这三条线,把一套真实的多维度限频实现讲透。
频控的工程骨架就是一张「写在旁路、读在关键路径」的读写分离图:写侧 回传服务→计数服务 异步落 Redis,读侧 bidder 就近读、热点维度走本地缓存兜住 QPS。Redis 是两条链路唯一的交汇点。
TL;DR
- 限频是多维度的,每维一个拦截器,key 统一成占位符 scheme:
{结构前缀}:{dim}:{cid}:{id}——{dim}是维度(device / bundle / tagid)、{cid}是 campaign、{id}是实体 id;结构前缀区分窗口实现:freq:(自然日 String)、hfreq:(小时桶 Hash)、sfreq:(精确滑动 ZSet)。 - 三种 Redis 结构对应三种精度/成本:① String + TTL = 自然日 tumbling,最省,有边界突刺;② Hash 小时桶(field=hourIndex, value=count)= 小时粒度近似滑动窗口,读侧求和最近 N 桶;③ ZSet(member=事件、score=时间戳)= 精确滑动窗口,一段 Lua 同时管「每小时 ≤ N 次」(总量)和「两次之间间隔 ≥ X 秒」(节奏),最准也最贵。
- 总量与节奏是两把尺子:「每小时 ≤ N 次」限的是总量,「两次之间间隔 ≥ X 秒」限的是节奏(recency)——即使没超 N,也不许 5 分钟内连投 3 次。前者看窗口内事件数、后者看最近一次事件距今多久,只有 ZSet 能一次原子判完两者并用返回码区分「超频」还是「间隔不足」。
- 写路径走旁路:回传服务把频次事件异步转发到计数服务,由计数服务落 Redis。旁路可异步、可容忍秒级延迟,绝不阻塞竞价。
- 读路径在关键链:
bidder拦截器在定向过滤级就近读计数,超限即 no-bid。读必须带超时 + 降级(Redis 抖动时放行而非拖垮竞价)。 - 热点维度走本地缓存:bundle/tagId 少数值被海量请求命中,先查进程内本地缓存(定时从 Redis 全量刷新),未命中再点查 Redis;device 维度请求分散,直接点查。
- 双写做平滑迁移:计数服务对 device 维度同时写自然日计数(老口径)和小时桶(新口径),读侧按
windowHours决定读哪个,切换零风险;先双写、再切读、后停旧写。
Table of contents
Open Table of contents
一、设计理念:读写分离 + 就近读 + 尽力而为
在写任何一行代码之前,先立三条铁律。它们决定了后面所有的结构选型和链路设计。
① 写在旁路,读在关键路径。 频次的「+1」发生在竞价赢价 / 曝光回传之后,这时候这次请求的输赢已成定局,写快写慢不影响本次出价——所以写可以异步、批量、容忍秒级延迟,扔到旁路服务去做。而「读」发生在下一次竞价的关键路径上,每条候选都要问一句「这个维度超没超」,慢一毫秒就从 ~20ms 的延迟预算里扣一毫秒——所以读必须极快、带超时、能降级。这一读一写的非对称,是整套设计的出发点。
② 热点就近读,分散直接读。 频控维度的访问分布差异极大:bundle / tagId 只有有限个热门值,却被海量请求反复命中(深度爆炸),适合把计数全量灌进进程内存、定时刷新,绝大多数请求本地命中、零网络;device 维度请求高度分散(每个设备各不相同),本地缓存命中率低、还占内存,直接点查 Redis 更划算。同一个「读计数」,因维度的访问分布不同而走两条实现。
③ 频控是尽力而为,读侧永远向「放行」降级。 计数分散在多实例、真实曝光有延迟,超投是固有现象而非 bug。工程上据此定一条铁律:Redis 读失败/超时时,一律放行(不 no-bid),宁可少限一点频,也不能因为计数存储抖动而把整条竞价链路拖垮或误杀流量。
一句话理念
频控工程 = 「写得起(旁路异步)+ 读得快(就近 + 超时降级)+ 存得省(按维度选结构)」。三者里,读的确定性优先级最高——它在关键路径上。
二、限频的多个维度与各自的拦截器
一次曝光要在多个维度上分别限频,每个维度语义不同、key 不同、甚至存储结构都不同。工程上把它们拆成一组独立的拦截器,挂在竞价漏斗的定向过滤级,逐条候选做布尔判定,任一超限即对该候选 no-bid。
| 维度 | 语义 | Redis key | 结构 | 窗口 | 读侧走哪 |
|---|---|---|---|---|---|
| Bundle | 单 app 一段时间出价 ≤ N | freq:{dim}:{cid}:{id} | String / Hash 汇总 | 自然日 / 小时 | 本地缓存 + Redis 兜底 |
| TagId | 单广告位一段时间出价 ≤ N | freq:{dim}:{cid}:{id} | String / Hash 汇总 | 自然日 / 小时 | 本地缓存 + Redis 兜底 |
| Device(自然日) | 单设备当日胜出 ≤ N | freq:{dim}:{cid}:{id} | String + TTL | 自然日 tumbling | 直接点查 Redis |
| Device(小时滚动) | 单设备近 N 小时 ≤ M | hfreq:{dim}:{cid}:{id} | Hash(field=hour) | 近似滑动 | 点查 Redis(Lua 求和) |
| Device(滑动窗口) | 单设备每小时精确 ≤ M 次,且两次间隔 ≥ X 秒 | sfreq:{dim}:{cid}:{id} | ZSet | 精确滑动 + 节奏 | 点查 Redis(Lua) |
几个 key 设计要点:
- campaign id 一定拼进 key。 限频是「这个 campaign 对这个设备/bundle」的次数,不是全局。把
cid放进 key(而不是放进 Hash 的 field),让不同 campaign 的计数天然隔离、天然可独立过期,也让「取单个 campaign 判定」这种最常见的读法只需一次点查。 - 前缀即窗口结构,
{dim}段即维度。freq:/hfreq:/sfreq:前缀区分三种窗口实现(自然日 String / 小时桶 Hash / 精确滑动 ZSet),{dim}段区分 device / bundle / tagid;这样不同维度、不同窗口天然隔离,也方便运维按前缀统计内存、设不同的淘汰策略。 - 同一维度可以有多套窗口实现并存(如 device 同时有自然日、小时滚动、滑动窗口三种)——这不是重复,而是不同 campaign 按配置选用不同精度。但「同一 campaign 同一维度只能命中一套」,否则就是同维度重复限频(读两次、判两次、语义打架)。
三、三种 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 分钟)。这两条正交、缺一不可:
- 总量(每小时 ≤ N):管的是一小时窗口里的事件总数。这里的「小时」若要精确到「过去正好 3600 秒」(滑动),String / Hash 都做不到——String 是自然日、Hash 只到自然小时格子。
- 节奏(间隔 ≥ X):管的是相邻两次事件的时间距离,与总量无关。即便这小时才投过 1 次、离 N 还很远,只要距上次不足 X 秒也要拒——避免「5 分钟内被同一广告砸 3 次」这种不超 cap 却很糟的体验。
要同时精确判这两条,就得把每次事件的时间点都记下来——用 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 把「检查」和「记录」合成了一次原子操作——这与本篇「读在关键路径、写在旁路」的主线有张力,实战里按严格程度二选一:
- 尽力而为(默认,保持读写分离):bidder 在读路径只读不写——
ZREMRANGEBYSCORE后ZCARD判总量、取末尾 score 判间隔,ZADD交给旁路的 计数服务 在 win / 曝光回传时补。代价是并发窗口:同一设备的多条候选、多个实例在同一瞬间都读到「没到 N、间隔够」,会一起放行,真实曝光可能略超 N 或间隔被小幅击穿——这正是频控固有的超投,在尽力而为的口径下可接受。 - 严格节奏(合并读写,按需付费):把上面的原子 Lua 直接放到出价时跑,检查通过即
ZADD占位(预扣),靠原子性堵住并发。代价是「写」被拉回关键路径(每条候选一次ZADD),且没赢价的占位必须清理——给占位 member 打可识别标记、竞价失败时ZREM,或依赖窗口 TTL 兜底——否则没真正曝光的事件也被算进频次而欠投。只对「间隔是硬约束」的少数 campaign 开这条路。
三选一的判断:
够用「今天 ≤ 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
两个关键设计:
- 窗口小时数从 Campaign 带下来。 回传服务转发时从
Campaign.winLimitHours取窗口配置,附在 URL 上传给计数服务;计数服务不需要自己查配置,写侧无状态、纯执行。 - 双写做平滑迁移。 device 维度同时写老口径(自然日 String)和新口径(小时桶 Hash)。这样读侧可以按 campaign 的
windowHours独立决定读哪个——老 campaign 继续读自然日、灰度 campaign 切小时桶——切换期两套数据都在,回滚零风险。等新口径完全验证、老口径就能下线。
双写是迁移期的常规手法:先双写,再切读,最后停旧写。频控这里天然适合——写在旁路、成本低,多写一份换来读侧切换的绝对安全。
五、读路径: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:
- 自然日:
GET freq:{dim}:{cid}:{id},一次点查。 - 小时滚动:一段 Lua 在 Redis 内求和最近 N 个小时桶(3.2),一次往返拿到窗口计数。
- 精确滑动(每小时 ≤ N + 间隔 ≥ X):一段 Lua 在 ZSet 上清窗口 + 判总量 + 判间隔(3.3),一次往返拿到布尔判定;默认只读不写,
ZADD留给旁路,严格间隔场景才在此处合并写。
无论哪种,Redis 异常一律 catch 住、返回 null、放行(第一节铁律③)——只记一行日志,不影响本次出价。
六、自然日 / 小时滚动 / 滑动窗口:怎么选
三种窗口对应三种「精度—存储—成本」三角,和配置篇的 tumbling vs sliding 是同一件事的工程落地:
| 窗口实现 | 结构 | 精度 | 存储 | 读成本 | 适用 |
|---|---|---|---|---|---|
| 自然日 tumbling | String+TTL | 粗(有边界突刺) | 最省 | 一次 GET | 「当日 ≤ N」粗限频 |
| 小时滚动(近似) | Hash 小时桶 | 中(小时粒度) | 省(N 个 field) | 一次 Lua 求和 | 「近 N 小时 ≤ M」主力 |
| 精确滑动 | ZSet | 高(到事件) | 贵(每事件一 member) | 一次 Lua | 每小时精确 ≤ N + 两次间隔 ≥ X |
一个反复被问的细节:「每小时最多 N 次」到底指什么? 同一句话至少有三种口径,分别落到不同结构:
- 自然小时 tumbling(本自然小时内 ≤ N,整点清零):就是单个小时桶——
HGET当前hourIndex一个 field 判定即可,比 3.2 的多桶求和还省。缺点同自然日:整点边界突刺(59 分和下一小时 01 分算两小时)。 - 滚动 1 小时近似(now 往前 60 分钟内 ≤ N):用小时桶「当前 + 前一小时」两 field 求和逼近,但刚跨小时时「前一小时」只覆盖几分钟,边界不平滑。
- 滚动 1 小时精确 + 间隔(now 往前正好 3600 秒 ≤ N,且两次间隔 ≥ X):必须上 ZSet(3.3),别无他法。
同理,「间隔 ≥ X 秒」这条节奏约束,只有 ZSet 能精确表达——String / Hash 都只有「计数」没有「时间点」,拿不到「最近一次是什么时候」。所以选型顺序很清楚:先看要不要控节奏(要 → ZSet),再看总量精度(自然小时够 → 单桶;滚动近似够 → 多桶求和;要精确 → ZSet)。精度需求驱动结构选择,别为用不上的精度付存储和延迟的账,也别为省一点存储把「间隔」这种 String/Hash 根本表达不了的约束硬塞进去。
七、速查表
- 读写分离:写在旁路(回传服务→计数服务 异步,可延迟)、读在关键路径(bidder,带超时 + 失败放行)。
- 多维度多拦截器:bundle / tagId / device 各一套 key,挂定向过滤级,任一超限即 no-bid。
- 三种结构:String+TTL(自然日,最省,有突刺)/ Hash 小时桶(field=hourIndex,近似滑动,读侧求和 N 桶)/ ZSet(member=事件,精确滑动,一段 Lua 同判「每小时 ≤ N」+「间隔 ≥ X」,最贵)。
- 总量 vs 节奏:「每小时 ≤ N 次」限总量、「两次间隔 ≥ X 秒」限节奏,正交;只有 ZSet 能精确表达「间隔」(String/Hash 只有计数没有时间点)。严格间隔要接受「读写合并 + 占位清理」的代价。
- key 设计:
cid拼进 key(不是 Hash field),前缀即维度,同维度可多窗口并存但单 campaign 只命中一套。 - 热点就近读:bundle/tagId 走进程内本地缓存(hash64 索引 + 定时刷新)+ Redis 兜底;device 分散,直接点查。
- 双写迁移:计数服务 对 device 同时写自然日 + 小时桶,读侧按
windowHours切;先双写、再切读、后停旧写。 - 窗口选型:精度需求驱动结构,别为用不上的精度买单;「近 N 小时」小时桶求和够用,不必上 ZSet。
配置篇把频控拆成五个维度的旋钮,本篇把这些旋钮拧下去之后的存储与链路补齐:一次限频判定,背后是一次按访问分布挑过的读(本地缓存或 Redis 点查)、一条旁路异步写、和一个按精度需求选定的 Redis 结构。理解了「写在旁路、读在关键路径、按维度选结构、按精度选窗口」这四句,也就掌握了把「运营配的一条频控」真正跑在十万级 QPS 上的工程要领。
延伸阅读
- Bidder 频次控制配置篇:五维配置与身份口径降级:本篇的配置面/理念面姊妹篇——运营能配什么、无 ID 流量怎么降级,与本篇的存储实现互补。
- Bidder 定向过滤(Targeting Filter):频控拦截器挂在漏斗的哪一级、哪半能前置粗筛哪半必须后置逐条判定。
- Bidder 竞价服务架构(总览):读频控为什么必须在 ~20ms 延迟预算内完成、分布式计数一致性的整体位置。
- Redis 数据结构内部原理:String / Hash / ZSet 各自的底层编码,理解「热门 key 的 ZSet 深度爆炸」为什么贵。
- 热点与冷数据分离:为什么 bundle/tagId 走本地缓存、device 走 Redis——访问分布决定实现。