Skip to content
Charles Shao
Go back

配置落地:从投放后台到索引快照(近线编译、触发机制与版本化快照)

Updated:
views

本文属于 Bidder 竞价服务架构 系列(配置面 · 生成一篇)。

层级配置篇讲了广告主在投放后台维护的那棵四层配置树(广告主 → 计划 → 广告组 → 创意),定向过滤篇讲了 Bidder 在运行时怎么毫秒级匹配那份定向规则。但中间缺了一环:那棵人工维护的配置树,是怎么被编译成一份版本化的只读索引快照的? 本篇专讲这条生成链路——从后台配置到索引快照:怎么编译、怎么被触发、依赖哪些组件与服务、设计上要做哪些取舍。至于快照如何被下发、拉取、装载进 Bidder 内存,属于手把手篇的范畴,本篇只交到「快照产出并落对象存储」为止。

读者与前置:面向做过或想做在线服务配置分发的工程师 / 架构师。读过层级配置篇Bidder 总览会更顺,但非必需——本篇自成一体。

层级配置篇结尾埋了一个钩子:配置从不直接喂给在线的 Bidder。广告主在后台配的是一棵结构复杂、随时改动的树;Bidder 要的是一份能在 20ms 里被反复求交的、扁平的只读数据结构。这两者之间隔着一条编译链路——它把「源码」(配置树)编译成「可执行文件」(版本化索引快照)。本篇不满足于「能跑」,而是把这条生成侧链路做到位:这份快照该由谁、在什么时机、经哪几步、算成什么样。

本篇的边界:上游层级配置篇那棵配置树,下游手把手篇的「快照 → OSS → Bidder」下发装载。本篇只讲夹在中间的那台「编译器」:把树编译成一份版本化不可变快照,并把它稳稳地落进对象存储。

TL;DR

Table of contents

Open Table of contents

一、控制面 vs 数据面:为什么配置要先编译成快照

最直接的疑问是:既然配置都在 MySQL 里,Bidder 匹配时直接查库、或者启动时把树读进来现算,不就行了?不行——这会同时违背 Bidder 总览第五节数据面第一性原理的每一条:

所以控制面(配置)与数据面(Bidder)之间必须隔一层。这一层做的事,本质就是编译

一个贯穿全篇的类比

配置树是源码,近线编译是编译器,索引快照是可执行文件,Bidder 是运行进程。你不会让 CPU 在每次执行时都重新编译源码——同理,不该让 Bidder 在每次竞价时都重新「编译」配置。编译一次、运行千万次。

厘清这条边界后,本篇的问题就收敛成一句:这台「编译器」该怎么设计——它把配置树编译成什么、由什么触发、依赖哪些组件、有哪些取舍? 而「编译好的快照怎么送进 Bidder 内存」是下一段(手把手篇)的事,本篇不展开。

二、生成侧全景:从配置树到版本化快照

先给一张生成侧的全景,把本篇要讲的都摆在一条线上——注意它止于「快照落进对象存储」,之后的扇出 / 拉取 / 装载是下游:

生成侧编译流水线总览:投放后台的四层配置树写入 MySQL(真相源),经 CDC/binlog 触发近线编译四步——①层级合并/展开得订单项有效定向、②口径归一+缺省语义定型、③建倒排+位图+全局稳定 position、④打全局版本号 gv 产出不可变版本化快照;快照切成 chunk 落对象存储,注册/配置中心只登记版本指针;之后的扇出/拉取/装载进 Bidder 属于下游(手把手篇) 生成侧全景:控制面把配置树经「层级合并 → 口径归一 + 缺省定型 → 建索引 + 全局 position → 打版本号」编译成不可变版本化快照,落进对象存储、只在配置中心登记一个版本指针。所有重活都在离开关键路径的地方一次算好。虚线连出的灰色「下发装载进 Bidder」是手把手篇的内容,本篇止于快照落地。

这条流水线可以浓缩成一句话:

