Skip to content
Charles Shao
Go back

Bidder 竞价服务架构设计:高并发 · 低延迟 · 强一致的在线决策系统

Updated:
views

Bidder 竞价服务系列(开篇总览 + 后续深挖)

本篇是总览:先建立 Bidder 的整体骨架。后续每一部分都将拆成独立一篇深入探讨:

  1. 竞价服务架构总览:20ms 延迟预算(本篇)
  2. 竞价拍卖机制:一价 vs 二价,成交价到底怎么算
  3. Bid Shading:一价世界里怎么科学压价
  4. 高并发定向召回:Bloom / Bitmap / 倒排索引
  5. 分布式频次控制(Frequency Capping)
  6. 预算 Pacing:PID 控制与消耗平滑

想先看 Bidder 在整条链路里的位置,建议先读程序化广告生态全景

程序化广告生态全景里,我们将 DSP 概括为「帮广告主以最低价买到目标受众」。但 DSP 是一整套系统 —— 预算管理、素材、报表、定向后台……在一次曝光发生的不到 100 毫秒窗口内、为广告主决定「面向哪个用户、投放哪条广告、出价多少」 的,是它内部一个专门的在线服务:Bidder(竞价服务)

Bidder 是 DSP 的核心,也是整个程序化技术栈中工程难度最高、最能体现「高并发 + 低延迟 + 强一致」综合能力的模块。其约束条件极为苛刻:ADX 发来竞价请求后,端到端可用时间通常只有 ~100ms(含两趟网络往返),扣除网络往返,真正留给 Bidder 计算的处理预算往往只有 20~50ms;在此时间内,它需完成召回、打分、出价与预算校验,同时保证不使广告主预算超支。一旦超时,该次曝光即被视为未参与。

本篇作为开篇,先建立 Bidder 的整体骨架:一次竞价的生命周期、该预算如何分配、七级漏斗式流水线的形态、数据面如何将慢 IO 全部移出请求路径、以及预算 Pacing 这一最难的一致性问题如何求解。理解这张骨架后,后续每篇深挖(拍卖机制、召回、频控、Pacing)才有承载的框架。

TL;DR

Table of contents

Open Table of contents

一、Bidder 是什么:DSP 唯一「卡在关键路径上」的模块

DSP 对外是「广告主的自助买量平台」,内部却是一堆服务的集合:Campaign 管理、预算与财务、素材审核、人群包(DMP 对接)、报表归因……这些绝大多数是离线或近线的,慢一点没关系。

唯一的例外,是 Bidder

它是 DSP 中唯一直接位于 ADX 竞价链路上、被实时调用的服务。ADX 每收到一次曝光机会,即向所有接入的 DSP 广播一个 Bid RequestOpenRTB 格式,携带曝光位、媒体、设备、地理、用户 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 被网络往返(3050ms)占用大半,Bidder 处理预算仅剩约 2050ms,再分配给解析到填充七个阶段,其中 CTR/CVR 打分占比最大,超时即被 ADX 丢弃 端到端 100ms 中,网络往返先占用一大半,真正留给 Bidder 计算的只有 2050ms;这段时间还需再分配给漏斗的七个阶段(下一节逐级拆解)。

关键认知:

2.1 为什么要减去「安全余量」

tmaxADX 一端测量的截止时间,而 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,但架构必须按最紧的一档设计。

安全余量主要覆盖四类东西:

这是一个典型权衡:余量过小 → 偶发踩线超时,尾部(P99)流量成批被丢弃,既消耗 CPU 又无收益;余量过大 → 内部 deadline 过紧,模型来不及精排而被迫降级,赢率下降。其本质是「以少量可用计算时间,换取不超时的确定性」。工程上不应凭经验固定取值,而应依据实测 RTT 的 P95/P99(而非均值)设定,按机房 / ADX 分别配置,必要时随实时到达情况动态自适应。

换言之,安全余量的取值是对「网络与运行时不确定性」的一次定量对冲:对该路径 RTT 分布掌握得越充分、超时代价越高,预留就应越充分。

核心设计原则(贯穿全文)

