故事

一九九八年深秋,上海。心脏专科医院的协调办公室在六楼,窗对着一条空旷的街。午夜将近。老周坐在一张漆色斑驳的木桌前,桌上放着两部电话——一部黑的,一部红的。黑电话通到兰州军区总医院的手术室,红电话通到楼下五楼的本院手术室。两条线都只通到他一个人。

桌上还摊着一本厚册,封皮上写五个字:移植协调规程。这本册子里一共四十七条,是十几年里用病人的命换来的。每加一条都意味着从前出过一次事故。

今晚的事很急。兰州一位四十二岁的中年男子昨日清晨车祸脑死,家属同意捐献心脏。上海这边等心脏的,是一位五十一岁的中学教师,名叫沈志远,扩张型心肌病已到终末期,今夜如果不换,多半挨不过这个秋天。兰州取心,飞机送沪,两边手术同步——这是一场要在四个小时之内跑完一千八百公里的事。

老周拿起黑电话。「兰州,我老周。供体小组就位了吗?」

那头是兰州的陈大夫,声音有点哑:「就位了。麻醉给完了,胸已经打开,主动脉钳子在手边。我们等你的信号。」

老周看了一眼墙上的钟。零点零七分。

放下黑电话,拿起红电话:「五楼,受体那边怎么样?」

「沈志远刚转入体外循环,泵开了五分钟,机器走得很稳。」红电话那头是林医生,「胸已开,他原本的心脏还在跳。我们也在等。」

「都等着,我这边再核一次。」老周挂了电话,回头翻规程册,找到第十九条,用铅笔在边上做了个小记号。

第十九条是这样写的:

取心之前,须先确认受体已就绪;受体之原心摘除之前,须先确认供体心已离体并在途。两端皆未动手,则尚可中止;任一端已动手而未告知另一端,事故由协调员承担。

这是十年前那次事故之后定下的。那一回,兰州的大夫先把心取了下来,飞机送到上海,上海这边打开了胸腔,准备摘原心,结果发现受体凝血指标突然崩了,根本不能继续。心送到了,人接不住,那颗心在冰盒里看了三十分钟,最后只能丢掉。后来追责,发现那一夜两端各做各的,没有人在中间确认过「受体真的能接」。从那以后,每一次移植都必须由一个人在中间,把两端的「就绪」对齐,然后下令两端同时动手。

老周做这件事做了八年。

零点一十四分。两端都报「就绪」。老周拿起黑电话:「兰州,你那边动手。我这边五分钟之内给五楼下指令,让他们摘原心。」

「明白。挂了。」兰州那头开始动作。陈大夫掐住主动脉,灌入冷停跳液,等心脏停跳之后,沿着上下腔静脉、肺静脉、肺动脉、主动脉一一离断,把那颗已经停跳的心脏托起来,放进装着冰生理盐水的不锈钢盒里。整个过程要八分钟。

放心盒的时候是零点二十二分。陈大夫的助手提着冰盒往外冲,从手术室到电梯,从电梯到救护车,从救护车到机场跑道边那架等了一夜的小型医疗包机。包机的引擎一直没停。

零点三十一分,包机离地。从兰州飞到上海虹桥,要三个小时四十分钟。

老周这边——老周拿起红电话,正要按号——电话死了。

死透了——连拨号音都没了。线路彻底断了。他抓起黑电话试,黑电话也死了。

他冲到办公室门口,按电梯——电梯按钮没亮。回头看灯——办公室的吊灯还亮着,但走廊那头是黑的。整个六楼除了他这间办公室,都没电。

后来的几天里,老周才弄清楚那一夜发生了什么。市政电缆在两条街外破了,医院的备用电源跳闸,跳闸的同时配电室一只继电器也烧了。结果是:医院的主供电恢复了,但通讯机房的转接电路没回来。六楼所有连接外线的电话——包括他桌上那两部——都成了废铁。

但他当时不知道这些。他只知道,兰州那边的心已经在飞机上,五楼那边的人正在体外循环上等他下令,而他什么也传不出去。

零点三十六分。

