在程序化广告交易中,频次控制(Frequency Capping)是保护用户体验与优化预算 ROI 的核心防线。然而,当运营在 DSP 后台轻松配置下「单设备每小时最多曝光 3 次」的规则时,底层系统却面临着严峻的工程挑战:如何在海量并发的竞价请求中,以极低的延迟精确判定每一次曝光是否超限?
本文是 买侧竞价链路 系列的第 7 篇(频控 · 工程)。 全系列 9 篇:
- Bidder 竞价服务架构
- OpenRTB 协议精讲
- Bidder 解析层
- Bidder 定向过滤
- RTA 竞价前过滤
- Bidder 频次控制:五维配置与身份降级
- Bidder 频次控制工程篇
- Bidder 预算 Pacing
- Pacing 目标曲线
一句话定位:本文从工程实现视角,剖析多维度频次控制在高并发场景下的读写分离架构,以及基于 String、Hash 与 ZSet 的 Redis 存储选型策略。
Bidder 频次控制配置篇讲的是配置面:运营能拧哪些旋钮(层级 × 窗口 × 事件 × 抑制 × 身份口径),以及无 ID 流量怎么降级。本篇补的是工程实现面,探讨这些旋钮拧下去之后,计数到底存在哪、怎么写、怎么读。同一个「过去 N 小时最多 M 次」,落到代码里是一次 Redis 结构选型、一条读写分离的链路,以及几个各管一维的拦截器。建议先读配置篇建立框架,再回到这里看它怎么跑起来。
虽然频次控制在配置面上仅仅体现为几个简单的下拉框,但在工程实现层面,它却是一道典型的高 QPS 读写分离难题。具体而言,写操作通常发生在广告曝光(Impression)或竞价成功之后,因此可以走旁路异步处理,并容忍一定的延迟;而读操作则处于竞价的关键路径上,必须对每条候选广告进行实时判定,任何毫秒级的延迟都会直接消耗宝贵的出价预算。为了解开这道难题,我们需要做出三个核心决策:一是确定限频维度以设计合理的键(Key);二是选择合适的 Redis 数据结构以平衡精度与存储成本;三是规划读写链路以兼顾数据一致性与系统延迟。
频控的工程骨架就是一张「写在旁路、读在关键路径」的读写分离图:写侧 回传服务→计数服务 异步落 Redis,读侧 bidder 就近读、热点维度走本地缓存兜住 QPS。Redis 是两条链路唯一的交汇点。
TL;DR
- 多维度限频与占位符设计:限频是多维度的,每维对应一个拦截器。Redis 键统一采用
{结构前缀}:{dim}:{cid}:{id}的占位符格式,其中{dim}代表维度(如 device、bundle、tagid),{cid}为 campaign,{id}为实体 ID。结构前缀则用于区分窗口实现,例如freq:对应自然日 String,hfreq:对应小时桶 Hash,sfreq:对应精确滑动 ZSet。 - 三种 Redis 结构的精度与成本权衡:String 结合 TTL 实现自然日 tumbling 窗口,成本最低但存在边界突刺;Hash 小时桶实现近似滑动窗口,读侧求和最近 N 桶;ZSet 实现精确滑动窗口,通过一段 Lua 脚本同时管理「每小时 ≤ N 次」的总量与「两次之间间隔 ≥ X 秒」的节奏,精度最高但成本也最大。
- 总量与节奏的双重约束:「每小时 ≤ N 次」限制的是总量,而「两次之间间隔 ≥ X 秒」限制的是节奏(Recency)。只有 ZSet 能够一次性原子判定这两者,并利用返回码区分是「超频」还是「间隔不足」。
- 写路径的旁路异步化:回传服务将频次事件异步转发至计数服务,由后者完成 Redis 写入。这种旁路设计可容忍秒级延迟,绝不阻塞竞价流程。
- 读路径的关键链降级:
bidder拦截器在定向过滤阶段就近读取计数,一旦超限即触发 no-bid。读操作必须具备超时与降级机制,确保在 Redis 抖动时优先放行,避免拖垮整条竞价链路。 - 热点维度的本地缓存策略:对于 bundle 或 tagId 等少数被海量请求命中的热点维度,优先查询进程内本地缓存(定时从 Redis 全量刷新),未命中时再点查 Redis;而对于请求分散的 device 维度,则直接进行点查。
- 双写机制保障平滑迁移:计数服务对 device 维度同时写入自然日计数(老口径)与小时桶(新口径)。读侧根据
windowHours决定读取策略,从而实现先双写、再切读、后停旧写的零风险平滑迁移。
Table of contents
Open Table of contents
一、设计理念:读写分离 + 就近读 + 尽力而为
在深入探讨结构选型与链路设计之前,我们需要明确贯穿全文的三条核心取舍原则。
① 写在旁路,读在关键路径。 频次的「+1」操作发生在竞价赢价或广告曝光回传之后。此时,当前请求的竞价结果已成定局,写入速度的快慢并不会影响本次出价。正因如此,写操作完全可以采用异步、批量的方式,并容忍秒级延迟,将其交由旁路服务处理。相对而言,「读」操作则发生在下一次竞价的关键路径上。系统必须针对每条候选广告实时判定该维度是否超限,任何微小的延迟都会从极其有限的延迟预算中扣除。因此,读操作必须具备极高的性能,并辅以超时与降级机制。这种读写非对称性,正是整套架构设计的出发点。
② 热点就近读,分散直接读。 频控维度的访问分布存在显著差异。例如,bundle 或 tagId 通常只有有限的热门值,却会被海量请求反复命中,极易引发深度爆炸。针对这类热点维度,最有效的策略是将计数全量加载至进程内存中并定时刷新,使得绝大多数请求能够在本地命中,从而实现零网络开销。相比之下,device 维度的请求高度分散,本地缓存不仅命中率低下,还会无谓消耗内存资源。因此,对于分散维度,直接点查 Redis 反而是更具性价比的选择。简而言之,同一个「读计数」动作,需要根据维度的访问分布特征采用不同的实现路径。
③ 频控是尽力而为,读侧向「放行」降级。 由于计数数据分散在多个实例中,且真实的广告曝光天然存在延迟,因此超投在程序化广告中属于固有现象而非系统 Bug。基于这一业务现实,工程实现上确立了明确的降级策略:当 Redis 读取失败或超时时,系统应一律予以放行(即不触发 no-bid)。我们宁可牺牲少量的限频精度,也绝不能因为计数存储的偶发抖动而拖垮整条竞价链路,甚至误杀正常流量。
综上所述,写走旁路异步、读就近并带超时降级、按维度选结构,构成了频控工程的三大支柱。其中,读操作的确定性始终处于最高优先级,因为它直接扼守着竞价的关键路径。
二、限频的多个维度与各自的拦截器
一次完整的广告曝光通常需要在多个维度上分别进行限频。由于每个维度的业务语义、Redis 键设计乃至底层存储结构都不尽相同,工程上将它们拆解为一组独立的拦截器。这些拦截器被挂载于竞价漏斗的定向过滤阶段,对每条候选广告进行逐一的布尔判定,任何一个维度超限都会直接导致该候选被 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) |
在设计这些 Redis 键时,需要把握以下几个核心要点:
- 将 Campaign ID 拼入键中:限频的本质是控制「特定 Campaign 对特定设备或 Bundle」的曝光次数,而非全局限制。通过将
cid直接体现在键名中(而不是作为 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(最省)
这是最简单直接的方案:为每个计数器分配一个独立的键,通过 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 秒」。这两条规则相互正交,缺一不可:
- 总量控制(每小时 ≤ N):约束的是一小时窗口内的事件总数。如果这里的「小时」要求精确到「过去正好 3600 秒」,那么 String 和 Hash 都无法胜任,因为 String 只能处理自然日,而 Hash 的最小粒度是自然小时网格。
- 节奏控制(间隔 ≥ X):约束的是相邻两次事件的时间距离,这与总量无关。即便在当前小时内仅投放了 1 次,只要距离上次曝光不足 X 秒,系统也必须予以拒绝。这一机制有效避免了「5 分钟内被同一广告连续轰炸 3 次」的糟糕体验。
为了同时精确判定这两条约束,系统必须记录每次事件的具体时间点。此时,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 脚本将「检查」与「记录」合并为一次原子操作,这与本篇「读在关键路径、写在旁路」的核心理念存在一定张力。在实战中,我们通常会根据严格程度在两种模式间进行抉择:
- 尽力而为模式(默认,保持读写分离):bidder 在读路径上仅执行读取操作,通过
ZREMRANGEBYSCORE清理后,利用ZCARD判定总量,并获取末尾 score 判定间隔。至于ZADD记录操作,则交由旁路的计数服务在赢价或曝光回传时异步补充。这种模式的代价是存在并发窗口:同一设备的多条候选广告在同一瞬间都可能读到「未超限」的状态并被同时放行,导致真实曝光略微超出限制。然而,正如前文所述,这种固有的超投现象在尽力而为的口径下是完全可以接受的。 - 严格节奏模式(合并读写,按需付费):将上述原子 Lua 脚本直接嵌入出价流程中,一旦检查通过即执行
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.winLimitHours中提取窗口配置,并将其作为参数附加在 URL 上传递给计数服务。这种设计使得计数服务无需自行查询配置,保持了写侧的无状态与纯执行特性。 - 双写机制保障平滑迁移:针对 device 维度,系统会同时写入老口径(自然日 String)与新口径(小时桶 Hash)。通过这种双写策略,读侧可以根据 Campaign 的
windowHours配置独立决定读取哪份数据:老 Campaign 继续读取自然日计数,而灰度 Campaign 则切换至小时桶。在整个切换期间,两套数据并行存在,确保了回滚操作的零风险。待新口径经过充分验证后,即可安全下线老口径的写入逻辑。
双写是系统迁移过程中的常规手法:先开启双写,接着切换读路径,最后停止旧数据写入。在频控场景下,由于写操作处于旁路且成本较低,用多写一份数据的代价换取读侧切换的高可用性与可回滚能力,无疑是一笔极具性价比的架构交易。
五、读路径: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:
- 自然日限频:执行
GET freq:{dim}:{cid}:{id},通过一次简单的点查即可获取结果。 - 小时滚动限频:利用一段 Lua 脚本在 Redis 内部求和最近 N 个小时桶(详见 3.2 节),仅需一次网络往返即可拿到窗口计数。
- 精确滑动限频(每小时 ≤ N + 间隔 ≥ X):同样借助 Lua 脚本,在 ZSet 上原子完成清理窗口、判定总量与判定间隔的操作(详见 3.3 节),一次往返即可获得布尔判定结果。在默认情况下,该路径仅执行读取操作,将
ZADD留给旁路服务;只有在面临严格间隔约束的场景下,才会在此处合并执行写入操作。
需要特别强调的是,无论采用哪种读取方式,一旦 Redis 发生异常或超时,系统必须一律予以 catch 并返回 null,进而放行当前请求(呼应第一节的第③条原则)。此时只需记录一行告警日志,绝不能让存储层的抖动影响本次出价。
六、自然日 / 小时滚动 / 滑动窗口:怎么选
三种窗口实现对应着三种截然不同的「精度—存储—成本」三角关系。这实际上正是配置篇中探讨的 tumbling 与 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 小时近似(当前时间往前 60 分钟内 ≤ N):采用小时桶方案,通过将「当前小时 + 前一小时」两个 field 求和来逼近真实值。不过,在刚刚跨越小时边界时,「前一小时」的数据实际上只覆盖了几分钟,导致边界过渡不够平滑。
- 滚动 1 小时精确 + 间隔(当前时间往前正好 3600 秒内 ≤ N,且两次间隔 ≥ X):面对这种苛刻要求,必须祭出 ZSet 方案(详见 3.3 节),别无他法。
同理,对于「间隔 ≥ X 秒」这条节奏约束,只有 ZSet 能够提供精确的表达能力。因为 String 和 Hash 仅仅记录了「计数」,而丢失了具体的「时间点」信息,根本无法获取「最近一次事件发生在何时」。
综上所述,合理的选型顺序应当是:首先评估是否需要控制节奏(如果需要,则直接选用 ZSet);其次评估总量的精度要求(如果自然小时粒度足够,则选用单桶;如果滚动近似可以接受,则选用多桶求和;如果必须绝对精确,则选用 ZSet)。总而言之,精度需求是驱动结构选择的唯一标准。切勿为了用不上的精度去支付昂贵的存储与延迟成本,更不要为了节省一点存储空间,而将「间隔」这种 String/Hash 根本无法表达的约束硬塞进去。
频控工程调优口诀:写走旁路,读在关键;热点本地缓,分散直接查;降级保大盘,精度定选型;日限用 String,滚动用 Hash,节奏靠 ZSet。
如果你对竞价链路的其他环节感兴趣,欢迎继续阅读 Bidder 竞价服务架构(总览) 了解全貌。
延伸阅读
- 本站. Bidder 频次控制配置篇:五维配置与身份口径降级:本文的配置面与理念面姊妹篇,详细探讨了运营可配置的维度以及无 ID 流量的降级策略,与本文的工程实现形成完整互补。
- 本站. Bidder 定向过滤(Targeting Filter):深入解析频控拦截器在竞价漏斗中的挂载位置,以及哪些过滤逻辑可以前置粗筛,哪些必须后置逐条判定。
- 本站. Bidder 竞价服务架构(总览):全面阐述为什么频控读取必须在 ~20ms 的延迟预算内完成,并揭示分布式计数一致性在整体架构中的位置。
- 本站. Redis 数据结构内部原理:剖析 String、Hash 与 ZSet 各自的底层编码机制,帮助理解为什么「热门键的 ZSet 深度爆炸」会带来高昂的成本。
- 本站. 热点与冷数据分离:探讨访问分布如何决定工程实现,解释为什么 bundle/tagId 适合走本地缓存,而 device 必须直接查询 Redis。