「尽力计算、按时降级、绝不超时。」 —— 超时的代价并非「出价略低」,而是该次曝光完全出局,连兜底出价的机会都不存在。因此宁取一个次优但按时的响应,也不取一个最优但迟到的响应。

三、架构总览:一条漏斗式流水线

Bidder 内部是一条漏斗:请求自上而下逐级处理,每一级都在筛除一批候选、并为存活候选补充信息 —— 候选集从百万级广告库逐步收窄到 Top-N,最终仅保留胜出的 1 条并产出出价。核心排布原则只有一条 —— 成本越低、可削减流量越多的过滤越靠前;开销越大的打分,仅施加于存活的少数候选

Bidder 竞价请求生命周期时序图:ADX 发来 Bid Request 后,Bidder 依次经解析 → 定向过滤 → 召回 → 打分 → 出价 → 排序 → 填充七级处理,其间向数据面读取候选、特征与预算分片,最终返回 Bid Response;随后竞价 / 曝光日志异步写入 Kafka,预算服务聚合真实消耗后回灌本地分片 按时序图阅读:竖线为各参与者的生命线,实线箭头=调用 / 发送、虚线箭头=返回;Bidder 上的活动条与黄色注释对应七级漏斗阶段。蓝底区为在线关键路径(~20ms 内完成、只读只算),其下为离开关键路径的异步回灌(先快后准,容忍少量超投)。

以下逐级拆解这七段 —— 漏斗的排布逻辑始终一致:越靠前成本越低、削减流量越多;越靠后开销越大、仅作用于存活的少数候选

3.1 解析(Parse)

漏斗入口,把一个原始请求变成内部可算的对象:

解析看似平平无奇,却是唯一被每个请求无差别执行的一级,也是延迟 deadline 的起点。它的方言适配、序列化选型、限时与连接复用已单独成篇:Bidder 解析层(Parse)

3.2 定向过滤(Targeting Filter,漏斗最前端)

将成本最低、可削减流量最多的判断置于最前,使后续高开销操作仅处理「有希望」的流量。这一级执行的是硬性资格判定——一票否决:

3.3 召回(Retrieval)

从几十万~百万条 campaign/creative 里,秒选出通过定向过滤的候选集

这一段是「高并发定向召回」的核心,本系列后续会专门拆 Bloom / Bitmap / 倒排索引的工程实现。

3.4 打分(Scoring:CTR / CVR 预估)

延迟的主要来源。需在毫秒级完成特征获取 + 模型推理,为每个候选估算点击率 / 转化率:

3.5 出价(Bidding)

把「这条广告值多少钱」算成一个具体出价:

3.6 排序(Ranking)+ 预算/频控闸门

多个候选各自得出出价后,这一级排出胜者,并通过最后一道涉及付费的闸门:

3.7 填充(Fill:响应构造)

确定胜者后,将其组装为符合 OpenRTB 规范的响应:

四、放大一层:Bidder 在 DSP 内部的位置与技术栈

前三节都聚焦于 Bidder 内部。但它并非独立运作 —— 对外,一次曝光会由 ADX 同时广播给 3~N 家 DSP,本方 Bidder 只是众多竞争者之一,且 tmax 对所有参与者一致;对内,Bidder 只是 DSP 众多服务中那个位于关键路径上的模块,其周边还有缓存服务、结算服务以及一组回传接收服务。将视角拉远一层,才能看清这些服务如何协作、由哪一套技术栈支撑、以及跨机房部署的含义。

DSP 服务协作时序图:缓存服务近线预热,将广告库 / 定向索引 / 特征推送进 Bidder 内存;ADX 竞价请求并发广播给本方 Bidder 与其它 DSP,Bidder 返回出价;赢得竞价后,曝光 / 点击 / 激活经回传接收(nurl / burl)与 Bidder 异步日志汇入 Kafka,Flink / Spark 聚合真实消耗并回灌结算服务与 Bidder,同时写入 Doris / InfluxDB 供 Grafana 看板与告警 按时序图阅读:参与者分为在线服务(Dubbo / Nacos / Sentinel 协作)与存储 / 消息 / 计算 / 观测两组分栏;实线箭头=调用 / 发送、虚线箭头=返回。蓝底区为在线竞价关键路径(tmax ≈ 100ms、一视同仁);其下为付费闭环——win → 曝光 → 回传 → Kafka → Flink 聚合真实消耗 → 结算回灌 Bidder 预算分片,以及报表与监控旁路。