投放后台 → MySQL →(CDC 触发)近线编译(层级合并 → 口径归一 + 缺省定型 → 建倒排 + 位图 + 全局 position → 打版本号 gv)→ 不可变版本化快照 → 落对象存储 + 配置中心登记版本指针。

三个要点贯穿始终:

本篇的主线由此展开:编译(第三节:编译成什么)、版本化(第四节:怎么标识)、全量 / 增量(第五节:怎么算得又快又准)、触发(第六节:什么时候算)、组件依赖(第七节:靠谁算、靠谁存)。

一张「编译四步一页速览」把生成侧要点收在一处,后文各节都是对某一行的展开:

步骤输入 → 输出消灭的在线开销主要风险
① 层级合并 / 展开四层树 → 订单项有效定向运行时跨层求交空集配置漏检
② 口径归一 + 缺省定型各家口径 → 统一枚举 + 具体集合运行时「未设」歧义口径与运行时对不齐
③ 建倒排 + 位图 + 全局 position有效定向 → 编译好的倒排片段运行时现建索引position 不稳定使位图错位
④ 打全局版本号 gv快照 → 版本化不可变 artifact无法对账 / 回滚快照内部版本不自洽

三、编译前置:把重活一次算到底

编译前置的目标只有一句:凡是「对所有请求结果都一样」的计算,全部挪到离线 / 近线一次算好,在线一步都不做。 它分四步,最优做法是每一步都往前多推一点,直到数据面只剩「装载」。

3.1 层级合并 / 展开:把树摊平成「订单项 → 有效定向」

配置树从根到叶,同一维度跨层取交集、排除项跨层累加,最终对每一条广告组 / 订单项算出一份有效定向(effective targeting)——一个不再有层级、可直接匹配的扁平定向集合:

订单项 LI-8842 的有效定向(编译产物,已无层级):
  geo     = {US-CA, US-NY}          // = 计划(US) ∩ 广告组(CA,NY)
  device  = {iOS}
  segment = {high_value}
  exclude = {gambling, ...}          // 各层排除项累加
  // 随订单项带下来的标量值(不合并,只归属):
  bid_strategy = tCPA(50 USD)
  budget_ref   = campaign#331 / li#8842
  freq_cap     = {campaign: 3/day, li: 1/day}

为什么必须在编译期做?因为层级合并的结果对所有请求都一样——一次算好存进快照,运行时零成本;留到运行时,就是对每个请求重复爬树(第一节的浪费)。注意 bid_strategy / budget_ref / freq_cap 这些层级配置篇 4.4 节强调的标量值:它们不做「求交 / 覆盖」的合并,只是归属于某一层、随订单项带进快照。编译在这里做的是「把归属解析清楚」,而不是「合并」。

3.2 口径归一 + 缺省语义定型:消灭一切「未设」歧义

展开出「有效定向」还不够——它必须用和运行时完全一致的口径表达,且不能留任何「未设」的歧义。

关键在于:运行时不该再面对「未设」这种模糊状态。所有歧义都在编译期消解成确定的集合。这正是层级配置篇 5.2 节「三方对齐」的技术前提——后台展示、编译定型、运行时匹配对「未设」的理解,在编译这一步被固化成同一个具体集合。

3.3 建倒排 + 位图 + 全局稳定 position:编译前置「到底」

有了「订单项 → 有效定向」,把它倒过来建成索引:

到这里,很多实现会止步于「下发合并好的订单项记录、让每个节点各自建索引」。最优做法要再往前推一步——把「建索引」也前置到编译侧

编译前置到底:产出编译好的索引片段,而非让节点现建。

让近线编译直接产出序列化好的倒排片段 / 位图片段,数据面收到后直接装载,createBitSet 这类解析逻辑从 Bidder 里彻底消失。这样「建索引」不再被 N 个节点各做一遍,编译逻辑也不再耦合进 Bidder 二进制(改索引格式无需重发所有节点)。

