本文是 买侧竞价链路 系列的第 1 篇(开篇总览)。 全系列 9 篇:
- Bidder 竞价服务架构
- OpenRTB 协议精讲
- Bidder 解析层
- Bidder 定向过滤
- RTA 竞价前过滤
- Bidder 频次控制:五维配置与身份降级
- Bidder 频次控制工程篇
- Bidder 预算 Pacing
- Pacing 目标曲线
一句话定位:Bidder 是 DSP 内部唯一卡在关键路径上的在线服务,必须在 20ms 延迟预算内跑完解析、过滤、召回、打分、出价七级漏斗,并依靠本地分片与异步回灌在延迟、效果与预算一致性之间达成严苛权衡。
在程序化广告生态全景中,我们将需求方平台(DSP)的核心价值概括为「帮广告主以最低价买到目标受众」。然而,当一次真实的广告曝光(Impression)机会转瞬即逝时,究竟是谁在不到 100 毫秒的极限窗口内,替广告主拍板决定「面向哪个用户、投放哪条广告、出价多少」?答案正是 DSP 内部最核心的在线服务——Bidder(竞价服务)。
作为 DSP 的核心枢纽,Bidder 面临着极为苛刻的工程约束。当 ADX 发来竞价请求(Bid Request)后,端到端的可用时间通常只有 100 毫秒左右;扣除两趟网络往返的开销,真正留给 Bidder 计算的处理预算往往仅剩 20 到 50 毫秒。正因如此,在这转瞬即逝的时间内,它不仅必须跑完召回、打分、出价与预算校验的全流程,还要确保广告主的预算不会严重超支。需要强调的是,一旦处理超时,该次广告曝光即被视为未参与,连兜底出价的机会都会丧失。
本篇作为开篇总览,将系统性地拆解一次实时竞价(RTB)的生命周期。我们将探讨延迟预算的分配机制、七级漏斗流水线的设计,以及数据面如何将慢 IO 移出请求路径,并深入剖析预算 Pacing 的核心逻辑。后续各篇关于广告竞价机制、召回、频控与 Pacing 的深入探讨,都将紧密挂载在这张基础架构的骨架之上。
TL;DR
- Bidder 是 DSP 内部真正参与 OpenRTB 竞价的在线服务:当 ADX 发出
Bid Request时,它必须在毫秒级返回包含出价与素材的Bid Response,是整个 DSP 唯一「卡在关键路径上」的模块。 - 所有设计服从同一条原则:尽力计算、按时降级、绝不超时。超时等同于响应被丢弃并无谓损失流量,其代价远比出价不够优更为严重。
- 处理预算是硬约束,常压缩至 20ms 量级:在端到端约 100 毫秒的窗口中,网络往返与 ADX 撮合开销占用大半,留给 Bidder 计算的通常仅剩 20 到 50 毫秒;在跨机房或
tmax更紧的场景下,甚至逼近 20 毫秒下限。因此,全链路必须携带 deadline,每一级均可提前放弃并以兜底值继续。 - 架构是一条漏斗式流水线:请求依次经过解析、定向过滤、召回、打分、出价、排序与填充。成本越低、可削减流量越多的过滤越靠前;开销越大的打分计算仅施加于存活的少数候选。
- Bidder 是 DSP 内部众多服务的协同枢纽:向上,一次广告曝光会被 ADX 并发广播给多家 DSP;向内,Bidder 依赖缓存、结算与回传接收等服务,通过 Dubbo、Nacos 与 Sentinel 协作,并在 K8s 跨数据中心底座上由多种大数据与存储组件支撑。
- 在线链路只做「读」与「算」,不做「写」与「远程慢调用」:广告库、定向索引与用户特征尽量常驻内存,日志异步写入消息队列,预算等状态则经近线回灌刷入本地缓存。
- 预算 Pacing 是最难的一致性问题:面对 Bidder 无会话状态、多实例且真实消耗存在延迟的现状,主流解法是采用本地预算分片结合异步回灌对账,辅以 PID 平滑控制,通过容忍少量超投来以最终一致性换取性能。
- 三者权衡:延迟、模型效果与预算一致性(超投容忍度)不可能同时拉满,架构设计的本质就是在这三者之间寻找最优的平衡点。
Table of contents
Open Table of contents
一、Bidder 是什么:DSP 唯一「卡在关键路径上」的模块
对外而言,需求方平台(DSP)是广告主(Advertiser)的自助买量平台;但向内剖析,它实则是一组庞大服务的集合。诸如 Campaign 管理、预算与财务、素材审核、人群包(DMP 对接)以及报表归因等模块,绝大多数都属于离线或近线系统,对处理时效的容忍度较高。然而,唯一的例外便是 Bidder。
作为 DSP 中唯一直接位于 ADX 竞价链路上且被实时调用的服务,Bidder 的地位极为特殊。每当 ADX 收到一次广告曝光机会,便会向所有接入的 DSP 广播一个 Bid Request。该请求通常遵循 OpenRTB 格式,携带着曝光位、媒体(Publisher)、设备、地理位置及用户 ID 等丰富的上下文信息。面对这一请求,Bidder 必须在严格的截止时间(tmax,常见为 100 到 120 毫秒)内返回 Bid Response,明确告知是否出价,并提供出价 price、素材 adm 或 adid,以及赢价与计费通知等关键字段。
ADX ──Bid Request(tmax=100ms)──▶ Bidder
│ 解析 → 定向过滤 → 召回 → 打分 → 出价 → 排序 → 填充
ADX ◀──Bid Response(bid $2.31)──── Bidder ← 必须在 tmax 内返回
正因如此,Bidder 具备的三条本质属性,直接决定了其后续所有的架构设计走向:
| 属性 | 含义 | 带来的约束 |
|---|---|---|
| 在线、卡关键路径 | 每次曝光都要它实时作答 | 延迟是硬指标,超时即出局 |
| 超高并发 | 大体量 DSP 常在十万级 QPS,头部可达百万级,且无效流量占比高 | 必须分层过滤、尽早削减流量 |
| 涉及真实资金 | 出价即承诺付费 | 预算不能严重超投,最难的一致性问题 |
总而言之,Bidder 是一个需要在约 20 毫秒内,面向近乎无状态的请求,迅速做出一次涉及真金白银付费决策的复杂系统;其核心挑战就在于必须同时满足低延迟、高并发与预算不超支这三项严苛约束。
二、一次竞价的生命周期与 20ms 延迟预算
要理解 Bidder 的架构,首先需要看清时间都花在了哪里。从买方(Buy Side)的视角来看,一次程序化广告曝光的端到端预算大约是 100 毫秒,但这 100 毫秒其实是被层层瓜分的:
端到端
100ms 中,网络往返先占用一大半,真正留给 Bidder 计算的只有 2050ms;这段时间还需再分配给漏斗的七个阶段(下一节逐级拆解)。
- 网络往返不可压缩:ADX 与 Bidder 之间一来一回的 RTT,在跨地域或跨运营商时可达 30 到 50 毫秒。此外,还需为 ADX 自身的撮合逻辑与安全余量预留时间。正因如此,真正用于服务端计算的时间,通常只有 20 到 50 毫秒。
- 模型打分是主要开销:对召回的数万条候选逐条预估点击率(CTR)与转化率(CVR),是整条链路中最耗时的一段。这也解释了为什么必须先通过粗排截断,再进行精排。
- deadline 必须全链路传递:请求进门即打上时间戳并计算剩余预算,向下每一级均需携带
context deadline。当某一级临近超时,系统会果断跳过该级并以兜底值继续(例如模型超时用默认分、召回超时用粗排结果),绝不阻塞等待。
2.1 为什么要减去「安全余量」
需要明确的是,tmax 是 ADX 一端测量的截止时间,而 Bidder 是在自身机器上进行计时,两者之间隔着网络延迟与各种不确定性。因此,我们绝不能将 tmax 视为自己可用的全部时间,而是必须先扣除一块「路途消耗与不确定冗余」,剩余的部分才是真正可用于计算的内部 deadline:
tmax = 100ms(ADX:100ms 内我要收到响应)
− 请求来程 + 响应回程 RTT 30~50ms ← 跨地域/跨运营商取高值
− 序列化 / 发送 / TCP 排队 ~3ms
− 时钟偏差 + GC 抖动冗余 ~2ms
─────────────────────────────
= 内部 deadline ≈ 45~65ms ← 才是你真正敢用来算的时间
上述公式展示的是同城且 tmax=100ms 时的乐观估算。现实情况往往更为严苛:不少 ADX 仅给出 60 到 80 毫秒的 tmax;若叠加跨机房的高 RTT,该值会进一步降至 20 毫秒上下,这便是标题中「20ms 延迟预算」的由来。因此,不应将「20ms」视为一个固定值,它是该估算在最紧凑场景下的下限;在宽松场景下或许能有 40 到 50 毫秒,但架构设计必须按最紧的一档来兜底。
具体而言,安全余量主要用于覆盖以下四类开销:
- 回程网络时间:计算完成后,响应还需要飞回 ADX;如果不预留这部分时间,等 ADX 收到时早就过了截止时间。
- 来程已消耗:
tmax通常从 ADX 发出请求那一刻起算,请求在飞往 Bidder 的途中已经用掉了一部分,当你收到时「剩余预算」其实早已小于 100 毫秒。 - 时钟偏差(clock skew):两台机器的时钟不可能完全一致,哪怕几毫秒的偏差,也会让你误以为还有时间,实则已经晚了。
- 序列化、发送与抖动冗余:从算完到字节真正上路还有序列化开销,外加 GC 停顿、线程调度以及突发负载带来的毛刺。
这是一个典型的工程权衡:余量设置过小,会导致偶发踩线超时,尾部(P99)流量成批被丢弃,既白白消耗了 CPU 又毫无收益;反之,余量设置过大,则会使内部 deadline 过紧,模型来不及精排而被迫降级,进而导致竞胜概率下降。工程实践中,我们实际上是在用少量可用的计算时间,去换取不超时的确定性。因此,不应凭经验固定取值,而应依据实测 RTT 的 P95 或 P99(而非均值)来设定,按机房或 ADX 分别配置,并在必要时随实时到达情况进行动态自适应。
归根结底,核心原则依然是「尽力计算、按时降级、绝不超时」。需要强调的是,超时的代价绝非出价略低那么简单,而是该次广告曝光完全出局,连兜底出价的机会都会丧失;在实时竞价(RTB)系统中,宁可取一个次优但按时的响应,也绝不取一个最优但迟到的响应。
三、架构总览:一条漏斗式流水线
从宏观视角来看,Bidder 内部本质上是一条漏斗式流水线。请求自上而下逐级处理,每一级都在筛除一批候选,并为存活候选补充必要信息;候选集从百万级的广告库逐步收窄到 Top-N,最终仅保留胜出的 1 条并产出确切出价。贯穿始终的核心排布原则只有一条:成本越低、可削减流量越多的过滤环节越靠前;开销越大的打分计算,仅施加于存活的少数候选之上。
按时序图阅读:竖线为各参与者的生命线,实线箭头=调用 / 发送、虚线箭头=返回;Bidder 上的活动条与黄色注释对应七级漏斗阶段。蓝底区为在线关键路径(~20ms 内完成、只读只算),其下为离开关键路径的异步回灌(先快后准,容忍少量超投)。
接下来,我们将逐级拆解这七段流程。
3.1 解析(Parse)
作为漏斗的入口,这一级负责将原始请求转化为内部可计算的对象:
- 协议解析与适配:由于对接多个 ADX,各家 OpenRTB 方言略有差异,此处需将其解析并归一化为内部统一模型。序列化方案优先选择 Protobuf,因为它相比 JSON 更省时且更节省 CPU 资源。
- 尽早限时:请求进门即打上时间戳,按
tmax减去安全余量算出内部 deadline,并向下透传。 - 连接与传输复用:通过 HTTP keep-alive 或 gRPC 长连接以及 TLS 会话复用,避免每次请求重复握手;同时按需启用 gzip(大报文可省带宽,小报文则可能反增延迟)。
解析环节看似平平无奇,却是唯一被每个请求无差别执行的一级,更是延迟 deadline 的起点。关于它的方言适配、序列化选型、限时机制与连接复用,已单独成篇:Bidder 解析层(Parse)。
3.2 定向过滤(Targeting Filter,漏斗最前端)
为了让后续的高开销操作仅处理「有希望」的流量,必须将成本最低、可削减流量最多的判断置于最前端;这一级执行的是硬性资格判定,实行一票否决制:
- 流量与反作弊过滤:执行黑白名单(如媒体、地域、设备类型、IP 段)以及无效流量(IVT)的初筛。
- 定向布尔匹配:将本次广告曝光的上下文(地域、时段、设备、人群包)与广告的定向条件进行求交,不匹配者直接排除。
- 频次控制:执行用户或广告级的曝光上限控制。此处需说明一个顺序上的细节:真正基于用户与 campaign 交叉维度的封顶,需在已有候选之后才能逐条判定,且往往需要一次带超时的 Redis 点查;因此,能置于最前端的通常是用户级全局频控,或是已达标广告的位图预排除这类「不依赖具体候选、可纯本地布尔判断」的粗筛。精细频控更多是在召回之后,随排序闸门一并执行。
- 快速失败:过滤后若无任何可投候选,立即返回
no-bid,不再进入后续阶段。
3.3 召回(Retrieval)
这一级的任务是从几十万甚至百万条 campaign 或 creative 中,毫秒级选出通过定向过滤的候选集:
- 广告候选全量常驻内存,利用倒排索引与位图(
RoaringBitmap)将定向条件的求交转化为毫秒级的位运算。 - 人群包命中判断采用 Bloom Filter 或位图,坚决避免远程查询。
- 当候选过多时,先通过简单规则或轻量模型做粗排与截断,仅将 Top-N 交给下游打分。正因下一级的模型打分开销极高,必须在此处先压低候选规模。
这一段是「高并发定向召回」的核心,本系列后续会专门拆解 Bloom Filter、Bitmap 与倒排索引的工程实现。
3.4 打分(Scoring:CTR / CVR 预估)
打分环节是延迟的主要来源。系统需在毫秒级完成特征获取与模型推理,为每个候选估算点击率(CTR)与转化率(CVR):
- 特征:用户特征预取自本地缓存或 Redis(点查要求 P99 达到 1 毫秒以内),上下文特征则实时拼装。
- 推理:采用 TensorFlow Serving、ONNX Runtime 或自研轻量模型对候选进行批量(batch)打分;候选较多时,通过「先粗排削减、再精排计算」来控制规模。
- 超时降级:若模型服务超时,立即使用兜底 CTR(如历史均值或简单模型结果),以保证链路不断。
3.5 出价(Bidding)
在这一级,系统需要把「这条广告值多少钱」算成一个具体的出价:
- 利用
eCPM把每个候选折算到同一把尺子(即「每千次广告曝光等效收入」)上,具体公式随计价目标而变:- CPC 目标(按点击付费):
eCPM ≈ CTR × bid_CPC × 1000,只用到 CTR。 - CPA / CPI 目标(按转化 / 激活付费):
eCPM ≈ CTR × CVR × target_CPA × 1000,此时才需要乘 CVR。 - 需要强调的是,切勿遗漏
× 1000,因为eCPM是「千次广告曝光」口径,漏了它量级就全错了。
- CPC 目标(按点击付费):
- 出价策略:涵盖 CPC/CPA 目标转 CPM 出价、一价拍卖下的 bid shading(压价),并结合 pacing 系数动态调整(预算消耗过快则压价、过慢则抬价)。
3.6 排序(Ranking)+ 预算/频控闸门
多个候选各自得出出价后,这一级负责排出胜者,并通过最后一道涉及付费的闸门:
- 按
eCPM或出价对候选进行排序,选出内部竞价的胜出广告。对比前一种方案,这其实是 DSP 内部的一次「小竞价」,随后胜出者才会与其他 DSP 在 ADX 展开竞争。 - 预算与频控闸门:胜出候选还需接受校验,确认该 campaign 此刻是否仍有预算,以及 pacing 是否允许此刻花费。无预算或已熔断者将被跳过,顺延至下一名。这是全篇最难的一致性问题,将在第六节详述。
- 兜底:若所有候选均被闸掉,则直接返回
no-bid。
3.7 填充(Fill:响应构造)
确定最终胜者后,将其组装为符合 OpenRTB 规范的响应:
- 构造
Bid Response:填充seatbid.bid数组中的出价price、素材adm或素材 IDadid、赢价通知nurl与计费通知burl,以及${AUCTION_PRICE}等价格宏(由 ADX 在回调时回填成交价)。 - 若无可投候选,按各 ADX 约定返回空
seatbid或 HTTP204 No Content,必要时附带无出价原因码(NBR)。 - 异步记录竞价日志(bid log),经内存队列写入 Kafka,确保关键路径上不做任何同步写操作。
四、放大一层:Bidder 在 DSP 内部的位置与技术栈
前三节我们都聚焦于 Bidder 的内部运作。然而,它并非一座孤岛:对外,一次广告曝光机会会被 ADX 同时广播给多家 DSP,本方 Bidder 只是众多竞争者之一,且 tmax 对所有参与者一视同仁;对内,Bidder 只是 DSP 众多服务中那个恰好位于关键路径上的模块,其周边还环绕着缓存服务、结算服务以及一组回传接收服务。只有将视角拉远一层,我们才能看清这些服务是如何协作的,由哪一套技术栈支撑,以及跨机房部署的真正含义。
按时序图阅读:参与者分为在线服务(Dubbo / Nacos / Sentinel 协作)与存储 / 消息 / 计算 / 观测两组分栏;实线箭头=调用 / 发送、虚线箭头=返回。蓝底区为在线竞价关键路径(tmax ≈ 100ms、一视同仁);其下为付费闭环:win → 曝光 → 回传 → Kafka → Flink 聚合真实消耗 → 结算回灌 Bidder 预算分片,以及报表与监控旁路。
4.1 一家 ADX,多家需求方平台
针对同一个曝光机会,ADX 会并发发送给所有接入的 DSP,最终出价最高且合规、有预算者胜出。这种机制直接带来了两个后果:
- 仅有 1/N 的胜率窗口:当
tmax到达后,ADX 绝不会等待任何一方。慢一毫秒并非意味着「排在后面」,而是直接出局。这正是前文反复强调延迟预算与安全余量的根本原因。 - 同一份流量被反复估值:每家 DSP 的 Bidder 都在对同一次广告曝光独立运行各自的漏斗流水线。因此,召回、打分与出价的性价比(即每毫秒换取的竞胜概率与 ROI)才是真正的竞争力所在,而不在于「计算得多全面」。
4.2 DSP 内部服务的分工协同
Bidder 仅承担「在线决策」的职责,它将所有耗时与有状态的工作统统交由周边服务处理:
- 竞价引擎 Bidder:作为唯一位于关键路径的在线服务,它专职运行第三节所述的七级漏斗,坚持只读只算。
- 缓存服务:负责将广告库、定向索引以及用户与上下文特征就近供给 Bidder。此处需要做个权衡:在线路径上 Bidder 应尽量避免发起同步的 Dubbo 远程调用,而是由缓存服务通过推送或定时加载,将热数据直接写入 Bidder 的进程内存;仅针对冷门或大 key,才执行一次带有超时与降级机制的远程读。
- 回传接收服务(IMP / CLICK / Install):专门接收来自 ADX、媒体或 MMP(如 AppsFlyer、Adjust)回调的广告曝光、点击与激活事件。它们是 Bidder 付费之后的闭环入口;没有回传,就没有真实消耗,没有归因,自然也没有模型训练样本。
- 结算服务:负责消费回传事件,执行计费、归因与真实消耗聚合,随后将结果回灌至 Bidder 的预算分片,并在必要时下发熔断指令。它正是第六节预算 Pacing 中「中心预算服务」的现实载体。
整体数据流向可概括为一条闭环:Bidder 出价(付费) → 回传接收采集事件 → 结算聚合真实消耗 → 回灌预算至 Bidder;与此同时,广告库与特征则由缓存服务单向供给 Bidder。
4.3 支撑全链路的技术栈底座
若将一套常见的 Java 系中间件逐一对应,便能清晰地看清各组件在链路中的位置:
| 组件 | 在 DSP 里干什么 | 处在链路哪一段 |
|---|---|---|
| Dubbo | 内部服务间 RPC(Bidder ⇄ 缓存 / 结算等) | 服务间调用(在线读要带超时 + 降级,离线随意) |
| Nacos | 服务注册发现 + 配置中心(开关、阈值、定向规则热更新) | 控制面 |
| Sentinel | 限流、熔断、降级;保护 Bidder 不被慢依赖拖垮、过载时丢低价值流量 | 每个在线服务入口 |
| Redis | 特征点查、频控计数、预算分片兜底 | 数据面(在线读) |
| MySQL | campaign / 素材 / 定向 / 账户等元数据(OLTP) | 离线源,加载进内存与缓存 |
| Kafka | 竞价/曝光/点击/激活日志总线、异步预算回灌、训练样本 | 异步链路主干 |
| Flume | 采集各服务日志文件,汇聚进 Kafka / HDFS | 采集层 |
| Flink | 实时流:真实消耗聚合、实时特征、实时报表 | 近线 |
| Spark | 离线批:训练样本、T+1 报表、数据回溯 | 离线 |
| Doris | 实时 + 离线 OLAP,投放报表与多维分析查询 | 分析层 |
| InfluxDB | 指标时序(QPS、超时率、win rate、延迟分位、消耗曲线) | 监控采集 |
| Grafana | 看板 + 分级告警(读 InfluxDB / Doris) | 监控展示 |
| K8s | 所有无状态在线服务的部署、弹性扩缩、跨 DC 编排 | 平台底座 |
可以看出,在线路径上真正参与那 20 毫秒决策的只有 Bidder 自身加上内存与 Redis;其余组件(如 Kafka、Flume、Flink、Spark、Doris 与 MySQL)几乎全部位于关键路径之外,它们负责将「已花费的预算」与「新获得的知识」以异步方式收敛回系统。这正是第五节数据面原则在组织架构层面的生动体现。
4.4 跨数据中心部署的工程含义
DSP 通常会在多个数据中心(DC)进行部署,这绝非出于形式主义,而是由延迟与容灾的硬性需求所决定的:
- 就近部署以压低 RTT:由靠近 ADX 或媒体的机房接管当地流量,能直接减小前文所述的「网络开销 + 安全余量」。RTT 每降低 10 毫秒,可用的算力预算即增加 10 毫秒。
- 数据需要复制:广告库与配置需由主库复制到各 DC;Redis 与缓存各 DC 需保持一份;MySQL 实行主从或多活架构。在线服务只读本地副本,绝不跨 DC 实时查询。
- 预算跨 DC 是真正的难点:同一 campaign 的消耗被分散在多个机房,全局预算必须跨 DC 聚合,这导致回灌链路更长、超投窗口更大。工程上通常会再切一层:先按 DC 分配预算,DC 内部再按实例进行分片。
- 容灾切流:当某个 DC 发生故障时,依靠全局负载均衡、DNS 与 K8s 将流量切至其它 DC。由于 Bidder 无会话状态,请求切流毫无压力;但需要特别留意它持有的软状态(即本地预算分片)。切流瞬间,那部分未回灌的扣减数据会丢失,因此需要在接管侧按回灌数据进行重建,并对相关 campaign 临时收紧熔断阈值,否则超投现象会被严重放大(详见第六节)。
五、数据面:将慢 IO 移出请求路径
第三节中描述的流水线之所以能在 20 毫秒内跑完,依靠的绝不仅是 CPU 的运算速度,而是它几乎不执行任何远程慢调用。在线链路必须坚守只做「读」和「算」,坚决不做「写」和「远程慢调用」的数据面原则。
具体落地策略如下:
| 数据 | 放哪 | 怎么更新 |
|---|---|---|
| 广告库 / 定向索引 | 进程内内存(倒排 + 位图) | 后台线程定时全量 + 增量推送,读写分离、原子切换 |
| 用户 / 上下文特征 | 本地 Cache(Caffeine)→ Redis / Aerospike | 近线特征计算写入,在线只点查 |
| 预算 / 频控计数 | 本地分片 + Redis 兜底 | 见第六节:本地扣减 + 异步回灌 |
| 竞价 / 曝光 / 点击日志 | 异步:内存队列 → Kafka | 完全离开关键路径 |
为实现上述目标,必须贯彻三条核心原则:
- 热数据不出进程:能置于内存的绝不放 Redis,能放 Redis 的绝不查 DB。通过构建分层缓存(本地 → Redis → DB),命中率越高,尾延迟自然越低。
- 写操作全异步:日志、计费与打点数据一律写入队列,交由下游消费;在线请求的处理周期内,绝不应存在任何同步写操作。
- 状态经回灌更新:诸如预算、模型、黑白名单等可变状态,一律通过近线管道(如 Kafka 或定时推送)刷回本地缓存,而非在每次请求时发起实时查询。
正因如此,此类高并发系统往往偏好 Go、C++ 或 Rust 等语言,因为它们的 GC 停顿更为可控,且并发模型更轻量。若采用 Java 亦无不可,但必须重点调优 GC(例如采用 ZGC 或 Shenandoah 来压低停顿),以避免一次 Full GC 导致整批在飞请求集体超时。关于这一点,可参考《Grafana JVM 监控分析》中关于监控 GC 停顿的详细探讨。
六、预算与 Pacing:最难的一致性问题
预算控制堪称 Bidder 架构中真正的难点。其复杂性源于以下三个方面:
- Bidder 是分布式、多实例且无会话状态的,同一 campaign 的竞价请求会被分散到成百上千个实例上并发处理。
- 一次出价胜出后,真实消耗并不在赢得广告竞价的当下立刻确认。赢价通知(
nurl)仅表明赢得了广告竞价,素材还需真正渲染产生曝光,再经由计费事件(burl或曝光回传)确认后才计入消耗。从竞胜到广告曝光本身就存在折损(如未渲染、被丢弃),整体存在秒级到分钟级的延迟可见性。 - 考虑到有限的延迟预算,系统根本不可能在每次竞价时,都向中心节点发起一次强一致的全局预算扣减远程调用。
由此,我们面对的是一个经典的「分布式限额 + 消耗延迟可见」难题:超投(花费超出预算)会直接造成资金损失,而欠投(预算未花完)则会浪费预算并损害广告主体验。
业界的主流解法如下:
中心预算服务把全局预算切成分片下发,Bidder 本地内存扣减(快);真实消耗经 Kafka 回灌、周期性重新分配(准),用最终一致性换性能,容忍少量超投。
该方案可拆解为四个核心部分:
- 本地预算分片:中心预算服务将 campaign 的总预算,按各实例的流量占比切分成小片下发。Bidder 仅在本地内存中扣减属于自己的那片预算,从而彻底避免了每次竞价的远程调用。
- 异步回灌对账:曝光与计费日志经由 Kafka 流入中心预算服务,聚合出真实消耗;随后,系统周期性(通常为秒级)重新计算剩余预算并重新分配回各实例,以此修正本地分片的偏差。
- Pacing(平滑投放):预算消耗绝非「有则尽花、花完即停」,而应在整个投放时段内保持均匀。系统通过令牌桶或 PID 控制器动态调节「出价概率」或「出价系数」,使消耗曲线紧密贴合目标。这一部分已单独成篇:预算 Pacing:PID 控制与消耗平滑。
- 容忍少量超投:分布式架构叠加延迟可见性,决定了绝对的零超投是不现实的。工程上通常会设定一个可接受的阈值(如 1% 到 3%),当预算临近耗尽时果断熔断该 campaign 的出价,本质上是用最终一致性来换取系统性能。
前文多次提及 Bidder 是「无状态、可任意扩缩、可自由迁移」的。严格来说,它仅是无会话状态,而本地预算分片与频控计数实则为进程内的软状态(soft state)。这带来了一个不容忽视的实际后果:当实例宕机或发生容灾切流时,那部分「本地已扣减但尚未回灌」的预算会随进程一同丢失,中心侧只能依据回灌数据进行近似重建,这一时间窗口会直接放大超投风险。因此,「无状态所以可迁移」指的是请求可以任意路由,并不意味着状态迁移是零成本的。在生产环境中,要么使分片能够通过 Redis 或回灌数据快速重建,要么在切流时对受影响的 campaign 施加更为保守的本地熔断策略。这正是「无状态水平扩展」这一表述背后必须付出的代价。
总而言之,预算控制的核心矛盾在于:准(强一致)必然需要慢(远程对账),而快(本地扣减)则必然不准(存在偏差)。Bidder 的最终选择是先快后准:本地先扣以保住延迟,再利用异步回灌将偏差收敛回来,并坦然接受一个微小的超投窗口。
七、铁三角:延迟 ↔ 效果 ↔ 预算一致性
若将上述所有的工程取舍收敛为一张图,我们会发现 Bidder 的所有决策都在这三个顶点之间相互牵制:
三个顶点不可能同时拉满:延迟预算固定时,模型越复杂(效果↑)就越吃时间,对账越频繁(一致性↑)也越吃时间。架构就是在这三者间选一个平衡点。
- 延迟 vs 效果:想要出价更准,就需要更全的召回、更复杂的模型以及更丰富的特征,而这些统统都在吞噬延迟预算。正因如此,系统才引入了粗排与精排的分层机制,以及模型超时降级策略。
- 延迟 vs 一致性:想要预算绝对不超投,就需要更频繁地与中心节点对账,这同样会严重拖慢延迟。于是,架构上选择了本地分片结合异步回灌,果断牺牲了强一致性。
- 效果 vs 一致性:预算切得越碎、熔断策略越保守,确实越不容易超投;但这极有可能导致某些高价值的广告曝光机会,仅仅因为「本地这片没预算了」而被白白错过,从而损害了整体的竞价效果。
可以说,一个成熟 Bidder 的架构评审,本质上就是在反复拷问一个问题:当前的改动,究竟在这三个顶点上各自挪动了多少权衡砝码?
八、可靠性与优雅降级
对于高并发在线服务而言,降级永远优于报错,而超时则是最坏的结果。Bidder 的容错设计全面贯彻了这一理念:
- 超时降级:无论是模型、特征还是召回,任何一级发生超时,都必须备有兜底路径。系统会果断使用默认分、粗排结果或保守出价继续推进,绝不允许让整个请求直接失败。
- 熔断隔离:当外部依赖(如 Redis、特征服务或预算服务)发生故障时,系统会快速失败并切换至本地兜底逻辑,坚决防止一个慢依赖拖垮整个线程池,从而引发雪崩效应。
- 无状态水平扩展:实例保持无会话状态,支持任意扩缩容;针对预算与频控这类软状态,采用一致性哈希或中心协调分配,并确保其能从 Redis 或回灌数据中快速重建,以免在扩缩容或宕机时放大超投风险。
- 优雅降载:在系统过载时,主动丢弃低价值流量(例如低底价或低匹配度的请求),全力保住高价值流量的延迟达标。
- 灰度与影子流量:新模型或新策略上线前,必须先跑影子流量,在对比竞胜概率(win rate)、成本、CTR 与成交
eCPM等指标无误后,再逐步放量。 - 可观测性:将 QPS、超时率、竞胜概率、成交
eCPM、预算消耗曲线以及模型 AUC 等核心指标,全部接入实时看板并配置分级告警(具体可参见广告系统可观测性思路)。
将「超时降级」理念落地,便是一张严密的逐级降级矩阵。每一级都预先设定好了「算不完时退到什么」,而不是临场抛出异常:
| 阶段 | 触发条件 | 兜底动作 | 代价 |
|---|---|---|---|
| 解析 | 已过 deadline / 报文异常 | 直接 no-bid(NBR=timeout) | 丢这次广告曝光 |
| 定向过滤 | 规则加载中 / 快照异常 | 用上一份快照;无则保守拒投 | 少量误拒 |
| 召回 | 候选爆炸 / 超时 | 截断为粗排 Top-N | 可能漏掉长尾优质候选 |
| 打分 | 模型服务超时 | 兜底 CTR(历史均值 / 轻量模型) | 排序略失真 |
| 出价 | 预算 / pacing 读取超时 | 用本地分片上次值 × 保守系数 | 出价偏保守 |
| 排序闸门 | 频控 / 预算点查超时 | 「未知即保守」跳过该候选 | 少量欠投 |
| 填充 | 素材缺失 / 渲染失败 | 顺延下一名,否则 no-bid | 损失一次广告曝光 |
正因每一级都铺设了「退一步仍能按时交付」的路径,Bidder 才绝不会因为某个单一依赖的抖动,就让整个请求超时出局。
九、技术选型速览
| 层 | 常见选型 | 为什么 |
|---|---|---|
| 在线服务语言 | Go / C++ / Rust(Java 需重点调 GC) | 低延迟、并发轻、停顿可控 |
| 内存索引 | 倒排 + RoaringBitmap + Caffeine | 定向求交快、常驻内存 |
| 特征存储 | Redis / Aerospike / 自研 KV | 低延迟点查(sub-ms) |
| 模型服务 | TensorFlow Serving / ONNX Runtime / Triton | 批量打分、可 GPU |
| 消息队列 | Kafka | 日志、异步预算回灌、训练样本 |
| 协议 / 序列化 | OpenRTB + Protobuf / gRPC | 省 CPU、省带宽 |
| 流式处理 | Flink / Spark Streaming | 近线特征、实时预算聚合 |
十、排障与调优口诀
- 盯紧 P99,无视均值:均值 10ms 看似达标,但 ADX 约束的是每一个请求,优化必须死磕尾延迟;
- 警惕 Full GC 拖垮全局:一次数百毫秒的 STW 会使在飞请求集体超时,低停顿 GC 是必备调优项;
- 对账越慢,超投越狠:回灌延迟直接决定超投规模,高价值 campaign 需缩短周期或激进熔断;
- 防范热点耗尽单片:大预算宽定向极易耗尽本地分片,必须按流量占比动态调整分片大小;
- 缓存更新必保原子:非原子切换必将出现半新半旧候选集,务必采用双 buffer 结合指针切换;
- 版本对齐防失真:精排特征必须与召回取自同一快照,否则线上线下不一致导致效果无法对齐;
- deadline 只减不加:跨级传递误用新起点会凭空多出预算,deadline 必须自入口算定且只透传;
- 敬畏 QPS 放大效应:最前端每多花 0.1ms 都会被百万 QPS 放大成灾难,每一步都要按乘以 QPS 评估;
- 兜底路径必压测:降级分支平时不走极易藏 Bug,必须定期用故障演练(混沌工程)覆盖;
- 影子流量防抢占:新模型影子评估会和主模型抢 CPU 抬高 P99,必须隔离资源池或严格限流。
通往下一篇:理解了 Bidder 内部的漏斗流水线后,不妨深入看看它所遵循的通用语言——OpenRTB 协议。
延伸阅读
先将 Bidder 放回整条链路,再逐一深入其各个组成部分:
- 程序化广告生态全景:Bidder 属于需求方平台(DSP),先看它在整个生态里的位置。
- Header Bidding:Prebid.js vs Prebid Server 架构:卖方侧的同源物,也是一个高并发、带超时预算的「扇出 / 收口」服务,和 Bidder 的工程命题一脉相承。
- RTA 精讲:竞价前实时 API 过滤:跑在 Bidder 出价之前的竞价前过滤器,同样卡在 100ms 内。
- 程序化交易模式:RTB / PMP / PD / PDB:Bidder 面对的不同交易类型(公开竞价 vs 私有交易)会改变它的出价逻辑。
- last look 与拍卖透明度:出价策略里 bid shading 与一价 / 二价的背景。
- Grafana JVM 监控分析:JVM 实现的 Bidder 怎么盯 GC 停顿与尾延迟。
规范与一手资料:
- IAB Tech Lab. OpenRTB 2.6 Specification:
Bid Request/Bid Response、tmax、win notice 与扩展字段的权威定义。 - RoaringBitmap. Roaring Bitmaps:高并发定向求交的核心数据结构。
- Google Cloud. Best practices for real-time bidding:Authorized Buyers 侧对 RTB 延迟预算与回调的官方约束。