林医生在五楼手术室,眼睛盯着一只挂在墙上的钟。沈志远的胸腔敞着,体外循环机在身边低声运转,他原本的心脏还在那里搏动,节律不稳,但还在。

按规程,老周应该在零点三十分前后下令。现在过了六分钟。

「老周怎么没消息?」林医生身边的助手问。

「再等三分钟。」林医生说。

三分钟过去。还是没有。林医生让循环科的小护士拿起内线电话拨协调办公室——内线是不通外线的另一套,应该没事。拨过去,没人接。再拨——还是没人接。

林医生心里一紧,但脸上不能露。他对助手说:「你下楼,跑去六楼协调办公室看一眼,看老周在不在。快去。」

助手脱了外面那层无菌罩,走出去。剩下林医生一个人站在台旁,对着沈志远敞开的胸腔。

他面前现在有两个选择,每一个都可能让人死。

第一个选择:照原计划摘掉沈志远的原心。如果兰州那边的心确实已经在路上,这是对的——心一到,吻合上去就行。但如果兰州那边出了事,心根本没下来,或者下来了但飞机出事——沈志远的胸腔里就是空的,体外循环最多再撑两个小时,到时候人就走了。

第二个选择:把沈志远从体外循环上撤下来,让他原本的心继续跳,等弄清楚情况再说。但如果兰州那边确实把心送来了,飞机三个多小时之后落在虹桥,那颗心进了医院、送上手术台,沈志远却已经撤泵、原心装回原位、胸腔正在缝合——那颗心就废了。换一颗心要等多久,沈志远还能不能等到,谁也说不准。

两条路都不能走。第三条路是——什么也不做,等。

林医生看了看钟。零点四十二分。

他这时候才真正明白第十九条规程的意思。规程真正写的是「老周不在了」的那一刻——平时根本用不上,平时老周一通电话什么都解决了。他过去八年里读过这条规程不下几十次,每一次都觉得它是在多此一举。今夜他第一次看清楚,那条规程其实是在给「协调员失声」这件不可能的事兜底——而所谓不可能的事,每隔几年总要发生一次。

那天夜里,林医生做的事,事后被写进了第四十八条规程,作为一种「协调员失声时的应急处理」。

他没去猜——他去找证据。

他先派人下楼去看老周。三分钟之后助手跑回来,气喘吁吁:「办公室门关着,里面有灯,老周一个人坐着,电话都不响。他说外线全断了,他这边也不知道兰州那边怎么样。」

林医生让助手再下楼,找医院总机,让总机用别的线路、别的途径,去问虹桥机场塔台:今夜有没有一架从兰州起飞的医疗包机?现在在哪里?预计什么时候落地?

二十二分钟之后,助手又跑回来:「问到了。包机零点三十一分从兰州起飞,现在在飞,预计四时一十分前后落虹桥。」

林医生听完,深深吐了一口气。

零点三十一分包机起飞——那就说明心已经离体并在飞机上。陈大夫在兰州的取心动作已经完成。这条信息绕过了老周,从飞机本身那里得来。

他没有得到老周的指令。他得到的是「老周原本会告诉他的那个事实」的一个外部证据。

他对台上的所有人说:「按原计划。摘原心,准备吻合。」

那一夜的手术比预计长了一个多小时——因为沈志远在体外循环上的时间比正常多了五十分钟,下来之后心肺都需要更多的辅助才能稳住。但人活下来了。

事后追责,没人怪老周。第四十八条规程是这样写的:

协调员失联时,前线小组不可妄自动手,亦不可妄自撤场。须以一切其他途径——内线、邻院、机场塔台、家属、警务——拼凑出对方端的状态,方可决定本端动作。

老周在医院里又干了五年才退休。退休之前,他在协调办公室里加装了三样东西:一部直通邻院的内线电话,一台不依赖市政电源的应急电台,以及一本「每一步操作的纸面副本」——每下一道指令,他都要在册子上写一行字,注明时间、对方、内容。这样万一他自己出事,下一个坐到这桌前的人翻开册子就知道事情进行到哪一步了。