这一步有个前置条件:位图是按 position 编址的,要产出可跨节点通用的片段,就必须让 position 全局稳定分配订单项 ID → 全局 position,各节点一致),而不是「各节点按本地加载顺序各自分配」。全局 position 由编译侧统一维护,也顺带让「删除 = 清某个 position 位」在所有节点上语义一致(增删改在位图上怎么落,见手把手篇)。

3.4 编译期校验:坏配置在此拦截

编译期是拦截坏配置的最佳时机(比上线后靠零消耗发现早得多):

本节小结:编译前置的最优形态不是「合并好就下发」,而是连建索引、连 position 分配都做完、数据面只装载。它是版本化与全量 / 增量的地基——只有产物是「编译好的、可结构共享的片段」,后面两条才好落地。

四、版本化:一根贯穿的版本脊柱

版本化是这套架构里最容易被省掉、却最不该省的一条。它的做法极简:给每份快照一个全局递增的版本号 gv,变更集也带 gv。但这一根版本号,是分布式下一切协调的锚点。

版本脊柱示意图:中心一根全局递增版本号 gv,贯穿快照、下发、对账、回滚;向外兑现四种能力——判断是否最新、版本差对账、生效确认、一键回滚 一根版本脊柱兑现四种能力:判断是否最新、版本差对账、生效确认、一键回滚。没有它,这四件事都做不了。

4.1 一根版本号,兑现四件事

能力靠版本怎么做没有版本会怎样
判断是否最新节点比 local.version < gv 才拉只能靠「收没收到通知」猜,多次变更 / 乱序时对不出
对账(拉取兜底的核心)精确拉取 > local.version 的变更退化成「时间片差集 + 启发式」,既可能漏又可能滥
生效确认节点上报当前版本 → 中心看版本分布、对落后实例告警某节点长期卡在旧版本无人察觉(第十节)
一键回滚中心保留 last_good 版本,广播「切回 gv=k」出了事只能靠重启和运气(第八节)

下游的「拉取兜底」之所以能从「差集猜漏」升级成「版本差精确对拉」、「回滚」之所以是一次指针操作,靠的都是这根脊柱(下发侧怎么用它,见手把手篇)。版本化不是可选项,是生成侧交给下游的第一件硬通货。

4.2 同一份快照内部也要版本自洽

版本号不只用于快照之间,也约束快照内部:同一份快照里,索引、订单项元数据、缺省语义口径必须来自同一次编译,不能新索引配旧元数据(呼应总览「模型与召回的版本一致性」)。做法是让编译产物整体带同一个 gv,装载时校验一致,任何拼接了不同版本片段的快照都视为非法、拒绝上线。

五、全量 vs 增量编译,与兜底

同一份快照,既要改一条就快速生效,又要长期不漂移。生成侧的答案是全量与增量两条腿走路

全量重建与增量编译对比图:全量重建(周期性/兜底、保正确)——读全量元数据 → 重编译整份快照 → 打新 gv;增量编译(高频、保时效)——捕获变更(CDC/MQ)→ 只重编译受影响订单项 → 合并成新快照、秒级生效;两者并存:增量为主保秒级生效,全量定期兜底以消除漂移、压缩空洞、对齐版本 全量与增量并存:增量(捕获变更、只重编译受影响订单项)保时效(秒级生效);全量(周期性重编译整份快照)保正确(消除增量累积的漂移、压缩空洞、对齐版本)。生产上通常增量为主、全量定期兜底对账。

5.1 增量编译:只重算受影响订单项

捕获到一条变更(某广告组改定向、暂停、预算耗尽),编译侧只重编译受影响的那几条订单项、只更新它们触碰的定向 key,合并成一份带新 gv 的快照——秒级生效。它是高频改动的主力。增量在位图 / 快照层面具体怎么「造新快照、换引用」(copy-on-write、结构共享、只碰受影响 key),是手把手篇 §5、§8 一步步走的内容,本篇不重复。

5.2 全量重建:消漂移、压空洞、对齐版本

