Skip to content
Charles Shao
Go back

手把手走一遍定位式位图倒排:4 条创意的增删改查 + OSS 全量/增量下发

Updated:
views

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

配置落地生成篇讲了「配置树怎么被近线编译成一份版本化索引快照、怎么触发、依赖哪些组件」——但它止于快照落进对象存储,把「快照怎么下发装载进 Bidder」留给了本篇。而落到代码上,很多人还是会问:那份「定位式位图倒排」到底长什么样?增一条、改一条、删一条创意时,位图具体怎么变?copy-on-write 每一步换的又是什么? 本篇就接着往下走,动手走一遍——用 4 条创意贯穿全程,把查询、增、改、删、全量重建、COW 切换一步步做出来,再把 OSS 布局与全量/增量拉取装载流程接上。

生成篇第九节把「下发装载」交给了本篇,并点出「下游要把全量与增量统一成『产出新快照、换引用』」;第十一节的实战对照又夸了「定位式位图删除零成本」。这两句话正是本篇的主角。我们不铺理论,直接开一个只有 4 条创意的小索引,把它的一生走完——你会看到每一次增删改,本质上都是同一个动作

本篇全景:一份不可变快照的一生。编译侧近线产出四张表 + 全局版本 gv 得到快照 v1,随后增 C5 → v2、改 C2 → v3、删 C4 → v4(留空洞)、全量重建 → vN(压缩空洞),每一步都是 copy-on-write 造新快照 + 一次引用切换;快照经 OSS 下发(全量拉 + 增量 chunk delta)到 Bidder 内存 activeRef,供竞价只读求交 本篇全景:4 条创意的索引经增 → 改 → 删 → 全量重建演化出一串版本化快照,每次变更都是造新快照 + 一次引用切换(COW),再经 OSS 全量 / 增量下发到 Bidder。全文就是把这条脊柱拆开一步步走。

TL;DR

Table of contents

Open Table of contents

一、先把索引的四张表摆出来

Bidder 内存里那份「定向索引」,最实用的一种组织方式叫定位式位图倒排(positional bitmap inverted index)。它由三张倒排 / 定位表 + 一张 payload 表组成,核心思想是:给每条创意分一个稳定的整数下标(position),所有位图都按 position 编址。

类型含义
indexMapMap<String, BitSet>定向值 → 位图。key 如 geo:USos:iOS;位图第 p 位 = 1 表示「position=p 的创意命中该定向值」
positionTableList<Integer>(下标即 position)position → 创意 ID。把求交后剩下的置位还原成 crId
indexPositionMapMap<Integer, Integer>创意 ID → position。反查一条创意占了哪个位
totalMapMap<Integer, Creative>创意 ID → payload(素材、出价、预算引用……),匹配后取物料用

前三张表的关系是两级间接:定向值先映射到一堆 position(位图),position 再映射到创意 ID。为什么不让位图直接存创意 ID?因为位运算要求连续、紧凑的整数下标——position 就是这个「内部下标」,创意 ID 则可能是稀疏的大整数。这层间接是位图能做毫秒级求交的前提。第四张 totalMap 站在这条链路之外,只按 crId 存物料(§1.2 细说)。

一句话记住indexMap 管「谁命中」(按位),positionTable 管「位是谁」(还原),indexPositionMap 管「我在第几位」(反查),totalMap 管「这条创意长什么样」(取物料)。

四张表关系图:请求经 indexMap 位图 AND 求交得 position,用 positionTable 还原成候选 crId,再用 totalMap 取物料;旁路一条写路径用 indexPositionMap 反查创意占的位 四张表如何协作:查询路径(蓝→橙→绿→黄)从请求出发,indexMap 求交得 position、positionTable 还原成 crId、totalMap 取物料;写路径(紫)在增 / 改 / 删时用 indexPositionMap 反查创意占的位。totalMap 站在求交链路之外,只按 crId 存物料。

想直接看动手走查的读者,可以先跳过 §1.1、§1.2 这两段深挖,直接从「第二节 开局」看起,回头再补。

