算法的故事 / 全书目录0 / 30 章已读

第 15 章

一致性哈希——1997年,六位研究者如何让服务器扩容不再引发数据大迁徙?

正在加载交互实验…

第一季 · 人类如何驯服数据历史主线:1994—2007年 · Web 缓存热点、MIT 的一致性哈希、分布式哈希表与 Amazon Dynamo1997年5月,美国得克萨斯州埃尔帕索。在第29届 ACM 计算理论研讨会(STOC)上,一篇研究论文正式发表。作者来自麻省理工学院,共有六人:David Karger、Eric Lehman、Tom Leighton、Matthew Levine、Daniel Lewin 和 Rina Panigrahy。论文标题是:Consistent Hashing and Random Trees: Distributed Caching Protocols for Relieving Hot Spots on the World Wide Web《一致性哈希与随机树:缓解万维网热点的分布式缓存协议》。这篇论文后来成为分布式计算领域最有影响力的研究成果之一。[数字对象标识符][people.csail.mit.edu]但是,如果我们只说“他们发明了一种服务器扩容时减少数据迁移的方法”,就没有讲完整个故事。因为他们最初面对的问题,比服务器扩容更加复杂:互联网正在迅速增长,一些网站突然变得极其热门,而大量缓存服务器甚至无法始终掌握完整、一致的网络状态。研究者真正想解决的是:如果缓存服务器会不断加入、退出,甚至不同用户看到的服务器列表还不完全一样,系统怎样继续高效运行?这个问题的答案,最终发展成了我们今天熟悉的一致性哈希。

第一幕:1994年,一颗彗星让互联网服务器不堪重负

1994年7月,苏梅克-列维9号彗星的碎片接连撞击木星。这是人类首次直接观测到太阳系中大型天体之间的撞击事件。全球科学爱好者和普通公众都急切地想查看照片。美国喷气推进实验室(JPL)的网站因此迎来大量访问。但当时的 Web 基础设施远没有今天成熟。突发的大量访问,可能让单台服务器以及周围网络不堪重负。1997年的一致性哈希论文,开篇就以这类网站热点现象为背景。论文还提到了 IBM 网站在 Deep Blue 与 Kasparov 国际象棋比赛期间的访问热点,以及美国选举之夜一些政治网站承受的访问压力。[ics.uci.edu]这里有个值得注意的事实:一致性哈希最初研究的核心场景,是分布式 Web 缓存与网络热点,而不是某家互联网公司数据库扩容的事故。今天我们经常用“给 Redis 集群增加一台机器”解释一致性哈希,这个类比完全合理,但它是后来更普及的应用叙事。

1990年代的缓存服务器如何工作?

假设某个网站有一份热门文件:/images/jupiter.jpg第一次访问时,用户需要从原始网站下载。如果很多用户都访问同一张图片,原始服务器就要不断重复传输相同内容。于是,工程师在网络中部署缓存服务器。用户访问时,先查询缓存。缓存命中,就直接返回数据。缓存未命中,再去源服务器获取。

1990年代的分布式 Web 缓存

用户 1用户 2用户 3缓存服务器源网站缓存降低重复访问源站的负担
查看 SVG 配图原件
示意图原件 15-003.svg
15-003.svg · 点击查看大图
但马上出现新的问题。如果缓存服务器只有一台,它自身也可能变成热点。于是,我们需要很多台缓存服务器。这时,必须回答:某一张图片究竟应该放在哪一台缓存服务器上?于是,第四章的哈希表再次登场。

第二幕:普通哈希为什么在单机上很好用,到了集群却变得麻烦?

假设现在有三台服务器:A、B、C。我们为每条缓存记录计算哈希值。然后规定:serverIndex=h(key) mod 3serverIndex=h(key)\bmod3余数为0,放A。余数为1,放B。余数为2,放C。这看起来非常合理。哈希计算快。查询时不需要遍历所有服务器。相同的 key 总能找到同一台机器。

危机出现:增加第四台服务器