全量重建周期性或异常兜底触发,从零重编译整份快照:把存活订单项重新紧凑编号、消除增量长期累积的漂移,并顺带压缩增量删除留下的 position 空洞、把所有节点对齐到同一个干净版本。成本高、频率低,但它是正确性的锚。全量重建的具体步骤(重排 position、压空洞、打新 gv)在手把手篇 §7 有逐步演示。

5.3 兜底:为什么两条腿都要有

增量保时效、全量保正确,缺一不可。但更关键的是:下游的推送与轮询都可能漏(节点重启 / GC 卡顿 / 网络抖动错过广播,新扩容节点没收到过),所以生成侧必须让全量重建真正周期运行,给下游一个「定期能对齐的干净版本」兜底。

务必让全量兜底真正运行。 一个常见的坑是「全量重建只在进程启动时跑、运行时的全量入口被关掉」,结果增量漂移无处收敛,只能靠重启 / 发布来消——这等于抽掉了兜底的一半(第十一节的实战案例正好踩了这个坑)。下游节点侧如何把「版本差对拉 + 周期全量拉取」接起来,见手把手篇 §10。

六、触发机制:这条流水线怎么被驱动

编译流水线不是「有人点一下才跑」的批处理,而是一台被事件与定时共同驱动的近线服务。触发分三类,各管一件事。

6.1 增量触发:CDC / binlog / 消息队列

投放后台把配置写进 MySQL 后,编译侧不轮询整库,而是订阅变更流

编译侧消费到变更后,走第五节的增量编译,只重算受影响订单项、打新 gv

6.2 全量触发:定时 / 空洞率 / 异常兜底

全量重建的触发通常三类并用:

6.3 快速通道:高优先级变更

从「广告主点保存」到「线上真正生效」之间的窗口,就是编译 + 下发延迟。它对不同改动的容忍度差别巨大:

这类高优先级变更应走快速通道:独立的增量链路、更短的编译 / 广播周期、更高的优先级,而不是排在常规全量编译后面。有了版本脊柱,快速通道也能被观测:高优先级变更打上版本后,可单独盯它「从发布到各节点确认」的收敛时间。

七、组件与服务依赖:这条链路依赖谁

把生成侧落到真实系统,它依赖一串各司其职的组件。核心是一条边界:编译 ≠ 分发

7.1 一张依赖清单

组件 / 服务在链路里的角色为什么需要它 / 要点
MySQL(或等价)真相源:投放后台维护的配置树落库处一切编译产物都可从它重算;快照只是它的只读派生物,坏了能从这里重建
CDC / Canal / binlog变更捕获:把「哪几行变了」低延迟推送给编译侧让增量触发对后台零侵入;真相仍在 MySQL,CDC 只搬运「变更事实」
消息队列(Kafka / RocketMQ 等)变更传输 + 削峰:承载变更事件流解耦「后台写库」与「编译消费」;重放能力让漏消费可补
近线编译服务编译器本体:跑第三~五节的四步 + 全量 / 增量低频近线、负载由变更频率决定(不由节点数决定);它只「产出并发布一份 artifact」
注册中心 / 配置中心(Nacos 等)承载版本指针 + 服务发现:只放开关 / 阈值 / gv 指针别把几十上百 MB 的快照塞进它——它不是为大 blob 设计的,会拖慢推送、压垮自己
对象存储 / 缓存服务(OSS/S3 等)承载大 blob:版本化的编译产物本体大 blob 走这里,配置中心只承载「版本指针 + 元信息」;下游按指针来拉
消息总线 / RPC(Dubbo 等)推信号:广播「变更集 + 版本 gv」的轻量信号总线只跑指针、不推数据;重数据由下游按版本差去对象存储拉(属下游,见第九节)

一个典型落地形态:Nacos 作注册中心(编译服务、缓存服务、Bidder 节点互相注册发现)+ 配置中心只放版本指针 / 开关 / 阈值;编译产物落对象存储(key 带版本、可长缓存);Dubbo 广播「变更集 + 版本 gv」(轻信号)。生成侧的职责到「落对象存储 + 登记版本指针」为止。

