IT 论文精读 · PAPER 71
Mace, Roelke, Fonseca · Brown University · SOSP 2015(最佳论文)
你打开一个 app,它今天特别卡。工程师想弄明白为什么,能翻的只有日志——程序顺手记下的流水账。麻烦在于:流水账记的是当初写代码的人想到要记的东西,你现在想问的那件事,多半不在里面。
2015 年布朗大学的三个人提出 Pivot Tracing,把这件事反了过来:你可以对着一个正在运行、不能停机的系统当场提问,几秒后答案就流回来,问完撤掉。而且它能问出一类以前谁也答不了的问题——比如「机房这块硬盘此刻被谁吃掉了」。
一个大系统是很多层叠起来的:最上面是用户的任务,中间是数据库,最下面是存储。现在存储层的磁盘被榨干了,大家都在排队。你问:到底是谁干的?结果所有人都答不上来。存储层只知道「是数据库来读的」;数据库只知道「是某个任务让我读的」;那个任务只知道自己交了活儿。每一层都只认得自己的上一站。
就像快递中转站只知道包裹来自上一站,问它「是哪个买家下的单」——它是真不知道。要命的是,答案并非不存在,而是被切碎分散在几个系统里,谁手上都不全。
旧世界的规矩是:你必须在写代码时就想好日后要查什么。想得到,事后能查;想不到,就得改代码、重新上线、干等它再犯一次——生产事故往往就这样错过。Pivot Tracing 换了个规矩:先别记,等你想问的时候再问。
第一件事:探头是临时装的。程序跑着的时候被就地改写,探头装上去、用完撤掉——像给正在营业的店临时挂个计数器,不用打烊装修。
第二件事,也是真正的新东西:让信息跟着请求走。给每个请求配一个随身小背包。它经过上层时,上层把「我是谁、我为谁跑这一趟」写进背包;一路穿过各种转手,到了最底层的存储,那里的探头打开背包就看见了来路,于是「这次读了多少字节」和「这是谁点的」当场对上号。
比喻是医院:病人自己带着病历本走,抽血处不必跑回挂号处查这人是谁。关键在于配对的依据不是时间——同一毫秒里全机房有上万件事,按时间猜必然猜错——而是「它俩本就在同一趟旅程上,一个在前、一个在后」。
还有一点让它便宜:背包里不塞流水账,只塞一句「这一趟属于某某任务」,剩下的边走边算,到底层算完只把结果发出去。代价也实打实:这一套要求沿途每个组件都老实地把背包传下去,中间有一环弄丢了,那条线就在那儿断掉。
它把「监控」的问法改了:从「上线前决定记什么」,变成「想问就问」。今天大家挂在嘴边的「可观测性」,衡量标准正是这一条——能不能回答一个事先没想到的问题。而那个「随身背包」,如今以 baggage 之名写进了主流追踪标准的接口。
给每个请求配一个随身背包,让上层把「我是谁」写进去、底层打开就能对上号——于是「这块盘现在被谁吃掉了」这种跨系统的问题第一次有了答案;再加上探头能对着运行中的系统随装随撤,监控就从「事前记账」变成了「事后提问」。
想看它的因果连接算子、查询怎么编译成探头、以及实测开销? → 切到精读版
Pivot Tracing 让你对正在运行的分布式系统即席提问:它提出一个新的关系算子——happened-before join(因果先后连接,记作 ⋈→),把「系统这一处产生的数字」与「只有另一处才知道的身份信息」沿着请求的因果路径配对起来,从而回答跨组件、跨机器的归因问题;再用动态插桩(dynamic instrumentation)把查询现场编译成代码、织进运行中的进程,问完即撤。它拿下 SOSP 2015 最佳论文。
(进程名=MRsort, 读取字节=4096)。作者是 Jonathan Mace、Ryan Roelke、Rodrigo Fonseca,来自布朗大学,论文发在 SOSP 2015 并获最佳论文奖,后来扩展为 ACM TOCS 期刊长文,并被《Communications of the ACM》选为 Research Highlight(2020 年 3 月)。它同时接续两条线:一条是端到端追踪——Fonseca 自己的 X-Trace(2007)与 Google 的 Dapper(2010);另一条是动态插桩——DTrace 与微软的 Fay。往下,它被 Facebook 的 Canopy(SOSP 2017,Mace 是作者之一)带进工业规模,而它抽出来的 baggage 概念,今天写在 OpenTracing / OpenTelemetry 的标准接口里。
在这篇之前,线上排查基本靠三样东西:日志、指标、追踪。它们有一个共同的硬伤——记什么,在开发期就被冻死了。你只能翻到当初有人想到要记的东西;想问一个新问题,就得改代码、过评审、重新部署、再等故障复现一次。而线上最贵的那类故障,往往只复现一次。
更麻烦的是第二点:信息按组件边界被切开了。论文开篇给的例子很具体:一个 8 台机器的集群上同时跑着 HBase、MapReduce 作业,以及直接读写 HDFS 的客户端。现在问一个运维每天都在问的问题——HDFS 各个 DataNode 此刻的磁盘读带宽,分别是被哪个高层客户端吃掉的?
HDFS 答不出来。它只看得见自己的直接客户端,也就是「HBase 这一整坨」「MapReduce 这一整坨」;至于背后是哪张表的扫描、哪个作业的哪个 task,它一无所知。而 HBase 和 MapReduce 虽然知道自己的作业名,却不知道每个作业最终在哪块盘上压出了多少字节。答案需要的两半信息分处两个系统,谁也拼不起来。论文还援引 HBASE-4145 这类真实 issue 说明:这不是学术臆想的问题,是工程师在 bug 跟踪系统里真的在要的东西。
「那就把什么都记下来、事后拼」也不行:全量记录的量大到存不起、开销也高;而降低开销要靠采样,采样又意味着你要的那一次多半不在样本里。「记全」和「记得起」在旧框架下是对立的。
这是全文真正新的东西。它是什么:一个新的关系算子,写作 a ⋈→ b。对 b 里的每一条记录,它把所有在因果上发生在这条记录之先的 a 记录配给它。
为什么非要造个新算子:数据库里的普通 join 是按值配对——两张表要有个共同字段(同一个用户 ID)才连得起来。可这里两头根本没有共同字段:上层手里只有作业名,底层手里只有字节数,它们之间唯一的联系是——这两件事发生在同一次请求的同一条执行路径上,一个在前、一个在后。所以配对条件不能是「值相等」,只能是 Lamport 的 happened-before。
为什么不用时间戳配对:机房里同一毫秒发生上万件事,且各机器的时钟本来就对不齐;按时间凑必然凑错。而 happened-before 不需要任何时钟——它只要求信息沿着执行路径传下去。这是分布式系统里唯一能白拿的、可靠的关系。
于是那个「谁吃了我的磁盘」就能写成一句查询了,大意如下(简化):
白话逐句:从 DataNode「又读了 N 个字节」这个事件出发;连接到因果上排在它前面的那条客户端协议记录(-> 就是 happened-before,First 表示只要最早的那条,也就是最外层的发起者);按发起者的进程名分组;求和得到每个高层客户端各自吃掉多少字节。数字取自最底层、分组键取自最顶层——这正是单个系统给不出的那张表。论文的名字 Pivot(数据透视表)就来自这里:行按机器、列按来源,随手换个分组键就换一个视角。
算子定义清楚了,接下来是真正难的:怎么算。要在底层那一端完成这个 join,底层就得知道上层那条记录。最笨的办法是把两头的记录统统发到一台中心机器上去对——那就绕回「全量记录」的老路:量大、贵、还慢。
论文的答案是baggage(随身包裹):把 join 要用的那几个字段塞进请求自己携带的一个小容器里,让它随请求穿过线程切换、任务队列、RPC 调用一路走下去。下游的探头直接从 baggage 里取出上游的元组,就地完成配对与聚合,只把结果吐出去。
这样做的妙处是:因果关系本来就靠「请求走到哪里、信息就跟到哪里」来定义,所以 baggage 传播路径天然就是 happened-before 关系本身——不需要额外维护任何图或时钟。
它还便宜。查询在编译时会被重排,过滤、投影、聚合尽量下推到源头,baggage 里只装下游真正要用的那几个字段。实测里:不跑查询时 baggage 默认为空、序列化后 0 字节;评测中最重的那个查询也只带 4 个元组、约 137 字节/请求。
最后是落地机制。三个概念:
Observe(从探点取变量做成元组)、Unpack(从 baggage 取上游元组)、Filter(筛)、Pack(把元组放进 baggage 带走)、Emit(把结果吐给用户)。整条路是:用户在前端写查询 → 编译成 advice → 经发布订阅广播到全集群的 agent → agent 就地织入探点 → 探点吐出的元组走消息总线回到前端 → 聚合成透视表,结果是流式的,边跑边看。撤销查询就把织入撤掉,系统回到原样。
工程代价也说清楚了:原型是 Java 的;要让 baggage 真的能一路传下去,得给每个系统在线程与 RPC 边界上做改造——HDFS、HBase、MapReduce、YARN 每个系统手工改了大约 50–200 行代码(Thread、Runnable、Callable、Queue 这类常见接口用 AspectJ 自动处理,RPC 的协议定义则要手工扩展)。
动机问题真的答出来了。在 8 台机器同时跑 HBase、MapReduce 与直接 HDFS 客户端的集群上,Pivot Tracing 把 DataNode 的磁盘读写按高层客户端进程名分组,呈现成一张「行=机器、列=来源进程」的透视表配走势图——这正是 HDFS 自己无论如何都给不出的视图。
案例研究定位了三类根因。作者在这套 Hadoop 集群上用一串逐步收窄的查询,分别定位出软件 bug、配置错误、以及「腿瘸的硬件」(limping hardware,一块慢下来但没坏掉的盘)。这三类恰好是日志最不擅长的:系统不崩、不报错,只是慢——没有异常可抓,只有归因可做。
开销。不跑查询时开销可忽略(baggage 为空、序列化 0 字节),所以这套东西可以常开着待命。跑查询时的成本主要来自打包、解包与传输元组:最坏情况是把 HDFS 压到极限的压力测试,吞吐下降约 16%——这是刻意压满时的上界数字,作者用它来说明代价的量级,而不是典型值。
第一,它改了「监控」这件事的提法。旧范式是「事前决定记什么」,新范式是「事后提问」。今天「可观测性(observability)」与「监控(monitoring)」的那条分界线——你能不能回答一个事先没想到的问题——这篇论文是把它讲得最清楚的源头之一。
第二,它把「跨层归因」形式化了。在此之前,工程师做这件事靠在日志里塞一个 request ID、事后用脚本 grep 着拼接。Pivot Tracing 指出:这件事本质上是一个沿因果序做的 join,并给出了可执行的定义与实现。把一个人人在手工做的苦活抽象成一个算子——这是系统论文最有价值的那类贡献。
第三,baggage 走出了论文。后续 Mace 与 Fonseca 把它单独抽象成「追踪平面」一系工作;而「随请求携带一小袋键值、跨进程传播」这个概念与 baggage 这个名字,如今就写在 OpenTracing / OpenTelemetry 的标准 API 里。你在现代微服务里传的 baggage,血缘在此。Facebook 的 Canopy(SOSP 2017)则把这套思路带到了工业级规模。
① 一句话:用 happened-before join 把「底层的数字」与「顶层的身份」沿因果路径配对,再用动态插桩把查询装进运行中的系统——监控从「事前记账」变成「事后提问」。
② 痛点一:记什么在开发期就冻死了,想问新问题得改代码、重新部署、等它再犯。
③ 痛点二:信息按组件边界切开——HDFS 只认得直接客户端,答不出「这块盘被哪个高层作业吃掉了」;两半信息分处两个系统。
④ 核心算子:a ⋈→ b,配对条件不是「值相等」也不是时间戳(时钟对不齐),而是 Lamport 的因果先后——分布式系统里唯一能白拿的可靠关系。
⑤ 实现关键:baggage——把要 join 的字段塞进请求的随身容器,随线程 / 队列 / RPC 走;下游 Unpack 后就地配对聚合,只发结果,不把原始事件运到中心。
⑥ 便宜的原因:聚合与过滤下推到源头,baggage 只装真正要用的字段——空查询 0 字节,最重的评测查询约 137 字节/请求。
⑦ 落地机制:查询 → 受限指令 advice(Observe / Unpack / Filter / Pack / Emit)→ 发布订阅下发 → agent 字节码织入探点 → 结果流式回流;撤销即归零。
⑧ 结果:Hadoop 集群上按高层客户端分解 DataNode 磁盘带宽,定位出软件 bug、配置错误、腿瘸的硬盘三类根因;每个系统改 50–200 行加 baggage 传播;压力测试最坏开销约 16%。
⑨ 影响:把「跨层归因」形式化成一个算子;「可观测性 = 能回答没想到的问题」这条分界线的源头之一;baggage 进了 OpenTelemetry 的标准接口。
⑩ 局限:baggage 断链是静默的;只看得见路径上的因果、看不见共享资源上的互相干扰;依赖运行时改写、注入生产进程有安全顾虑;完整形态至今未被工业界广泛采用。