那本册子,就放在桌上,红黑两部电话之间。

第二天上午,老周回到办公室,头一件事是把那一夜从零点零七到四时一十的每一通电话、每一个动作,倒推着写进册子里。他写了整整三个小时,写满了七页。最后一页底下,他用铅笔加了一行小字:

我那时只能一个人在中间做这件事。今后这件事不能再只靠一个人。

概念解析

故事里那本规程册讲的是分布式系统里反复出现的一个协议:两阶段提交(Two-Phase Commit, 2PC)。它要解决的问题是——在多台机器、多个独立服务、甚至跨数据中心的多个数据库之间,如何让一组操作要么全部生效,要么全部不生效。换句话说,如何把单机数据库里那种「事务的原子性」推广到分布式环境里。

协议的形态。 2PC 的角色和故事里的角色一一对应:

  • 协调者(Coordinator) —— 老周。
  • 参与者(Participants) —— 兰州的供体小组、上海的受体小组。
  • 第一阶段,准备(Prepare) —— 协调者问每一个参与者「你能不能提交?」每个参与者各自做完所有的准备工作,把要做的事写进自己的预写日志(参见《土匪走了怎么算账》),但不让结果对外可见,然后回复「可以」或「不行」。在故事里,这就是兰州把胸腔打开、麻醉给完、钳子拿在手边,上海把病人转入体外循环、胸腔打开。每一端都做完了「随时可以下手」之前的所有事,只剩最后那一刀。
  • 第二阶段,提交(Commit) —— 如果协调者收到所有参与者的「可以」,它向每一个参与者发送「提交」的指令;任何一个参与者回复「不行」或超时,则发送「中止」。参与者收到指令之后,把准备好的修改正式生效,或者把准备好的修改丢弃。

这个协议的妙处在于:在第一阶段结束之前,整件事还能取消;一旦第二阶段开始,所有参与者都会朝同一个方向走。「准备好了」和「真的做」之间隔着一道协调者的指令。

致命的弱点。 但 2PC 有一个无法绕过的问题:协调者一旦在两个阶段之间出事,所有参与者都被锁死在「已准备」的状态里,进退两难。

故事里的 0:36–1:04 那二十多分钟,正是这个问题的真身:

  • 兰州已经收到「提交」并且执行完了——心已离体,飞机已起飞。
  • 上海还在「已准备」状态——病人在体外循环上,原心还在跳,等指令。
  • 协调者失联——线路断了,传不出指令。

上海那一端面对的,正是 2PC 教科书里的经典困境:

  • 不能擅自提交。 如果擅自往下走,万一兰州其实没成功(飞机出事、取心失败),结果是病人的胸腔空着,体外循环撑两个小时就到极限。
  • 不能擅自中止。 如果擅自撤场,万一兰州其实成功了,那颗心送到却没人接,整次手术作废。
  • 超时也救不了你。 单机数据库里超时可以默认中止;这里超时之后中止就废了一颗已经离体的心。参与者在 prepared 状态里能做的只有一件事——

这个「等」就是 2PC 的阻塞性(blocking)。这是协议本身的性质,怪不到实现头上。任何只靠协调者一个声音的协议都摆脱不了这个问题。

事实上,这正是《雾中无回音》中 FLP 不可能性在工程上的具体体现:在异步网络里,单靠消息传递做出确定性的共识是不可能的,2PC 就是这条不可能性在跨节点事务里的物理表现。它和《两位将军》讲的那种「永远没法用消息确认的对称协调」是同一族问题——多了一个协调者也只是把对称问题变成了「协调者在不在」的问题,没有本质上消除它。

林医生那一夜做对的事。 林医生没有擅自提交,也没有擅自中止。他做的是外部状态恢复:当协调者失声,他从其他途径——内线电话、医院总机、机场塔台——拼凑出「兰州那一端到底执行到哪一步了」的事实。