7.2 编译 ≠ 分发:把生成与扇出彻底分家

一个高频踩坑点:把「编译」和「分发」混在一个服务里。 索引动辄几十上百 MB,成百上千个节点在整批发布 / 机房切流时同时来拉,会瞬间打满这个服务的带宽。破法是把两者彻底分家:

一句话:生成侧只对「产出一份正确、版本化、可结构共享的 artifact 并落地」负责;「把它可靠地铺到 N 个节点」是下游分发层的职责。两者一旦混在一个服务里,编译服务就会被节点数量牵制。

八、时效与一致性

生成侧落地后,还要回答两个「跨时间」的问题(跨实例 / 跨机房的版本收敛属于下发侧,见手把手篇)。

8.1 编译延迟:改了多久才生效

从「广告主点保存」到「快照产出」之间的窗口,就是编译延迟(下发延迟另算)。它由触发方式决定:CDC + 增量编译通常秒级,定时全量则按周期。高优先级变更走第六节的快速通道压缩这段窗口。有了版本脊柱,这段延迟可被精确观测——从变更事件到新 gv 产出的耗时。

8.2 快照内部一致性

编译产出的每一份快照必须内部自洽:索引、订单项元数据、缺省语义口径来自同一次编译、整体带同一个 gv(第 4.2 节)。这是生成侧交给下游的质量承诺——下游装载时只需校验 gv 一致,就能保证「位图匹配出的 crId」和「元数据里取到的 payload」永远同版本。

8.3 和预算回灌方向相反,别混

最后厘清一个易混点:本篇讲的配置生成 / 下发是「控制面 → 数据面」(把规则编译给 Bidder);而总览第六节的预算 Pacing 回灌是「数据面 → 控制面」(把真实消耗收回来、再回灌预算分片)。两条链路方向相反、目的不同。别把余额 / 实时预算塞进配置快照——那属于回灌链路,混进来会让配置生成被高频的消耗变动拖成高频重编译与下发。

九、下游:快照怎么下发进 Bidder(交给手把手篇)

本篇止于「快照产出、落对象存储、登记版本指针」。快照如何被成百上千个 Bidder 实例可靠地拉取、原子地装载进内存,属于手把手篇的范畴。这里只把生成侧对下游的三条设计意图交代清楚,细节请移步:

两篇的交接点:版本化不可变快照,就是生成侧交付、下游消费的那份「契约对象」——生成侧保证它正确、自洽、带版本;下游保证它被原子地、无遗漏地铺到每个节点。两篇正好在这份快照上交接。

十、审计、灰度与可观测性

十一、实战验证:一个真实索引更新架构的对照

前面给出的是「最优的生成侧设计」。这一节用一套脱敏后的真实 Bidder 索引更新架构做对照——它骨架完全正确,却在几条原则上各有欠缺,正好从反面印证:这些不是锦上添花,而是「做对」的必要条件。

11.1 现状骨架:方向对

它其实就是「推信号 + 拉数据」的一个具体落地:

后台改配置 → 写库 → cache 服务:查库、更新缓存快照、记录"本时间片变更的创意集"

        ① 推信号:变更的创意 ID 经 Dubbo 广播到所有 Bidder 节点
        ② 拉数据:节点收到 ID → 到 cache 拉该创意的完整记录(已反范式、层级已合并)
        ③ 建索引:节点把记录写进本地"定位式位图倒排"

它有两条更新通道 + 一条初始化:全量(启动时新建索引、建好后一次引用切换)、增量(收到广播就地在在线索引上增删位)、兜底(定时把「cache 记录的变更集」与「本节点实际收到的广播集」做差集,补拉漏收的)。其中定位式位图倒排位置表 + 定向值 → 位图)是个亮点:删一条创意只需清对应 position 位(这份数据结构怎么工作,见手把手篇)。

11.2 用原则体检:生成侧最欠缺的两条

