Skip to content
Charles Shao
Go back

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

Updated:
–views

本文是 广告配置与索引 系列的第 2 篇(配置落地)。 全系列 3 篇:

  1. 程序化广告投放层级与定向配置
  2. 配置落地:从投放后台到索引快照
  3. 手把手走一遍定位式位图倒排

一句话定位:本篇专讲从广告主后台配置到只读索引快照的生成侧编译流水线,涵盖编译前置、版本化设计、全量与增量触发机制及组件边界。

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

程序化广告投放层级与定向配置结尾埋了一个钩子:配置从不直接喂给在线的 Bidder。广告主(Advertiser)在后台配置的是一棵结构复杂、随时改动的树形结构;而 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 个节点重复执行 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」出了事故只能靠重启和运气(详见第八节)

下游的「拉取兜底」之所以能从粗糙的「差集猜漏」升级为精确的「版本差精确对拉」,「回滚」之所以能简化为一次指针操作,全靠这根版本脊柱(下发侧如何使用它,详见手把手篇)。版本化绝非可选项:判断是否最新、版本差对拉、回滚,下游的正确性完全依赖这根 gv。

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

版本号不仅用于快照之间的比对,同样约束着快照内部的一致性:在同一份快照里,索引、订单项元数据以及缺省语义口径必须来自同一次编译。绝不能出现新索引搭配旧元数据的情况(这呼应了Bidder 总览中强调的「模型与召回的版本一致性」)。具体的做法是,让编译产物整体携带同一个 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);而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 中间层、推信号加拉数据、定位式位图以及变更真相源的设计都是正确的,生成侧真正缺失的是版本脊柱和切实运行的全量兜底机制。

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

将「在离线或近线阶段把配置编译成一份庞大的只读数据,并版本化地发布」这个问题放到整个业界来看,无论是搜索索引、广告定向索引、机器学习模型还是特征数据,常见的做法最终都收敛到同一条路径:离线/近线构建 → 不可变版本化快照 + 小指针 → 全量 + 流式增量。(至于后续的 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)驱动近线加工

版本化设计可以参照 TensorFlow Serving;全量、增量结合版本的机制可以参照 LinkedIn Venice。至于大产物扇出侧的 Dragonfly、Kraken 或 Owl 等系统,手把手篇会进行详细对照。

十三、生产取舍:常见问题

本篇止于「快照产出并落入对象存储」。至于这份快照如何被成百上千个节点可靠拉取并原子装载,请继续阅读下一篇:手把手走一遍定位式位图倒排。


延伸阅读

规范与一手资料:

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

附录:行业黑话总结

控制面与数据面;近线编译;有效定向(effective targeting);缺省语义定型;编译前置;全局稳定 position;版本脊柱(version spine);索引快照(index snapshot);全量与增量编译;增量触发;全量触发;快速通道;编译 ≠ 分发;真相源(source of truth);CDC(Change Data Capture);生效确认。


–views
Share this post on:

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