网站用户越来越多。公司决定增加服务器D。这时取模规则变成:serverIndex=h(key) mod 4serverIndex=h(key)\bmod4注意,我们只增加了一台机器。但几乎所有原来的 key 都要重新计算服务器位置。许多记录会从 A 移到 B,从 B 移到 C,或者从 C 移到其他服务器。这会造成两种不同的成本。对于持久化数据库分片,可能真的要迁移大量数据。对于缓存系统,即使不实际搬迁旧数据,新位置上的缓存也会发生大量未命中,从而引发回源流量。所以真正的成本不只是磁盘搬运,还包括:缓存命中率下降、网络流量上涨、源站负载增加,以及集群状态协调。

为什么变化会这么大?

用一个很小的例子。假设某条记录的哈希值为10。三台服务器时:10 mod 3=110\bmod3=1映射到 B。四台服务器时:10 mod 4=210\bmod4=2映射到 C。原来的数据并没有改变。改变的只是服务器数量。可是记录位置已经变了。这就是普通取模分片最明显的弱点:分片规则直接依赖服务器总数,所以服务器数量一变,许多 key 的映射就跟着改变。研究者需要一种新的映射方式:服务器增加或减少时,尽量只改变真正受影响的那部分 key。

第三幕:MIT 的突破——为什么不把整个哈希空间画成一个圆?

David Karger

MIT 计算机科学家 · 1997年一致性哈希论文作者之一研究领域包括算法、随机化方法、信息系统、网络与数据管理。
1997年的论文提出一种特别重要的思想:让哈希映射在可用目标集合变化时,只发生尽可能少的变化。研究者将其称为 Consistent Hashing。我们现在用更直观、后来广为使用的哈希环模型来理解。假设哈希值的范围是:0,1,2,…,990,1,2,\ldots,99这只是教学用的100个位置。真实系统往往使用更大的哈希空间,例如64位、128位或更大。我们把这些数字首尾连接。于是:99的后面又回到了0。整个哈希空间就变成一个圆环。

把服务器映射到环上

假设:h(A)=12h(A)=12h(B)=43h(B)=43h(C)=76h(C)=76我们在圆环上放置三台服务器。然后规定:一条记录归属于顺时针方向遇到的第一台服务器。例如:某条记录哈希值为30。从30顺时针走,首先遇到43号位置的B。因此记录归属于B。另一条记录哈希值为90。从90继续顺时针走,经过99、0,最后到达12号位置的A。因此它归属于A。关键是:服务器拥有自己的哈希位置,记录也拥有自己的哈希位置。记录的位置不再直接通过 mod 服务器数量 计算。于是新增机器时,旧服务器位置不必全部改变。

第四幕:亲眼看看,第四台服务器加入之后到底发生了什么

现在我们做整章最重要的实验。最初有三台服务器:A 在12。B 在43。C 在76。后来新增:D 在90。所有数据的哈希位置都保持不变。我们要观察:普通取模与一致性哈希,分别有多少记录改变归属。

哈希环扩容模拟器

30条固定记录 · 比较3台与4台服务器
交互实验
A12B43C763台服务器哈希空间 0—99顺时针查找服务器
查看 SVG 配图原件
示意图原件 15-007.svg
15-007.svg · 点击查看大图

从3台扩容到4台的变化对比
普通取模改变归属

0/30

0.0%
一致性哈希改变归属

0/30

0.0%
取模迁移比例0.0%
一致性哈希迁移比例0.0%
这是30条确定性样本的精确计算,服务器环位置固定,并不代表真实系统的平均迁移率;为便于比较,取模方案直接使用样本哈希值对服务器数量取余。
这个实验最值得关注的不是某一个具体百分比。而是:新服务器D在位置90加入后,只接管了原本由A负责的环上区间 (76,90]。因为之前的环上,从位置76顺时针到12之间都属于A。加入D后,76到90之间的记录会优先遇到D,而不是A。至于其他地方的数据:原本属于B的,还是属于B。原本属于C的,还是属于C。原本属于A且不在新接管区间的,也仍然属于A。这就是一致性哈希最重要的性质:新增一个节点,只需要重新分配有限范围内的键,而不是重算整个集群的布局。

第五幕:一致性哈希为什么叫 Consistent,而不是 Circular Hash?