原则判定差在哪最优做法
编译前置🟠 半符合层级合并已前置,但**「建倒排」在每个节点各做一遍**3.3 产出编译好的片段 + 全局 position
版本化❌ 基本不符合创意维度无版本号,对账靠「时间片差集 + 出现次数启发式」第四节 版本脊柱
全量兜底🟠 有洞运行时全量重建一度被关闭,增量漂移无处收敛5.3 让全量兜底真正周期运行
原子装载(下游)🟠 半符合全量走引用切换 ✅;增量在共享位图上原地改(下发侧问题,详见手把手篇)手把手篇 §8

生成侧最欠缺的两处,恰好对应最容易被省的两条:版本化完全缺席(连带回滚、生效确认都做不了)、和全量兜底被关(增量漂移无处收敛)。至于「增量在共享位图上原地改」,那是装载侧的并发正确性问题,属下游范畴,手把手篇有完整的 copy-on-write 解法。

11.3 改造:把原则一条条补上

对照前面几节,这套架构的生成侧演进路线很清晰(先应急止损、后根治):

一句话:四根柱子(cache 中间层、推信号 + 拉数据、定位式位图、变更真相源)都对,生成侧缺的是「版本脊柱」和「真正运行的全量兜底」。 这也正是前面那版最优设计存在的意义——它并非纸上谈兵,而是把真实系统里最容易失效的环节一一补齐。

十二、业界参照:通用范式与代表系统

前面这套设计并非自创。把「离线 / 近线把配置编译成一份大只读数据、并版本化地发布」这个问题拿到业界看——无论是搜索索引、广告定向索引、ML 模型还是特征数据——大家高度收敛到同一条范式:

离线 / 近线构建 → 不可变版本化快照 + 小指针 → 全量 + 流式增量。(其后的 CDN / P2P 扇出与内存原子切换属下游,见手把手篇。)

分层对照几个成熟系统(聚焦生成 / 版本这一段):

层面通用做法代表系统
构建离线 / 近线 build,在线只 load(编译前置)搜索索引、广告索引、ML 训练产物普遍如此
版本化 + 不可变 + 指针版本化产物 + 一个小可变指针,切换即翻指针TensorFlow Serving(版本目录 + servable 原子切换 + 版本策略);LinkedIn Venice(batch push 产出新 store-version,翻 current 指针,留 backup 供回滚)
全量 + 增量周期全量重建(消漂移)+ 流式增量(保时效)Venice(batch push + nearline)、搜索的全量段 + 增量段、bidder 的定期全量 + Kafka/CDC 增量
触发CDC / binlog / 事件驱动增量,定时驱动全量数据管道普遍用 CDC(Debezium / Canal)驱动近线加工

一句话:本篇推导出的生成侧设计,和 TF Serving(版本化)、Venice(全量 + 增量 + 版本)这些成熟系统是同一条路——它是业界通用范式在「DSP 配置编译」这个场景下的具体化,而非主观臆断。大产物扇出侧的 Dragonfly / Kraken / Owl 等,手把手篇会对照。

十三、生产实战:常见问题

十四、速查表

配置落地的生成侧,本质是一次编译:把广告主人工维护的、结构复杂的配置树,编译成一份 Bidder 能在 20ms 里反复求交的扁平只读快照,贴上版本号,稳稳落进对象存储。做到「最优」,靠的是四件事——编译前置(在线零重活)、版本化(分布式一根锚)、全量 / 增量(既快又准)、清晰的触发与组件边界(编译 ≠ 分发)。它承上启下:上承层级配置篇的「树怎么组织」,下接手把手篇的「快照怎么下发装载」、再到定向过滤篇的「快照怎么被毫秒匹配」。


延伸阅读

规范与一手资料:

业界同类范式(第十二节对照):

附录:术语表


views
Share this post on:

Previous Post
手把手走一遍定位式位图倒排:4 条创意的增删改查 + OSS 全量/增量下发
Next Post
程序化广告投放层级与定向配置:Campaign 层级模型、定向继承与缺省语义