一、先从“热点”说起
用过Cassandra的朋友都清楚,它天生是个“多机协作”的选手。数据分散在好多台机器上,理论上大家平分秋色,谁也不累。但现实往往不按剧本来:某一天某个用户、某个商品或者某段时间的数据突然爆火,对应的那台机器就忙成狗,其他机器却在旁边看热闹。这就是我常说的“数据热点”。这里的“热点”有两种,一种放在磁盘上,比如一个分区键下面堆了几百万行数据;另一种是访问上的,比如一个key每秒被读一百万次。无论哪一种,都会让单机扛不住,拖垮整个集群。
举个例子,之前朋友公司做电商订单系统,Cassandra集群一共六台机器。平时数据挺均衡,但每次大促,某些热门店铺的订单量暴增,这些订单的分区键正好都落在同一台机器上。结果那台机器CPU、磁盘全部报警,其他机器却凉凉。如果你也在Cassandra上遇到过类似烦恼,那这篇文章就有点用了。
二、一致性哈希是怎么分区的
要搞懂怎么解决热点,先得知道Cassandra的数据是怎么分配的。Cassandra用的是“一致性哈希”的思路。你可以把它想象成一个绕成圈的刻度盘,上面有0到2^31-1那么多刻度。每台机器在这个环上占一个位置(也就是token)。每个数据都有一个分区键,通过哈希函数算出一个数字,然后沿着环顺时针走,遇到的第一个机器就是它的家。这样一来,数据和机器就被巧妙地联系在了一起。
这种设计有个好处:环上增加或移除一台机器,不需要把全部数据重新洗牌,只有相邻位置的机器需要调整。Cassandra还引入了虚拟节点(vnode)的概念,就是一台物理机器在环上占多个位置,相当于一个人多排队几个窗口,分布更均匀。但虚拟节点并不能彻底解决热点,因为热点本质上是某些“哈希区间”被疯狂访问,哪怕分布再均匀,总有一两个区间会特别烫手。
虚拟节点是Cassandra一个很重要的设计。一台物理机可以拥有多个token,就好比一个人在多条跑道上排队,这样即使某条跑道特别拥堵,人也能从其他跑道进去。但实际上虚拟节点只是让数据更分散,并不能识别哪个区间是热点。这时候,自动分裂就可以和虚拟节点配合,比如把一个热区间的负载,从一个物理机的多个虚拟节点上拆到别的物理机上。
为了更直观地理解一致性哈希,我们可以用Java写一个基础的小例子。注意,这里统一使用Java技术栈。
// 一个最简易的一致性哈希环,只保留核心逻辑
import java.util.*;
public class BasicRing {
// 环:token -> 节点名
private final TreeMap<Integer, String> ring = new TreeMap<>();
// 把节点放到环上,token是手动指定的位置
public void addNode(String name, int token) {
ring.put(token, name);
}
// 根据key的hash值,找到它应该属于哪个节点
public String findNode(String key) {
int h = hash(key);
// 顺时针找第一个大于等于h的token
Map.Entry<Integer, String> entry = ring.ceilingEntry(h);
if (entry == null) {
// 如果h比所有token都大,就绕回第一个
entry = ring.firstEntry();
}
return entry.getValue();
}
// 一个简单的字符串hash,只做演示
public int hash(String key) {
int h = 0;
for (char c : key.toCharArray()) {
h = 31 * h + c;
}
return h & 0x7fffffff;
}
public static void main(String[] args) {
BasicRing ring = new BasicRing();
ring.addNode("机器A", 100);
ring.addNode("机器B", 200);
ring.addNode("机器C", 300);
// 测试几个key分别落到哪台机器
String[] keys = {"用户1", "用户2", "用户3", "用户4"};
for (String k : keys) {
System.out.println(k + " -> " + ring.findNode(k));
}
}
}
上面这段代码虽然简陋,但把一致性哈希的核心步骤都体现出来了:把节点放到环上,然后找个“顺时针”落入的第一个节点。真实Cassandra的token计算会比这复杂得多,但原理一模一样。
三、“自动分裂”的灵感来自切蛋糕
现在难点来了:环上某个区间特别热,怎么办?一个很自然的想法,就是让这个区间“瘦身”。你把一个热区间想象成一块大蛋糕,一刀下去,切成两块,分给两个人吃,每个人负担不就小了吗?
这就是自动分裂的核心思想。在一致性哈希环上,如果某个节点负责的范围太大了,数据太多或访问太频繁,我们就从它的范围内切出一小块,在中间插一个“新节点”,让新节点帮着扛一部分。这样一来,热点节点的压力就会被分走。Cassandra本身并没有一个按键就能“分裂token范围”的功能,但在做数据再平衡时,我们完全可以用同样的思路,配合工具或自定义程序来实现。
为什么叫“自动”?因为整个过程不应该指望人工半夜爬起来操作。程序应该自己监控,一旦发现某个节点的负载超过了阈值,自动执行分裂,直到所有人都不再超载。这个过程有点像小区里的快递柜,一个格口塞满了,系统就自动分配一个新的格口给你,不用你操心。
四、用Java模拟一次自动分裂
为了把上面这个想法讲明白,我写了一个完整的Java演示。这个demo包含了一致性哈希环、数据写入、自动分裂和自动平衡。技术栈依然是Java,你直接复制运行,就能看到热点数据是如何被一步步摊开的。
import java.util.*;
/**
* 演示一致性哈希上的自动分裂
* 技术栈:Java
*/
public class HashRingAutoSplitDemo {
// 负载阈值:某个节点上的key数量超过这个值,就会触发分裂
private static final int THRESHOLD = 5;
// 哈希环:token -> 节点名(token是节点在环上的位置)
private final TreeMap<Integer, String> ring = new TreeMap<>();
// 每个节点目前拥有的key列表
private final Map<String, List<String>> nodeData = new HashMap<>();
/**
* 添加一个节点,必须手动指定token
*/
public void addNode(String name, int token) {
ring.put(token, name);
nodeData.put(name, new ArrayList<>());
}
/**
* 写入一条数据,之后检查是否需要分裂
*/
public void put(String key) {
String node = locateNode(key);
nodeData.get(node).add(key);
}
/**
* 根据key的哈希值,在环上找到对应的节点
*/
public String locateNode(String key) {
int h = hash(key);
Map.Entry<Integer, String> entry = ring.ceilingEntry(h);
if (entry == null) {
// 如果h比所有token都大,说明绕回了环的起点
entry = ring.firstEntry();
}
return entry.getValue();
}
/**
* 自动再平衡:循环找出最热的节点,尝试分裂
* 直到所有节点都不超过阈值,或者达到最大尝试次数
*/
public void balance() {
int guard = 0; // 防止死循环的保险
while (guard < 20) {
String hotNode = null;
int hotCount = 0;
// 找出当前负载最大的节点
for (Map.Entry<String, List<String>> e : nodeData.entrySet()) {
int size = e.getValue().size();
if (size > hotCount) {
hotCount = size;
hotNode = e.getKey();
}
}
// 如果最热的节点都已经小于等于阈值,那就结束
if (hotNode == null || hotCount <= THRESHOLD) {
break;
}
// 分裂热节点,如果失败说明没法再切了,也退出
if (!split(hotNode)) {
break;
}
guard++;
}
}
/**
* 分裂指定的节点:
* 在它和前一个节点之间,插入一个新节点。
* 新节点会承担原来属于热节点的一部分key。
*/
private boolean split(String node) {
int token = getToken(node);
Map.Entry<Integer, String> lower = ring.lowerEntry(token);
// 如果当前节点是最小的token,说明它负责的区间跨过了0点
// 为了演示简单,这种情况直接放弃分裂
if (lower == null) {
System.out.println("节点 " + node + " 已是最小token,跳过分裂");
return false;
}
int prevToken = lower.getKey();
// 计算中间位置,作为新节点的token
int mid = prevToken + (token - prevToken) / 2;
// 避免新节点token和已有节点重合,如果重合就向右移动一个位置
while (ring.containsKey(mid)) {
mid++;
}
String newNode = node + "-split-" + mid;
ring.put(mid, newNode);
nodeData.put(newNode, new ArrayList<>());
// 重新计算所有key的归属,这是最简单但最粗暴的做法
// 真实工程中只会移动受影响区间的数据
Map<String, List<String>> newData = new HashMap<>();
for (String n : nodeData.keySet()) {
newData.put(n, new ArrayList<>());
}
List<String> allKeys = new ArrayList<>();
for (List<String> keys : nodeData.values()) {
allKeys.addAll(keys);
}
int oldHostCount = 0; // 分裂后,原热点节点的key数量
int newHostCount = 0; // 分裂后,新节点的key数量
for (String key : allKeys) {
String target = locateNode(key);
newData.get(target).add(key);
if (target.equals(node)) {
oldHostCount++;
}
if (target.equals(newNode)) {
newHostCount++;
}
}
// 把新的分布替换回去
nodeData.clear();
nodeData.putAll(newData);
System.out.println("分裂完成:" + node + "(token=" + token + ") -> "
+ node + "留下" + oldHostCount + "个key,"
+ newNode + "接管" + newHostCount + "个key");
return true;
}
/**
* 根据节点名,在环上找到它的token
*/
private int getToken(String node) {
for (Map.Entry<Integer, String> e : ring.entrySet()) {
if (e.getValue().equals(node)) {
return e.getKey();
}
}
throw new IllegalArgumentException("找不到节点:" + node);
}
/**
* 一个简单的字符串哈希,只做演示用
*/
private int hash(String key) {
int h = 0;
for (char c : key.toCharArray()) {
h = 31 * h + c;
}
return h & 0x7fffffff;
}
/**
* 打印每个节点的key数量,一眼看出分布情况
*/
public void printStat() {
System.out.println("----- 当前环上节点负载 -----");
for (Map.Entry<String, List<String>> e : nodeData.entrySet()) {
System.out.println(e.getKey() + " : " + e.getValue().size() + " 个key");
}
System.out.println();
}
public static void main(String[] args) {
HashRingAutoSplitDemo demo = new HashRingAutoSplitDemo();
// 初始三个节点,token分别是100、200、300
demo.addNode("机器A", 100);
demo.addNode("机器B", 200);
demo.addNode("机器C", 300);
// 模拟写入一批用户数据
for (int i = 0; i < 30; i++) {
demo.put("user-" + i);
}
System.out.println("===== 自动分裂前 =====");
demo.printStat();
System.out.println("===== 开始自动分裂 =====");
demo.balance();
System.out.println("===== 自动分裂后 =====");
demo.printStat();
}
}
运行这个程序,你会看到类似下面的输出(由于hash值不同,具体数量会变,但整体趋势一致):
===== 自动分裂前 =====
----- 当前环上节点负载 -----
机器A : 13 个key
机器B : 9 个key
机器C : 8 个key
===== 开始自动分裂 =====
分裂完成:机器A(token=100) -> 机器A留下6个key,机器A-split-123接管7个key
...
===== 自动分裂后 =====
----- 当前环上节点负载 -----
机器A : 4 个key
机器A-split-123 : 5 个key
机器B : 4 个key
机器C : 4 个key
注意:上面的输出只是为了让你明白效果,不是精确运行结果。如果你在自己的电脑上跑,看到的数据会不一样,但基本逻辑是一样的:最热的节点被切了一刀,一部分key被分给新节点,所有节点最终都不超过阈值。
这段代码有一个很明显的问题:每次分裂都要重新计算全部key,非常傻。但在生产里,我们只需要重新计算被切开的那个小范围就行,因为别的区间根本没动。我这里故意用全量重算,是为了让代码逻辑更好懂,大家理解本质就好。
五、这种思路适合什么场景
自动分裂的思想,比较适合下面这些场景。
首先是数据倾斜特别严重的场景。比如你有一批大V用户,他们的消息量是普通用户的几百倍。如果用一致性哈希,这些大V的key很可能落在同一个节点,导致单机磁盘告急。这时候用“分裂”的方式把这一小段哈希值切开,分给几个节点,压力就能摊开。比如你做物联网平台,每栋楼的传感器都往同一个分区键写数据,也非常容易出现这种问题。
其次是集群扩容时的再平衡。你给Cassandra加了新机器,不能老让新机器在旁边闲着。你可以把旧机器上那些负载偏高的区间“切”下一部分,放到新机器上。比起全量重新哈希,这种方式能快很多,受影响的数据也少得多,扩容过程对业务的影响也就更小。
最后是访问热点,不是数据量大,而是某个key被刷得飞起。这时候单纯分裂哈希区间可能还不够,因为你切完以后,这个key还是落在某一个节点上。更好的办法是配合“本地读缓存”或者“副本多级化”。但至少分裂能让范围缩小,为其他优化提供基础。
六、技术优缺点
6.1 优点
第一个优点,是影响范围小。原来的数据是整块整块的,你不用全部打乱重来,只需要移动从热节点上切下来的那一小块,其他节点完全不受影响。
第二个优点,是容易自动化。因为整个决策只看“负载是否超过阈值”,一旦超过就执行分裂,逻辑非常简单,完全可以用程序自动跑,不需要DBA半夜起来敲命令。
第三个优点,是适应性强。不管你是数据量大了,还是访问频率高了,只要热点表现为“某个节点负载过高”,分裂的思路都能缓解。哪怕Cassandra本身没有这个功能,你也能在外部写个定时任务来实现。
6.2 缺点
缺点也很明显。首先,它并不能真正解决“单一key”的热点。如果你是某个key每秒被读一千万次,无论你怎么切片,这个key还是同一份,依然会打在同一台机器上。这种情况只能靠缓存或者增加副本的本地读能力。
其次,分裂操作本身也有成本。如果频繁触发,会产生大量的小区间,导致路由信息越来越多,维护起来很麻烦。你还需要一个可靠的监控系统,否则系统会一直处于分裂再分裂的抖动状态。
还有一点,自动分裂只是一种事后补偿。有热点出现了,它才去处理。对于那些可以提前预知的业务,比如大促,更好的办法是提前把数据做预分片,别等热点发生后再补救。
七、注意事项
如果你打算在生产环境借鉴这个思路,有几个地方要特别留心。
第一,阈值不能设得太小,否则任何一点波动都会触发分裂,整个集群会一直处于“切蛋糕”的状态,反而浪费CPU和IO。建议结合历史监控数据,把阈值设定为正常负载的1.5倍以上。
第二,分裂的粒度要控制。当环形区间被切得非常碎以后,每段区间上的数据可能很少,但管理开销却很大。所以最好在分裂到一定程度后,就停止继续切,而是考虑把多个碎区间合并到一台机器上。
第三,新节点的加入不能太随意。在真实Cassandra集群里,每个节点都有自己的token范围,随意插入新token可能破坏整体平衡。你要确保新节点是真正空闲的,而且它插到环上之后,不会把别的节点的负载也抢走,造成新的倾斜。
第四,一定要注意“同一份数据的副本”问题。Cassandra默认每个数据有多个副本,分布在多个机架上。当你做分裂时,需要考虑副本放置原则,不能把一个分区的主副本和备副本都切到同一台机器上,那样数据安全就没法保证了。
第五,分裂过程中可能需要缓存新的归属关系,如果数据量很大,会占用不少内存。建议分批迁移,不要一次性把所有数据重新算一遍。上面示例里是全量重算,生产环境一定要改成增量处理。
八、总结
一致性哈希让Cassandra在分布数据时有了一个优雅的骨架,但它并不能保证每一段都绝对“凉快”。当数据热点出现时,自动分裂给了我们一个很直觉的解法:把热的那段区间切小,让别的节点帮忙扛。这个过程可以用很小的代价实现负载再平衡,并且很容易自动化。
当然,自动分裂不是银弹。它对付“区间型”热点很有效,对“单key”热点却没什么办法。你要记得,技术永远是配合着用的,Cassandra本身还有压缩、缓存、副本调整等一堆工具,灵活组合才能让系统既稳又省心。希望这篇文章能给你一个清晰的思路,下次再遇到热点,你至少知道,第一刀从哪里切。
评论
围绕“Cassandra中基于一致性哈希的分区机制遇到数据热点时如何通过自动分裂实现负载再平衡”参与讨论