1.1 positionTableindexPositionMap 明明互为逆映射,为什么两张都要留?

既然 positionTable(position → crId)和 indexPositionMap(crId → position)互为逆映射,一张不就能推出另一张、省掉一张吗?理论上能推导,工程上不该省——它们服务于方向相反、路径不同的访问:

方向用在哪条路径频率
positionTableposition → crId查询热路径AND 完遍历置位,把每个 bit 还原成 crId每请求 × 每个候选,极高频
indexPositionMapcrId → position写路径:改 / 删时定位某条创意占的位低频(配置变更时)

一句话:这是空间换时间的双向索引——多存一张 int 数组只花几 MB,却让查询与写两条方向相反的路径都保持 O(1),还顺带承载了墓碑与位宽信息。在 Bidder 这种「读极热、写低频」的场景里,这笔交易毫无悬念。

1.2 totalMap 存在哪里、该存什么不该存什么

totalMap 有三个层面的「存在哪里」,别混:

  1. 运行时——每个 Bidder 节点的进程内存(JVM 堆)。它和 indexMappositionTable 一样,是内存里那份 IndexSnapshot 的一个字段,只读、随 COW 一起原子切换(§8)。竞价求交拿到 crId 后就地 totalMap.get(crId) 取物料拼 Bid Response——在线路径零 IO
  2. 编译 / 分发形态——对象存储里的 meta.bintotalMap 不是节点自己算的,是编译侧近线产出、存进 OSS 的一个 chunk(§9 的 v/{gv}/meta.bin),节点冷启动全量拉、运行时增量拉后装进内存。真相源仍是控制面的 MySQLmeta.bin 只是它编译后的只读产物。
  3. 该进 / 不该进——这是最容易踩坑的一点。totalMap 只装相对静态、匹配 / 响应时要用的 payload,且大素材只存引用不存本体:
该进 totalMap(静态 payload / 指针)绝不进 totalMap(高频可变状态)
创意素材的 URL / 素材 ID(图片、mp4 本体在 CDN,不塞进内存)实时预算余额 / pacing 分片 → 走预算回灌链路、本地分片
出价策略参数、落地页、频控配置频次实时计数 → Redis / 本地计数(见定向过滤篇
budget_ref(一个指针,指向预算,不是余额本身)任何「每次曝光都在变」的数字

一句话totalMap 存「这条创意长什么样」的静态描述(含指向 CDN 素材与预算服务的引用),存在节点内存的只读快照里、由 OSS 的 meta.bin 下发;而「已经花了多少 / 曝光了几次」这类实时状态不在 totalMap,走方向相反的回灌链路(见生成篇 8.3「别把余额塞进配置快照」)。把余额塞进 totalMap,等于让高频消耗变动打成高频快照下发——直接压垮下发链路。

二、开局:4 条创意的初始快照 v1

上四条创意,只用两个定向维度(geo、os)把例子压到最小。假设它们的有效定向(跨层合并、口径归一、缺省定型都已在编译期做完,见生成篇 3.1~3.2)如下:

crId 101 (C1):geo={US}      os={iOS}
crId 102 (C2):geo={US}      os={Android}
crId 103 (C3):geo={TH}      os={iOS}
crId 104 (C4):geo={US, TH}  os={iOS}

编译侧按稳定顺序给它们分配 position(全局稳定 position,见生成篇 3.3):

position:   0     1     2     3
crId:      101   102   103   104

于是三张定位 / 倒排表加一张 payload 表长这样。

先说清位串怎么读:本篇位图一律从左到右按 position 0,1,2,3… 排列(最左是 position 0)。例如 1101 读作「position 0、1、3 置位,position 2 为 0」——这不是二进制数值,和平时「最高位在左」的写法相反,只是把 BitSet 的第 0…n 位平铺出来看。

positionTable    = [101, 102, 103, 104]              # position → crId
indexPositionMap = {101:0, 102:1, 103:2, 104:3}      # crId → position

indexMap:                                            # 定向值 → 位图
  geo:US       -> 1101   (bit0,1,3 → C1,C2,C4)
  geo:TH       -> 0011   (bit2,3   → C3,C4)
  os:iOS       -> 1011   (bit0,2,3 → C1,C3,C4)
  os:Android   -> 0100   (bit1     → C2)

totalMap = {101:C1, 102:C2, 103:C3, 104:C4}          # crId → payload(素材/出价/预算引用)

注意 totalMap 按 crId 编址、与 position 和定向都无关——它只回答「这条创意长什么样」,不参与求交。这个「定向索引管匹配、totalMap 管物料」的分离,是后面增删改里 payload 逻辑能和位图逻辑解耦的关键(尤其是第五节「仅改 payload」那种最便宜的更新)。

这就是快照 v1gv=1)。它是不可变的——后面每一次改动都不会原地改它,而是造一份新的。