这正是真实的 2PC 系统在协调者崩溃后做的事。参与者会保留自己的 prepared 日志(参见《土匪走了怎么算账》),等协调者恢复或者由备用协调者接管,从各方的日志状态里重建出整个事务的进度。这个过程在工程上叫事务恢复(transaction recovery),常常需要人工介入——和林医生那一夜让人下楼跑遍机场是一样的。

老周后来加装的三样东西,对应的也是工程实践里的标准应对:直通邻院的内线 = 冗余通讯;应急电台 = 带外控制通道(out-of-band control channel);纸面副本 = 可重放的协调日志(coordinator log)。这些东西并没有让 2PC 不再阻塞,只是让协调者本身更不容易出事,以及万一出事之后下一个人能接得上。

后来的人怎么改。 工程界对 2PC 的不满由来已久,后来出了几条路:

  • 三阶段提交(3PC)。 在「准备」和「提交」之间多插一个「预提交(pre-commit)」阶段,号称解决了阻塞问题。但 3PC 的假设是网络同步——一旦发生网络分区,它和 2PC 一样会出错,而且推理起来更复杂。当反面教材有用,实际生产里几乎没人用。
  • Paxos Commit。 把协调者本身做成一组用 Paxos 选出来的节点(参见《传灯人》),这样单点协调者的故障被化解成了一组节点的多数派故障——而 Paxos 这群节点能容忍少数派失败。Spanner、CockroachDB 用的就是这条路。代价是延迟变大、协议变复杂。
  • Sagas。 干脆不要全局原子性,把长事务拆成一串本地步骤,每一步都配一个反向的「补偿动作」——如果中途失败,按相反的顺序把已经做过的步骤一一撤掉。这是另一种世界观:承认完美的同步不可能,于是不再追求它,转而保证「最终所有步骤要么都做完,要么都被撤掉」。下一则讲的就是这个。

工程界对 2PC 的普遍不满。 在真实的生产系统里,2PC 落到地上的样子常常是 X/Open XA 标准——MySQL XA、PostgreSQL prepared transactions、Java 的 JTA、Microsoft 的 DTC,都是这一族。理论上很漂亮,实际跑起来麻烦不断。

最常见的痛点有三个。第一,协调者一旦垮掉,参与者那一端的资源就被冻住——锁还在握着、连接还占着、磁盘上的 prepared 事务等着被处理。运维半夜接到的告警,多半就是「某某数据库有 N 条 in-doubt 事务」。第二,性能极差——一次跨节点提交至少两轮网络往返,再加上每一端的 fsync 落盘,整体延迟比单机事务高一个数量级。第三,故障恢复脆弱——任何一端的日志格式不兼容、版本升级没考虑 prepared 事务、协调者状态丢失,都会让一个本来该提交的事务永远卡住,最后还得人去翻日志手工处理。

正因如此,过去十几年里,互联网架构的主流是绕开 2PC:要么用消息队列加幂等的方式做最终一致(参见《两位信使》),要么直接拆成 Sagas,要么把强一致性需求收缩到单个分片内,跨分片就接受异步。Spanner 那一类「真敢用 Paxos Commit 把 2PC 跑在生产里」的系统是少数派——它们的代价是工程复杂度极高,平均延迟以几十毫秒起步。

为什么这则故事是分水岭。 这一辑分布式系统的故事到此为止,第一次正面写出了「跨节点的事务原子性」这个问题。前面《两个抄经人》讲的是单机的原子性与隔离性,单机里这一切都简单——一道屏障一拉,世界就分成「之前」和「之后」。一旦把这道屏障跨到多台机器、多座城市,上面的所有难题就一起涌出来:协调者要不要存在?协调者出事怎么办?参与者能不能擅自决定?阻塞能不能消除?

这些问题没有干净的答案。Paxos Commit 让协调者更可靠,但更慢;Sagas 放弃原子性,换回可用性;3PC 试图绕过阻塞,但只在更强的假设下才成立。每一条路都付出了一些代价。

故事里的协议和真实系统里的协议,在这一点上是一致的:没有免费的协调。你想让多个独立的参与者在某一刻同时下决心,就必须有人在中间承担风险——而那个人一旦出事,所有人都要付出代价。