最近帮朋友排查一个生产环境的问题,他们的Cassandra集群只是加了一个节点,结果数据迁移搞了整整两天,业务侧一直有延迟告警。我们查了半天,发现单纯看每秒钟拷贝多少数据,其实速度并不低,但整体迁移就是磨磨蹭蹭。最后把目光聚焦到Cassandra底层那套“一致性哈希”和“vnode”的组合拳上,才找到了真凶。今天就借这个机会,用大白话把这块门道掰扯清楚。
一、现象:加了个节点,迁移就像蜗牛爬
想象一下你有一排书架,上面的书原本放得好好的。现在你买了一个新书架,想把一部分书挪过去。如果规则简单,比如“按字母顺序前半部分留这里,后半部分挪过去”,那很轻松。但Cassandra不是这样,它用一种叫一致性哈希的办法,把数据撒在环上。当你加了新节点,很多数据其实要跨好几个旧节点搬来搬去。看起来每个文件都在拷贝,但有些数据搬完还得二次搬,甚至搬错了再返回,自然就慢了。
二、一致性哈希到底是个啥
2.1 一个环,多个点
一致性哈希可以理解成一个首尾相接的哈希环。这个环的范围通常是0到2的32次方减一。每个节点通过哈希函数算出一个随机位置,坐落在环上。数据呢,也有自己的哈希值,它顺时针遇到的第一个节点,就是它的“家”。
举个生活例子:一群小朋友围成一圈丢手绢,手绢从某个小朋友开始顺时针传,第一个停下的人就是手绢的主人。节点就是这些小朋友,数据就是手绢。
2.2 增加节点时发生了什么
当你往环里加一个新节点,它会随机落在环的某个位置。那么哪些手绢需要换主人呢?只有新节点和它逆时针方向那个旧节点之间的那段数据会归新节点管。其他区域完全不受影响。这就是一致性哈希的巧妙之处:大约只有1/n的数据会迁移,n是节点总数。
但Cassandra没有直接用这么简单的方案,它引入了“vnode”,也就是虚拟节点。
三、vnode分裂带来的变化
3.1 一个节点拆成很多个“小分身”
vnode的意思是把一个物理节点分裂成若干个虚拟节点,每个虚拟节点在环上占一个位置。比如一台机器有256个vnode,那这256个点都算这台机器的。这样做的初衷很美好:让数据分布更均匀,因为随机撒256个点比撒1个点平得多。
3.2 好处与隐性代价
好处自不必说:扩容时每个旧节点都拿出一部分vnode给新节点,迁移压力分散到所有节点,不会让个别节点累死。然而,代价也是实实在在的。vnode数量变多,意味着环上的“所有权分界线”数量暴增。每次增删节点,数据迁移的粒度变成“迁移一堆小碎片”,而不是“一个连续的大区间”。
这就好比搬家时,你把整个书房的书分成几百个小纸箱,每个箱子单独找车运,虽然每辆车都很轻松,但你要协调几百次车。如果调度不得当,很多箱书会来回倒腾,时间全耗在“安排车”上了。
四、迁移慢的隐性原因分析
4.1 数据分布不均匀
vnode虽然让整体均匀,但不代表每个vnode上的数据量一样大。哈希函数的随机性有时候会“扎堆”,某些vnode管了一片数据特别密的区域。当新节点加进来,它拿到的vnode碰巧都是大块头,那迁移量就远超平均值,新节点成了瓶颈,整个迁移被拖慢。
4.2 搬家的“多米诺骨牌效应”
Cassandra的vnode迁移不是一次性把数据从源头拷到目的地。有时候为了保持副本数一致,旧节点会先把数据传给一个临时节点,然后再转给最终节点。如果这个过程设计得不够优化,就会出现“A传给B,B又传给C”的接力赛。每个接力点都要等待上一棒完成,整体速度自然上不去。
4.3 压缩和GC的干扰
数据迁移时,源节点要把SSTable(存储文件)读出来,过滤出需要搬走的key,然后写到新节点。这个过程会占用大量IO和CPU。同时,Cassandra后台还在做压缩(compaction)和垃圾回收。压缩会重写文件,GC会STW(暂停业务线程)。当迁移和压缩撞在一起,就像高速公路上同时有三辆车并行变道,谁都走不快。
4.4 副本策略的“隐藏机制”
Cassandra常用NetworkTopologyStrategy,它会在同一机架上放多少个副本,其他机架放多少个。当一个节点上的vnode发生迁移时,它不仅要管自己作为主副本的数据,还要管自己作为备份副本的数据。备份迁移往往优先级低,而且有时需要跨数据中心拷贝,网络往返开销很大。如果你没注意到机架感知配置,很多迁移流量会在机架之间横跳,慢是必然的。
五、用一个例子彻底看懂迁移过程
下面我们用Python写一个小程序,模拟一致性哈希环上有vnode和没有vnode时,增加一个节点到底有多少数据的归属权会变化。为了贴合Cassandra的场景,我们不走跑偏的路子,就用一种技术栈:Python。大家把代码复制到本地跑一下,直观感受差别。
5.1 模拟环境准备
我们定义3个原始节点,每个节点占若干个vnode。我们生成10000个模拟数据key,计算每个key在增加节点前后归属的节点是否变化,统计变化比例。
# python 3.8+
import hashlib
import bisect
class ConsistentHashRing:
"""一致性哈希环模拟器"""
def __init__(self, nodes, vnodes_per_node=1):
# 参数1:节点列表,每个节点是个字符串名字
# 参数2:每个节点对应多少个vnode(>=1)
self.ring = [] # 环上的所有哈希点
self.node_map = {} # 哈希点 -> 节点名
for node in nodes:
for i in range(vnodes_per_node):
# 给每个vnode生成一个哈希值,模拟md5哈希
vnode_name = f"{node}:{i}"
h = int(hashlib.md5(vnode_name.encode()).hexdigest()[:8], 16)
self.ring.append(h)
self.node_map[h] = node
self.ring.sort() # 环上所有点排序
self.keys_cache = {} # 缓存key的归属结果,避免重复计算
def get_node(self, key):
"""返回某个key顺时针遇到的第一个节点"""
# 如果key已经算过,直接返回
if key in self.keys_cache:
return self.keys_cache[key]
h = int(hashlib.md5(key.encode()).hexdigest()[:8], 16)
# 使用二分查找找到第一个大于等于h的点
pos = bisect.bisect_left(self.ring, h)
if pos == len(self.ring):
pos = 0 # 如果超过环尾,回到第一个
node = self.node_map[self.ring[pos]]
self.keys_cache[key] = node # 计算结果缓存起来,加速后续判断
return node
def get_all_nodes(self):
"""获取所有vnode对应的节点集合(用于去重)"""
return set(self.node_map.values())
def simulate(seed_keys, vnodes_per_node):
"""模拟一次扩容,返回迁移key占比"""
# 固定随机种子,保证每次结果可复现
import random
random.seed(42)
# 随机生成10000个模拟key
keys = [f"user_{random.randint(1, 1000000)}" for _ in range(seed_keys)]
# 初始节点:node1, node2, node3
old_nodes = ["node1", "node2", "node3"]
# 扩容后增加node4
new_nodes = old_nodes + ["node4"]
# 构建旧环和新环
old_ring = ConsistentHashRing(old_nodes, vnodes_per_node)
new_ring = ConsistentHashRing(new_nodes, vnodes_per_node)
# 统计有多少key归属变了
changed = 0
for key in keys:
# 注意:这里为了公平比较,需要清缓存?其实每个实例有独立缓存,无妨
if old_ring.get_node(key) != new_ring.get_node(key):
changed += 1
return changed / len(keys), len(keys)
if __name__ == "__main__":
# 场景1:每个节点只有1个vnode(老式做法)
ratio1, _ = simulate(10000, 1)
print(f"无vnode(每个节点1个vnode)迁移比例: {ratio1:.2%}")
# 场景2:每个节点有30个vnode(模拟真实Cassandra配置)
ratio2, _ = simulate(10000, 30)
print(f"有vnode(每个节点30个vnode)迁移比例: {ratio2:.2%}")
运行这段代码,你会发现无vnode时迁移比例大约在25%左右(四分之一),而启用vnode后迁移比例可能高达80%甚至更多。奇怪吗?不奇怪。因为vnode把环切得细碎,增加一个节点,所有节点几乎都要让出一些碎片,导致差不多每个key都有机会被重新映射。当然,现实中因为副本数和数据位置的关系,真正搬移的数据量不一定完全等于归属变化比例,但趋势是明确的:vnode越多,迁移波及面越广。
上面这个例子也许让你直观看到了vnode的“副作用”。有人会说,那Cassandra还默认用vnode,不是自己找坑吗?其实并非如此,vnode带来的负载均衡收益远远大于迁移开销,但我们需要理解它的脾性,才能用好它。
六、怎么改善迁移速度
6.1 合理设置vnode数量(num_tokens)
在Cassandra里,每个节点默认有256个vnode。如果你只有三五个节点,256个token会让环非常碎,迁移时碎片太多。可以考虑调低到32或64。当然,节点规模大了,vnode太少又会导致数据分布不均。这需要权衡。一般建议节点数在10台以下时用64,10到50台用128,50台以上再考虑256。
修改方式是在cassandra.yaml里调整num_tokens,然后滚动重启每个节点。注意,改这个参数不会自动重新balance已有token,需要配合“数据重分布”工具来操作。
6.2 使用“无损迁移”工具
Cassandra自带的nodetool rebuild和rebalance命令在vnode场景下略显笨重。社区里有一些辅助工具,比如cassandra-reaper,它专注于管理修复(repair),可以控制并发和速率。对于纯迁移,可以考虑先关掉压缩,等迁移完成后再手动触发压缩。另外,实时监控每个节点上的“在途迁移任务数”,如果发现某些vnode卡住,就手动清理一下状态表。
6.3 把网络和磁盘的“无形墙”拆掉
迁移慢,很多时候是网络带宽或磁盘IO被写满。检查节点之间的网络队列是否有丢包,NIC的ring buffer是否太小。同时,使用SSD磁盘,确保commitlog和data文件分盘存放。别忘了设置合理的stream_throughput_outbound,它如果被误设成很小的值,会硬生生把迁移速度限制住。
6.4 分批迁移,别一窝蜂
如果你有较大的增量,建议不要一次性加入多个节点。每次只加一个节点,等它完全稳定后,再加下一个。因为每加一个节点,vnode归属都会全局洗牌一次。如果同时加三个,洗牌三次,数据可能被反复迁移,白白消耗资源。
七、注意事项
第一,不要盲信“一致性哈希可以让迁移影响最小化”这句话。这是理想情况,前提是vnode数量为1。vnode本质上是“用迁移广度换负载均衡”,所以你要时刻记住:vnode越密,单次迁移越轻,但总迁移量越大。
第二,监控迁移状态时,别只看总字节数。因为总字节数可能看着没变化,但某个节点上的vnode一直处于“PENDING”状态,是因为该节点在等待其他节点的副本ack。这时需要去system_distributed表里查询pending_release_version,看看是否有多个vnode在同一个节点上互相争锁。
第三,如果你使用的是多数据中心,迁移速度会更复杂。因为跨数据中心传输要走公共网络,而且默认使用了TLS加密,CPU开销也不小。建议对跨数据中心流量做限速,避免影响线上业务。
第四,老版本Cassandra的vnode迁移算法有bug,可能误判vnode属主。如果你还在用2.x版本,强烈建议升级到3.11或4.0以上,里面的“streaming”重写后,迁移效率提升了很多。
八、总结
Cassandra节点间数据迁移过慢,并不是因为“Cassandra笨”,而是因为我们在享受vnode带来的均衡红利时,忽略了它带来的迁移碎片化、哈希不确定性以及副本联动效应。理解了这些隐性原因,我们就可以通过调整vnode数量、规范运维动作、精细化网络配置等手段,让迁移速度回到可接受的范围内。技术选型从来都是权衡,没有银弹。希望这篇文章能帮你在遇到类似卡顿的时候,多一个排查思路。
评论
围绕“Cassandra节点间数据迁移过慢?解读一致性哈希与vnode分裂带来的隐性问题”参与讨论