到了这里,你可能会以为:“原来它的创新就是把服务器画在一个圆上。”实际上,并不是。圆环只是实现和解释这种思想的一种常见方式。1997年论文中更重要的概念,是哈希映射在节点集合变化时具备的稳定性质。我们可以从两个角度理解。

第一,单调性(Monotonicity)

假设原来有三台缓存服务器:A、B、C。现在增加D。理想情况下,一条记录要么仍然保留在原来的服务器上,要么移动到新增的D。不应该出现:原本属于A的记录,因为增加D,却无缘无故被转移到了B。这就是单调性的直观含义。在标准哈希环中新增一个服务器节点,确实符合这种性质。但普通取模:h(key) mod Nh(key)\bmod N一般没有这种保证。因为 N 改变后,所有余数映射规则都变了。

第二,尽量均衡(Balance)

仅仅减少迁移还不够。如果新增D后,它只接管1%的数据,那么扩容效果并不好。而如果它一下接管70%的数据,又可能把自己压垮。我们希望服务器承担相对均衡的负载。不过,必须区分两个问题:键数量是否均衡,与实际请求负载是否均衡,不是同一件事。一台服务器即使只存储10%的 key,如果其中包含一个每秒被请求百万次的热门对象,也可能过载。这恰好呼应了1997年论文的原始主题:缓解 Web 热点。论文并非只研究了圆环分片,还研究了如何利用随机化与分布式缓存机制处理热点,以及如何应对客户端对服务器集合看法不一致的情况。[people.csail.mit.edu]

一个容易忽略的原始贡献:不同客户端不必始终看到相同的服务器列表

1990年代的 Web 网络非常分散。缓存节点可能临时失联。用户与缓存之间的网络路径可能不同。某些客户端知道A、B、C三台机器,另一些客户端可能只知道A、C。如果必须保证每个用户在任何时刻都拥有完全一致的全局服务器名单,那么协调成本可能非常高。一致性哈希的理论研究希望:即使不同客户端看到的候选缓存集合略有差异,对相同对象的映射也不要发生大范围、不可预测的变化。这也是论文标题中 Distributed Caching Protocols 所强调的研究方向。所以,1997年的真正贡献比“扩容迁移少”更加深刻。它研究的是:如何在动态变化、信息不完全一致的分布式网络中,建立稳定、可扩展的对象映射规则。

第六幕:一个新问题——为什么三台服务器可能一台忙死,另外两台闲着?

假设环上服务器的位置是:A:12B:43C:76三个节点之间的间隔分别是:从A到B:31。从B到C:33。从C绕回A:36。这看起来非常均衡。但现实里的哈希位置并不一定这么巧妙。如果服务器位置变成:A:10B:15C:80那么环上区间长度分别是:5、65、30。这意味着,在均匀分布的 key 哈希值下,B可能只负责约5%的空间,而C却负责65%。
单节点环可能出现的分布失衡
A

30%

B

5%

C

65%

根据三个环位置计算出的理论区间长度比例,不代表实际访问请求的百分比。
如果只使用一个物理节点对应一个环位置,就可能出现严重失衡。于是,工程师使用了一种重要优化:虚拟节点(Virtual Nodes,简称 VNodes)。

一台物理服务器可以对应很多个虚拟位置

例如:物理服务器 A,不再只占据一个哈希位置。而是映射到:A#0A#1A#2A#3……物理服务器 B、C 也一样。这些虚拟位置会较均匀地散布在环上。一条记录首先找到顺时针方向的虚拟节点,再找到它对应的物理服务器。这样,一个物理服务器最终负责的是环上许多小片段,而不再是一个巨大的连续片段。由于多个片段的长度会相互平均,数据分布通常会更加平衡。

但是虚拟节点并不是没有成本

它需要更多的映射元数据。如果每台机器对应数百个虚拟节点,整个环就可能包含成千上万个位置。因此,实际系统需要考虑:
  • 虚拟节点数量。
  • 服务器的硬件容量差异。
  • 迁移时的数据量与网络带宽。
  • 实际 key 和请求的分布。
  • 故障切换策略。
虚拟节点也不一定能够解决单个 key 过热的问题。因为在最简单的一致性哈希设计中,相同 key 仍然会被映射到相同的逻辑负责人。如果这个 key 极其热门,还需要副本、请求分散、局部缓存等额外机制。

