Skip to content
Charles Shao
Go back

Bidder 竞价服务架构:高并发低延迟的在线决策系统

Updated:
–views

本文是 买侧竞价链路 系列的第 1 篇(开篇总览)。 全系列 9 篇:

  1. Bidder 竞价服务架构
  2. OpenRTB 协议精讲
  3. Bidder 解析层
  4. Bidder 定向过滤
  5. RTA 竞价前过滤
  6. Bidder 频次控制:五维配置与身份降级
  7. Bidder 频次控制工程篇
  8. Bidder 预算 Pacing
  9. Pacing 目标曲线

一句话定位:Bidder 是 DSP 内部唯一卡在关键路径上的在线服务,必须在 20ms 延迟预算内跑完解析、过滤、召回、打分、出价七级漏斗,并依靠本地分片与异步回灌在延迟、效果与预算一致性之间达成严苛权衡。

在程序化广告生态全景中,我们将需求方平台(DSP)的核心价值概括为「帮广告主以最低价买到目标受众」。然而,当一次真实的广告曝光(Impression)机会转瞬即逝时,究竟是谁在不到 100 毫秒的极限窗口内,替广告主拍板决定「面向哪个用户、投放哪条广告、出价多少」?答案正是 DSP 内部最核心的在线服务——Bidder(竞价服务)。

作为 DSP 的核心枢纽,Bidder 面临着极为苛刻的工程约束。当 ADX 发来竞价请求(Bid Request)后,端到端的可用时间通常只有 100 毫秒左右;扣除两趟网络往返的开销,真正留给 Bidder 计算的处理预算往往仅剩 20 到 50 毫秒。正因如此,在这转瞬即逝的时间内,它不仅必须跑完召回、打分、出价与预算校验的全流程,还要确保广告主的预算不会严重超支。需要强调的是,一旦处理超时,该次广告曝光即被视为未参与,连兜底出价的机会都会丧失。

本篇作为开篇总览,将系统性地拆解一次实时竞价(RTB)的生命周期。我们将探讨延迟预算的分配机制、七级漏斗流水线的设计,以及数据面如何将慢 IO 移出请求路径,并深入剖析预算 Pacing 的核心逻辑。后续各篇关于广告竞价机制、召回、频控与 Pacing 的深入探讨,都将紧密挂载在这张基础架构的骨架之上。

TL;DR

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 被网络往返(3050ms)占用大半,Bidder 处理预算仅剩约 2050ms,再分配给解析到填充七个阶段,其中 CTR/CVR 打分占比最大,超时即被 ADX 丢弃 端到端 100ms 中,网络往返先占用一大半,真正留给 Bidder 计算的只有 2050ms;这段时间还需再分配给漏斗的七个阶段(下一节逐级拆解)。

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 毫秒,但架构设计必须按最紧的一档来兜底。

具体而言,安全余量主要用于覆盖以下四类开销:

这是一个典型的工程权衡:余量设置过小,会导致偶发踩线超时,尾部(P99)流量成批被丢弃,既白白消耗了 CPU 又毫无收益;反之,余量设置过大,则会使内部 deadline 过紧,模型来不及精排而被迫降级,进而导致竞胜概率下降。工程实践中,我们实际上是在用少量可用的计算时间,去换取不超时的确定性。因此,不应凭经验固定取值,而应依据实测 RTT 的 P95 或 P99(而非均值)来设定,按机房或 ADX 分别配置,并在必要时随实时到达情况进行动态自适应。

归根结底,核心原则依然是「尽力计算、按时降级、绝不超时」。需要强调的是,超时的代价绝非出价略低那么简单,而是该次广告曝光完全出局,连兜底出价的机会都会丧失;在实时竞价(RTB)系统中,宁可取一个次优但按时的响应,也绝不取一个最优但迟到的响应。

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

从宏观视角来看,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 Filter、Bitmap 与倒排索引的工程实现。

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

打分环节是延迟的主要来源。系统需在毫秒级完成特征获取与模型推理,为每个候选估算点击率(CTR)与转化率(CVR):

3.5 出价(Bidding)

在这一级,系统需要把「这条广告值多少钱」算成一个具体的出价:

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

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

3.7 填充(Fill:响应构造)

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

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

前三节我们都聚焦于 Bidder 的内部运作。然而,它并非一座孤岛:对外,一次广告曝光机会会被 ADX 同时广播给多家 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,多家需求方平台

针对同一个曝光机会,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 编排平台底座

可以看出,在线路径上真正参与那 20 毫秒决策的只有 Bidder 自身加上内存与 Redis;其余组件(如 Kafka、Flume、Flink、Spark、Doris 与 MySQL)几乎全部位于关键路径之外,它们负责将「已花费的预算」与「新获得的知识」以异步方式收敛回系统。这正是第五节数据面原则在组织架构层面的生动体现。

4.4 跨数据中心部署的工程含义

DSP 通常会在多个数据中心(DC)进行部署,这绝非出于形式主义,而是由延迟与容灾的硬性需求所决定的:

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

第三节中描述的流水线之所以能在 20 毫秒内跑完,依靠的绝不仅是 CPU 的运算速度,而是它几乎不执行任何远程慢调用。在线链路必须坚守只做「读」和「算」,坚决不做「写」和「远程慢调用」的数据面原则。

具体落地策略如下:

数据放哪怎么更新
广告库 / 定向索引进程内内存(倒排 + 位图)后台线程定时全量 + 增量推送,读写分离、原子切换
用户 / 上下文特征本地 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 是「无状态、可任意扩缩、可自由迁移」的。严格来说,它仅是无会话状态,而本地预算分片与频控计数实则为进程内的软状态(soft state)。这带来了一个不容忽视的实际后果:当实例宕机或发生容灾切流时,那部分「本地已扣减但尚未回灌」的预算会随进程一同丢失,中心侧只能依据回灌数据进行近似重建,这一时间窗口会直接放大超投风险。因此,「无状态所以可迁移」指的是请求可以任意路由,并不意味着状态迁移是零成本的。在生产环境中,要么使分片能够通过 Redis 或回灌数据快速重建,要么在切流时对受影响的 campaign 施加更为保守的本地熔断策略。这正是「无状态水平扩展」这一表述背后必须付出的代价。

总而言之,预算控制的核心矛盾在于:准(强一致)必然需要慢(远程对账),而快(本地扣减)则必然不准(存在偏差)。Bidder 的最终选择是先快后准:本地先扣以保住延迟,再利用异步回灌将偏差收敛回来,并坦然接受一个微小的超投窗口。

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

若将上述所有的工程取舍收敛为一张图,我们会发现 Bidder 的所有决策都在这三个顶点之间相互牵制:

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

可以说,一个成熟 Bidder 的架构评审,本质上就是在反复拷问一个问题:当前的改动,究竟在这三个顶点上各自挪动了多少权衡砝码?

八、可靠性与优雅降级

对于高并发在线服务而言,降级永远优于报错,而超时则是最坏的结果。Bidder 的容错设计全面贯彻了这一理念:

将「超时降级」理念落地,便是一张严密的逐级降级矩阵。每一级都预先设定好了「算不完时退到什么」,而不是临场抛出异常:

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

十、排障与调优口诀

通往下一篇:理解了 Bidder 内部的漏斗流水线后,不妨深入看看它所遵循的通用语言——OpenRTB 协议。


延伸阅读

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

规范与一手资料:


–views
Share this post on:

Previous Post
Bidder 解析层:将原始竞价请求转为内部可算对象
Next Post
OEM 系统广告:海外手机厂商的预装与系统位变现