IT 论文精读 · PAPER 21
Peng & Dabek · Google · OSDI 2010
2010 年,谷歌两位工程师(Peng 和 Dabek)造了一个叫 Percolator 的系统,用它重建了谷歌搜索的索引。作用一句话说清:让一个新网页从被爬到、到能在搜索结果里被搜到,从「等一整批重算」变成「来一篇、更新一篇」。上线后,搜索结果里文档的平均「年龄」降了一半——网页更新得更快了。
在此之前,谷歌用的是上一代神器 MapReduce:把全网上百亿网页当成一大锅,整锅一起算,算完产出一版新索引。问题是——你只改了一个网页,也得把整锅重炒一遍。全网重算一轮要好几天,于是你今天发的文章,可能过好几天才被搜到。想让它更新快,唯一的办法是更频繁地整锅重炒,可那太贵了,炒不起。
Percolator 的想法是:当一个网页变了,只去更新「跟它有关」的那一小部分,别碰其余几十亿个没变的。这就是「增量处理」——像往一锅汤里补一勺料,而不是倒掉重熬。
可这事以前做不了,卡在两个坎上,Percolator 正好补上两样东西:
更新一个网页往往要同时改好几处记录(这个词指向那篇文、那篇文的排名、反向链接……)。要是改到一半机器崩了,索引就成了「一半新一半旧」的烂账。Percolator 给这些散落各处的改动套上一个「事务」:要么全部生效,要么当作啥也没发生,绝不留半拉子。
它怎么保证「全成或全不成」?打个比方:搬家时你给每个箱子贴张便利贴锁住,其中指定一个「主箱子」当总开关——只有主箱子那张便利贴一翻面,整场搬家才算「正式完成」;翻面之前谁来看都是「还没搬」。这样哪怕搬到一半停电,别人一看主箱子没翻面,就知道这趟不算数,安全。一个原子的小动作,锁定了一大堆改动的命运。
索引是一环扣一环的:网页内容变了 → 得重算它的关键词 → 关键词变了 → 得更新倒排表……Percolator 加了一套「触发器」:你盯着某类数据,只要它一被改动,系统就自动叫醒一段你写好的处理逻辑去跟进,跟进又可能触发下一环——像 Excel 里改一个单元格,依赖它的公式格自动重算、连锁刷新一整片。一小撮改动就这样自己「渗」遍了整个索引——系统的名字 Percolator(渗滤器)正是这意思。
谷歌用它替掉了老的 MapReduce 索引流水线(这套新系统对外叫 Caffeine)。处理同样多的网页,搜索结果的平均新鲜度提升了一倍——你刚发的内容能更快被搜到。诚实说一句代价:这份「随时更新」不是白来的——比起 MapReduce 顺畅地整锅扫,Percolator 每更新一篇文档要东一下西一下地读写几十次,更费机器;谷歌是拿资源换新鲜度,觉得划算才这么干。
Percolator 让谷歌搜索索引从「整批重算、慢好几天」变成「来一篇更新一篇」。它靠两样东西做到:给散落各处的改动套上「要么全成要么全不成」的事务(用一个「主锁」当总开关),再加一套「某处一变、自动通知下游跟进」的触发器。新鲜度翻倍,代价是更费机器。
想看它怎么在 Bigtable 上用时间戳和「主锁」做出事务、观察者怎么级联? → 切到精读版
Percolator 在 Bigtable 之上加了两样东西——跨行跨表的 ACID 事务(快照隔离,snapshot isolation)和观察者 / 通知机制(observers / notifications)——把谷歌网页索引从「用 MapReduce 整批重建」改造成「增量处理:来一篇、只更新受影响的那一小部分」。上线后(这套系统即 Caffeine)在处理同样吞吐的前提下,把搜索结果中文档的平均年龄降低了约 50%。
作者 Daniel Peng 与 Frank Dabek,谷歌,论文发在 OSDI 2010。它上承谷歌「老三件套」——建在 Bigtable(存储)之上、替换掉 MapReduce(批处理)搭的索引流水线,并借助类似 Chubby(Paper 20)的轻量锁服务做故障判定;被视作谷歌「新三驾马车」之一(与 Pregel、Dremel 并列)。它把「大规模分布式事务」从学界的「不可能高效」变成了工业现实,直接启发了后来的 TiDB / TiKV 等分布式数据库——它们的事务模型几乎照搬了 Percolator。
谷歌的网页索引本质是一套流水线:爬到一个网页 → 抽取内容、算 PageRank、聚类去重 → 更新倒排索引。2010 年前,这套流水线是用 MapReduce 整批跑的:把当时的整个网络仓库(数十 PB)当输入,跑一连串 MapReduce,产出一整版新索引。
痛点是批处理的粒度:MapReduce 要求把输入整个扫一遍。哪怕这一轮只有极小一部分网页真的变了,你也得重新处理整个仓库——因为一个新网页可能改变别的网页的排名、聚类归属,牵一发而动全身,批处理没法「只算受影响的部分」。结果就是:一个网页从爬到、到进入线上索引,要等上一整轮全量重算(数天量级)。想更新更快,就得更频繁地全量重算,成本高到不可行。
那为什么不干脆上一个数据库、来一篇网页就发一个事务改几行?因为规模:当时没有任何 DBMS 能在几千台机器上撑住这个体量的随机读写吞吐。于是问题变成:能不能在能扛住规模的存储(Bigtable)之上,补出「只更新受影响部分」所需的两样能力——多行事务,和「谁变了通知谁」的触发机制?
增量处理的本质是:维护一堆互相依赖的数据,当上游变了、把变化「渗」到下游。做这件事,你需要两样:一是多行事务——一次更新常要一致地改多处,不能改一半就崩(否则索引出烂账);二是通知——你得知道「谁变了、接下来该唤醒哪段计算」,否则只能又回到「全扫一遍找变化」的老路。Percolator 就是把这两样,架在 Bigtable 之上。
Bigtable 只保证单行原子。Percolator 要跨行、跨表做 ACID,办法是给每个数据列额外配几个「元数据」列,把锁和版本信息直接存进 Bigtable 里,再用一个两阶段提交协议把它们串成事务。核心用到三种列:
data(数据):某时间戳下这个格子的实际值。Bigtable 天生按时间戳存多版本。lock(锁):标记「此格有一个未提交的事务正占着」。事务里所有被写的格子中,指定一个当「主锁」(primary lock),其余是「副锁」(secondary),每个副锁记着主锁在哪。write(写记录 / 提交点):一个指针,指向「已提交的那个 data 版本」的时间戳。读操作只认 write——没有 write 记录的数据,读者看不见。每个事务从一个全局服务时间戳预言机(timestamp oracle)拿两个时间戳:开始时拿 start_ts,提交时拿 commit_ts。读,就读「时间戳 < start_ts 的最新已提交版本」——这就构成它的一致快照。写,则分两阶段:
data(打上 start_ts)。上锁前先检查两件事:有没有 start_ts 之后别人已提交的 write(有 = 写写冲突,中止);有没有别人的锁还占着(有 = 撞锁,退避重试)。write 记录(指向 start_ts 的数据)并抹掉主锁。这一步是整笔事务的「生效瞬间」:主锁一提交,交易就算数了。之后再从容地把各副锁逐个转成 write 记录。妙处在「主锁当总开关」:一笔事务改了几十个格子,但它算不算数,只由「主锁那一格有没有变成 write 记录」这一个原子动作决定。若客户端在提交主锁后、清理副锁前崩溃了,副锁虽还留着,但别的事务撞见它,会顺着副锁记的地址去查主锁:主锁已提交 → 帮它把这个副锁也提交(前滚 roll-forward);主锁还在/已回滚 → 把这个副锁清掉(回滚 roll-back)。判定客户端是否真死,用一个类似 Chubby 的轻量锁服务加超时。于是不需要一个专门的事务管理器,清理是懒惰、去中心化的,由后来的事务顺手完成。
快照隔离要求时间戳严格递增。Percolator 用一个专门的时间戳预言机服务集中发号。它不是每来一个请求就写一次磁盘——那样太慢;而是一次性把「已分配到的最大号」批量写进稳定存储,之后在这段区间内纯内存发号,攒够一批请求一起回。靠批处理,单台机器能发到每秒约 200 万个时间戳,足以支撑整个集群。(这是个中心化组件,但状态极小、可复制,且批量分摊后不成瓶颈。)
光有事务还不够增量:你还得知道「哪块数据脏了、该唤醒哪段计算」。Percolator 给列加了观察者(observer):程序员把「哪些列被写时、该跑什么逻辑」注册好;当某列被写入,就在它的 notify(通知)列打个标记。一批常驻的 worker 进程随机扫描这些通知标记,撞见就运行对应的观察者代码——而观察者自己又会写别的列、触发下一批通知,如此级联。像 Excel 改一格、依赖它的公式格连锁重算。为防重复触发与死循环,Percolator 保证每条通知至多触发一次观察者(用 ack 确认列去重)。这套「写→通知→观察者→再写」的链条,就是「增量把变化渗遍索引」的引擎——系统名 Percolator(渗滤)由此而来。
Percolator 落地成了谷歌的网页索引系统 Caffeine,替换掉此前基于 MapReduce 的批处理索引。论文给出的核心结论:在处理相同数量文档 / 相同吞吐的前提下,把搜索结果中文档的平均年龄降低了约 50%——也就是新鲜度翻倍。系统跑在数千台机器上、管理 PB 级数据;时间戳预言机单机可达每秒约 200 万次发号。作者也诚实给出代价:相比 MapReduce 顺序扫描的高效,Percolator 每处理一篇文档要做数十次 Bigtable 随机读写,资源效率显著更低——这是用机器换新鲜度的明确取舍。
它的历史意义有两层。第一,工程上:证明了「在能扛规模的存储之上,补出跨行事务 + 增量通知」这条路走得通,让谷歌搜索获得了此前不可能的新鲜度。第二,也更深远——它把「基于时间戳 + 锁列 + 主锁 2PC」的分布式事务范式,做成了可复制的工程模板。后来的开源分布式数据库 TiKV / TiDB 几乎照搬了 Percolator 的事务模型(start_ts / commit_ts、primary lock、懒惰清理),CockroachDB 等也受其影响。可以说,今天很多「跨节点还能做事务」的系统,血脉里都有 Percolator。它也是谷歌「新三驾马车」中,把强一致事务带回大规模系统的那一篇——为两年后的 Spanner(全球一致事务)铺了路。
① 一句话:在 Bigtable 上补出「跨行 ACID 事务 + 观察者通知」,把谷歌网页索引从 MapReduce 整批重建改成增量处理。
② 痛点:MapReduce 改一个网页也得重扫整个仓库,新网页要等数天才进索引;而没有 DBMS 能在这个规模上做随机事务。
③ 事务机制:给每列加 data/lock/write 元数据列,两阶段提交——预写上锁(一主一副)、提交时先原子地把主锁转成 write 记录(=生效瞬间),再收尾副锁;读只认 write,半成品不可见。
④ 快照隔离:事务从时间戳预言机拿 start_ts / commit_ts,读一致快照、写时查写写冲突;预言机批量发号、单机约 200 万/秒。
⑤ 容错:无专用事务管理器,崩溃留下的锁由后续事务顺着副锁→主锁懒惰前滚 / 回滚,靠类似 Chubby 的锁服务加超时判死。
⑥ 通知机制:观察者注册在列上,写→打 notify→worker 随机扫→跑观察者→再写,变化级联「渗」遍索引;每条通知至多触发一次(ack 去重)防死循环。
⑦ 结果:落地为 Caffeine 索引系统,同吞吐下文档平均年龄降约 50%(新鲜度翻倍);代价是每文档数十次随机操作、资源效率低。
⑧ 影响:把「时间戳 + 主锁 2PC」做成分布式事务模板,直接启发 TiKV / TiDB 等;为 Spanner 的全球一致事务铺路。局限:只有快照隔离(有写偏斜)、单事务延迟高、时间戳预言机为逻辑单点。