本文属于 Bidder 竞价服务架构 系列(配置面 · 动手一篇)。
配置落地生成篇讲了「配置树怎么被近线编译成一份版本化索引快照、怎么触发、依赖哪些组件」——但它止于快照落进对象存储,把「快照怎么下发装载进 Bidder」留给了本篇。而落到代码上,很多人还是会问:那份「定位式位图倒排」到底长什么样?增一条、改一条、删一条创意时,位图具体怎么变?copy-on-write 每一步换的又是什么? 本篇就接着往下走,动手走一遍——用 4 条创意贯穿全程,把查询、增、改、删、全量重建、COW 切换一步步做出来,再把 OSS 布局与全量/增量拉取装载流程接上。
生成篇第九节把「下发装载」交给了本篇,并点出「下游要把全量与增量统一成『产出新快照、换引用』」;第十一节的实战对照又夸了「定位式位图删除零成本」。这两句话正是本篇的主角。我们不铺理论,直接开一个只有 4 条创意的小索引,把它的一生走完——你会看到每一次增删改,本质上都是同一个动作。
本篇全景:4 条创意的索引经增 → 改 → 删 → 全量重建演化出一串版本化快照,每次变更都是造新快照 + 一次引用切换(COW),再经 OSS 全量 / 增量下发到 Bidder。全文就是把这条脊柱拆开一步步走。
TL;DR
- 四张表撑起整个索引:
indexMap(定向值 → 位图)负责求交、positionTable(position → 创意)负责把命中的位还原成创意、indexPositionMap(创意 → position)负责反查、totalMap(创意 → payload)负责匹配后取物料。位图的第 p 位 = 「position 为 p 的那条创意命中这个定向值」。 - 查询 = 位图求交:请求命中的各定向值位图做
AND(维度内 OR、维度间 AND),剩下的置位就是候选,再用positionTable还原成创意 ID、totalMap取物料。多维匹配变成毫秒级位运算。 - 增 = 追加 position:新创意分到一个新 position,先落
totalMap再往它的有效定向 key 的位图里置位;其余 key 原封不动。 - 改 = 清位 + 写新:先把这条创意的 position 从所有位图里清掉,再按新的有效定向重新置位。定位式结构让「清旧」不需要额外反向映射——清同一个 position 位即可。
- 删 = 墓碑清位:把这条创意的 position 从所有位图清 0、
positionTable标记为墓碑(不复用),O(受影响 key 数) 完成,留下一个空洞。 - 全量重建:周期性把活着的创意重新紧凑编号、消掉空洞、打一个新全局版本
gv——顺带把增量攒下的漂移归零。 - 每一步都是 COW:增/改/删都不是原地改在线位图,而是浅拷贝引用表 + 只重建受影响 key + 结构共享未变部分 → 造出一份新的不可变快照 → 一次引用切换。全量重建则是在空闲 buffer 里整份造好再切。
- 下发落地:OSS 存不可变版本化 blob(key 带
gv、可长缓存)+ 一个很小的 manifest 指针;冷启动全量拉(按 manifest 拉齐所有 chunk)、运行时增量拉(manifest 版本一变,只拉 hash 变了的 chunk),拉齐后 COW 换引用。
Table of contents
Open Table of contents
- 一、先把索引的四张表摆出来
- 二、开局:4 条创意的初始快照 v1
- 三、查询:一次竞价请求怎么在位图上求交
- 四、增:第 5 条创意上线(追加一个 position)
- 五、改:先分清改的是「定向」还是「payload」
- 六、删:下线一条创意(墓碑清位、留空洞)
- 七、全量重建:压缩空洞、重排 position、打新版本
- 八、把四步统一起来:每一次改动都是 COW「造新快照、换引用」
- 九、OSS 布局:不可变 blob + 一个很小的 manifest 指针
- 十、拉取流程:全量(冷启动 / 兜底)+ 增量(chunk delta)
- 十一、把一生串起来:一条时间线
- 十二、落地陷阱:手把手里最容易踩的坑
- 十三、速查表
- 延伸阅读
一、先把索引的四张表摆出来
Bidder 内存里那份「定向索引」,最实用的一种组织方式叫定位式位图倒排(positional bitmap inverted index)。它由三张倒排 / 定位表 + 一张 payload 表组成,核心思想是:给每条创意分一个稳定的整数下标(position),所有位图都按 position 编址。
| 表 | 类型 | 含义 |
|---|---|---|
indexMap | Map<String, BitSet> | 定向值 → 位图。key 如 geo:US、os:iOS;位图第 p 位 = 1 表示「position=p 的创意命中该定向值」 |
positionTable | List<Integer>(下标即 position) | position → 创意 ID。把求交后剩下的置位还原成 crId |
indexPositionMap | Map<Integer, Integer> | 创意 ID → position。反查一条创意占了哪个位 |
totalMap | Map<Integer, Creative> | 创意 ID → payload(素材、出价、预算引用……),匹配后取物料用 |
前三张表的关系是两级间接:定向值先映射到一堆 position(位图),position 再映射到创意 ID。为什么不让位图直接存创意 ID?因为位运算要求连续、紧凑的整数下标——position 就是这个「内部下标」,创意 ID 则可能是稀疏的大整数。这层间接是位图能做毫秒级求交的前提。第四张 totalMap 站在这条链路之外,只按 crId 存物料(§1.2 细说)。
一句话记住:
indexMap管「谁命中」(按位),positionTable管「位是谁」(还原),indexPositionMap管「我在第几位」(反查),totalMap管「这条创意长什么样」(取物料)。
四张表如何协作:查询路径(蓝→橙→绿→黄)从请求出发,
indexMap 求交得 position、positionTable 还原成 crId、totalMap 取物料;写路径(紫)在增 / 改 / 删时用 indexPositionMap 反查创意占的位。totalMap 站在求交链路之外,只按 crId 存物料。
想直接看动手走查的读者,可以先跳过 §1.1、§1.2 这两段深挖,直接从「第二节 开局」看起,回头再补。
1.1 positionTable 和 indexPositionMap 明明互为逆映射,为什么两张都要留?
既然 positionTable(position → crId)和 indexPositionMap(crId → position)互为逆映射,一张不就能推出另一张、省掉一张吗?理论上能推导,工程上不该省——它们服务于方向相反、路径不同的访问:
| 表 | 方向 | 用在哪条路径 | 频率 |
|---|---|---|---|
positionTable | position → crId | 查询热路径:AND 完遍历置位,把每个 bit 还原成 crId | 每请求 × 每个候选,极高频 |
indexPositionMap | crId → position | 写路径:改 / 删时定位某条创意占的位 | 低频(配置变更时) |
- 查询要的是「位是谁」:求交结果是一堆 position,
positionTable是个按 position 下标直接寻址的稠密数组,crId = positionTable[p]是 O(1) 且 cache 友好。若只留indexPositionMap,就只能反向扫整张 map 找 value==p 的项——每个置位一次 O(n) 扫描,百万候选下是灾难;唯一补救是「查询前先把它反转成一张 position→crId 表」,而那张表就是positionTable。所谓「省掉」,最后还得把它建出来。 - 写要的是「我在第几位」:改 / 删时手里是 crId,要先知道它占的 position 才能清位,这正是
indexPositionMap的 O(1) 反查。反过来只留positionTable,写路径就得 O(n) 扫数组找 crId。两条路径方向相反,各留一张才都 O(1)。 positionTable还携带indexPositionMap没有的信息:删除后我们把positionTable[3]标成墓碑、却从indexPositionMap删掉了 104(见第六节)。于是positionTable知道「position 3 是空洞、不可复用」、也用.length定义了位图位宽 / position 空间;而indexPositionMap只剩活创意、根本不知道 position 3 曾存在。全量重建靠positionTable里的墓碑才能压缩空洞。
一句话:这是空间换时间的双向索引——多存一张 int 数组只花几 MB,却让查询与写两条方向相反的路径都保持 O(1),还顺带承载了墓碑与位宽信息。在 Bidder 这种「读极热、写低频」的场景里,这笔交易毫无悬念。
1.2 totalMap 存在哪里、该存什么不该存什么
totalMap 有三个层面的「存在哪里」,别混:
- 运行时——每个 Bidder 节点的进程内存(JVM 堆)。它和
indexMap、positionTable一样,是内存里那份IndexSnapshot的一个字段,只读、随 COW 一起原子切换(§8)。竞价求交拿到 crId 后就地totalMap.get(crId)取物料拼 Bid Response——在线路径零 IO。 - 编译 / 分发形态——对象存储里的
meta.bin。totalMap不是节点自己算的,是编译侧近线产出、存进 OSS 的一个 chunk(§9 的v/{gv}/meta.bin),节点冷启动全量拉、运行时增量拉后装进内存。真相源仍是控制面的 MySQL,meta.bin只是它编译后的只读产物。 - 该进 / 不该进——这是最容易踩坑的一点。
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」那种最便宜的更新)。
这就是快照 v1(gv=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:US和geo: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)只是长度补零,置位不变。
两个关键点:① 增一条创意只新增/触碰了两个定向 key(geo:JP、os:Android)加一个 totalMap 条目,其它位图逻辑上没变——这是下一节 COW 的地基,没变的部分直接结构共享、不复制;② 顺序上先落 totalMap 再置位(totalMap.put 在 indexMap 置位之前),保证任何「可被匹配到的 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 视角是:
| 操作 | 受影响的定向 key | totalMap | 复制了什么 | 结构共享了什么 |
|---|---|---|---|---|
| 仅改 payload | 无 | put 1 条 | 只换 totalMap 一个条目 | 全部位图 |
| 增 C5 | geo:JP(新)、os:Android | put 1 条 | 2 个 key 的新位图 + 1 条 payload | 其余所有 key 的位图 |
| 改 C2 定向 | geo:US、os:iOS、os:Android | 不变(payload 没动) | 这 3 个 key 的新位图 | 其余 key + 整张 totalMap |
| 删 C4 | 含 position 3 的那几个 key | remove 1 条 | 那几个 key 的新位图 | 其余 key |
| 全量重建 | 全部 | 整份重建 | 整份新快照(空闲 buffer 里建) | 无(本就是从零重建) |
于是增、改、删、全量重建统一成同一个动作:产出一份新的不可变快照、做一次引用切换。区别只是「复制了多少」——增量只复制少数几个受影响 key 的位图引用 + 个别 payload 条目(几十 MB 位图本体纹丝不动),全量则整份新建。切换那一刻靠一次 volatile 写完成,竞价线程要么读到全旧、要么读到全新,永不半份;旧快照只要还有在途请求引用着就不回收(生成篇 §9 下游把这条「原子装载」交给了本篇)。
索引与 payload 的版本一致性是「免费」的:因为
indexMap、positionTable、totalMap同属一份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"
两类对象,两种缓存策略:
- 不可变版本化 blob(
v/{gv}/*.bin):key 里带gv,Cache-Control: immutable。因为每版 key 都不同,CDN 永不需要失效,可长期缓存、跨节点共享。 - manifest 指针(
manifest.json):极小,no-cache或极短 TTL。节点只对它做If-None-Match条件轮询——没变返回304、近乎零流量。
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 出发、最后汇到同一个「一次引用切换」:
全量与增量两条拉取流程:全量(右)拉齐整个
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.bin、meta.bin 的 hash 没变直接沿用本地——chunk delta 把带宽省到只为真正变化的部分买单。拉齐后同样走 §8 的 COW 切换,和内存里那次增量改在语义上完全一致,只是数据来源从「本地重算」变成了「从 OSS 拉编译好的片段」。
totalMap 的增量怎么加载到 Bidder:totalMap 编译进 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.bin(gv=3')——这正是 §1.2、§5、§10.2 反复强调的「payload 与定向解耦」。
从头到尾,在线只做两件事:抓快照求交(读)、切引用升版本(COW)。所有重活——层级合并、口径归一、建位图、分配 position、切 chunk——都在编译侧一次算好,下发的是编译好的片段。这正是配置面两篇落到一个具体索引上的样子:编译前置(片段是编译好的)、版本化(每步一个 gv)来自生成篇,拉取兜底(manifest 轮询 + 全量兜底)、原子切换(每步都是 COW 换引用)则是本篇的主场。
十二、落地陷阱:手把手里最容易踩的坑
把前面每一步对应的「反例」收在一处——这些坑几乎都源自破坏了「position 稳定」「不可变 + 原子切换」「先 blob 后指针」这三条铁律:
- position 复用导致位图错位:删创意后若把腾出的 position 立刻分配给新创意,任何还持有旧位图视图的在途请求都会「张冠李戴」。position 一旦分配就只增不复用,回收统一留给全量重建(见第六、七节)。
- 先翻指针后传 blob:manifest 指针先于 blob 落地,拉取方会去下载一个还不存在的 chunk。铁律是先写完所有 blob、校验齐全,再翻 manifest 指针(见第九节)。
- 增量长期不做全量:只跑增量、迟迟不全量重建,空洞越积越多、position 越来越稀疏,位图体积和内存虚高。周期性全量兜底必须真的按期跑(见第七节)。
- 切引用不是原子的:COW 造好新快照后,若引用切换没用
volatile/ 原子写,别的线程可能读到「半更新」的引用。切换必须是一次原子写,且单请求全程读同一版本(见第八节)。 - 改 payload 却顺手动了位图:仅出价 / 素材变更本应只
totalMap.put,若顺手重建了位图,就把最便宜的操作做成了最贵的。先分清改的是定向还是 payload(见第五节)。 - chunk 粒度太粗:一个 chunk 塞太多创意,任何小改动都让整块 hash 变化、被整块重拉,增量退化成近似全量。chunk 粒度要和「典型改动的局部性」对齐(见第十节)。
十三、速查表
- 四张表:
indexMap(定向值→位图)求交、positionTable(position→crId)还原、indexPositionMap(crId→position)反查、totalMap(crId→payload)取物料;位图第p位 = position 为p的创意命中该定向值。totalMap按 crId 编址、不参与求交。 - 查:请求各维位图
AND→ 置位即候选 →positionTable还原成 crId →totalMap取 payload。 - 两类「改」:仅 payload 变(出价/素材)→ 只
totalMap.put,位图不碰(最便宜);定向变 →indexMap清位+写新(payload 也变则一并totalMap.put)。 - totalMap 增删改:增 → 先
put(crId,payload)再置位;删 → 先清位再remove(crId);全量 → 只装存活创意 payload(兜底清死物料)。 - 版本一致性免费:
indexMap/positionTable/totalMap同属一份快照、一次切换齐生效,匹配出的 crId 与取到的 payload 永远同gv。 - 增:追加一个 position,只往新创意的有效定向 key 置位;其余 key 不动。
- 改:先把该 position 从所有(旧)位图清 0,再按新有效定向重新置位;清旧无需反向映射。
- 删:该 position 从所有位图清 0 +
positionTable标墓碑(不复用),留空洞,O(受影响 key)。 - 全量重建:活创意紧凑重排 position、消空洞、打新
gv、消漂移;成本高频率低。 - COW:增删改都不原地改——浅拷贝引用表 + 只重建受影响 key + 结构共享 → 新不可变快照 → 一次
volatile引用切换;单请求整段读同一版本。 - OSS 布局:不可变版本化 blob(
v/{gv}/*.bin,可长缓存)+ 极小 manifest 指针(条件轮询);先传完 blob 再翻指针;ETag 只做变更检测、版本身份归gv。 - 全量拉:读 manifest → 拉齐该 gv 所有 chunk → 校验版本自洽 → 组装 → 切引用;本地 last-good warm start、降级不 crash、抖动错峰。
- 增量拉:条件轮询 manifest → 只拉 hash 变了的 chunk(chunk delta)→ COW 合并切换;配周期全量兜底 + 版本上报对账。
延伸阅读
- 配置落地:从投放后台到索引快照:本篇的上游生成篇——讲这份快照怎么被近线编译出来(编译前置 / 版本化 / 全量增量 / 触发机制 / 组件依赖),止于快照落对象存储,并有一节真实架构的对照体检。本篇接着讲它怎么下发装载进 Bidder。
- 程序化广告投放层级与定向配置:本篇例子里「有效定向」的上游——那棵四层配置树怎么跨层合并成一条订单项的扁平定向。
- Bidder 定向过滤(Targeting Filter):本篇建好的位图在毫秒级怎么被请求级 gating 与候选级布尔匹配消费,以及「未设定向」的缺省语义。
- Bidder 竞价服务架构设计(总览):数据面第一性原理(只读只算、原子切换)与版本一致性的完整背景。
规范与一手资料:
- RoaringBitmap. Roaring Bitmaps:生产里做定向位图求交、支持不可变视图与结构共享的核心数据结构。
- Amazon. S3 ETag / conditional requests:对象存储的 ETag 与
If-None-Match条件请求语义(manifest 轮询的基础)。 - LinkedIn. Venice:batch push 产出新 store-version、翻
current指针、留 backup 回滚,且并存增量——全量 + 增量 + 版本化的工业级样板。