2020 年我做一个分布式缓存。业务的读压力越来越大,单台 Redis 扛不住了,我决定加机器——上 4 台 Redis,把缓存数据分散到这 4 台上。要解决的核心问题只有一个:一个 key 进来,我怎么决定它该存到、该去哪一台机器上读?第一版我做得很直接:把 key 算个哈希值,对机器数取模——hash(key) % 4,算出 0、1、2、3,就去对应那台。本地一测——很顺:几百万个 key 均匀地散在 4 台机器上,每台四分之一,读写都飞快,缓存命中率稳定在 95% 以上。我心里很踏实:"缓存分片嘛,不就是 hash 一下取个模。"可大半年后,业务又涨了,4 台又不够了,我要加第 5 台。我以为这是件小事——加台机器、改个配置而已。结果配置一改、服务一重启,监控瞬间炸了:缓存命中率断崖式地从 95% 跌到了个位数,几乎所有请求都没命中缓存、直接砸到了数据库上,数据库 CPU 瞬间打满,整个系统濒临瘫痪。我盯着这个监控图想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"缓存分片,就是把 key 哈希一下,对机器数取模,分到对应的机器上"。这个想法在机器数不变时毫无破绽。可它藏着一个致命的耦合:hash(key) % N 这个算式里的 N,就是机器数——一个 key 该去哪台机器,死死地绑在了"当前有几台机器"上。机器数从 4 变成 5,% 4 变成 % 5,于是几乎每一个 key,算出来的机器号都变了。这意味着:几乎全部缓存,在我加机器的那一刻,集体失效了。问题的根子,就是 hash 取模这种分片方式,把"key 到机器的映射",和"机器总数"绑死了。要解决它,需要一种加减机器时,绝大多数 key 的归属都不变的分片办法——这就是一致性哈希。这篇文章就把一致性哈希梳理一遍:为什么 hash 取模一加机器就雪崩、哈希环怎么把映射和机器数解耦、虚拟节点怎么解决数据倾斜、节点增删时为什么只挪一小段数据、加权分布怎么做,以及哈希函数选择、热点 key、迁移期双查这些把分布式分片真正做对要避开的坑。
问题背景
先把那次缓存雪崩的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:一个 4 台机器的分布式缓存,用 hash(key) % 4 决定 key 落在哪台。要扩到 5 台时,只是改了配置重启,缓存命中率从 95% 断崖跌到个位数,请求全部穿透到数据库,数据库 CPU 打满、系统濒临瘫痪。
我当时的错误认知:"缓存分片就是 hash 取模,N 台机器就对 N 取模;加机器无非改个数字。"
真相:hash(key) % N 把"key 到机器的映射"死死绑在了机器总数 N 上。N 一变(4→5),% 4 变 % 5,几乎每个 key 的归属机器都变——等于全部缓存集体失效。正确做法是一致性哈希:把节点和 key 都映射到一个环上,key 顺时针归属最近的节点;加减一个节点,只影响环上相邻的一小段,被搬动的 key 只有约 K/N。
要把分布式分片做对,需要几块认知:
- 为什么 hash 取模一加机器就雪崩——映射和机器数被绑死了;
- 哈希环——把节点和 key 放到同一个环上,解耦机器总数;
- 虚拟节点——解决环上分布不均导致的数据倾斜;
- 节点增删——为什么只挪相邻一小段,而不是全量重映射;
- 加权分布、哈希函数、热点 key、迁移期双查这些工程坑怎么处理。
一、为什么 hash 取模一加机器就雪崩
先把这件最根本的事钉死:hash 取模分片,把"key 落在哪台机器"这件事,和"当前有几台机器"这个数字焊死在了一起;只要机器数一变,几乎每个 key 的归属都会跟着变——在缓存场景里,这等于让全部缓存在同一瞬间集体失效。
下面这段代码,就是我那个"加台机器就雪崩"的第一版——它对机器数取模:
import hashlib
def hash_key(key: str) -> int:
"""把任意字符串映射成一个 32 位整数。"""
h = hashlib.md5(key.encode("utf-8")).hexdigest()
return int(h[:8], 16) # 取前 32 bit,范围 0 ~ 2^32-1
def node_of_key_naive(key: str, nodes: list) -> str:
# 反面教材:hash 取模分片。N 台机器,就对 N 取模。
idx = hash_key(key) % len(nodes)
return nodes[idx]
# 破绽:返回值【强依赖 len(nodes)】。一旦增删机器,
# len(nodes) 变了,几乎每个 key 算出的 idx 都跟着变。
这段代码在机器数固定时完美无瑕:哈希函数足够散,几百万 key 会均匀地落在每台机器上。它的问题不在代码本身,而在一个被忽略的隐含依赖:返回结果 idx,不只取决于 key,还取决于 len(nodes)。我们来实测一下,从 4 台扩到 5 台,到底有多少 key 被重新映射:
def count_remapped(keys: list, old_nodes: list,
new_nodes: list) -> int:
"""统计:节点列表从 old 变到 new,多少 key 的归属节点变了。"""
moved = 0
for k in keys:
old = node_of_key_naive(k, old_nodes)
new = node_of_key_naive(k, new_nodes)
if old != new:
moved += 1
return moved
# 实测:4 台扩到 5 台,约 80% 的 key 都被重新映射。
# 这 80% 的 key 在新机器上全部 miss —— 缓存瞬间失效,
# 请求穿透到数据库,这就是扩容引发雪崩的根源。
实测结果触目惊心:4 台扩到 5 台,大约 80% 的 key 算出的机器号都变了。从理论上讲,% N 改成 % (N+1) 后,一个 key 还能落回原机器的概率,只有大约 1/(N+1)——也就是说,扩容时被搬动的 key 比例,高达 N/(N+1),机器越多,这个比例越接近 100%。在数据库分片里,这意味着一次扩容要搬走几乎全部数据;在缓存里更要命——缓存不需要"搬",但那 80% 的 key 在新机器上全是 miss,等于80% 的缓存在加机器的一瞬间集体失效,海量请求同时穿透到数据库。问题的根子清楚了:取模分片把映射和机器数绑死了,我们需要一种映射方式,让它在机器数变化时,绝大多数 key 的归属保持不变。
二、哈希环:把节点和 key 放到同一个环上
一致性哈希的核心思路,是不再对机器数取模。它想象出一个首尾相接的环(比如 0 到 2^32-1,最大值之后绕回 0)。然后做两件事:第一,把每台机器,用哈希算出一个值,放到环上的某个位置;第二,把每个 key,也用同一个哈希函数算出值,放到环上。一个 key 该归哪台机器?规则极其简单:从 key 的位置出发,顺时针走,遇到的第一台机器,就是它的归属。
import bisect
class ConsistentHashRing:
"""一致性哈希环:节点和 key 都映射到同一个环上。"""
def __init__(self, replicas: int = 150):
self.replicas = replicas # 每个物理节点的虚拟节点数
self.ring = {} # 环上坐标 -> 物理节点名
self.sorted_keys = [] # 环上所有坐标,排好序供二分查找
def add_node(self, node: str):
"""把一个物理节点,以 replicas 个虚拟节点撒到环上。"""
for i in range(self.replicas):
point = hash_key(f"{node}#{i}")
self.ring[point] = node
bisect.insort(self.sorted_keys, point)
def remove_node(self, node: str):
"""把一个物理节点的全部虚拟节点,从环上摘掉。"""
for i in range(self.replicas):
point = hash_key(f"{node}#{i}")
del self.ring[point]
self.sorted_keys.remove(point)
查询一个 key 落在哪,就是在环上做一次顺时针查找——因为环上坐标是排好序的,这一步可以用二分查找,非常快:
def get_node(self, key: str) -> str:
"""查 key 归哪个节点:在环上【顺时针】找最近的那个节点。"""
if not self.sorted_keys:
raise RuntimeError("环上没有任何节点")
h = hash_key(key)
# 二分查找:第一个 >= h 的坐标,就是顺时针遇到的第一个节点
idx = bisect.bisect(self.sorted_keys, h)
if idx == len(self.sorted_keys):
idx = 0 # 越过环的末端,绕回开头
return self.ring[self.sorted_keys[idx]]
哈希环精妙的地方,就在于它彻底改变了"映射依赖什么"。在 hash 取模里,key 的归属依赖 N;在哈希环里,key 的归属只依赖"它顺时针方向最近的那台机器在哪"。这就带来一个关键性质:当你加一台新机器,它落到环上某个位置,它只会"接管"从它逆时针方向到上一台机器之间那一段的 key——环上其它所有位置的 key,归属完全不受影响。减一台机器同理:只有原本属于它的那一段 key,会顺延给环上的下一台机器,别处纹丝不动。映射和"机器总数"解耦了。但这个朴素的环,还藏着一个问题:机器在环上的位置是哈希随机撒的,如果只撒几台,它们很可能挤在环的某一小段,导致有的机器管很长一段、有的只管很短一段——数据倾斜。
三、虚拟节点:解决环上的数据倾斜
设想环上只有 3 台真实机器。它们的哈希位置纯靠运气,完全可能两台挨得很近、一台离得很远。结果就是:那台"身后空旷"的机器,要管大半个环的 key,被压垮;另外两台很轻松。这就是数据倾斜。解法很巧妙——虚拟节点:不让一台物理机器在环上只占一个点,而是让它化身成几百个"虚拟节点",均匀地撒满整个环。上面 add_node 里的 replicas 参数,做的就是这件事。我们写个函数,实测一下分布:
def distribution_report(ring: ConsistentHashRing,
keys: list) -> dict:
"""统计每个物理节点实际分到多少 key —— 检查数据是否倾斜。"""
counts = {}
for k in keys:
node = ring.get_node(k)
counts[node] = counts.get(node, 0) + 1
avg = sum(counts.values()) / len(counts)
# 关键:虚拟节点数 replicas 太小,环上分布不均,某些节点
# 会分到远超均值的 key —— 这就是数据倾斜。把 replicas 调大
# (如 150),每台机器的几百个分身均匀铺满环,分布就平了。
skew = {n: round(c / avg, 2) for n, c in counts.items()}
return {"counts": counts, "skew_ratio": skew}
虚拟节点的精髓,是用"大数定律"来对抗"少数点的随机性"。3 个点撒在环上,运气差就严重不均;但 3 台机器 × 150 个虚拟节点 = 450 个点撒在环上,它们几乎必然会非常均匀地铺开——每台物理机器,因为有几百个分身遍布全环,它管辖的总长度就趋于相等。虚拟节点还带来一个额外的好处:当一台机器宕机,它那几百个虚拟节点留下的"空缺",会被环上各个不同的其它机器分别接管——于是宕机机器的负载,被均匀分摊给了所有存活机器,而不是一股脑全压给它顺时针的那一台(否则那一台很可能被连带压垮,引发连锁宕机)。replicas 不能太小(分布不均),也不必太大(环上点太多、查询和内存开销上升),一两百是常见的取值。环和虚拟节点都就位了,现在来验证它最关键的承诺:加减节点,到底搬动了多少 key?
四、节点增删:只挪相邻一小段,不是全量
一致性哈希全部价值的兑现,就在这一节。我们用和第一节完全相同的实验——加一个节点,数一数有多少 key 的归属变了——但这次跑在哈希环上:
def count_moved_consistent(keys: list, ring: ConsistentHashRing,
new_node: str) -> int:
"""统计:在一致性哈希环上加一个节点,多少 key 的归属变了。"""
before = {k: ring.get_node(k) for k in keys} # 加节点前的归属
ring.add_node(new_node) # 加入新节点
after = {k: ring.get_node(k) for k in keys} # 加节点后的归属
moved = sum(1 for k in keys if after[k] != before[k])
return moved
# 关键对比:同样是加一个节点 ——
# hash 取模:约 80% 的 key 被重新映射(第一节实测)
# 一致性哈希:只有约 1/(N+1) 的 key 被移动
# 而且这些被移动的 key,只来自环上相邻的节点。
结果是决定性的:同样加一个节点,hash 取模搬动了 80% 的 key,一致性哈希只搬动了约 1/(N+1)——4 台扩到 5 台,大约只有 20%,而且这 20% 只来自新节点在环上相邻的那几个节点。为什么?因为新节点的每一个虚拟节点落到环上,只接管它逆时针方向、到上一个虚拟节点之间那一小段的 key——环上绝大部分区段,压根没察觉有新节点加入。这正是一致性哈希名字里"一致"二字的含义:节点集合发生变化时,尽可能多的 key 保持原有的映射不变。被影响的 key 数量,从 O(K)(全量)降到了 O(K/N)。对缓存来说,这意味着扩容时只有 1/N 的缓存失效,命中率只小幅波动,数据库压力可控;对数据库分片来说,这意味着扩容只需迁移 1/N 的数据。一致性哈希的基本盘到此完成。但生产环境里,机器往往不是一样的配置。
五、加权分布:让强弱不均的机器各尽其力
到目前为止,我们默认所有机器一样强,所以让它们平摊 key。但真实集群里,机器常常配置不一:有的是 64G 内存的新机器,有的是 16G 的旧机器。让它们扛一样多的 key,旧机器会先撑爆。一致性哈希应对这个问题非常自然——既然一台物理机器的"份额",取决于它在环上撒了多少虚拟节点,那给强的机器多撒一些虚拟节点就行了:
def add_weighted_node(self, node: str, weight: int = 1):
"""加权:weight 越大,撒的虚拟节点越多,分到的 key 越多。"""
# 关键:机器配置不均时,给高配机器更大的 weight,
# 让它按能力多扛 key —— 而不是所有节点一律均摊,
# 把弱机器压垮。weight=2 的机器,份额约是 weight=1 的两倍。
count = self.replicas * weight
for i in range(count):
point = hash_key(f"{node}#{i}")
self.ring[point] = node
bisect.insort(self.sorted_keys, point)
加权的道理很朴素:虚拟节点的数量,直接正比于一台机器在环上占据的总长度,也就正比于它分到的 key 数量。给 64G 的机器 weight=4、16G 的机器 weight=1,前者就会自动分到大约后者 4 倍的 key。这件事在 hash 取模的方案里极难优雅地做到——取模天然假设"所有槽位平等"。一致性哈希因为有"虚拟节点数量"这个旋钮,加权几乎是免费送的能力。环、虚拟节点、增删、加权——一致性哈希的四块都齐了。但要把它真正用在生产上,还有几个绕不开的坑。
六、工程坑:哈希函数、热点 key 与迁移期双查
四块设计之外,还有几个工程坑,不处理就会在生产上出事。坑 1:哈希函数要选"分布均匀"的,别用业务 ID 直接当哈希值。一致性哈希的全部均匀性,都建立在"哈希函数能把输入打散得足够随机"这个前提上。md5、murmurhash 这类分布优良的函数是合适的;千万不要偷懒用"用户 ID 本身"或"自增 ID"当哈希值——它们不随机,会让 key 挤在环的一小段,虚拟节点也救不回来。坑 2:一致性哈希解决不了"热点 key"。它保证的是 key 的"数量"在节点间均匀,但如果某一个 key 本身被疯狂访问(比如一个爆款商品的详情缓存),那这一个 key 落在的那台机器,照样会被单独打爆——这是另一个维度的问题,要靠热点 key 单独探测、本地多级缓存或 key 拆分来解决。先看一个迁移期的关键工具:
def get_with_migration(ring_old: ConsistentHashRing,
ring_new: ConsistentHashRing,
key: str, cache_get) -> object:
"""扩容迁移期:先查新环,未命中再回查旧环 —— 平滑过渡。"""
node = ring_new.get_node(key)
val = cache_get(node, key)
if val is not None:
return val
# 关键:数据还没从旧节点搬完时,新环上会 miss。此时回查
# 旧环对应的节点;命中了就【顺手回填】到新节点。这样
# 迁移期内不会出现大面积穿透,数据被逐步、平滑地搬过去。
old_node = ring_old.get_node(key)
val = cache_get(old_node, key)
return val
坑 3:扩容那一刻,被搬走的 1/N 仍然会 miss。一致性哈希把扩容失效的缓存从 80% 降到了 20%,但不是 0。这 1/N 的 key 在新节点上第一次访问仍是 miss。如果你的库很脆弱,连这 1/N 的穿透都受不了,就要配合上面的迁移期双查,或在扩容前预热新节点。坑 4:节点列表要让所有客户端"看到的一致"。如果是客户端各自计算环,那么"当前有哪些节点"这份名单,必须通过配置中心等方式让所有客户端拿到完全一致的版本——否则两个客户端环不一样,同一个 key 会被路由到不同机器,缓存各写各的。坑 5:别忘了数据迁移本身。一致性哈希告诉你"哪些 key 该换节点了",但把这些 key 的数据真正搬过去(对数据库分片而言)仍是一项独立的工程——环只负责路由,不负责搬运。下面用一段代码,把"一次查询如何在环上定位"完整串起来:
def demo_full_flow():
"""完整演示:建环、查路由、扩容、再看 key 的归属变化。"""
ring = ConsistentHashRing(replicas=150)
for n in ["cache-A", "cache-B", "cache-C", "cache-D"]:
ring.add_node(n)
keys = [f"user:{i}" for i in range(100000)]
before = {k: ring.get_node(k) for k in keys}
moved = count_moved_consistent(keys, ring, "cache-E") # 扩到 5 台
after = {k: ring.get_node(k) for k in keys}
# 被搬动的 key,应只占约 1/5,且去向只是新节点 cache-E
to_new = sum(1 for k in keys
if before[k] != after[k] and after[k] == "cache-E")
return {"total": len(keys), "moved": moved, "moved_to_new": to_new}
下面这张图,把一次 key 路由如何在环上完成串起来:
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| hash 取模分片 | 对机器数取模决定 key 归属,机器数一变几乎全部 key 被重映射 |
| 扩容雪崩 | 取模分片加机器时约 N/(N+1) 的缓存瞬间失效,请求穿透打垮数据库 |
| 一致性哈希 | 节点和 key 映射到同一个环,key 顺时针归属最近节点,映射不依赖机器总数 |
| 哈希环 | 0 到 2^32-1 首尾相接的环,加减节点只影响环上相邻一小段 |
| 顺时针查找 | 从 key 坐标出发顺时针找第一个节点,环坐标排序后可用二分查找 |
| 数据倾斜 | 真实节点少时哈希位置随机不均,某节点管辖过长一段被压垮 |
| 虚拟节点 | 一台物理机化身几百个虚拟节点撒满环,用大数定律让分布趋于均匀 |
| 只挪 1/N | 增删一个节点只移动约 K/N 的 key,且只来自环上相邻节点 |
| 加权分布 | 给高配机器多撒虚拟节点,让它按能力多扛 key,弱机器不被压垮 |
| 热点 key | 一致性哈希只均匀 key 数量,单个 key 被狂刷仍会打爆所在机器 |
避坑清单
- hash 取模分片把映射绑死在机器数上,加减一台机器几乎全部 key 被重映射。
- 缓存集群用取模分片扩容,约 N/(N+1) 的缓存瞬间失效,会引发穿透雪崩。
- 用一致性哈希把节点和 key 放到环上,让映射不再依赖机器总数。
- 哈希环加减节点只影响相邻一小段,被搬动的 key 只有约 K/N。
- 真实节点少时环上分布会倾斜,必须用虚拟节点把每台机器撒成几百个分身。
- 虚拟节点数一两百为宜,太小分布不均,太大徒增查询和内存开销。
- 机器配置不均时用加权,给高配机器多撒虚拟节点,别让弱机器被压垮。
- 哈希函数要选分布均匀的如 md5 murmurhash,别拿自增 ID 直接当哈希值。
- 一致性哈希解决不了热点 key,单个 key 被狂刷要靠探测和多级缓存单独处理。
- 扩容时被搬走的 1/N 仍会 miss,脆弱的库要配迁移期双查或预热新节点。
总结
回头看那次"缓存集群加了一台机器、命中率瞬间归零"的事故,以及我后来在分布式分片上接连踩的坑,最该记住的不是某一段哈希环代码,而是我动手前那个想当然的判断——"缓存分片,就是 hash 一下取个模"。这句话错在它只看见了"怎么把 key 分匀"这一个目标,却完全没看见另一个同样重要的目标:"当机器数变化时,这个映射稳不稳"。hash(key) % N 在分匀这件事上做得很好,可它在稳定性上糟透了——它把 key 的归属和 N 焊死,N 一动,满盘皆乱。分布式分片这件事想清楚的,正是这个:一个分片算法,要同时满足两个要求——平时要把数据分得均匀,变更时要让映射尽可能稳定。取模只满足了第一个,一致性哈希两个都满足。
所以做分布式分片,真正的工程量不在"% N"那一行取模上。那一行,任何教程的第一页就教完了。真正的工程量,在于你要预见到"机器数一定会变"这件事——它会因为扩容而变,会因为宕机而变,会因为缩容而变——然后选一个能从容应对这种变化的映射方式:你要用哈希环,让映射不再依赖机器总数;你要用虚拟节点,让少数几台机器也能把环分得均匀、让宕机的负载被均摊而非转嫁;你要用加权,让强弱不一的机器各尽其力;你还要为扩容那一刻残余的 1/N 失效,想好迁移期双查的退路。这篇文章的几节,其实就是顺着这条思路展开的:先想清楚取模为什么一扩容就雪崩,再用哈希环解耦机器数,用虚拟节点抹平倾斜,然后验证"只挪 1/N"这个核心承诺,最后是加权、哈希函数、热点这几个把分片真正做扎实的工程细节。
你会发现,一致性哈希的思路,和现实里怎么安排一栋楼的住户门牌完全相通。一种糟糕的办法是:按"第几个搬进来的"对楼层数取模分房间——今天楼里 4 层,大家按 % 4 住下;明天加盖了第 5 层,变成 % 5,于是几乎每一户都得搬家,整栋楼鸡飞狗跳。而有经验的物业会怎么做?他们给每户一个固定的、和"楼里总共住了多少户"无关的门牌号——你的门牌就是你的门牌,加盖新楼层只影响新楼层那一小片,老住户纹丝不动(这是哈希环);他们还懂得,不能让一部电梯只服务一整片区域,而是每隔几层就设一个电梯厅,让人流均匀分散(这是虚拟节点);宽敞的大户型,自然对应更多的房间号段(这是加权)。好的门牌系统,衡量标准从来不是"此刻分得多齐",而是"将来加楼、拆楼时,要惊动多少老住户"。
最后想说,分片算法选得对不对,差距永远不会在集群规模稳定时暴露——机器数一直不变的日子里,hash % N 和一致性哈希,跑起来一模一样快,你甚至会觉得后者那个哈希环、虚拟节点是没必要的复杂。它只在真实的、集群因为扩容、因为宕机、因为缩容而不得不变的那一刻才显形。那时候它会用最难堪的方式给你结账:做不好,你会像我一样,只是加了一台机器,就看着缓存命中率断崖归零、看着海量请求穿透把数据库瞬间打满,一次本该波澜不惊的扩容,变成一场濒临瘫痪的事故;而做对了,无论你是从容地加机器,还是突然挂掉一台,变化都只波及环上一小段,绝大多数 key 归属不变,命中率只是小幅抖动,系统稳稳地扛了过去。所以别等扩容变成一场雪崩,在你写下第一行"把 key 分到机器上"的代码之前就该想清楚:我的集群将来一定会增减机器吗?到那一刻,会有多少 key 的归属被打乱?这个代价,我承受得起吗?这几个问题都有了答案,你的分片才不只是晴天里那个"分得很齐"的样子,而是一套无论机器怎么增减都稳得住的可靠路由。
—— 别看了 · 2026