- 引言 — 两个节点之间,总有一张网络
- 重试与退避 — 别把消息刷屏
- 超时 — 提前定好要等到什么时候
- 心跳 — 「今天过得怎么样」这样一句小小的 ping
- 一致性模型 — 我们现在,在同一页上吗
- 背压 — 只按对方能承受的量来传
- 优雅降级 — 难熬的日子里,只留核心功能
- 幂等性 — 道歉说两遍,不会变成两倍的抱歉
- 两将军问题 — 那句话,真的传到了吗
- 作为带 TTL 缓存的信任 — 不更新就会过期
- 结语 — 没有完美的网络
- 参考资料
引言 — 两个节点之间,总有一张网络
分布式系统之所以困难,原因是明确的。网络不可信,消息会丢失或延迟到达,甚至连对方节点是否还活着都无法确定。而最关键的是,我永远无法直接看到对方节点的内部状态。我只能凭对方发来的消息去推测对方。
再读一遍这句话,会发现它同样也是在描述人与人靠近这件事。两个人是各自独立运行的系统,两者之间总是横着一条不可信的信道。表情、语气、回复的速度、沉默。我们靠这些不完整的信号,去推测对方内心这个观测不到的状态,度过一生。
所以在长期打交道分布式系统之后,我发现里面的解法竟渐渐听起来像是关系的建议。假设网络完美的系统,终究会崩溃。假设对方完美的关系,也是一样。两个世界的答案完全相同:不是完美,而是恢复力,加上明确的确认,以及一点点的宽容。本文把这种重叠拆成九个部分来讲。
重试与退避 — 别把消息刷屏
请求失败时,不能不假思索地立刻重发。如果对方服务器正因过载而摇摇欲坠,失败的客户端们同时一拥而上地重试,就会彻底把服务器压垮,这就是重试风暴(retry storm)。所以设计良好的系统会使用指数退避(exponential backoff):第一次重试等待 1 秒,下一次 2 秒,再下一次 4 秒。失败越持续,间隔就拉得越长,好给对方留出恢复的余地。再加上随机的抖动(jitter),防止所有人在同一瞬间蜂拥而至。
对方不回复时的态度,正应该是这样。已读不回就立刻连着发出「?」「在吗?」「在干嘛?」,这就是重试风暴。对方现在可能正在开会,可能很疲惫,也可能只是在整理思绪。这种时候需要的不是敲得更用力,而是把间隔拉长。发过一次就等,接下来要等得更久。
没有回复时,答案不是更频繁,而是更稀疏。退避不是漠不关心,而是给对方留出恢复的空间。
抖动也有它的教训。不要每次都在完全相同的时间、用完全相同的语气联系对方。可预测的压力,本身就是一种负担。
超时 — 提前定好要等到什么时候
没有超时的请求,是系统里最危险的东西之一。无限期等待响应的线程会一直占着资源、永远停在那里,这样的线程一旦堆积起来,整个系统就会被慢慢扼住喉咙。所以成熟的系统会给每一次外部调用设一个上限:「这段时间内没有回应,就视为失败,转去处理下一件事。」等待必须有一个终点。
在关系里,无限的等待同样是一个卡死的系统。无限期等待「总有一天他会敞开心扉」「总有一天他会改变」,就等同于一直占着资源、却什么也处理不了的线程。与此同时,我人生中其他的请求全都被挤到队列后面,悄悄地被饿死。
设定超时,不是在催促对方,而是对自己诚实:提前想清楚自己要等多久、想确认什么。如果那段时间过去了依然没有信号,这不是憎恨对方的理由,而是一个安静的许可——允许自己转向下一个状态。
没有设定终点的等待,不是奉献,而是卡死。
心跳 — 「今天过得怎么样」这样一句小小的 ping
分布式系统里的节点,为了确认彼此还活着,会定期交换小小的信号,这就叫心跳(heartbeat)。它不是沉重的数据,而是一句轻巧的「我在这里」。心跳如果连续几次没有到达,节点就会判定对方已经死掉,并开始做出应对。连接还活着的信心,不是来自某个重大事件,而是来自这些小信号的持续稳定。
关系的维系,方式完全一样。撑起一段关系的,从来不是盛大的事件或纪念日,而是「吃饭了吗?」「今天过得怎么样?」「上班顺利吗?」这类信息量几乎为零、却持续不断的 ping。当这些小信号开始中断时,连接其实早已在悄悄变弱。
重要的是频率,而不是大小。心跳本该是轻的,不需要每次都进行一场深度对话,只需要保持规律。而如果对方的心跳已经好几天没有出现,比起生气,更需要的是先察觉到这件事的敏感度。系统会把沉默读作故障的信号,人也是一样。
一致性模型 — 我们现在,在同一页上吗
在分布式系统里,强一致性(strong consistency) 意味着所有节点始终看到同一个值——不管谁在什么时候读取,都能保证拿到最新状态。代价是昂贵:协调需要时间,还会侵蚀可用性。相反,最终一致性(eventual consistency) 允许短暂的不一致:此刻不同节点的值可能各不相同,但只要没有新的更新,它们最终都会收敛到同一个值。好的设计不会给所有数据都强加强一致性,而是挑出真正需要强一致性的地方。
关系也是这样。如果想让一切时时刻刻都完全一致,两个人都会精疲力竭。「我们现在算是在交往吧?」「我们彼此都是认真的吧?」这类问题,属于需要强一致性的项。要是把这些交给最终一致性来处理,很可能等到很晚才发现,两个人其实一直活在两段不同的关系里,届时会狠狠破裂。这类事情必须明确达成一致,并立刻同步。
而「这周末看什么」「去哪家餐厅」这类事情,最终一致性就足够了。即便此刻没有完全对齐,几轮对话来回之后自然会收敛。如果连这种程度也要求强一致性,关系就会被开不完的会议塞满。
有些事必须现在就取同一个值,有些事只要终将一致就好。分清这两者,就是成熟。
背压 — 只按对方能承受的量来传
当速度快的生产者向速度慢的消费者不断倾泻数据时,消费者的缓冲区会溢出,系统最终崩溃。所以设计良好的管道会有背压(backpressure):消费者反过来向生产者发信号,「我现在只能接收这么多」,生产者就按这个速度放慢流量。处理能力由接收方决定,发送方去适应它,这就是整个结构。
人也有处理容量。把自己的烦恼、计划、委屈、要求,一股脑地倒给一个正过着艰难一周的人,对方的缓冲区就会溢出。再怎么正确的话,如果在对方没法接收的那一刻硬塞进去,也会丢失。这不是因为爱不够,而是因为队列已经满了。
背压的核心,是把调节速度的权力交给消费者。放到关系里,就体现为先开口问一句「现在有空聊这件事吗?」;而当对方发出「今天有点吃不消」这样的信号时,把它读作流量控制,而不是拒绝。好的伴侣会一边观察对方的容量,一边调整自己发送的速度。这不是减少爱,而是按对方能接住的节奏去给。
优雅降级 — 难熬的日子里,只留核心功能
大型服务不会因为一个部分故障,就把整体全部下线。如果推荐引擎挂了,就只暂时关掉推荐功能,把支付、登录这类核心继续运转下去。这就是优雅降级(graceful degradation):与其失去一切,不如为了守住最重要的部分,有意地放下不那么重要的功能。
人也不是只过好日子。有生病的日子,有疲惫的日子,也有心情崩溃的日子。在这样的日子里,要求关系全速运转是不现实的。风趣的玩笑、细腻的体贴、长时间的对话,这些附加功能可以暂时关闭。只要核心服务,也就是「我站在你这边」这个信号还活着,关系就能撑过那一天。
这对自己适用,对对方也一样适用。自己难熬的日子里,与其硬撑着当一个完美的伴侣,不如只守住核心。对方难熬的日子里,需要的是一种宽容——不要把不如平时细心这件事,当成故障来处理。要知道,只是前端此刻有点粗糙,后端依然好好地活着。
崩溃那天的目标,不是完美运转,而是让核心服务活着。
幂等性 — 道歉说两遍,不会变成两倍的抱歉
幂等性(idempotency) 是指同一个操作无论执行多少次,结果都和只执行一次一样。给支付请求加上幂等键之后,就算网络出错导致同一个请求到达两次,扣款也只会发生一次。改变状态的是第一次执行,之后的重发不会再进一步改变状态。
道歉正是如此。真正的道歉只要一次、好好传达到位,就能改变状态。可如果把同一句道歉重复十遍,关系会因此恢复十倍吗?不会。如果一次真心的道歉已经改变了状态,之后的重复就再也改变不了任何东西。反而「我都已经道歉成这样了」这种重复,会把道歉从帮助对方恢复的行为,悄悄变成减轻自己愧疚感的要求。那就不再是幂等的了,而是一次不断累积副作用的重发。
相反的陷阱也存在:对方其实已经接受、状态已经改变了,却因为没能确认到这一点,而不断重发同一句道歉。放到系统里,应当信任对方的 ACK 并停止重发。放到关系里,当对方回应「没事了,我明白了」时就此打住,就是这份信任。
说两次对不起,不会变成两倍的抱歉。一次到位的道歉才会改变状态,之后的重复都是为了自己。
两将军问题 — 那句话,真的传到了吗
两将军问题(Two Generals' Problem) 是分布式计算中最著名的不可能性结果之一。两位将军隔着一道山谷,只能靠穿越敌营的信使来联络,必须就进攻的时刻达成一致。问题在于,任何一条消息都可能在途中丢失。将军 A 发出「黎明进攻」。将军 B 发回确认回执。可是 A 不知道那份回执有没有送到。于是 B 又想要确认 A 是否收到了回执。这种「确认的确认」会无止境地延续下去,在不可信的信道上,完美的共识在理论上是不可能的。
关系里最悄无声息的误解,正是从这里诞生的。我明明觉得自己已经把心意传达出去了。可那条消息到底有没有送到对方那里,就算送到了,是否按我的意图被理解,我都无法百分之百确定。「他当然知道」是一个未经验证的假设。没有表达出来的心意,是一个没有发送的数据包,即便发送出去的心意,也可能遭遇误解这种丢失。
既然理论上完美的共识不可能达成,实践中的答案是什么呢?和系统的答案一样:明确地互相交换 ACK,重要的事情多跑一轮确认。反问一句「我是这样理解的,对吗?」;把对方说的话,用自己的语言复述回去加以确认。虽然永远无法抵达完美的确定,但每多跑一轮明确的确认,误解的概率就确实会降低。
假定对方已经明白了,就是一条没有送达回执的消息。越是重要的话,越要多绕一轮确认。
作为带 TTL 缓存的信任 — 不更新就会过期
缓存是为了不必每次都重复一次昂贵的查询,而把结果预先保存下来的装置。但它通常会带一个TTL(Time To Live),也就是有效期。一旦过了那段时间,缓存的值就会过期,必须重新回到源头去确认。TTL 存在的理由很简单:世界在变化,陈旧的缓存迟早会和现实脱节。
信任正是这样一种缓存。我们不会每一刻都从零开始验证对方,而是基于长期积累的经验,把「这个人值得信任」这个值缓存下来,从而跳过每次都要怀疑一遍的昂贵运算。这正是信任能让关系变得又快又轻的原因。只是这份缓存也带着 TTL——长期没有更新、被放置不管的信任,会悄悄地陈旧下去。所以信任需要靠持续的互动,不断地被刷新。
从这个角度看,背叛就是缓存失效(cache invalidation)。一次背离信任的事件,会把此前缓存下来的信任值整个作废。失效之后,就重新回到了昂贵查询的时代——每一次都必须重新回源确认,而重建缓存所花的时间,远比第一次建立时更长。信任这个缓存,建立得很慢,失效只在一瞬间,而重建则是代价最高的。
信任是一个缓存的值,不是永久存储的真理。不更新就会过期,背叛则会把这份缓存整个作废。
结语 — 没有完美的网络
以上九件事看起来是各自独立的故事,但根源只有一个:分布式系统困难的理由,和关系困难的理由,其实完全相同。我无法直接看到对方的内部状态,我们之间的信道不可信任,而对方是一个不会按我的意愿行动的独立存在。就在这样的前提之上,我们必须协作。
在这样的前提之上,任何以完美为目标的设计都注定失败。相信消息绝不会丢失的协议,相信对方总是能完美理解的关系——两者都会在第一次故障时崩溃。所以好的系统和好的关系,追求的是同一件事:不是消除失败,而是扛过失败。也就是恢复力、明确的确认,以及把对方的不完美当作正常状态、而非故障来接纳的宽容。
与其把消息刷屏,不如退避;与其无限等待,不如设定超时;持续地发送小小的心跳;重要的事情就强一致地达成共识;按对方的容量来传递;难熬的日子里只守住核心;道歉就好好地道一次;重要的话多绕一轮确认;勤快地刷新信任这份缓存。这是好的工程,或许,也是好的爱。
参考资料
- Designing Data-Intensive Applications (Martin Kleppmann): https://dataintensive.net/
- 两将军问题: https://en.wikipedia.org/wiki/Two_Generals%27_Problem
- 幂等性(Idempotence): https://en.wikipedia.org/wiki/Idempotence
- Timeouts, retries, and backoff with jitter (AWS Builders' Library): https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/
- 一致性模型(Consistency model): https://en.wikipedia.org/wiki/Consistency_model
현재 단락 (1/38)
分布式系统之所以困难,原因是明确的。网络不可信,消息会丢失或延迟到达,甚至连对方节点是否还活着都无法确定。而最关键的是,我永远无法直接看到对方节点的内部状态。我只能凭对方发来的消息去推测对方。