4.1 一家 ADX,3~N 个 DSP

同一个曝光机会,ADX 会并发发给所有接入的 DSP,出价最高(且合规、有预算)者胜出。这带来两个直接后果:

4.2 DSP 内部服务怎么分工

Bidder 只承担「在线决策」,将所有耗时与有状态的工作交由周边服务处理:

数据流向可概括为:Bidder 出价(付费)→ 回传接收采集事件 → 结算聚合真实消耗 → 回灌预算至 Bidder,构成一条闭环;广告库/特征则由缓存服务单向供给 Bidder

4.3 这一套是被什么技术栈粘起来的

将一套常见的 Java 系中间件逐一对应,即可看清各组件位于链路的哪一段:

组件在 DSP 里干什么处在链路哪一段
Dubbo内部服务间 RPC(Bidder ⇄ 缓存 / 结算等)服务间调用(在线读要带超时 + 降级,离线随意)
Nacos服务注册发现 + 配置中心(开关、阈值、定向规则热更新)控制面
Sentinel限流、熔断、降级;保护 Bidder 不被慢依赖拖垮、过载时丢低价值流量每个在线服务入口
Redis特征点查、频控计数、预算分片兜底数据面(在线读)
MySQLcampaign / 素材 / 定向 / 账户等元数据(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 通常在多个数据中心部署,这并非出于形式,而是由延迟与容灾需求决定的:

厘清这张全景图后,再看后续各节——数据面(第五节)、预算 Pacing(第六节)、可靠性(第八节)——本质上都是在这张拓扑的某一条边上展开。

五、数据面:将慢 IO 移出请求路径

第三节的流水线之所以能在 20ms 内完成,依靠的不是 CPU 速度,而是几乎不执行任何远程慢调用。这是 Bidder 工程的第一性原理:

在线链路只做「读」和「算」,不做「写」和「远程慢调用」。

具体怎么做到:

数据放哪怎么更新
广告库 / 定向索引进程内内存(倒排 + 位图)后台线程定时全量 + 增量推送,读写分离、原子切换
用户 / 上下文特征本地 Cache(Caffeine)→ Redis / Aerospike近线特征计算写入,在线只点查
预算 / 频控计数本地分片 + Redis 兜底见第六节:本地扣减 + 异步回灌
竞价 / 曝光 / 点击日志异步:内存队列 → Kafka完全离开关键路径

三条落地原则:

  1. 热数据不出进程:能置于内存则不放 Redis,能放 Redis 则不查 DB。采用分层缓存(本地 → Redis → DB),命中率越高,尾延迟越低。
  2. 写操作全异步:日志、计费、监测一律写入队列,交由下游消费。在线请求中不应存在任何同步写
  3. 状态经回灌更新:预算、模型、黑白名单等可变状态,通过近线管道(Kafka / 定时推送)刷回本地缓存,而非每次请求实时查询。

这也解释了此类系统为何偏好 Go / C++ / Rust:GC 停顿可控、并发模型轻量。采用 Java 亦可,但需重点调优 GC(以 ZGC / Shenandoah 压低停顿),避免一次 Full GC 使整批在飞请求超时 —— 这一点可参考《Grafana JVM 监控分析》中如何监控 GC 停顿。

六、预算与 Pacing:最难的一致性问题

这是 Bidder 的真正难点,也是系统设计中最能体现工程权衡的一环。其难点在于:

由此面对一个经典的**「分布式限额 + 消耗延迟可见」** 问题:超投(花费超出预算)直接造成资金损失,欠投(预算未花完)则浪费预算、损害广告主体验。

主流解法是一套组合拳:

预算控制与 Pacing 数据流图:中心预算服务按流量占比把 campaign 预算切成分片下发,Bidder 在本地内存扣减(无远程调用、低延迟);日志异步进 Kafka,中心服务聚合真实消耗后周期性重新分配、回灌本地分片并在临近耗尽时下发熔断,允许 1~3% 超投换性能 中心预算服务把全局预算切成分片下发,Bidder 本地内存扣减(快);真实消耗经 Kafka 回灌、周期性重新分配(准)—— 用最终一致性换性能,容忍少量超投。

拆解为四个部分:

  1. 本地预算分片:中心预算服务把 campaign 预算,按各实例的流量占比切成小片下发。Bidder 在本地内存扣减自己的那片,避免每次竞价都远程调用。
  2. 异步回灌对账:曝光 / 计费日志经 Kafka → 中心预算服务聚合出真实消耗 → 周期性(秒级)重新计算剩余预算、重新分配回各实例,修正本地分片的偏差。
  3. Pacing(平滑投放):预算并非「有则尽花、花完即停」,而应在投放时段内均匀消耗。以令牌桶 / PID 控制器动态调节「出价概率」或「出价系数」,使消耗曲线贴合目标 —— 这一部分已单独成篇:预算 Pacing:PID 控制与消耗平滑
  4. 容忍少量超投:分布式 + 延迟可见决定了不可能零超投。工程上设定一个可接受阈值(如 1~3%),临近耗尽时熔断该 campaign 的出价。用一致性换性能。

一个必须点破的张力:Bidder 并非「纯无状态」

前文多次称 Bidder「无状态、可任意扩缩、可自由迁移」——严格而言,它是无会话状态,但本地预算分片、频控计数实为进程内的软状态(soft state)。这带来一个实际后果:实例宕机或容灾切流时,那部分「本地已扣减、尚未回灌」的预算会随进程一同丢失,中心侧只能依据回灌数据近似重建,此窗口会直接放大超投。因此「无状态所以可迁移」指的是请求可任意路由,并不意味着状态零成本;生产上或使分片可通过 Redis/回灌快速重建(软状态),或在切流时对受影响 campaign 施加更保守的本地熔断。这正是「无状态水平扩展」这一表述背后的代价。

一句话本质

预算控制的核心矛盾是:准(强一致)需要慢(远程对账),快(本地扣减)必然不准(有偏差)。 Bidder 的选择永远是「先快后准」—— 本地先扣保延迟,再用异步回灌把偏差收敛回来,并接受一个小小的超投窗口。

七、铁三角:延迟 ↔ 效果 ↔ 预算一致性

将上述所有取舍收敛为一张图:Bidder 架构的所有决策,本质上都在这三个顶点之间相互牵制:

Bidder 设计铁三角图:中心是竞价服务设计,三顶点为延迟(20ms 内返回,超时出局)、效果(模型越复杂召回越全越优)、预算一致性(超投容忍度越低越安全但越慢);三者相互牵制、延迟预算固定时效果与一致性只能取舍,不可能同时拉满 三个顶点不可能同时拉满:延迟预算固定时,模型越复杂(效果↑)就越吃时间,对账越频繁(一致性↑)也越吃时间。架构就是在这三者间选一个平衡点。

没有银弹,只有权衡。 一个成熟 Bidder 的架构评审,本质上就是在反复回答:「这个改动,在这三个顶点上各挪动了多少?」

八、可靠性与优雅降级

高并发在线服务,降级优于报错,超时是最坏结果。Bidder 的容错设计:

把「超时降级」落成一张逐级降级矩阵,是这条原则可执行的关键 —— 每一级都预先想好「算不完时退到什么」,而不是临场抛异常:

阶段触发条件兜底动作代价
解析已过 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近线特征、实时预算聚合

十、生产实战:常见问题与经验

十一、速查表

Bidder 的本质,是一个在极限时间约束下、面向近乎无状态的请求、做出涉及付费决策的系统。理解它的骨架 —— 漏斗流水线、只读只算的数据面、先快后准的预算控制、以及那组持续相互牵制的铁三角 —— 即掌握了程序化买方技术栈中最核心的基础。后续每一篇深挖(拍卖机制、召回、频控、Pacing),都是在这张骨架上补充细节。


延伸阅读

先将 Bidder 放回整条链路,再逐一深入其各个组成部分:

规范与一手资料:

附录:术语表


views
Share this post on:

Previous Post
Bidder 解析层(Parse):把原始竞价请求变成内部可算对象
Next Post
OEM 系统广告(海外市场):手机厂商怎么靠预装、预加载与系统位变现