本文是 广告配置与索引 系列的第 2 篇(配置落地)。 全系列 3 篇:
- 程序化广告投放层级与定向配置
- 配置落地:从投放后台到索引快照
- 手把手走一遍定位式位图倒排
一句话定位:本篇专讲从广告主后台配置到只读索引快照的生成侧编译流水线,涵盖编译前置、版本化设计、全量与增量触发机制及组件边界。
读者与前置:面向做过或想做在线服务配置分发的工程师与架构师。读过层级配置篇与 Bidder 竞价服务架构设计(总览)会更顺,但非必需——本篇自成一体。
程序化广告投放层级与定向配置结尾埋了一个钩子:配置从不直接喂给在线的 Bidder。广告主(Advertiser)在后台配置的是一棵结构复杂、随时改动的树形结构;而 Bidder 运行时需要的是一份能够在 20ms 内被反复求交的、扁平的只读数据结构。这两者之间隔着一条关键的编译链路,它负责把「源码」(配置树)编译成「可执行文件」(版本化索引快照)。本篇专讲这条生成链路:这份快照该由谁生成、在什么时机触发、经过哪几步处理、最终算成什么样,直到快照落入对象存储。
本篇的边界:上游是层级配置篇中的那棵配置树,下游是手把手篇里的「快照 → OSS → Bidder」下发装载。本篇只聚焦夹在中间的这台「编译器」:如何把层级树编译成一份版本化不可变快照,并将其落入对象存储。
TL;DR
- 配置与在线解耦:配置树是人工维护、低频变更且结构复杂的控制面对象;
Bidder则是高并发、低延迟、只读只算的数据面服务。两者之间必须通过一台编译器,将层级树一次性计算为版本化快照,否则慢 IO 侵入关键路径、每请求重复爬树将直接压垮数据库。 - 生成侧流水线:本篇聚焦从后台配置到索引快照的生成侧,涵盖编译四步、触发机制、组件依赖与设计取舍。至于快照如何下发、拉取并装载进
Bidder内存,详见手把手篇。 - 编译前置:将层级合并、口径归一与缺省定型、构建倒排索引及位图一次性计算到底。甚至连全局稳定的
position都在近线阶段确定,直接产出编译好的索引片段,使得数据面只需装载、实现零编译。 - 全局版本化:为每份快照赋予一根全局递增的版本脊柱
gv。这一根版本号兑现了四项核心能力:判断是否最新、版本差精确对账、节点生效确认以及一键回滚。 - 全量与增量双轨制:增量编译仅重算受影响的订单项,以保障秒级生效的时效性;全量重建则周期性重编译整份快照,用于消除漂移、压缩空洞并对齐版本,确保正确性。两者相辅相成。
- 多元触发机制:增量编译通过
CDC、binlog或消息队列实现近线触发;全量重建则依赖定时任务、空洞率阈值或异常兜底。对于暂停、预算耗尽或违规下线等高优先级变更,则走快速通道。 - 组件边界与解耦:链路依次依赖
MySQL(真相源)、CDC(变更捕获)、近线编译服务(编译器本体)、配置中心(承载版本指针)以及对象存储(承载大 blob)。核心原则是「编译 ≠ 分发」,低频的近线编译服务绝不亲自将庞大的产物扇出给众多节点。 - 数据流向区分:配置下发是控制面到数据面的规则传递;而预算消耗则是数据面到控制面的回灌。切忌将实时余额塞入配置快照中。
Table of contents
Open Table of contents
一、控制面 vs 数据面:为什么配置要先编译成快照
最直接的疑问是:既然配置都存在 MySQL 里,Bidder 在匹配时直接查库,或者在启动时把配置树读进内存现算,难道不行吗?答案是坚决不行。这种做法会同时违背 Bidder 竞价服务架构设计(总览)第五节中关于数据面原则的每一条要求(只读只算、状态靠回灌、原子切换):
- 慢 IO 侵入关键路径:在线路径上查询一次
MySQL或遍历一次配置树,意味着几毫秒甚至更久的同步 IO 开销,这会直接吃掉本就只有 20ms 的竞价预算。 - 每请求重复冗余计算:层级合并(逐维求交、排除累加)对于每个请求而言结果都是一致的,如果在运行时处理,就意味着对每个请求都要重算一遍,这纯粹是算力的浪费,且会被高 QPS 剧烈放大。
- 真相源面临崩溃风险:十万至百万级的 QPS 直接压向
MySQL,任何在线数据库都难以承受这种量级的读取压力。
正因如此,控制面(配置)与数据面(Bidder)之间必须隔离出一层。这一层的核心职责,本质上就是编译。
一个恰当的类比是:配置树如同源码,近线编译服务是编译器,索引快照是生成的可执行文件,而 Bidder 则是运行中的进程。正如你不会让 CPU 在每次执行时都重新编译源码,同理,也不该让 Bidder 在每次竞价时都重新「编译」配置。核心理念在于:编译一次,运行千万次。
厘清这条边界后,本篇将深入探讨这台「编译器」的设计方案:它把配置树编译成什么形态、由什么机制触发、依赖哪些底层组件,以及在设计上有哪些取舍。至于「编译好的快照怎么送进 Bidder 内存」,则是下一段手把手篇的内容,本篇暂不展开。
二、生成侧全景:从配置树到版本化快照
首先,我们通过一张生成侧的全景图,将本篇涉及的核心环节串联起来。需要强调的是,这条流水线止于「快照落进对象存储」,后续的扇出、拉取与装载属于下游流程:
生成侧全景:控制面把配置树经「层级合并 → 口径归一 + 缺省定型 → 建索引 + 全局 position → 打版本号」编译成不可变版本化快照,落进对象存储、只在配置中心登记一个版本指针。所有重活都在离开关键路径的地方一次算好。虚线连出的灰色「下发装载进 Bidder」是手把手篇的内容,本篇止于快照落地。
整条流水线的流转过程如下:
投放后台 → MySQL →(CDC 触发)近线编译(层级合并 → 口径归一 + 缺省定型 → 建倒排 + 位图 + 全局 position → 打版本号 gv)→ 不可变版本化快照 → 落对象存储 + 配置中心登记版本指针。
贯穿始终的三个核心要点是:
- 编译发生在关键路径之外:作为离线或近线任务,编译过程慢一点并无大碍,唯一的要求是「改动能够在可接受的延迟内生效」(详见第八节)。
- 产物是只读、扁平、版本化且可结构共享的快照:快照中不再有层级嵌套,也没有待求交的多层配置,只保留「订单项 → 有效定向」的倒排索引与元数据,并且带有全局版本号。
- 本篇止于产出落地:快照产出并落地后,如何可靠地分发到成百上千个
Bidder实例并实现原子装载,请参考手把手篇(第九节给出了边界与指针)。
本篇的主线由此展开:编译成什么(第三节)、怎么标识版本(第四节)、怎么算得又快又准(第五节:全量与增量)、什么时候算(第六节:触发机制),以及靠谁算、靠谁存(第七节:组件依赖)。
编译四步的对照如下,后文各节将逐一展开:
| 步骤 | 输入 → 输出 | 消灭的在线开销 | 主要风险 |
|---|---|---|---|
| ① 层级合并 / 展开 | 四层树 → 订单项有效定向 | 运行时跨层求交 | 空集配置漏检 |
| ② 口径归一 + 缺省定型 | 各家口径 → 统一枚举 + 具体集合 | 运行时「未设」歧义 | 口径与运行时对不齐 |
③ 建倒排 + 位图 + 全局 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 口径归一 + 缺省语义定型:消灭一切「未设」歧义
仅仅展开出「有效定向」还不够,它必须采用与运行时完全一致的口径来表达,并且绝不能遗留任何「未设」的歧义。
- 口径归一:地域、设备、币种等维度必须统一到内部标准口径(这与Bidder 解析层 2.2 节的归一化逻辑同源)。在面向海外多市场时,这一点尤为关键。例如,地域应统一采用 ISO 3166 标准(使用
US-CA而非「California / Calif. / CA」混用);时区必须显式声明(明确dayparting是按广告主时区还是用户本地时区);币种必须统一(出价与底价的比对必须在同币种下进行)。如果归一化没做好,快照与运行时请求的口径就会对不上,导致匹配结果时对时错。 - 缺省语义定型:必须在编译期将「未设 = 不限 / 全不通 / 继承父层」的模糊语义定死为一个具体的集合。如果约定「未设 = 不限」,就将其展开为全量集合(或打上显式的「通配」标记);如果约定「未设 = 全不通」,则直接落为空集。
关键在于:运行时不该再面对「未设」这种模糊状态。所有的歧义都必须在编译期消解为确定的集合。这正是层级配置篇 5.2 节中「三方对齐」的技术前提——后台展示、编译定型与运行时匹配对于「未设」的理解,在编译这一步被彻底固化为同一个具体集合。
3.3 建倒排 + 位图 + 全局稳定 position:编译前置「到底」
在获得「订单项 → 有效定向」的映射后,接下来需要将其倒转,构建为倒排索引:
- 倒排索引:将「订单项 → 定向值」转换为「定向值 → 命中该值的订单项集合」(例如
geo:US-CA → {LI-8842, LI-9001, ...})。 - 位图承载:订单项集合采用位图(
RoaringBitmap)来表示,从而将多维求交转化为毫秒级的位运算。关于数据结构的深挖属于手把手篇与召回篇的范畴,本篇仅交代「编译产出的是位图」。
到这里,许多系统的实现会止步于「下发合并好的订单项记录,让每个节点各自建索引」。然而,最优的做法是再向前推进一步:将「建索引」的动作也前置到编译侧。
让近线编译服务直接产出序列化好的倒排片段或位图片段,数据面收到后直接装载。这样一来,像 createBitSet 这类的解析逻辑就能从 Bidder 中彻底剥离。如此设计,「建索引」不再被 N 个节点重复执行 N 遍,编译逻辑也不再与 Bidder 的二进制文件耦合(修改索引格式无需重新发布所有节点)。
这一步有一个重要的前置条件:位图是按 position 编址的。要产出可跨节点通用的片段,就必须让 position 实现全局稳定分配(即 订单项 ID → 全局 position,各节点保持一致),而不是「各节点按本地加载顺序各自分配」。全局 position 由编译侧统一维护,这也顺带保证了「删除 = 清除某个 position 位」在所有节点上的语义绝对一致(关于增删改在位图上如何落地,详见手把手篇)。
3.4 编译期校验:坏配置在此拦截
编译期是拦截异常配置的最佳时机,这比上线后依靠零消耗来发现问题要早得多:
- 跨层冲突校验:如果某订单项求交后结果为空集(例如层级配置篇 4.3 节提到的「子层越界」),编译期就应当触发告警或进行标记,而不是默默产出一条「永远投不出」的无效订单项。
- 必填与合法性校验:对于出价缺失、预算为负、频控阈值非法等明显错误,编译期应直接拒绝,并将错误信息反馈给投放后台。
总结来说,编译前置绝不是简单的「合并好就下发」,而是连建索引、连 position 分配都一并完成,使得数据面只需执行纯粹的装载动作。后续的版本化与全量/增量机制,都是建立在「产物已经是编译好的、可结构共享的片段」这一基础之上的。
四、版本化:一根贯穿的版本脊柱
版本化是这套架构中最容易被省略,却又最不该省略的一环。它的实现方式极为极简:为每份快照赋予一个全局递增的版本号 gv,且变更集也携带该 gv。正是这一根简单的版本号,构成了分布式环境下一切协调的锚点。
一根版本脊柱兑现四种能力:判断是否最新、版本差对账、生效确认、一键回滚。没有它,这四件事都做不了。
4.1 一根版本号,兑现四件事
| 能力 | 靠版本怎么做 | 没有版本会怎样 |
|---|---|---|
| 判断是否最新 | 节点比对 local.version < gv 才拉取 | 只能靠「收没收到通知」去猜,多次变更或乱序时根本对不出 |
| 对账(拉取兜底的核心) | 精确拉取 > local.version 的变更集 | 退化成「时间片差集 + 启发式」,既可能漏拉又可能滥拉 |
| 生效确认 | 节点上报当前版本 → 中心查看版本分布,对落后实例告警 | 某节点长期卡在旧版本却无人察觉(详见第十节) |
| 一键回滚 | 中心保留 last_good 版本,广播「切回 gv=k」 | 出了事故只能靠重启和运气(详见第八节) |
下游的「拉取兜底」之所以能从粗糙的「差集猜漏」升级为精确的「版本差精确对拉」,「回滚」之所以能简化为一次指针操作,全靠这根版本脊柱(下发侧如何使用它,详见手把手篇)。版本化绝非可选项:判断是否最新、版本差对拉、回滚,下游的正确性完全依赖这根 gv。
4.2 同一份快照内部也要版本自洽
版本号不仅用于快照之间的比对,同样约束着快照内部的一致性:在同一份快照里,索引、订单项元数据以及缺省语义口径必须来自同一次编译。绝不能出现新索引搭配旧元数据的情况(这呼应了Bidder 总览中强调的「模型与召回的版本一致性」)。具体的做法是,让编译产物整体携带同一个 gv,在装载时进行一致性校验。任何拼接了不同版本片段的快照都应被视为非法,并拒绝上线。
五、全量 vs 增量编译,与兜底
对于同一份快照,系统既要求改动一条配置就能快速生效,又要求长期运行不产生漂移。生成侧给出的答案是:全量与增量两条腿走路。
全量与增量并存:增量(捕获变更、只重编译受影响订单项)保时效(秒级生效);全量(周期性重编译整份快照)保正确(消除增量累积的漂移、压缩空洞、对齐版本)。生产上通常增量为主、全量定期兜底对账。
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 后,编译侧并不会轮询整个数据库,而是订阅变更流:
- CDC / binlog(例如使用
Canal订阅MySQL binlog):捕获「哪张表的哪几行发生了变更」,并将变更事件投递至消息队列。这是最常见的增量触发源:真相源依然是MySQL,CDC只是将「变了什么」低延迟地暴露出来。 - 业务消息队列:后台在保存配置时,除了写库,还显式发送一条领域事件(例如「广告组 X 暂停」)。这种方式语义更清晰,但要求后台进行改造;而
CDC则对后台透明,实现零侵入。两者常常结合使用:CDC负责兜底防漏,领域事件提供清晰语义。
编译侧消费到变更事件后,执行第五节所述的增量编译,仅重算受影响的订单项,并打上新的 gv。
6.2 全量触发:定时 / 空洞率 / 异常兜底
全量重建的触发通常结合以下三类机制:
- 定时触发:例如每 1 至 2 小时执行一次兜底,将增量累积的漂移清零,实现版本对齐。
- 空洞率超阈值:当墓碑(被删除订单项留下的
position空洞)占比超过设定的水位线(如 20%)时,说明位图已被严重稀释,大量无效位正在被空算,此时触发一次全量压缩。 - 异常兜底:当增量链路出现错误,或版本对账发现严重漂移时,强制触发全量重建。
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 并发布一次」,绝不亲自将 artifact 扇出给 N 个节点。
- 扇出交给「为扇出而生」的分发层:通用首选对象存储加
CDN,超大集群可采用P2P,多机房则使用分层或就近镜像。这属于下游的分发与拉取范畴,手把手篇 §9、§10 会详细展开。
生成侧仅对「产出一份正确、版本化、可结构共享的 artifact 并落地」负责;而「将其可靠地铺到 N 个节点」则是下游分发层的职责。两者一旦耦合在一个服务中,编译服务就会被庞大的节点数量所牵制。
八、时效与一致性
生成侧落地后,还需要回答两个「跨时间」的问题(跨实例或跨机房的版本收敛属于下发侧,详见手把手篇)。
8.1 编译延迟:改了多久才生效
从「广告主点击保存」到「快照产出」之间的窗口期,即为编译延迟(下发延迟另算)。它由触发方式决定:CDC 配合增量编译通常能达到秒级,而定时全量则按设定的周期执行。高优先级变更通过第六节提到的快速通道来压缩这段窗口。有了版本脊柱,这段延迟就可以被精确观测:即从变更事件发生到新 gv 产出的耗时。
8.2 快照内部一致性
编译产出的每一份快照必须保证内部自洽:索引、订单项元数据、缺省语义口径必须来自同一次编译,且整体携带同一个 gv(见 4.2 节)。这是生成侧向下游交付的质量承诺:下游在装载时只需校验 gv 一致,就能确保「位图匹配出的 crId」与「元数据中获取到的 payload」永远处于同一版本。
8.3 和预算回灌方向相反,别混
最后需要厘清一个容易混淆的概念:本篇探讨的配置生成与下发是「控制面 → 数据面」(将规则编译给 Bidder);而Bidder 总览第六节中提到的预算 Pacing 回灌则是「数据面 → 控制面」(将真实的消耗数据收集回来,再重新回灌预算分片)。这两条链路方向相反,目的截然不同。切忌将实时余额或预算塞入配置快照中,那属于回灌链路的范畴。一旦混入,配置生成链路就会被高频的消耗变动拖垮,陷入无休止的高频重编译与下发中。
九、下游:快照怎么下发进 Bidder(交给手把手篇)
本篇的叙述止于「快照产出、落对象存储、登记版本指针」。快照如何被成百上千个 Bidder 实例可靠地拉取,并原子地装载进内存,属于手把手篇的范畴。这里仅将生成侧对下游的三条核心设计意图交代清楚,具体细节请移步:
- 编译 ≠ 分发(承接 7.2):生成侧只产出一份 artifact,扇出工作交给对象存储加
CDN/P2P/ 就近镜像。→ 详见手把手篇 §9(OSS 布局:不可变 blob + manifest 指针)。 - 推信号 + 拉数据:总线仅广播「变更集 + 版本 gv」(轻量信号),节点根据版本差去对象存储拉取编译好的片段(重度数据);配合「版本差对拉 + 周期全量」进行兜底。→ 详见手把手篇 §10(全量拉 / 增量 chunk delta / 冷启动兜底)。
- 原子装载:装载过程绝不能就地修改在线索引(这会导致读到「半新半旧」的数据,甚至撕裂位图),必须采用不可变快照结合
copy-on-write,通过一次引用切换,确保单次请求整段读取同一版本。→ 详见手把手篇 §8(增 / 改 / 删 / 全量统一抽象为「造新快照、换引用」)。
两篇文章的交接点正是这份版本化不可变快照:它是生成侧交付、下游消费的「契约对象」。生成侧负责保证它的正确性、自洽性并打上版本;下游则负责保证它被原子地、无遗漏地铺设到每一个节点。
十、审计、灰度与可观测性
- 审计:谁在何时修改了哪条配置、编译出了哪个版本——排障与合规审查都需要这条链路具备可追溯性(版本号使得「哪个版本」变得精确可查)。
- 灰度:新快照(尤其是涉及大改的编译逻辑或模型口径)应先进行小流量或影子验证,比对
win rate、成本及异常率,确认无误后再全量发布(呼应Bidder 总览中的灰度与影子流量机制)。 - 可观测性:生成侧至少需要监控以下几项指标:编译耗时、编译期校验失败数(如空集或非法配置)、产出版本
gv的推进节奏、全量兜底是否按期运行,以及配合下游的生效确认(后台改动是否真正在线上生效)。如果缺少「生效确认」,一次静默的下发失败会让广告主误以为配置已改,而实际上并未生效。
十一、实战验证:一个真实索引更新架构的对照
本节将使用一套脱敏后的真实 Bidder 索引更新架构进行对照:其骨架虽然正确,但在几条核心原则上却各有欠缺。
11.1 现状骨架:方向对
这套架构本质上就是「推信号 + 拉数据」模式的一个具体落地:
后台改配置 → 写库 → cache 服务:查库、更新缓存快照、记录"本时间片变更的创意集"
│
① 推信号:变更的创意 ID 经 Dubbo 广播到所有 Bidder 节点
② 拉数据:节点收到 ID → 到 cache 拉该创意的完整记录(已反范式、层级已合并)
③ 建索引:节点把记录写进本地"定位式位图倒排"
它具备两条更新通道和一条初始化链路:全量(启动时新建索引,建好后进行一次引用切换)、增量(收到广播后,就地在在线索引上增删位)、兜底(定时将「cache 记录的变更集」与「本节点实际收到的广播集」做差集,补拉漏收的数据)。其中,定位式位图倒排(位置表 + 定向值 → 位图)是一个亮点:删除一条创意只需清除对应的 position 位(关于这份数据结构如何工作,详见手把手篇)。
11.2 用原则体检:生成侧最欠缺的两条
| 原则 | 判定 | 差在哪 | 最优做法 |
|---|---|---|---|
| 编译前置 | 🟠 半符合 | 层级合并已前置,但「建倒排」在每个节点各做一遍 | 3.3 产出编译好的片段 + 全局 position |
| 版本化 | ❌ 基本不符合 | 创意维度无版本号,对账靠「时间片差集 + 出现次数启发式」 | 第四节 版本脊柱 |
| 全量兜底 | 🟠 有洞 | 运行时全量重建一度被关闭,增量漂移无处收敛 | 5.3 让全量兜底真正周期运行 |
| 原子装载(下游) | 🟠 半符合 | 全量走引用切换 ✅;增量在共享位图上原地改(下发侧问题,详见手把手篇) | 见手把手篇 §8 |
生成侧最欠缺的两处,恰好对应最容易被省去的两条原则:版本化完全缺席(连带导致回滚、生效确认都无法实现),以及全量兜底被关闭(增量漂移无处收敛)。至于「增量在共享位图上原地改」,那是装载侧的并发正确性问题,属于下游范畴,手把手篇中提供了完整的 copy-on-write 解法。
11.3 改造:把原则一条条补上
对照前文各节,这套架构在生成侧的演进路线非常清晰(策略是先应急止损,后彻底根治):
- P0 · 应急止损:恢复运行时的周期性全量重建(复用启动时的那套「新建 → 建好 → 切引用」逻辑)。→ 使得全量兜底转绿(5.3)。
- P1 · 版本脊柱:让快照与变更集携带
gv,对账机制从「时间片差集」升级为精确的「版本差」,节点上报版本,并增加落后告警与回滚功能。→ 使得版本化转绿(第四节)。 - P2 · 编译前置到底:让 cache 服务直接产出编译好的 key 集合或位图片段;配套实现全局稳定的
position。→ 使得编译前置转绿(3.3)。 - P3 · 下游原子性:增量修改采用
copy-on-write配合引用切换(属于装载侧,详见手把手篇)。
总结来说,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 等系统,手把手篇会进行详细对照。
十三、生产取舍:常见问题
- 让 Bidder 在节点上现建索引:这会导致编译逻辑耦合进在线二进制文件中,且 N 个节点重复计算。正解是将建索引也前置到编译侧,直接产出编译好的片段(见 3.3)。
- 没有版本号:会导致无法判断节点是否最新、无法精确对账、无法回滚以及无法进行生效确认。正解是为每份快照赋予一根版本脊柱(见第四节)。
- 全量重建只在启动时跑:这会导致增量漂移无处收敛,只能依靠重启来消除。务必确保运行时全量兜底机制真正周期性运行(见 5.3)。
- 缺省语义没在编译期定死:这会让运行时面对「未设」的歧义,从而造成错投或欠投。正解是在编译期将「未设」固化为具体集合(见 3.2)。
- 口径没归一就建索引:地域、币种、时区等口径与运行时对不上,会导致匹配结果时对时错,且极难复现。正解是在编译期完成归一化,并与运行时严格对齐(见 3.2)。
- 把编译和分发混在一个服务里:在整批发布时,N 个节点同时来拉取,会瞬间打满编译服务的带宽。正解是编译服务只负责发布一份 artifact,扇出工作交给专职的分发层(见 7.2)。
- 大快照直接塞进 Nacos:这会拖慢推送速度并压垮配置中心。正解是快照走对象存储,配置中心仅登记版本指针(见 7.1)。
- 把余额 / 实时预算塞进配置快照:高频的消耗变动会把配置生成链路拖入高频重编译与下发的泥潭。正解是预算走回灌链路,切忌混淆(见 8.3)。
- 高优先级变更走常规链路:暂停、预算耗尽或违规下线等操作延迟生效,直接意味着预算损失或合规事故。正解是让这类变更走快速通道(见 6.3)。
- 增量与全量抢着写同一份快照:如果增量任务与周期性全量重建没有互斥机制或版本序,后完成的旧全量可能会覆盖新增量的结果,造成「改了又回退」。正解是以
gv单调递增原则结合版本比较来决定谁能落地,旧版本一律丢弃(见第四节)。 - 对象存储最终一致导致读到半份:如果在分片写入对象存储后就急于登记版本指针,个别节点可能会读到尚未完全可见的分片。正解是必须先写完所有分片并校验完整(如核对分片数或校验和),然后再登记版本指针,让指针成为唯一的「就绪」信号(见 7.1、8.2)。
本篇止于「快照产出并落入对象存储」。至于这份快照如何被成百上千个节点可靠拉取并原子装载,请继续阅读下一篇:手把手走一遍定位式位图倒排。
延伸阅读
- 本站. 手把手走一遍定位式位图倒排:本篇的下游配套——本篇产出的那份快照,怎么组织成「定位式位图倒排」、增删改怎么在位图上落、以及 OSS 布局与全量 / 增量拉取装载进 Bidder 的完整流程。
- 本站. 程序化广告投放层级与定向配置:本篇的输入——配置树怎么分层、定向怎么跨层合并、缺省语义怎么约定。
- 本站. Bidder 定向过滤(Targeting Filter):本篇产物的最终消费者——编译好的快照如何在毫秒级被请求级 gating 与候选级布尔匹配消费。
- 本站. Bidder 竞价服务架构设计(总览):只读只算、状态靠回灌、原子切换,以及版本一致性与跨数据中心的完整背景。
- 本站. Bidder 解析层(Parse):口径归一化的同源问题——编译期的归一必须与解析层对齐。
规范与一手资料:
- RoaringBitmap. Roaring Bitmaps:可投快照里做定向求交、支持不可变视图与结构共享的核心数据结构。
- Apache. Canal:订阅 MySQL binlog 的 CDC 组件(增量触发源的一种实现)。
- Alibaba. Nacos:服务发现与配置中心(承载版本指针的一种实现)。
- Apache. Dubbo:内部服务间 RPC 与广播(推信号通道的一种实现)。
- ISO. ISO 3166 Country Codes:跨市场地域口径归一的标准编码。
业界同类范式(第十二节对照):
- Google. TensorFlow Serving:版本化 servable + 原子切换 + 版本策略,「在线只 load、切换即翻版本」的经典实现。
- LinkedIn. Venice:batch push 产出新 store-version、翻
current指针、留 backup 回滚,且并存 nearline 增量——全量 + 增量 + 版本化的工业级样板。
附录:行业黑话总结
控制面与数据面;近线编译;有效定向(effective targeting);缺省语义定型;编译前置;全局稳定 position;版本脊柱(version spine);索引快照(index snapshot);全量与增量编译;增量触发;全量触发;快速通道;编译 ≠ 分发;真相源(source of truth);CDC(Change Data Capture);生效确认。