本文属于 Bidder 竞价服务架构 系列(配置面 · 生成一篇)。
层级配置篇讲了广告主在投放后台维护的那棵四层配置树(广告主 → 计划 → 广告组 → 创意),定向过滤篇讲了 Bidder 在运行时怎么毫秒级匹配那份定向规则。但中间缺了一环:那棵人工维护的配置树,是怎么被编译成一份版本化的只读索引快照的? 本篇专讲这条生成链路——从后台配置到索引快照:怎么编译、怎么被触发、依赖哪些组件与服务、设计上要做哪些取舍。至于快照如何被下发、拉取、装载进 Bidder 内存,属于手把手篇的范畴,本篇只交到「快照产出并落对象存储」为止。
读者与前置:面向做过或想做在线服务配置分发的工程师 / 架构师。读过层级配置篇与 Bidder 总览会更顺,但非必需——本篇自成一体。
层级配置篇结尾埋了一个钩子:配置从不直接喂给在线的 Bidder。广告主在后台配的是一棵结构复杂、随时改动的树;Bidder 要的是一份能在 20ms 里被反复求交的、扁平的只读数据结构。这两者之间隔着一条编译链路——它把「源码」(配置树)编译成「可执行文件」(版本化索引快照)。本篇不满足于「能跑」,而是把这条生成侧链路做到位:这份快照该由谁、在什么时机、经哪几步、算成什么样。
本篇的边界:上游是层级配置篇那棵配置树,下游是手把手篇的「快照 → OSS → Bidder」下发装载。本篇只讲夹在中间的那台「编译器」:把树编译成一份版本化不可变快照,并把它稳稳地落进对象存储。
TL;DR
- 配置绝不直接喂给在线 Bidder:配置树是「人肉维护、低频变更、结构复杂」的控制面对象;Bidder 是「高并发、低延迟、只读只算」的数据面服务。中间必须隔一台编译器,把树一次算成版本化快照,否则慢 IO 进关键路径、每请求重复爬树、DB 被压垮。
- 本篇只讲生成侧(后台配置 → 索引快照):编译四步 + 触发机制 + 组件依赖 + 设计取舍。快照怎么下发拉取装载进 Bidder,见手把手篇。
- 编译前置:把层级合并、口径归一 + 缺省定型、建倒排 + 位图一次算到底,甚至连全局稳定 position 都在近线定好、直接产出编译好的索引片段——数据面只装载、零编译。
- 版本化:给每份快照一根全局递增的版本脊柱
gv,一根版本号兑现四件事——判断是否最新、版本差对账、生效确认、一键回滚。 - 全量 vs 增量编译:增量只重编译受影响订单项(保时效、秒级生效),全量周期性重编译整份快照(保正确、消漂移、压空洞),两条腿并用。
- 触发机制:增量走 CDC / binlog / 消息队列(配置一改就近线触发)、全量走定时 / 空洞率超阈值 / 异常兜底,高优先级变更(暂停 / 预算耗尽 / 违规下线)走快速通道。
- 组件依赖:MySQL(真相源)→ CDC/Canal(变更捕获)→ 近线编译服务(编译器本体)→ 注册 / 配置中心(承载版本指针)→ 对象存储(承载大 blob)。核心边界是编译 ≠ 分发:编译是低频近线服务,绝不亲自把大产物扇出给 N 个节点。
- 方向要分清:配置下发是控制面 → 数据面(规则);预算消耗是数据面 → 控制面(回灌)。别把余额塞进配置快照。
- 最后有一节实战验证:用一个脱敏的真实索引更新架构做对照——它骨架正确、却在编译前置与版本化上各有欠缺,正好反证这两条在生成侧为什么是「最优」的必要条件。
Table of contents
Open Table of contents
一、控制面 vs 数据面:为什么配置要先编译成快照
最直接的疑问是:既然配置都在 MySQL 里,Bidder 匹配时直接查库、或者启动时把树读进来现算,不就行了?不行——这会同时违背 Bidder 总览第五节数据面第一性原理的每一条:
- 慢 IO 进关键路径:在线路径上查一次 MySQL / 爬一次配置树,就是几毫秒甚至更久的同步 IO,直接吃掉本就只有 20ms 的预算。
- 每请求重复做同一件事:层级合并(逐维求交、排除累加)对每个请求都是一样的结果,却要对每个请求重算一遍——纯粹的浪费,还被 QPS 放大。
- DB / 配置源被压垮:十万至百万 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 口径归一 + 缺省语义定型:消灭一切「未设」歧义
展开出「有效定向」还不够——它必须用和运行时完全一致的口径表达,且不能留任何「未设」的歧义。
- 口径归一:地域、设备、币种统一到内部口径(与解析篇 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 个节点各做一遍,编译逻辑也不再耦合进 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」 | 出了事只能靠重启和运气(第八节) |
下游的「拉取兜底」之所以能从「差集猜漏」升级成「版本差精确对拉」、「回滚」之所以是一次指针操作,靠的都是这根脊柱(下发侧怎么用它,见手把手篇)。版本化不是可选项,是生成侧交给下游的第一件硬通货。
4.2 同一份快照内部也要版本自洽
版本号不只用于快照之间,也约束快照内部:同一份快照里,索引、订单项元数据、缺省语义口径必须来自同一次编译,不能新索引配旧元数据(呼应总览「模型与召回的版本一致性」)。做法是让编译产物整体带同一个 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);而总览第六节的预算 Pacing 回灌是「数据面 → 控制面」(把真实消耗收回来、再回灌预算分片)。两条链路方向相反、目的不同。别把余额 / 实时预算塞进配置快照——那属于回灌链路,混进来会让配置生成被高频的消耗变动拖成高频重编译与下发。
九、下游:快照怎么下发进 Bidder(交给手把手篇)
本篇止于「快照产出、落对象存储、登记版本指针」。快照如何被成百上千个 Bidder 实例可靠地拉取、原子地装载进内存,属于手把手篇的范畴。这里只把生成侧对下游的三条设计意图交代清楚,细节请移步:
- 编译 ≠ 分发(承接 7.2):生成侧只产出一份 artifact,扇出交给对象存储 + CDN / P2P / 就近镜像。→ 手把手篇 §9(OSS 布局:不可变 blob + manifest 指针)。
- 推信号 + 拉数据:总线只广播「变更集 + 版本 gv」(轻),节点按版本差去对象存储拉编译好的片段(重);配「版本差对拉 + 周期全量」兜底。→ 手把手篇 §10(全量拉 / 增量 chunk delta / 冷启动兜底)。
- 原子装载:装载不能就地改在线索引(会读到「半新半旧」甚至撕裂位图),必须不可变快照 + copy-on-write + 一次引用切换,单请求整段读同一版本。→ 手把手篇 §8(增 / 改 / 删 / 全量统一成「造新快照、换引用」)。
两篇的交接点:版本化不可变快照,就是生成侧交付、下游消费的那份「契约对象」——生成侧保证它正确、自洽、带版本;下游保证它被原子地、无遗漏地铺到每个节点。两篇正好在这份快照上交接。
十、审计、灰度与可观测性
- 审计:谁在何时改了哪条配置、编译出哪个版本——排障与合规都需要这条链路可追溯(版本号让「哪个版本」变得精确)。
- 灰度:新快照(尤其涉及大改的编译逻辑或模型口径)先小流量 / 影子验证,比对 win rate、成本、异常率,再全量(呼应总览灰度与影子流量)。
- 可观测性(生成侧至少盯这几项):编译耗时、编译期校验失败数(空集 / 非法配置)、产出版本
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 中间层、推信号 + 拉数据、定位式位图、变更真相源)都对,生成侧缺的是「版本脊柱」和「真正运行的全量兜底」。 这也正是前面那版最优设计存在的意义——它并非纸上谈兵,而是把真实系统里最容易失效的环节一一补齐。
十二、业界参照:通用范式与代表系统
前面这套设计并非自创。把「离线 / 近线把配置编译成一份大只读数据、并版本化地发布」这个问题拿到业界看——无论是搜索索引、广告定向索引、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 在节点上现建索引:编译逻辑耦合进在线二进制、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)。
十四、速查表
- 一句话:把配置树编译成版本化的不可变快照、落对象存储;在线只运行、不编译;下发装载见手把手篇。
- 编译前置:层级合并(→ 订单项有效定向)→ 口径归一 + 缺省定型(未设落成具体集合)→ 建倒排 + 位图 + 全局稳定 position(产出编译好的片段)。
- 版本化:一根
gv兑现四件事——判断最新 / 版本差对账 / 生效确认 / 一键回滚;同一份快照内部也要版本自洽。 - 全量 / 增量:增量只重编译受影响订单项(保时效);全量周期重建整份(保正确、消漂移、压空洞);全量兜底务必真正运行。
- 触发:增量走 CDC / binlog / MQ;全量走定时 / 空洞率 / 异常兜底;高优先级变更走快速通道。
- 组件依赖:MySQL(真相源)→ CDC/Canal → 编译服务 → 注册 / 配置中心(版本指针)→ 对象存储(大 blob);核心边界是编译 ≠ 分发。
- 时效:编译延迟可观测;高优先级走快速通道。
- 方向:配置下发 = 控制面 → 数据面;预算回灌 = 数据面 → 控制面(别把余额塞进快照)。
- 可观测:编译耗时、校验失败数、
gv推进节奏、全量兜底是否按期、生效确认。 - 边界:本篇止于「快照落对象存储」;扇出 / 拉取 / 原子装载见手把手篇。
配置落地的生成侧,本质是一次编译:把广告主人工维护的、结构复杂的配置树,编译成一份 Bidder 能在 20ms 里反复求交的扁平只读快照,贴上版本号,稳稳落进对象存储。做到「最优」,靠的是四件事——编译前置(在线零重活)、版本化(分布式一根锚)、全量 / 增量(既快又准)、清晰的触发与组件边界(编译 ≠ 分发)。它承上启下:上承层级配置篇的「树怎么组织」,下接手把手篇的「快照怎么下发装载」、再到定向过滤篇的「快照怎么被毫秒匹配」。
延伸阅读
- 手把手走一遍定位式位图倒排:本篇的下游配套——本篇产出的那份快照,怎么组织成「定位式位图倒排」、增删改怎么在位图上落、以及 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 增量——全量 + 增量 + 版本化的工业级样板。
附录:术语表
- 控制面 / 数据面:配置的组织与编译下发(离线 / 近线)/ 在线竞价决策(关键路径);本篇是两者之间的「编译器」。
- 近线编译:把配置树编译成索引快照的过程(层级合并 → 口径归一 + 缺省定型 → 建倒排索引 + 位图 + 全局 position → 打版本号)。
- 有效定向(effective targeting):配置树逐层合并后、每条订单项真正用于匹配的扁平定向集合。
- 缺省语义定型:编译期把「未设 = 不限 / 全不通 / 继承」落成一个确定的具体集合,消除运行时歧义。
- 编译前置:凡「对所有请求结果都一样」的计算全部挪到离线 / 近线算好,在线零编译;最优形态连建索引与 position 分配都前置。
- 全局稳定 position:由编译侧统一维护的
订单项 ID → 位图位映射,各节点一致,使编译好的位图片段可跨节点通用。 - 版本脊柱(version spine):贯穿快照 / 下发 / 对账 / 回滚的全局递增版本号
gv;判断是否最新、版本差对拉、节点版本上报、一键回滚都靠它。 - 索引快照(index snapshot):索引 + 订单项元数据打包、带版本号的不可变只读数据结构,是生成侧交付、下游装载的契约对象。
- 全量 / 增量编译:重编译整份快照(保正确、消漂移、压缩空洞)/ 只重编译受影响订单项(保时效)。
- 增量触发:由 CDC / binlog / 消息队列驱动,配置一改就近线触发增量编译。
- 全量触发:由定时 / 空洞率超阈值 / 异常兜底驱动,周期性重建整份快照。
- 快速通道:高优先级变更(暂停 / 预算耗尽 / 违规下线)走的独立、更短、更高优先级的编译下发链路。
- 编译 ≠ 分发:编译是低频近线服务(负载由变更频率决定),只发布一份 artifact;扇出交给 CDN / P2P / 分层镜像(下游,见手把手篇)。
- 真相源(source of truth):投放后台维护的 MySQL 配置库;快照只是它的只读派生物,坏了能从这里重建。
- CDC(Change Data Capture):订阅数据库变更日志(如 MySQL binlog)把「变了什么」低延迟推送给下游的技术(如 Canal)。
- 生效确认:校验后台改动是否真的在线上生效的观测手段(节点上报版本),防止静默下发失败。