Bidder 竞价服务系列(开篇总览 + 后续深挖)
本篇是总览:先建立 Bidder 的整体骨架。后续每一部分都将拆成独立一篇深入探讨:
- 竞价服务架构总览:20ms 延迟预算(本篇)
- 竞价拍卖机制:一价 vs 二价,成交价到底怎么算
- Bid Shading:一价世界里怎么科学压价
- 高并发定向召回:Bloom / Bitmap / 倒排索引
- 分布式频次控制(Frequency Capping)
- 预算 Pacing:PID 控制与消耗平滑
想先看 Bidder 在整条链路里的位置,建议先读程序化广告生态全景。
在程序化广告生态全景里,我们将 DSP 概括为「帮广告主以最低价买到目标受众」。但 DSP 是一整套系统 —— 预算管理、素材、报表、定向后台……在一次曝光发生的不到 100 毫秒窗口内、为广告主决定「面向哪个用户、投放哪条广告、出价多少」 的,是它内部一个专门的在线服务:Bidder(竞价服务)。
Bidder 是 DSP 的核心,也是整个程序化技术栈中工程难度最高、最能体现「高并发 + 低延迟 + 强一致」综合能力的模块。其约束条件极为苛刻:ADX 发来竞价请求后,端到端可用时间通常只有 ~100ms(含两趟网络往返),扣除网络往返,真正留给 Bidder 计算的处理预算往往只有 20~50ms;在此时间内,它需完成召回、打分、出价与预算校验,同时保证不使广告主预算超支。一旦超时,该次曝光即被视为未参与。
本篇作为开篇,先建立 Bidder 的整体骨架:一次竞价的生命周期、该预算如何分配、七级漏斗式流水线的形态、数据面如何将慢 IO 全部移出请求路径、以及预算 Pacing 这一最难的一致性问题如何求解。理解这张骨架后,后续每篇深挖(拍卖机制、召回、频控、Pacing)才有承载的框架。
TL;DR
- Bidder 是 DSP 内部真正参与 OpenRTB 竞价的在线服务:ADX 发
Bid Request,它在毫秒级返回Bid Response(出价 + 素材),是整个 DSP 唯一「卡在关键路径上」的模块。 - 所有设计服从同一条原则:「尽力计算、按时降级、绝不超时。」 超时等同于响应被丢弃、无谓损失流量,其代价比出价不够优更严重。
- 处理预算是硬约束,常压缩至 20ms 量级:端到端 ~100ms 中,两趟网络往返与 ADX 自身撮合开销即占用大半,Bidder 真正可用于计算的通常只剩 20~50ms;跨机房、
tmax更紧时逼近 20ms 下限(标题所指即此)。全链路携带 deadline,每一级均可提前放弃、以兜底值继续。 - 架构是一条漏斗式流水线:解析 → 定向过滤 → 召回 → 打分 → 出价 → 排序 → 填充。成本越低、可削减流量越多的过滤越靠前;开销越大的计算(打分)仅施加于存活的少数候选。
- Bidder 只是 DSP 里那个「卡关键路径」的服务:往上一次曝光 ADX 并发广播给 3~N 家 DSP(你只有 1/N 胜率窗口);往内 Bidder 身边还有缓存服务、结算服务、IMP/CLICK/Install 回传接收服务,靠 Dubbo + Nacos + Sentinel 协作、Redis/MySQL/Kafka/Flume/Flink/Spark/Doris/InfluxDB/Grafana 支撑、K8s 跨数据中心部署。
- 在线链路只做「读」与「算」,不做「写」与「远程慢调用」:广告库、定向索引、用户特征尽量常驻内存;日志异步写入消息队列;预算等状态经近线回灌刷入本地缓存。
- 预算 Pacing 是最难的一致性问题:Bidder 无会话状态、多实例、真实消耗存在延迟。主流解法是本地预算分片 + 异步回灌对账 + 令牌桶/PID 平滑 + 容忍少量超投(以最终一致性换取性能)。
- 铁三角权衡:延迟 ↔ 效果(模型多复杂)↔ 预算一致性(超投容忍度)—— 三者不可能同时拉满,架构就是在这三者间做取舍。
Table of contents
Open Table of contents
一、Bidder 是什么:DSP 唯一「卡在关键路径上」的模块
DSP 对外是「广告主的自助买量平台」,内部却是一堆服务的集合:Campaign 管理、预算与财务、素材审核、人群包(DMP 对接)、报表归因……这些绝大多数是离线或近线的,慢一点没关系。
唯一的例外,是 Bidder。
它是 DSP 中唯一直接位于 ADX 竞价链路上、被实时调用的服务。ADX 每收到一次曝光机会,即向所有接入的 DSP 广播一个 Bid Request(OpenRTB 格式,携带曝光位、媒体、设备、地理、用户 ID 等上下文);Bidder 必须在截止时间(tmax,常见 100~120ms) 内返回 Bid Response(是否出价,以及出价 price、素材 adm/adid、赢价与计费通知等字段)。
ADX ──Bid Request(tmax=100ms)──▶ Bidder
│ 解析 → 定向过滤 → 召回 → 打分 → 出价 → 排序 → 填充
ADX ◀──Bid Response(bid $2.31)──── Bidder ← 必须在 tmax 内返回
它的三条本质属性,决定了后面所有设计:
| 属性 | 含义 | 带来的约束 |
|---|---|---|
| 在线、卡关键路径 | 每次曝光都要它实时作答 | 延迟是硬指标,超时即出局 |
| 超高并发 | 大体量 DSP 常在十万级 QPS,头部按接入的 ADX 家数与地域可达百万级,且无效流量占比高 | 必须分层过滤、尽早削减流量 |
| 涉及真实资金 | 出价即承诺付费 | 预算不能严重超投,最难的一致性问题 |
一句话本质
Bidder 是一个「在约 20 毫秒内、面向近乎无状态的请求、做出一次涉及付费的决策」的系统。其全部复杂度,都源于同时应对三项约束:低延迟、高并发、预算不超支。
二、一次竞价的生命周期与 20ms 延迟预算
先看时间都花在哪了。从广告主视角,一次程序化曝光的端到端预算大约是 100ms,但它是被层层瓜分的:
端到端
100ms 中,网络往返先占用一大半,真正留给 Bidder 计算的只有 2050ms;这段时间还需再分配给漏斗的七个阶段(下一节逐级拆解)。
关键认知:
- 网络往返不可压缩:ADX↔Bidder 一来一回的 RTT,跨地域 / 跨运营商时可达 30~50ms(同城同运营商显著更低),此外还需为 ADX 自身撮合与安全余量预留。因此真正用于服务端计算的时间,通常只有 20~50ms。
- 模型打分是主要开销:对召回的数万条候选逐条预估 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~80ms 的 tmax,叠加跨机房的高 RTT,该值会进一步降至 20ms 上下 —— 这即标题「20ms 延迟预算」的由来。因此不应将「20ms」视为固定值,它是该估算在最紧场景下的下限;宽松场景可有 40~50ms,但架构必须按最紧的一档设计。
安全余量主要覆盖四类东西:
- 回程网络时间:你算完,响应还要飞回 ADX;不预留,等 ADX 收到早就过点了。
- 来程已消耗:
tmax常从 ADX 发出请求那一刻起算,请求飞到你这儿已用掉一部分,你收到时「剩余预算」其实已 < 100ms。 - 时钟偏差(clock skew):两台机器时钟不可能完全一致,几毫秒偏差会让你以为还有时间、其实已经晚了。
- 序列化 / 发送 / 抖动冗余:算完到字节真正上路还有开销,再加 GC 停顿、线程调度、突发负载的毛刺。
这是一个典型权衡:余量过小 → 偶发踩线超时,尾部(P99)流量成批被丢弃,既消耗 CPU 又无收益;余量过大 → 内部 deadline 过紧,模型来不及精排而被迫降级,赢率下降。其本质是「以少量可用计算时间,换取不超时的确定性」。工程上不应凭经验固定取值,而应依据实测 RTT 的 P95/P99(而非均值)设定,按机房 / ADX 分别配置,必要时随实时到达情况动态自适应。
换言之,安全余量的取值是对「网络与运行时不确定性」的一次定量对冲:对该路径 RTT 分布掌握得越充分、超时代价越高,预留就应越充分。
核心设计原则(贯穿全文)
「尽力计算、按时降级、绝不超时。」 —— 超时的代价并非「出价略低」,而是该次曝光完全出局,连兜底出价的机会都不存在。因此宁取一个次优但按时的响应,也不取一个最优但迟到的响应。
三、架构总览:一条漏斗式流水线
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) 初筛。
- 定向布尔匹配:把这次曝光的上下文(地域、时段、设备、人群包)与广告的定向条件求交,不匹配的直接排除。
- 频次控制:用户 / 广告级曝光上限(同一用户对某广告的封顶)。此处需说明一个顺序上的细节:真正 per-user × per-campaign 的封顶需在已有候选之后才能逐条判定,且往往需要一次带超时的 Redis 点查;可置于最前端的,通常是用户级全局频控 / 已达标广告的位图预排除这类「不依赖具体候选、可纯本地布尔判断」的粗筛。精细频控更多是在召回之后、随排序闸门一并执行(分布式频控后续单独成篇讨论)。
- 快速失败:过滤后若无任何可投候选,立即返回 no-bid,不再进入后续阶段。
3.3 召回(Retrieval)
从几十万~百万条 campaign/creative 里,秒选出通过定向过滤的候选集:
- 广告候选全量常驻内存,用倒排索引 + 位图(RoaringBitmap) 把定向条件的求交做成毫秒级的位运算。
- 人群包命中判断用 Bloom Filter / 位图,避免远程查询。
- 候选过多时先做粗排 / 截断(简单规则或轻量模型),仅将 Top-N 交给下游打分 —— 因为下一级的模型打分开销高,必须先压低候选规模。
这一段是「高并发定向召回」的核心,本系列后续会专门拆 Bloom / Bitmap / 倒排索引的工程实现。
3.4 打分(Scoring:CTR / CVR 预估)
延迟的主要来源。需在毫秒级完成特征获取 + 模型推理,为每个候选估算点击率 / 转化率:
- 特征:用户特征预取自本地缓存 / Redis(点查要求 P99 < 1ms 级),上下文特征实时拼装。
- 推理:候选批量(batch)打分,采用 TensorFlow Serving / ONNX Runtime / 自研轻量模型;候选较多时通过「先粗排削减、再精排计算」控制规模。
- 超时降级:模型服务超时 → 用兜底 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 同时广播给 3~N 家 DSP,本方 Bidder 只是众多竞争者之一,且 tmax 对所有参与者一致;对内,Bidder 只是 DSP 众多服务中那个位于关键路径上的模块,其周边还有缓存服务、结算服务以及一组回传接收服务。将视角拉远一层,才能看清这些服务如何协作、由哪一套技术栈支撑、以及跨机房部署的含义。
按时序图阅读:参与者分为在线服务(Dubbo / Nacos / Sentinel 协作)与存储 / 消息 / 计算 / 观测两组分栏;实线箭头=调用 / 发送、虚线箭头=返回。蓝底区为在线竞价关键路径(tmax ≈ 100ms、一视同仁);其下为付费闭环——win → 曝光 → 回传 → Kafka → Flink 聚合真实消耗 → 结算回灌 Bidder 预算分片,以及报表与监控旁路。
4.1 一家 ADX,3~N 个 DSP
同一个曝光机会,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 编排 | 平台底座 |
一句话本质
在线路径上真正参与那 20ms 决策的只有 Bidder + 内存/Redis;其余组件(Kafka / Flume / Flink / Spark / Doris / MySQL)几乎全部位于关键路径之外,负责将「已花费的预算」与「新获得的知识」以异步方式收敛回系统。这正是第五节数据面原则在组织层面的体现。
4.4 跨数据中心意味着什么
DSP 通常在多个数据中心部署,这并非出于形式,而是由延迟与容灾需求决定的:
- 就近部署以压低 RTT:由靠近 ADX / 媒体的机房接管当地流量,直接减小 2.1 节所述的「网络开销 + 安全余量」—— RTT 每降低 10ms,可用算力即增加 10ms。
- 数据需要复制:广告库、配置由主库复制到各 DC;Redis / 缓存各 DC 一份;MySQL 主从或多活。在线只读本地副本,不跨 DC 实时查询。
- 预算跨 DC 是真正的难点:同一 campaign 的消耗被分散在多个机房,全局预算需跨 DC 聚合——回灌链路更长、超投窗口更大。工程上通常再切一层:先按 DC 分配预算,DC 内部再按实例分片。
- 容灾切流:某个 DC 挂了,靠全局负载 / DNS + K8s 把流量切到其它 DC。Bidder 无会话状态,请求切得动;但要留意它持有的软状态(本地预算分片) —— 切流瞬间那部分未回灌的扣减会丢,需要在接管侧按回灌数据重建、并对相关 campaign 临时收紧熔断,否则超投会被放大(见第六节)。
厘清这张全景图后,再看后续各节——数据面(第五节)、预算 Pacing(第六节)、可靠性(第八节)——本质上都是在这张拓扑的某一条边上展开。
五、数据面:将慢 IO 移出请求路径
第三节的流水线之所以能在 20ms 内完成,依靠的不是 CPU 速度,而是几乎不执行任何远程慢调用。这是 Bidder 工程的第一性原理:
在线链路只做「读」和「算」,不做「写」和「远程慢调用」。
具体怎么做到:
| 数据 | 放哪 | 怎么更新 |
|---|---|---|
| 广告库 / 定向索引 | 进程内内存(倒排 + 位图) | 后台线程定时全量 + 增量推送,读写分离、原子切换 |
| 用户 / 上下文特征 | 本地 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/ 曝光回传)确认才计入消耗;从 win 到 impression 本身还存在折损(未渲染、被丢弃),整体有秒级到分钟级的延迟。 - 不可能对每次竞价都向中心节点强一致地扣减一次全局预算 —— 有限的延迟预算不足以支撑一次远程往返。
由此面对一个经典的**「分布式限额 + 消耗延迟可见」** 问题:超投(花费超出预算)直接造成资金损失,欠投(预算未花完)则浪费预算、损害广告主体验。
主流解法是一套组合拳:
中心预算服务把全局预算切成分片下发,Bidder 本地内存扣减(快);真实消耗经 Kafka 回灌、周期性重新分配(准)—— 用最终一致性换性能,容忍少量超投。
拆解为四个部分:
- 本地预算分片:中心预算服务把 campaign 预算,按各实例的流量占比切成小片下发。Bidder 在本地内存扣减自己的那片,避免每次竞价都远程调用。
- 异步回灌对账:曝光 / 计费日志经 Kafka → 中心预算服务聚合出真实消耗 → 周期性(秒级)重新计算剩余预算、重新分配回各实例,修正本地分片的偏差。
- Pacing(平滑投放):预算并非「有则尽花、花完即停」,而应在投放时段内均匀消耗。以令牌桶 / PID 控制器动态调节「出价概率」或「出价系数」,使消耗曲线贴合目标 —— 这一部分已单独成篇:预算 Pacing:PID 控制与消耗平滑。
- 容忍少量超投:分布式 + 延迟可见决定了不可能零超投。工程上设定一个可接受阈值(如 1~3%),临近耗尽时熔断该 campaign 的出价。用一致性换性能。
一个必须点破的张力:Bidder 并非「纯无状态」
前文多次称 Bidder「无状态、可任意扩缩、可自由迁移」——严格而言,它是无会话状态,但本地预算分片、频控计数实为进程内的软状态(soft state)。这带来一个实际后果:实例宕机或容灾切流时,那部分「本地已扣减、尚未回灌」的预算会随进程一同丢失,中心侧只能依据回灌数据近似重建,此窗口会直接放大超投。因此「无状态所以可迁移」指的是请求可任意路由,并不意味着状态零成本;生产上或使分片可通过 Redis/回灌快速重建(软状态),或在切流时对受影响 campaign 施加更保守的本地熔断。这正是「无状态水平扩展」这一表述背后的代价。
一句话本质
预算控制的核心矛盾是:准(强一致)需要慢(远程对账),快(本地扣减)必然不准(有偏差)。 Bidder 的选择永远是「先快后准」—— 本地先扣保延迟,再用异步回灌把偏差收敛回来,并接受一个小小的超投窗口。
七、铁三角:延迟 ↔ 效果 ↔ 预算一致性
将上述所有取舍收敛为一张图:Bidder 架构的所有决策,本质上都在这三个顶点之间相互牵制:
三个顶点不可能同时拉满:延迟预算固定时,模型越复杂(效果↑)就越吃时间,对账越频繁(一致性↑)也越吃时间。架构就是在这三者间选一个平衡点。
- 延迟 vs 效果:想出价更准,就要更全的召回、更复杂的模型、更多特征 —— 全都吃延迟。于是有粗排 / 精排分层、模型超时降级。
- 延迟 vs 一致性:想预算不超投,就要更频繁地和中心对账 —— 吃延迟。于是选本地分片 + 异步回灌,牺牲强一致。
- 效果 vs 一致性:预算切得越碎、熔断越保守,越不会超投,但可能让某些高价值曝光因「本地这片没预算了」而错过。
没有银弹,只有权衡。 一个成熟 Bidder 的架构评审,本质上就是在反复回答:「这个改动,在这三个顶点上各挪动了多少?」
八、可靠性与优雅降级
高并发在线服务,降级优于报错,超时是最坏结果。Bidder 的容错设计:
- 超时降级:任何一级(模型、特征、召回)超时,都有兜底路径 —— 用默认分 / 粗排结果 / 保守出价继续,而不是让整个请求失败。
- 熔断隔离:依赖(Redis、特征服务、预算服务)故障时快速失败、走本地兜底,绝不让一个慢依赖把线程池拖垮(雪崩)。
- 无状态水平扩展:实例无会话状态,可任意扩缩容;预算 / 频控这类软状态用一致性哈希 / 中心协调分配,并保证可从 Redis/回灌快速重建(扩缩容、宕机时才不放大超投)。
- 优雅降载:过载时主动丢弃低价值流量(如低底价、低匹配度),保住高价值流量的延迟。
- 灰度与影子流量:新模型 / 新策略先跑影子流量,对比 win rate、成本、CTR、成交 eCPM,再逐步放量。
- 可观测性:QPS、超时率、win rate、成交 eCPM、预算消耗曲线、模型 AUC 全部实时看板 + 分级告警(参见广告系统可观测性思路)。
把「超时降级」落成一张逐级降级矩阵,是这条原则可执行的关键 —— 每一级都预先想好「算不完时退到什么」,而不是临场抛异常:
| 阶段 | 触发条件 | 兜底动作 | 代价 |
|---|---|---|---|
| 解析 | 已过 deadline / 报文异常 | 直接 no-bid(NBR=timeout) | 丢这次曝光 |
| 定向过滤 | 规则加载中 / 快照异常 | 用上一份快照;无则保守拒投 | 少量误拒 |
| 召回 | 候选爆炸 / 超时 | 截断为粗排 Top-N | 可能漏掉长尾优质候选 |
| 打分 | 模型服务超时 | 兜底 CTR(历史均值 / 轻量模型) | 排序略失真 |
| 出价 | 预算 / pacing 读取超时 | 用本地分片上次值 × 保守系数 | 出价偏保守 |
| 排序闸门 | 频控 / 预算点查超时 | 「未知即保守」跳过该候选 | 少量欠投 |
| 填充 | 素材缺失 / 渲染失败 | 顺延下一名,否则 no-bid | 损失一次曝光 |
一句话:每一级都有「退一步仍能按时交付」的路径,绝不因为一个依赖抖动就让整请求超时出局。
九、技术选型速览
| 层 | 常见选型 | 为什么 |
|---|---|---|
| 在线服务语言 | 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 依据
tmax约束的是每一个请求。一旦 P99 触及 deadline,该部分流量即被成批丢弃。优化应始终针对尾延迟(GC 停顿、慢查询、锁竞争、批处理毛刺)。 - 一次 Full GC 拖垮整批请求:Java 实现中,一次数百毫秒的 STW 会使该时段内所有在飞请求集体超时。分代、堆大小、低停顿 GC 是必备的调优项。
- 对账窗口越长,超投越多:回灌延迟直接决定超投规模。高价值 campaign 应缩短回灌周期,或对临近耗尽者施加更激进的本地熔断。
- 热点 campaign 耗尽单个分片:大预算、宽定向的 campaign 会命中绝大多数流量,其本地分片被迅速耗尽。应按流量占比动态调整分片大小,而非均分。
- 缓存更新须原子切换:广告库全量刷新时若非原子切换,会出现「半新半旧」的候选集,导致出价异常。采用双 buffer + 指针切换。
- 模型与召回的版本一致性:精排模型使用的特征,必须与召回、粗排取自同一份快照,否则线上线下不一致,效果无法对齐。
- deadline 只减不加:跨级传递时,某一级若误用本地新起点计时、而非全局到达时刻,会「凭空多出预算」,最终整体踩线。deadline 必须自入口一次算定,只透传、不重置。
- QPS 放大效应:任何在解析 / 过滤最前端每请求多花的 0.1ms,都会被百万 QPS 放大成整集群的 CPU 与尾延迟。最前端的每一步都要按「乘以 QPS」来评估收益与代价。
- 降级路径也要被压测:兜底分支平时不走,往往在主依赖真的挂掉时才第一次被触发,因而容易藏着未初始化、超时叠加等 bug。降级路径必须定期用故障演练(混沌工程)覆盖。
- 影子流量不计费,但要计延迟:新模型影子评估只比效果、却忘了它和主模型抢 CPU,会把主链路 P99 抬高。影子流量要么隔离资源池,要么严格限流。
十一、速查表
- Bidder = DSP 唯一卡在关键路径上的在线服务:收
Bid Request、毫秒级回Bid Response。 - 一句总纲:算不完就降级,绝不超时(超时 = 丢弃)。
- 20ms 预算:端到端
100ms,网络占用大半,服务端计算仅剩 2050ms;全链路携带 deadline,可逐级降级。 - 漏斗流水线(七级):解析 → 定向过滤 → 召回 → 打分 → 出价 → 排序 → 填充;成本越低的过滤越靠前,开销越大的打分越靠后。
- 数据面第一性原理:只读只算,不写不远程;广告库/索引/特征常驻内存,写全异步,状态靠回灌。
- 预算 Pacing:本地分片扣减(快)+ 异步回灌对账(准)+ 令牌桶/PID 平滑 + 容忍少量超投(1~3%)。
- 铁三角:延迟 ↔ 效果 ↔ 预算一致性,三者不可兼得,架构即取舍。
- 可靠性:降级优于报错、熔断隔离防雪崩、无状态水平扩展、盯 P99。
Bidder 的本质,是一个在极限时间约束下、面向近乎无状态的请求、做出涉及付费决策的系统。理解它的骨架 —— 漏斗流水线、只读只算的数据面、先快后准的预算控制、以及那组持续相互牵制的铁三角 —— 即掌握了程序化买方技术栈中最核心的基础。后续每一篇深挖(拍卖机制、召回、频控、Pacing),都是在这张骨架上补充细节。
延伸阅读
先将 Bidder 放回整条链路,再逐一深入其各个组成部分:
- 程序化广告生态全景:Bidder 属于买方的 DSP,先看它在 DSP / SSP / ADX 里的位置。
- 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 延迟预算与回调的官方约束。
附录:术语表
- Bidder(竞价服务):DSP 内部直接参与 OpenRTB 竞价、毫秒级返回出价的在线服务。
Bid Request/Bid Response:ADX 发来的竞价请求 / Bidder 返回的出价响应(OpenRTB 定义)。tmax:Bid Request里 ADX 给出的截止时间(ms),超时响应被丢弃。- 延迟预算(latency budget):端到端时间里,扣除网络与安全余量后,真正留给 Bidder 计算的时间(常 20~50ms)。
- 安全余量(safety margin):从
tmax里预扣的一块时间,覆盖回程网络、来程已耗、时钟偏差、GC/发送抖动,扣完才得到内部 deadline;一般按实测 RTT 的 P95/P99 来定。 - deadline 传递:请求入口算出剩余时间,往下每一级都带着它,快超时就降级。
- 漏斗(funnel):解析 → 定向过滤 → 召回 → 打分 → 出价 → 排序 → 填充的逐级筛选流水线。
- 召回(retrieval):从全量广告里,按定向条件快速选出候选集。
- 粗排 / 精排:先用轻量方法截断候选,再用复杂模型对少量候选精细打分。
- eCPM:把不同计价归一成「每千次曝光等效收入」,Bidder 内部比价的核心指标。
- Pacing(平滑投放):使预算在投放时段内均匀消耗,而非集中花费后停止。
- 本地预算分片:把 campaign 预算切片下发到各实例、本地内存扣减,避免远程强一致。
- 异步回灌:真实消耗经 Kafka 聚合后周期性刷回本地分片,收敛偏差。
- 超投 / 欠投:实际花费超过 / 少于预算;分布式 + 消耗延迟决定了少量超投不可避免。
- 优雅降级:某一级超时 / 故障时,用兜底值或保守路径继续,绝不让整请求失败或超时。
- P99 延迟:99% 请求的延迟上界;Bidder 真正被
tmax卡的是尾延迟而非均值。