第七幕:用 Python 实现真正的哈希环

现在,我们自己实现一个最小版本。首先需要一个稳定的哈希函数。Python 内置的:
python
hash("hello")
通常不适合直接拿来生成跨进程、持久化的分布式分片位置。因为 Python 对字符串等对象的哈希包含随机化机制,不同进程中结果可能不同。因此,我们改用 hashlib。这里选择 SHA-256 作为稳定映射函数。注意,这里使用它是为了得到稳定且分布较均匀的哈希位置,并不意味着一致性哈希本身是一种密码协议。
python
import hashlib
import bisect


def hash_position(value: str) -> int:
    digest = hashlib.sha256(
        value.encode("utf-8")
    ).digest()

    return int.from_bytes(digest, "big")
接着定义哈希环:
python
class ConsistentHashRing:
    def __init__(self, replicas=100):
        self.replicas = replicas
        self.positions = []
        self.owners = {}

    def add_node(self, node: str):
        for i in range(self.replicas):
            key = f"node:{node}:replica:{i}"
            position = hash_position(key)

            if position in self.owners:
                raise ValueError("Hash position collision")

            self.owners[position] = node
            bisect.insort(self.positions, position)

    def remove_node(self, node: str):
        remaining = [
            p for p in self.positions
            if self.owners[p] != node
        ]

        self.positions = remaining
        self.owners = {
            p: owner
            for p, owner in self.owners.items()
            if owner != node
        }

    def get_node(self, key: str) -> str:
        if not self.positions:
            raise ValueError("Hash ring is empty")

        position = hash_position(f"key:{key}")

        index = bisect.bisect_left(
            self.positions,
            position
        )

        if index == len(self.positions):
            index = 0

        return self.owners[self.positions[index]]
这里 replicas=100 表示每台物理服务器对应100个虚拟节点。现在使用它:
python
ring = ConsistentHashRing(replicas=100)

ring.add_node("server-A")
ring.add_node("server-B")
ring.add_node("server-C")

keys = [f"user:{i}" for i in range(10000)]

before = {
    key: ring.get_node(key)
    for key in keys
}

ring.add_node("server-D")

after = {
    key: ring.get_node(key)
    for key in keys
}

moved = sum(
    before[key] != after[key]
    for key in keys
)

print("moved:", moved)
print("ratio:", moved / len(keys))
你可以亲自运行它,观察新增D之后的迁移比例。在哈希分布合理、虚拟节点数量足够的情况下,从三台容量相近的服务器扩容到四台,理论上新增服务器最终会接管约:14=25%\frac14=25\%的键空间。所以,预期迁移比例也接近25%。相比之下,普通 hash % N 在三台变四台时,通常会改变大部分 key 的归属。但这里要强调:上面的25%是均匀分布下的期望,不是每次实验的严格保证值。

代码还有哪些工程不足?

这是一个可运行的教学实现,而不是生产级一致性哈希库。例如:bisect.insort() 会在 Python 列表中移动元素,添加大量虚拟节点时成本并不是 O(log⁡n)O(\log n)。真实实现还要处理哈希碰撞、多进程配置一致性、节点成员变更、迁移状态、故障恢复等问题。另外,查到新负责人,不等于数据已经被迁移过去。如果用它做数据库分片,扩容时还必须设计:数据复制。双写或迁移期间的读写路由。校验。切换。清理旧副本。以及失败回滚。一致性哈希解决的是映射问题,不是完整的数据迁移协议。

第八幕:2007年,Amazon 把一致性哈希带入大型分布式存储

时间来到2007年。互联网已经从1990年代的静态网页,发展成了复杂的在线服务。Amazon 的网站需要支持海量用户访问、购物车、商品信息和各种后端服务。如果某台机器突然故障,系统不能因此长时间不可用。而且,一个大型系统里,服务器故障不再是极端意外。在拥有大量机器的系统中,硬件和网络故障是日常必须面对的问题。2007年,在第21届 ACM 操作系统原理研讨会(SOSP)上,Amazon 的研究人员发表:Dynamo: Amazon's Highly Available Key-value Store《Dynamo:Amazon 的高可用键值存储》。论文作者包括 Giuseppe DeCandia、Deniz Hastorun、Avinash Lakshman、Swaminathan Sivasubramanian、Werner Vogels 等九位研究者。这篇论文详细介绍了一种以高可用为重要设计目标的键值存储系统。[Amazon Science]阅读 Amazon Science:2007年 Dynamo 原始论文

