故事
五月初,塔钦的雪还没全化。
次仁是个五十七岁的向导。他站在自家屋子门口,望着不远处的冈仁波齐——山尖还裹着一圈新雪,阳光从东面斜着打下来,把南坡那几道冰壁照成一片淡金。今年是他第三十二个朝山季。
早晨来的是一队三十人的四川人。他们昨天从拉萨坐车过来,高原反应大多已经缓过去了,脸上是那种头一次见这座山的人特有的神情——紧张、兴奋,还有一点不知该把话说到哪里的沉默。次仁在塔钦的服务点把他们分成一队,给每人发了氧气袋、登山杖,又让他们把厚外套全拿上,因为五月的转山路上午热得出汗,下午一过三点就起风。
"今天走到哲日普。"他对大家说。这是一句他在三十二年里说过不知多少遍的开场话。"明天上卓玛拉山口,再下到尊珠寺。后天回塔钦。"
转山的路有一种秩序。
从塔钦出发,顺时针绕山,一圈五十二公里。路上有几处歇脚的地方,从塔钦数下去:先是曲古寺,再是哲日普寺,然后上到卓玛拉山口——那是全程最高的点,五千六百多米——再下到尊珠寺,最后回塔钦。这几处大寺之间,又散落着一些小一点的歇脚点:帐篷茶馆、几家牧民的夏季窝棚、从前商队留下的几间石屋。它们没有名字,朝圣者们按地点叫——"过了曲古寺的那个茶馆"、"哲日普前面那片经幡下的那家"。
朝圣者走得快慢不同。磕长头的人走得最慢——他们把身体整个伏在路上,三步一磕,一天走不到三公里。骑马的人最快,一天就能把一圈绕完。中间还有各式各样的人——结伴的家庭、一个人背着行李的苦修者、外国登山客、从尼泊尔来的印度教徒。他们都在同一条顺时针的路上走着,速度不同,中途歇脚的地方也不同。
次仁观察过很多年。朝圣者到哪一段路会选择停下,其实有一个极其朴素的规则——他们会走到太阳开始低下去的时候,停在路上能看到的下一处歇脚点。不是刻意选的,是路自然这么安排的。你从塔钦出发,走了一上午,下午两点钟,你抬头看见远处经幡一大片,底下曲古寺的金顶反着光——你就在曲古寺吃午饭。继续走,下午快五点,你看见前面一个黑色的帐篷冒着烟——那是曲古寺和哲日普寺之间有人新搭的茶馆——你就在那里歇脚。你要走得慢一点,到五点半才看到茶馆的烟,那就歇在那里;你要走得快一点,天还亮着就过了那个茶馆,那就继续往哲日普寺走。
每一处歇脚的地方,自然而然地就接收了从它上一个歇脚点到它自己之间那段路上走累了的人。
没有人专门安排过。山路上也没有任何一块牌子告诉你该在哪里停下。但这件事就这么发生着——一年接一年,一代接一代。
第一天傍晚,次仁带着他的队伍到了哲日普寺。
哲日普在山的北面,正对着冈仁波齐的北壁。这一段山壁是冈仁波齐最震撼的一面——一整块几乎垂直的岩石,从山脚直拔到六千六百米的顶上。住在哲日普的朝圣者通常都会在傍晚走到寺外面的一片草地上,面朝北壁磕几个头,或者就静静地坐着。
次仁把他的队伍安顿好之后,到寺里厨房那边和几位熟悉的向导喝酥油茶。一位叫格桑的老向导——他七十多岁了,这几年不怎么带队,只在寺里帮忙——说:
"达瓦死了。"
次仁放下茶碗。"什么时候?"
"冬天里。一月份。他儿子从阿里来接他下山,他说他不走。后来大雪封了路,四月才找到他。"
达瓦是从曲古寺到哲日普的那段路中间搭帐篷茶馆的那个人。六十多岁,一个人住一顶帐篷。次仁认识他大概二十年——每次路过那里,都进去喝一碗酥油茶,坐十分钟就走。今年春天进山的时候,次仁路过那个位置,没看见帐篷,以为还没搭起来。
"帐篷呢?"
"他儿子把帐篷拆了拉回阿里了。那个位置今年应该没人再去了。"格桑说。"明年或者后年会不会有别人去——说不准。"
次仁点了点头,没说话。他喝完那碗酥油茶,出门走到北壁前那片草地上,坐下来看了很久的山。
达瓦不在了。那段路上少了一个歇脚点。
第二天一早,次仁带队上卓玛拉山口。爬山的那段是全程最苦的——从四千八百米爬到五千六百米,高度差八百米,要在缺氧的情况下走将近五个小时。队伍里有两个四川人开始呕吐,次仁分了一个氧气袋给他们,又放慢了整体的速度。
过了山口下来,到尊珠寺已经下午五点了。第二天的行程比预计的长了一个多小时,但总体上顺利。
第三天回到塔钦。队伍散了,四川人各自回拉萨。次仁在服务点登记完这一队,回家睡了一整天。
五月下旬,他带第二队。这是十个从尼泊尔过来的印度教徒。他们走得比四川人慢——转山对印度教徒来说是重要的修行,他们不急。
第一天到曲古寺,照常歇脚。第二天从曲古寺出发走到哲日普。
走到达瓦原来那个帐篷的位置——现在只剩一圈石头,大概是从前压帐篷边用的。次仁没停。队伍里有两位年长的印度教徒也没打算停——他们本来的计划就是一口气走到哲日普。但队伍里一个小伙子走得快,想停一下喝口茶——他的向导手册上写着这里有一家茶馆。次仁给他看了看那圈石头:
"今年这里没有茶馆了。"
小伙子看了看天。下午两点多。前面到哲日普还有大概两个半小时的路。
"能走得到吗?"他问。
"走得到。"次仁说。
他们接着走。下午快五点的时候到了哲日普。小伙子有点累,但没出任何事。
次仁晚上在寺里喝酥油茶的时候想到:这件事就这么过去了。达瓦不在了之后,原本会在他那儿歇脚的那十几个朝圣者——五月这一季大概就是这个数——全都走到了下一个歇脚点,也就是哲日普。哲日普每晚多十几个客人,厨房多煮几锅汤,僧人多铺几张床。多出来的事情哲日普接得住。
山的其他地方呢?塔钦到曲古寺之间的那些朝圣者不知道发生了什么,不受任何影响。走到曲古寺歇脚的人——他们本来在曲古寺歇脚,还是在曲古寺歇脚,人数没变。过了卓玛拉山口之后到尊珠寺的那些人,更加不知道——他们在山的另一边,他们的路上什么都没变。
变化只发生在一段路上:从曲古寺到哲日普之间那一段。只有这一段的朝圣者需要改变计划——走到下一个歇脚点。其他每一段都完全照旧。
次仁意识到,这正是这条路几百年来的办法。一个歇脚点不在了,它原来守着的那段路上的人,自然地就顺延到下一个歇脚点。不需要有人去重新安排整条山路,不需要通知所有的朝圣者,甚至不需要所有的朝圣者知道这件事。整座山几乎察觉不到。
六月中旬,次仁带第三队。
这次带的队伍里有一个从林芝过来的年轻向导,是次仁带的徒弟——一个二十二岁的藏族小伙,叫丹增。丹增这两年跟着几个老向导学,今年开始独立带队。次仁顺便把他带上来走一圈,算是年终的一次考核。
走到曲古寺和哲日普之间那段路,丹增看到一顶崭新的白色帐篷,架在达瓦原来那个位置稍微往前一点——大概往哲日普方向走了五百米的位置。帐篷外面有一个年轻女人在烧茶,一个三四岁的小孩在帐篷边玩石头。
次仁和这个女人打了招呼。她说她是那曲那边过来的,男人也是做茶马生意的,今年听说这条路上五月到十月能做点生意,就搬过来了。帐篷是上个月搭的。
他们在这顶新帐篷里喝了两碗酥油茶,丹增付了钱。出帐篷之后,次仁对丹增说:
"达瓦没了之后,这段路上少了一个歇脚的地方。现在这个女人来了,又多了一个歇脚的地方——但她的位置和达瓦不完全一样,更靠哲日普一点。"
"那有什么区别?"丹增问。
次仁想了想。
"原本从达瓦帐篷到哲日普之间那段路上走累的人,现在有了一个新的选择——他们可以在这个女人的帐篷里歇下来,不用一直走到哲日普。而从曲古寺到达瓦帐篷之间那段路上走累的人,还是得走到这个新帐篷。"
"曲古寺那边呢?"
"不影响。曲古寺每天来多少人歇脚,还是多少人。"
丹增点了点头。
"所以——"次仁继续说,"这条山路每年都会少一个人、多一个人——帐篷搭起来,帐篷拆掉,寺庙修好,寺庙倒了——每一次变化,都只影响那一处的上下两段。整条路从来不需要重新编排。"
那天傍晚到哲日普的时候,风开始起来了。次仁和丹增站在寺外那片草地上,面对着北壁。太阳快要落下去了,山壁一层层变成深红色。
丹增问:"师傅,这条路是谁最早安排的?"
次仁笑了一下。
"没人安排。"他说。"就是走出来的。几百年里有人搭过帐篷,有人开过茶馆,有人立过石屋——哪里能遮风,哪里有水,哪里离路不远——人就在哪里停下。停的人多了,就成了一个歇脚点。歇脚点多了,就成了这条路。"
丹增沉默了一会儿。
"那要是哪天整条路上所有的歇脚点都变了——曲古寺塌了,哲日普关了,所有的帐篷都撤了——怎么办?"
"不会那样。"次仁说。"从来不会所有的都变。每年都有一两处变,其它的照旧。这样朝圣者就永远有地方歇脚,我们带队的也永远知道下一站在哪里。这座山能一直这么转下去,不是因为它一动不动——是因为它每一次的变动都很小,很局部。"
风更大了。山顶的雪被风吹起来,在天空里成了一道白线。次仁把外套的拉链拉高了一点。
"转山的规矩,"他说,"不在山上,也不在我们这些带队的身上——在这条路的脾气里。"
概念解析
这则故事讲的是分布式系统里被引用最广、落地最多的一项工程技术——一致性哈希(Consistent Hashing)。这一方法由 David Karger 等人在 1997 年发表的论文 Consistent Hashing and Random Trees: Distributed Caching Protocols for Relieving Hot Spots on the World Wide Web 中提出,最初是为了解决网络缓存的分布问题。今天,它是 Amazon Dynamo、Apache Cassandra、Memcached、以及几乎所有现代分布式键值存储的底层技术。
问题
假设你有 N 台服务器,要在它们之间分配一堆数据(以键值对的形式,比如用户 ID 到用户资料)。最直接的办法是用取模哈希:
server_index = hash(key) mod N
这个办法在 N 不变的时候工作得很好——每个键都有一个确定的服务器,查找和写入都很快。但问题出在N 变化的时候。你加了一台服务器,N 从 10 变成 11;或者一台服务器坏了,N 从 10 变成 9。无论哪种情况,hash(key) mod N 对几乎每一个键给出的结果都会变——大约有 (N-1)/N 比例的数据需要搬家。对一个有十亿条记录的系统,这意味着几乎十亿条数据要重新分配位置。
在大规模系统里,这种"洗牌式"的重新分布是灾难性的。它意味着扩容要么极慢、要么需要临时停机,任何一台机器的故障都要触发大规模数据移动,而这些移动本身又会让剩下的机器过载、引发级联故障。
一致性哈希的办法
一致性哈希重新设计了这个映射:
- 把哈希空间(比如 0 到 2³²-1 的所有整数)想象成一个环——最大值的下一个是 0,形成一个闭环。
- 每台服务器用自己的身份(比如 IP 加端口)算一个哈希值,定位到环上的某一点。N 台服务器就在环上分散成 N 个点。
- 每个数据键也算一个哈希值,定位到环上的某一点。
- 这个键归属于哪台服务器?从它的位置出发,沿顺时针方向找到的第一台服务器。
这条规则产生了一个关键性质:加/减一台服务器,只有环上的一段会受到影响。
具体地——假设服务器 A、B、C 顺时针排列在环上。所有落在 A 和 B 之间环段上的键,归属于 B(顺时针遇到的第一台)。所有落在 B 和 C 之间的键,归属于 C。现在你新加一台服务器 D,它在环上落到了 B 和 C 之间的某个位置。发生了什么?
- 原本归属 C 的键里,有一部分(落在 B 到 D 之间的那些)现在归属 D。
- 原本归属 A 的键,还是归属 A。
- 原本归属 B 的键,还是归属 B。
- 原本归属 C 的键里,落在 D 到 C 之间的那些,还是归属 C。
只有"B 到 D"那一段环上的键需要从 C 搬到 D 上。其它所有数据都不动。
如果 N 台服务器均匀分布在环上,新增一台只会引起大约 1/N 比例的数据重新分配——朴素取模哈希下这个比例是 (N-1)/N。这是一个数量级的改善。
和故事的对应
冈仁波齐的转山路就是这样一个环。塔钦是起点也是终点,五十二公里的顺时针路线刚好闭合。路上的每一处歇脚点(曲古寺、达瓦的帐篷、哲日普寺、尊珠寺、一路上各种小茶馆)相当于环上的一台"服务器"。每一个朝圣者相当于一个需要"分配"的键。
- 分配规则:朝圣者走到天色将晚的时候停下来歇脚——停在他顺着路能看到的下一处歇脚点。这就是"沿环顺时针找到的第一台服务器"。
- 局部性:达瓦的帐篷没了,只有从曲古寺到达瓦帐篷之间这一段路上的朝圣者受到影响——他们顺延到哲日普。
- 环的其它部分完全不受影响:塔钦到曲古寺,卓玛拉山口前后,尊珠寺到塔钦——这些路段上的朝圣者不知道达瓦去世这件事,也完全不需要知道。
- 新增歇脚点:那位那曲来的女人搭了新帐篷之后,只影响新帐篷和哲日普之间那一小段——朝圣者可以选择提前歇脚。其它一切照旧。
故事结尾次仁说的那句话——"这座山能一直这么转下去,不是因为它一动不动,是因为它每一次的变动都很小,很局部"——正是一致性哈希这个工程构造的精髓。
虚拟节点(Virtual Nodes)
原始的一致性哈希有一个实际问题:如果服务器少,环上的分布可能非常不均匀。三台服务器各自哈希到环上一个随机位置,有可能一台占了半个环,另一台只占十分之一——数据和负载就严重倾斜。
解决办法是虚拟节点:让每台物理服务器在环上占多个位置(比如一台机器生成 150 个不同的哈希值,每一个都算作一个"虚拟"的环上点,但所有这些点都指向同一台物理机)。这样 N 台机器在环上就有 N×150 个点,分布变得均匀得多。
这在转山的类比里是什么?——大致相当于每座寺庙不止守一段路。哲日普寺既接纳从曲古寺方向来的朝圣者,也接纳过了卓玛拉山口下来的朝圣者(如果有人从反方向走过来)——它在路上"占"了好几个位置。每一处有影响力的歇脚点都这样。结果是整条路上的流量被自然地分散。
Rendezvous Hashing
一致性哈希有一个后来出现的亲戚叫 Rendezvous Hashing(David Thaler 和 Chinya Ravishankar 在 1996 年提出,稍早于 Karger 等人)。它不用环:对每一对 (key, server),算一个哈希值 h(key, server_i),key 归属于哈希值最大的那台服务器。
两者的权衡:
- 一致性哈希查询是 O(log N)(在环上二分),适合服务器数量大的场景。
- Rendezvous Hashing 查询是 O(N)(要遍历所有服务器算哈希),但代码更简洁,而且在某些加/减机器的情况下表现更好。
在小规模系统(比如 N < 100)里,Rendezvous Hashing 常常是更好的选择。大规模则用一致性哈希。
一致性哈希的应用
- Amazon Dynamo(2007)——整个 Dynamo 风格的数据库(包括 Cassandra、Riak、DynamoDB)都把一致性哈希作为数据分片的基础。
- Memcached / Redis Cluster——分布式缓存的经典用法。
- Akamai 的 CDN——Karger 那篇原始论文就是为解决 Web 缓存的"热点"问题写的,Akamai 基于这项技术成为了世界上最大的 CDN。
- 现代 Load Balancer——比如 Google 的 Maglev 和 Envoy,在负载均衡时用到一致性哈希的变种。
- 分布式哈希表(DHT) ——[✓#7 九叠屏] 讲的 Chord 协议,底层就是一致性哈希。
更深的启示
一致性哈希体现了分布式系统设计里一条非常普遍的原则——局部性原则(Locality Principle):
当一个大系统发生变动时,变动应该只影响局部,不应该波及全局。
这条原则在分布式系统的很多地方反复出现:
- 一致性哈希让节点的加入退出只影响少数邻居。
- Shuffle Sharding ——[待写] 让每个用户只依赖一小组随机选择的服务器,一个用户的流量异常不会影响其他用户。
- Cell-based Architecture ——[待写] 让一个"cell"的故障只影响 cell 内部的用户,爆炸半径被隔离。
- Rack/AZ Awareness ——[待写] 让同一份数据的副本分散在不同的机架、不同的可用区,一次局部故障不能打掉所有副本。
这些技术的共同点都是:系统的故障半径 ≠ 系统的规模。故障永远只影响一小片区域,而系统整体的可用性由大量"各自独立"的小区域叠加而来。
这条原则其实远远超出计算机科学。冈仁波齐的转山路是一个几百年来都在以这种方式运作的"分布式系统"——没有中央调度,没有全局协调,但它有韧性,有容错能力,有新陈代谢。每年有歇脚点消失,有新的歇脚点出现,但整座山的"服务"从未中断。塔钦到曲古寺那一段路上的朝圣者,完全感受不到哲日普附近发生的任何变化。整个系统通过无数次局部的、几乎察觉不到的调整来吸收一切扰动。
这正是一致性哈希追求的理想。一个好的分布式系统,不应该在节点变动时抖动;不应该要求所有参与者都知道全局状况;不应该让一次故障波及整个系统。它应该像冈仁波齐这条转山路一样——变动永远是局部的,而整体永远能转下去。