三、查询:一次竞价请求怎么在位图上求交

来一个竞价请求:geo=US, os=iOS。匹配就是把请求命中的各维位图 AND 起来:

        geo:US   = 1101
    AND os:iOS   = 1011
    ─────────────────────
        候选      = 1001   → 置位在 position 0 和 3

positionTable 还原:position 0 → crId 101,position 3 → crId 104。候选 = {C1, C4}

验证一下语义对不对:C1(US/iOS)命中 ✅、C4(US或TH / iOS)命中 ✅;C2(US/Android)被 os 挡掉、C3(TH/iOS)被 geo 挡掉。完全正确。多维、百万候选时,这一串 AND 就是毫秒级位运算——这正是位图倒排的价值。

匹配语义:维度内 OR、维度间 AND。 C4 定向 geo={US,TH},所以它同时出现在 geo:USgeo:TH 两个 posting 里——请求只要命中其中一个就算过 geo 关(维度内 OR);而 geo 和 os 两个维度必须都过,所以是维度间 AND(两个位图 AND)。若请求本身在某维带多个值(如可投多个 bundle),就先把该维这几个值的位图 OR 起来、再和其它维 AND

关于「未设定向」:这里每条创意的 geo/os 都显式设了值。真实系统里「某维未设 = 不限」这类缺省,会在编译期被展开成一个显式的全量集合或通配位(见生成篇 3.2定向过滤篇的「缺省陷阱」),运行时不再面对「未设」歧义。本篇为聚焦增删改,略去这层展开。

四、增:第 5 条创意上线(追加一个 position)

新广告主上线 C5(crId 105):geo={JP}, os={Android}

定位式索引加一条创意的做法是在末尾追加一个新 position,只碰它的有效定向 key:

① 先把 payload 落进 totalMap(keyed by crId,与 position 无关):
   totalMap[105] = C5           ← 先有 payload,再置位;否则可能匹配到"有位、没物料"的创意

② 分配 position 4,登记两张定位表:
   positionTable    = [101, 102, 103, 104, 105]
   indexPositionMap = {..., 105:4}

③ 只往 C5 的有效定向 key 的位图里置第 4 位:
   geo:JP       -> 00001   (新 key,只有 bit4)
   os:Android   -> 01001   (原 0100 → 追一位变 01001:C2 + C5)

④ 其余 key(geo:US / geo:TH / os:iOS)只是长度补零,置位不变。

两个关键点:① 增一条创意只新增/触碰了两个定向 keygeo:JPos:Android)加一个 totalMap 条目,其它位图逻辑上没变——这是下一节 COW 的地基,没变的部分直接结构共享、不复制;② 顺序上先落 totalMap 再置位totalMap.putindexMap 置位之前),保证任何「可被匹配到的 position」背后都已有 payload,不会还原出一个查不到物料的 crId。

五、改:先分清改的是「定向」还是「payload」

「改一条创意」其实分两类,代价天差地别——先分清,再动手:

改了什么要碰哪些表代价
仅 payload(出价、素材、预算引用……,定向没动)totalMap.put(crId, 新payload)最便宜:位图 / positionTable 一律不碰
改了定向(geo / os / …)indexMap 清位 + 写新(+ 若 payload 也变则一并 totalMap.put要重建受影响 key 的位图

这就是 §2 那句「定向索引管匹配、totalMap 管物料」的红利:因为 totalMap 按 crId 编址、与 position 和定向无关,改出价、换素材这类最高频的更新根本不进倒排——换掉 totalMap 里一个条目就完事,几十 MB 位图纹丝不动。只有定向变了才需要动位图。别把这两类混成一条路径全走位图重建。

下面走改了定向这条(更难的一条)。广告主把 C2(crId 102, position 1) 的 os 从 Android 改成 iOS(geo 不变、payload 也没变,所以 totalMap 这次不动)。

改一条创意的定向,最干净的做法是先清后写——把它的 position 从旧的有效定向 key 里清掉,再往新的有效定向 key 里置位。定位式结构的妙处在于:「清旧」不需要知道它旧的定向值具体是什么,只要把它的 position 位从相关位图清 0 即可(等价于「先把这条创意从索引里抹掉,再按新定向重新加回同一个 position」):

两点先说清:① 下面的「清位 / 置位」只是心智模型——线上绝不在在线位图上原地改(那会让并发读到半新半旧),真实做法是 copy-on-write 造新快照再一次切换,见 §8,所以中间态对读者永不可见。② 这里为讲清楚「先清后写」演示的是「清掉再全量写回所有有效定向 key」;工程实现通常只 diff 变化的维度(本例 geo 没变,可跳过对 geo:US 的清 + 写,只动 os 两个 key)——效果等价、更省。

C2 的 position = 1(查 indexPositionMap 得到)
把它当成"删了 C2、再按新定向把 C2 加回同一个 position=1":

① 清位:把第 1 位从所有位图清 0(抹掉旧的 C2 的一切痕迹)
   geo:US       11010 → 10010   (清 bit1)
   os:Android   01001 → 00001   (清 bit1)
   os:iOS       10110 → 10110   (本就没有 bit1,不变)

② 写新有效定向 {geo:US, os:iOS},把第 1 位置回去
   geo:US       10010 → 11010   (置 bit1)
   os:iOS       10110 → 11110   (置 bit1)

改完后的 indexMap(position 4 = C5 那一列也在):

  geo:US       -> 11010   (bit0,1,3 → C1,C2,C4)      不变
  geo:TH       -> 00110   (bit2,3   → C3,C4)          不变
  geo:JP       -> 00001   (bit4     → C5)             不变
  os:iOS       -> 11110   (bit0,1,2,3 → C1,C2,C3,C4)  ← C2 加入
  os:Android   -> 00001   (bit4     → C5)             ← C2 移除

清旧为什么不用反向映射? 因为要抹掉 position 1 的旧痕迹,只需清各位图的第 1 位——position 是稳定的、全局唯一的坐标。如果索引是「定向值 → 创意 ID 列表」那种非定位式结构,改一条就必须先知道「它旧的定向值有哪些」才能删干净,否则残留脏 posting、造成定向漂移(这正是生成篇一开始担心、后来被定位式结构优雅绕过的坑)。

六、删:下线一条创意(墓碑清位、留空洞)

广告主下线 C4(crId 104, position 3)。删除是三种操作里最能体现定位式优势的:把这条创意的 position 位从所有位图清 0,再把 positionTable 的这一格标记成墓碑(tombstone)、不复用。

C4 的 position = 3

① 清位:把第 3 位从所有位图清 0
   geo:US       11010 → 11000   (bit0,1 → C1,C2)
   geo:TH       00110 → 00100   (bit2   → C3)
   os:iOS       11110 → 11100   (bit0,1,2 → C1,C2,C3)

② 墓碑标记(不复用该位):
   positionTable[3]    = ⊥ (dead)
   indexPositionMap 删除 104

③ 最后从 totalMap 移除 payload:
   totalMap.remove(104)         ← 在清位之后;否则可能还原出一个 payload 已消失的 crId

顺序上先清位、后删 payload(和「增」正好相反):先让这条创意不再被任何位图匹配到,再抹掉它的物料,中间就不会出现「匹配到了、却查不到 payload」的窗口。删除的代价是 O(受影响 key 数)——甚至可以进一步用一个「pos → 该位出现在哪些 key」的辅助结构把它压到只碰真正含该位的 key。无需维护「创意 → 它占了哪些定向值」这种额外反向映射,也不用挪动其它创意的 position(位置稳定是硬约束)。代价是留下一个空洞(position 3 永久作废,直到全量重建才回收)。

再查一次 geo=US, os=iOS 验证增改删都自洽了:

        geo:US   = 11000
    AND os:iOS   = 11100
    ─────────────────────
        候选      = 11000   → position 0, 1 → crId 101, 102

候选 = {C1, C2}。C4 已下线(对)、C2 因为第五节改成了 iOS 现在能命中(对)、C3 是 TH 被 geo 挡掉(对)。三步改动叠加后结果依然正确。

七、全量重建:压缩空洞、重排 position、打新版本

增改删跑久了,positionTable 会攒下一堆墓碑空洞(这里是 position 3)。全量重建周期性地把活着的创意重新紧凑编号,顺带对齐版本、消除增量漂移(见生成篇 5.2):

当前存活创意:101, 102, 103, 105(104 已墓碑)
重排紧凑 position:
   position:   0     1     2     3
   crId:      101   102   103   105     ← 空洞消失,105 从 4 挪到 3

重新建 indexMap(从存活创意的有效定向从零建):
   geo:US       -> 1100    (C1,C2)
   geo:TH       -> 0010    (C3)
   geo:JP       -> 0001    (C5)
   os:iOS       -> 1110    (C1,C2,C3)
   os:Android   -> 0001    (C5)

重建 totalMap(只保留存活创意的 payload):
   totalMap = {101:C1, 102:C2, 103:C3, 105:C5}   ← 104 的 payload 一并清除,不留残骸

打新全局版本:gv = N(比如从 1 一路增量到某个号,全量后取一个更大的 gv)

全量重建把四张表整份从存活创意从零建positionTable / indexPositionMap 重排紧凑、indexMap 重算、totalMap 只装存活 payload。增量删除时 totalMap.remove 已经把死 payload 摘了,但全量重建是兜底的一遍——即使某次增量漏删了一条 payload,全量会以「存活创意全集」为准把它彻底清掉,这也是全量作为「正确性锚」的一部分。

什么时候触发全量重建? 三类触发条件常并用:① 定时(如每 1~2 小时兜底一次);② 空洞率超阈值——墓碑占 positionTable 的比例超过某个水位(如 20%),说明位图被删稀了、白算了很多死位,该压缩;③ 异常兜底——增量链路出错、版本对账发现漂移时强制重建。

全量重建成本高、频率低,但它是正确性的锚:把增量长期累积的任何漂移一次性归零、把空洞压掉、让所有节点能对齐到同一个干净版本。它和增量是「两条腿」——增量保时效(秒级生效),全量保正确(见生成篇 5.2/5.3)。

八、把四步统一起来:每一次改动都是 COW「造新快照、换引用」

前面为了讲清楚位图怎么变,说的是「清位」「置位」这种就地语言。但线上绝不能真的原地改在线位图——竞价线程正在无锁读它,原地改会读到「半新半旧」甚至撕裂的位图(生成篇 §9 下游点出这条设计意图,细节在本节)。真实的做法是 copy-on-write(写时复制)

// 不可变快照:竞价线程一次抓引用,整段求交 + 取物料都在同一版本上
final class IndexSnapshot {
    final Map<String, ImmutableBitmap> indexMap;   // 定向值 → 不可变位图
    final int[] positionTable;                      // position → crId
    final Map<Integer, Creative> totalMap;          // crId → payload(条目不可变,改即换新对象)
    final long version;                             // ← 版本脊柱 gv
}

// 增/改/删:浅拷贝两张"引用表"(微秒级,不动位图 / payload 本体),只重建受影响的部分
IndexSnapshot applyDelta(IndexSnapshot cur, Delta d) {
    Map<String, ImmutableBitmap> nextIdx = new HashMap<>(cur.indexMap); // 浅拷贝引用
    Map<Integer, Creative>       nextTot = new HashMap<>(cur.totalMap); // 浅拷贝引用
    for (String key : d.affectedKeys())            // 只碰受影响的 key(仅改 payload 时为空)
        nextIdx.put(key, rebuild(key, d));          // 造新位图替换旧引用
    d.applyPayload(nextTot);                        // 增/改→put(crId,新payload);删→remove(crId)
    return new IndexSnapshot(nextIdx, d.newPositionTable(cur), nextTot, cur.version + 1);
}

activeRef = applyDelta(activeRef, delta);           // 一次 volatile 写,原子切换

「仅改 payload」在这段代码里会自然退化d.affectedKeys() 为空、positionTable 也不变,applyDelta 就只剩「浅拷贝 totalMap + put 一个条目」,倒排位图整份结构共享——这正是第五节说的最便宜更新。

对照前几节,每种操作换到 COW 视角是:

操作受影响的定向 keytotalMap复制了什么结构共享了什么
仅改 payloadput 1 条只换 totalMap 一个条目全部位图
增 C5geo:JP(新)、os:Androidput 1 条2 个 key 的新位图 + 1 条 payload其余所有 key 的位图
改 C2 定向geo:USos:iOSos:Android不变(payload 没动)这 3 个 key 的新位图其余 key + 整张 totalMap
删 C4含 position 3 的那几个 keyremove 1 条那几个 key 的新位图其余 key
全量重建全部整份重建整份新快照(空闲 buffer 里建)无(本就是从零重建)

于是增、改、删、全量重建统一成同一个动作:产出一份新的不可变快照、做一次引用切换。区别只是「复制了多少」——增量只复制少数几个受影响 key 的位图引用 + 个别 payload 条目(几十 MB 位图本体纹丝不动),全量则整份新建。切换那一刻靠一次 volatile 写完成,竞价线程要么读到全旧、要么读到全新,永不半份;旧快照只要还有在途请求引用着就不回收(生成篇 §9 下游把这条「原子装载」交给了本篇)。

索引与 payload 的版本一致性是「免费」的:因为 indexMappositionTabletotalMap 同属一份 IndexSnapshot、一次引用切换一起生效,竞价线程抓到的快照里,「位图匹配出的 crId」和「totalMap 里取到的 payload」永远来自同一个 gv,绝不会出现「新位图配旧物料」(呼应生成篇 4.2「同一份快照内部版本自洽」)。

这就是那句「全量与增量统一为『产出新快照、换引用』」落到代码上的样子。 定位式位图 + 不可变快照 + COW,三者合起来让「改一条创意」既不脏(原子)、又不贵(只碰受影响 key)、还好删(清 position 位)。

九、OSS 布局:不可变 blob + 一个很小的 manifest 指针

快照在节点内存里怎么演化讲完了。但快照是编译侧近线产出的,要送到成百上千个 Bidder 节点——这一步走对象存储(OSS/S3)+ manifest 指针,核心原则是生成篇 §9那句「可变的轻指针 + 不可变的重数据分开」。

把上面那份索引按维度切成 chunk,落到 OSS 的布局大致是:

oss://ad-index/
  ├── v/10237/geo.bin        # geo 维倒排片段(不可变,key 带版本号)
  ├── v/10237/os.bin         # os 维倒排片段
  ├── v/10237/position.bin   # positionTable + indexPositionMap
  ├── v/10237/meta.bin       # totalMap 里匹配必需的元信息
  └── manifest.json          # ← 唯一可变的小指针,指向"当前应生效的 gv"

两类对象,两种缓存策略:

manifest 长这样,列出当前版本每个 chunk 的 key、ETag 与内容 hash:

{
  "gv": 10237,
  "chunks": [
    { "name": "geo",      "key": "v/10237/geo.bin",      "etag": "", "hash": "a1b2…" },
    { "name": "os",       "key": "v/10237/os.bin",       "etag": "", "hash": "c3d4…" },
    { "name": "position", "key": "v/10237/position.bin", "etag": "", "hash": "e5f6…" },
    { "name": "meta",     "key": "v/10237/meta.bin",     "etag": "", "hash": "0718…" }
  ]
}

两条铁律(见生成篇 §9):① 先传完所有 blob,再翻 manifest 指针——否则节点会拉到指向「还没传完」的版本。② ETag 只当「chunk 变没变」的检测器,别当版本号——版本身份(排序 / 回滚 / 对账)永远由显式的 gv 承担。

十、拉取流程:全量(冷启动 / 兜底)+ 增量(chunk delta)

把快照送进节点分全量增量两条腿,都从 OSS 出发、最后汇到同一个「一次引用切换」:

全量与增量两条拉取流程图:全量拉(读 manifest → 并发拉齐该 gv 全部 chunk → 校验自洽 → 空闲 buffer 组装),增量拉(条件轮询 manifest,未变则 304,变了只拉 hash 变化的 chunk → 浅拷贝只换受影响部分),两条都汇入一次 volatile 引用切换到新快照 全量与增量两条拉取流程:全量(右)拉齐整个 gv 保正确、增量(左)只拉 hash 变化的 chunk 保时效,二者最后统一收敛到一次引用切换——这正是 §8 「产出新快照、换引用」在下发侧的镜像。

10.1 全量拉取:冷启动 / 周期兜底

节点冷启动(新扩容、重启)或周期全量兜底时,走全量拉取——把某个 gv 的所有 chunk 拉齐、装载、切引用:

① 读 manifest.json → 得到当前 gv=10237 与 4 个 chunk 的 key/hash
② 逐个(并发)GET v/10237/{geo,os,position,meta}.bin
      └─ 命中 CDN 边缘缓存,origin 通常只被回源一次
③ 校验:每个 chunk 的 hash 对上 manifest,且都属于同一个 gv(版本自洽)
④ 在空闲 buffer 里组装成一份完整的 IndexSnapshot(gv=10237)
⑤ 一次引用切换:activeRef → 新快照(§8 的 COW)

抗启动风暴的三件事(生成篇 §7.2 / §9交代了「编译 ≠ 分发」的边界):① 节点本地盘留 last-good 快照,启动先从本地 warm 起来直接可服务,再拉版本差补齐——别把「拉全量」设成硬启动依赖;② 拉不到最新就先服务旧版本,不要 crash;③ 拉取加随机抖动、分发层限流退避,别让所有节点卡同一秒同时回源。

10.2 增量拉取:版本差 + chunk delta

节点跑起来后,靠对 manifest 的条件轮询发现变更,只拉真正变了的 chunk:

① 定时对 manifest 做 If-None-Match(带上次的 ETag)
      └─ 没变 → 304,零下载,结束
② 变了 → 新 manifest gv=10238;逐 chunk 比对 hash:
      geo.hash      未变 → 跳过(沿用本地)
      os.hash       变了 → GET v/10238/os.bin
      position.hash 变了 → GET v/10238/position.bin
      meta.hash     未变 → 跳过
③ 只把拉到的 os / position 两个 chunk 装载
④ COW:浅拷贝当前快照引用表,替换 os / position 对应的 key,
      其余 chunk 结构共享 → 造出 IndexSnapshot(gv=10238)
⑤ 一次引用切换到新快照

拿第五节那次「改 C2 的 os」来说:它只动了 os 维(和 position 表若发生重排),所以 manifest 一翻,节点只拉 os.bin(可能加 position.bin),geo.binmeta.bin 的 hash 没变直接沿用本地——chunk delta 把带宽省到只为真正变化的部分买单。拉齐后同样走 §8 的 COW 切换,和内存里那次增量改在语义上完全一致,只是数据来源从「本地重算」变成了「从 OSS 拉编译好的片段」。

totalMap 的增量怎么加载到 BiddertotalMap 编译进 meta.bin,所以它的增量遵循同一条 chunk delta 通道——只有当某条创意的 payload 变了,meta.bin 的 hash 才变,节点才会拉它。对应第五节的「两类改」:

仅改 payload(改出价 / 换素材,定向没动):
  → 只有 meta.hash 变 → 节点只 GET meta.bin
  → COW:浅拷贝 totalMap 引用、put 变更的条目,indexMap/位图整份结构共享
  → 一次引用切换(这是最便宜的一类增量:几十 MB 倒排一位都不用拉)

改了定向(geo/os 变):
  → os.bin / geo.bin / position.bin 的 hash 变、meta.hash 可能不变
  → 节点只拉变了的倒排 chunk,meta.bin 沿用本地

也就是说,payload 的增量下发和倒排的增量下发是解耦的两条 chunk——改出价这类高频操作只在 meta.bin 上产生增量,完全不惊动倒排位图;这正是 §1.2「totalMap 与定向无关」在下发侧的体现。大素材本体(图片 / mp4)不在 meta.bin 里,meta.bin 只带 URL / 素材 ID,本体由 CDN 就近分发、和索引下发是两条独立通道。

别忘了兜底(见生成篇 5.3):推送/轮询都可能漏,所以除了增量轮询,还要定期走一次 §10.1 的全量拉取,把任何静默落后彻底归零。节点上报自己当前的 gv,中心看版本分布、对落后实例告警——这就是「版本脊柱」在下发侧的兑现。

十一、把一生串起来:一条时间线

把前十节浓缩成一条时间线,就是这份 4 条创意索引的完整一生:

gv=1   建初始快照(C1..C4)         全量拉:4 chunk 全下 → 装载 → 切引用

查询    geo=US,os=iOS → {C1,C4}   位图 AND,positionTable 还原,totalMap 取物料

gv=2   增 C5(append position 4) COW 碰 geo:JP/os:Android → 拉 geo+os+position+meta
  │                               (增必动 position 表与新 payload)
gv=3   改 C2 定向(os→iOS)       COW 碰 os:iOS/os:Android → 只拉 os(定向变、payload 没变)

gv=3'  改 C2 出价(仅 payload)    COW 只换 totalMap 一条 → 只拉 meta(倒排一位不动)

gv=4   删 C4(墓碑清位,留空洞)  COW 碰含 pos3 的 key → 拉 geo+os+position+meta

查询    geo=US,os=iOS → {C1,C2}   C4 已删、C2 改后命中,结果自洽

gv=N   全量重建(压缩空洞、重排) 空闲 buffer 整份重建 → 全量拉 → 切引用

时间线里能一眼看出两类增量代价的差别:改定向 / 增 / 删会动倒排 chunk(gv=2/3/4),而仅改出价这类只碰 meta.bingv=3')——这正是 §1.2、§5、§10.2 反复强调的「payload 与定向解耦」。

从头到尾,在线只做两件事:抓快照求交(读)、切引用升版本(COW)。所有重活——层级合并、口径归一、建位图、分配 position、切 chunk——都在编译侧一次算好,下发的是编译好的片段。这正是配置面两篇落到一个具体索引上的样子:编译前置(片段是编译好的)、版本化(每步一个 gv)来自生成篇拉取兜底(manifest 轮询 + 全量兜底)、原子切换(每步都是 COW 换引用)则是本篇的主场。

十二、落地陷阱:手把手里最容易踩的坑

把前面每一步对应的「反例」收在一处——这些坑几乎都源自破坏了「position 稳定」「不可变 + 原子切换」「先 blob 后指针」这三条铁律:

十三、速查表


延伸阅读

规范与一手资料:


views
Share this post on:

Previous Post
广告形式全解:主流形式、协议底层与可见性计费
Next Post
配置落地:从投放后台到索引快照(近线编译、触发机制与版本化快照)