Dynamo 面对什么问题?

设想有一个购物车记录:
text
cart:user:10086
如果它被存放在某一台服务器上,而那台服务器突然故障,用户是不是就无法打开购物车了?简单的一致性哈希能够告诉我们:这条记录应该归属哪一台机器。但如果这台机器已经离线呢?所以 Dynamo 不能只使用一致性哈希。它还必须解决数据副本、故障容忍、版本冲突和读写可用性。

一致性哈希在 Dynamo 中承担什么职责?

Dynamo 将键映射到环上的位置,并利用节点位置决定数据放置。2007年的论文介绍了虚拟节点,以及按照环上顺序选择副本节点的机制。它还进一步讨论了:故障期间如何继续服务。如何在多个副本之间协调。如何发现版本冲突。如何同步副本。因此,Dynamo 并不是“在 Redis 前面加了一个一致性哈希环”那么简单。一致性哈希负责相对稳定地划分键空间;副本管理和一致性机制才负责让数据在节点故障时仍能被访问。

为什么 Dynamo 不追求每次操作都立即全局强一致?

因为在部分机器不可达或网络分区的情况下,如果系统必须等待所有相关节点达成一致,某些请求可能无法继续完成。Dynamo 的设计选择是:对某些业务场景,优先保证较高可用性,并允许特定情况下出现需要后续解决的版本冲突。论文介绍了向量时钟、对象版本以及由应用协助解决冲突等机制。这并不意味着“所有 Amazon 数据都是最终一致性的”。Dynamo 是其中一种特定的系统设计,适用于相应业务要求。[Amazon Science]

第九幕:一致性哈希不等于分布式数据库

这其实是本章最值得强调的工程认识。假设你用一致性哈希,把一百万条记录分散到四台服务器。从映射层看,系统似乎完成了。但只要深入一点,就会遇到新问题。

第一种情况:某台服务器宕机

记录的负责人消失了。你需要决定:是暂时向下一个节点读取?还是由其他副本接管?如果有多个副本,哪一个才是最新版本?一致性哈希本身不会回答。

第二种情况:某台服务器扩容

新的服务器进入环。它应该接管一部分键。可是,那些键的数据还没有实际复制过来。在迁移期间,客户端究竟应该向旧节点读,还是向新节点读?如果写请求同时发生,怎样避免丢失更新?一致性哈希本身也不会回答。

第三种情况:不同客户端看到不同成员列表

客户端甲认为服务器集合是:A、B、C。客户端乙认为是:A、B、C、D。那么同一个 key 的映射可能不同。原始一致性哈希理论关心这种不一致视图造成的映射变化,但它不会自动保证客户端具有一致的集群成员视图。真实系统通常还需要成员发现、配置传播、故障检测和可能的共识机制。

第四种情况:热门 key

假设:product:iphone每秒被访问100万次。即使哈希环把整个键空间均匀划分,这一个 key 仍然可能集中到一个逻辑负责人上。因此还要采用:热点副本。请求级负载分散。CDN 缓存。本地缓存。或者业务级拆分策略。一致性哈希解决的是“数据应该映射到哪里”,而不是“整个分布式系统怎样保证正确性与高可用”。

第十幕:真的必须使用圆环吗?

现在,我们来提出一个更深的问题。Karger 等人的核心目标是:服务器集合变化时,尽量让 key 的归属保持稳定。那是否一定要把服务器放在圆环上?并不一定。还有一种很有趣的设计,叫作:Rendezvous Hashing,也称 Highest Random Weight(HRW)Hashing。其早期思想由 David Thaler 和 Chinya Ravishankar 等研究者在1990年代研究提出。它与一致性哈希有相近的目标,但不需要显式构造一个有序圆环。对于一个 key:我们分别计算它与每台服务器对应的评分:score(key,server)score(key,server)然后选择评分最高的服务器。当新增服务器D时:原来A、B、C之间的评分完全不变。只有当D的评分超过原负责人时,key 才会移动到D。因此,它也可以实现良好的最小迁移性质。这说明:一致性哈希最重要的思想不是圆形,而是在成员变化时保持映射稳定。这也是为什么今天的分布式存储系统会采用不同的分区策略,包括哈希环、HRW、一致性分片、固定槽位以及更复杂的自动分片管理。具体选择,取决于系统的容量、拓扑、元数据成本和迁移要求。

终章挑战:一台新服务器加入后,哪些数据应该移动?

现在回到最初的哈希环。有三台服务器:A:12。B:43。C:76。新增服务器D:D:90。规定:一条记录归属于顺时针方向遇到的第一台服务器。那么,哈希值为85的记录,在扩容前后应分别归属于谁?
你来决定数据归属
如果再问:哈希值为50的记录呢?扩容前,它归属于C,因为C在76。扩容后,它仍然归属于C。因为D位于90,根本没有影响50到76之间的映射关系。这就是一致性哈希的单调性。

尾声:1997年,一篇研究 Web 缓存的论文改变了分布式系统的设计方式

让我们回顾整个故事。1994年,苏梅克-列维9号彗星撞击木星,互联网网站承受突发的公众访问压力。1997年,MIT 的六位研究者发表论文,试图让分布式缓存系统能够在节点变化、热点出现以及网络状态不一致的情况下继续扩展。他们提出一致性哈希,强调映射在候选节点集合变化时尽量保持稳定。后来,工程师把这一思想运用到更广泛的系统中。虚拟节点改善了键空间分布。缓存集群用它减少扩容造成的缓存失效。分布式存储利用它组织数据分片。2007年,Amazon Dynamo 将一致性哈希与复制、故障恢复和版本管理等机制结合,展示了大规模键值存储中的实际应用。但这一章真正值得记住的,不只是一个哈希环。而是分布式系统设计中的一条重要原则:当系统的一部分发生变化时,尽量不要让不相关的部分被迫一起改变。我们前面学过的哈希表,追求快速定位。而一致性哈希,则在此基础上增加了一个目标:不仅要定位快,还要让定位规则在系统扩缩容时尽可能稳定。这就是从单机数据结构,走向分布式算法的一次重要跨越。

第十五章知识总结

概念核心思想
普通取模分片根据 key 哈希值对服务器数量取模
Consistent Hashing成员变化时尽量减少 key 的归属变化
Hash Ring把键与节点映射到环形哈希空间
Clockwise Successor由顺时针遇到的第一个节点负责
Monotonicity新增节点时,旧节点之间不必互相重新分配键
Virtual Nodes用多个虚拟位置改善物理节点的负载分布
Hot Key少量热门 key 可能造成请求热点
Replication通过副本提高可用性与容错能力
Dynamo结合一致性哈希、复制和版本管理的高可用存储设计

本章历史原始资料

Karger 等六位作者(1997):《Consistent Hashing and Random Trees》完整论文ACM STOC 1997:论文正式发表记录Amazon(2007):《Dynamo: Amazon's Highly Available Key-value Store》Daniel Lewin(1998):MIT 一致性哈希与随机树硕士论文
下一章预告

第十六章:分布式共识——1982年,为什么三位将军可能无法达成共同决定?

1982年,Leslie Lamport、Robert Shostak 和 Marshall Pease 发表了一篇后来被无数分布式系统工程师引用的论文:The Byzantine Generals Problem《拜占庭将军问题》。故事里,几位将军率领军队包围一座城市。他们必须统一决定:进攻,还是撤退?但将军之间只能通过信使通信。而且,某些将军可能是叛徒。他们不仅会撒谎,还可能向不同的人发送完全不同的消息。这时,问题就变得极其尖锐:如果系统中的某些参与者会故意欺骗其他人,剩下的参与者怎样才能达成一致?这将带我们进入分布式计算最困难、也最深刻的领域之一:拜占庭容错、Paxos、Raft,以及为什么“所有服务器都同意一件事”远没有想象中那么简单。