一致性Hash算法
概述
一致性Hash,一种特殊的Hash算法,由于其均衡性、持久性的映射特点,被广泛应用于负载均衡领域,如nginx和memcached都采用一致性Hash来作为集群负载均衡的方案,也常用于分布式节点扩展场景
普通的Hash函数最大的作用是散列,即将一系列在形式上具有相似性质的数据,打散成随机的、均匀分布的数据。md5算法就是一种hash函数。
对于分布式部署应用,多个节点的IP地址非常相似,都是在同一个ip段上。如10.11.aa.bb,甚至是10.11.12.aaa。
负载均衡利用Hash,将大量随机的请求或调用均匀映射到多节点,从而实现压力的平均化。
问题来了,应用重新发布时,先停掉一个节点,拉出流量;或高峰期时,增加一个节点,拉入流量,那之前的所有Hash映射就会全部失效。对于分布式缓存系统而言,映射全部失效就意味着之前的缓存全部失效,后果将会是灾难性的。
简介
一致性Hash通过构建环状的Hash空间代替线性Hash空间的方法解决上述问题:
整个Hash空间被构建成一个首尾相接的环,使用一致性Hash时需要进行两次映射:
- 给每个节点(集群)计算Hash,然后记录它们的Hash值,这就是它们在环上的位置
- 给每个Key计算Hash,然后沿着顺时针的方向找到环上的第一个节点,就是该Key储存对应的集群
当节点被删除时,其余节点在环上的映射不会发生改变,只是原来打在对应节点上的Key现在会转移到顺时针方向的下一个节点上去。增加一个节点也是同样的,最终都只有少部分的Key发生失效。不过发生节点变动后,整体系统的压力已经不是均衡的。
一致性Hash环通常使用二叉查找树实现。
问题及解决方案
- 数据倾斜
如果节点的数量很少,而hash环空间很大(一般是 0 ~2^32
),直接进行一致性hash上去,大部分情况下节点在环上的位置会很不均匀,挤在某个很小的区域。最终对分布式缓存造成的影响就是,集群的每个实例上储存的缓存数据量不一致,即数据倾斜。 - 缓存雪崩
如果每个节点在环上只有一个节点,假如当某一集群从环中消失时,它原本所负责的任务将全部交由顺时针方向的下一个集群处理。如当group0退出时,它原本所负责的缓存将全部交给group1处理。这就意味着group1的访问压力会瞬间增大。如果group1因为压力过大而崩溃,那更大的压力又会向group2压过去,最终服务压力就像滚雪球一样越滚越大,最终导致雪崩。 - 请求漂移
假设某一段时间服务因网络因素访问某个服务节点失败,此时会将数据的更新、获取都迁移到下一个节点上。后续网络恢复后,应用服务探测到服务节点可用,则继续从原服务节点中获取数据,导致在故障期间所做的更新操作,对于原服务节点不可见。
引入虚拟节点
引入虚拟节点的概念扩展(即增加)整个环上的节点数量。一个实际节点将会映射多个虚拟节点,这样Hash环上的空间分割就会变得均匀。
同时,引入虚拟节点还会使得节点在Hash环上的顺序随机化,这意味着当一个真实节点失效退出后,它原来所承载的压力将会均匀地分散到其他节点上去。
优雅缩扩容
扩容
缓存服务器对于性能有着较高的要求,因此我们希望在扩容时新的集群能够较快的填充好数据并工作。但是从一个集群启动,到真正加入并可以提供服务之间还存在着不小的时间延迟,要实现更优雅的扩容,可以从两个方面出发:
- 高频Key预热
负载均衡器作为路由层,是可以收集并统计每个缓存Key的访问频率的,如果能够维护一份高频访问Key的列表,新的集群在启动时根据这个列表提前拉取对应Key的缓存值进行预热,便可以大大减少因为新增集群而导致的Key失效。
不过这个方案在实际使用时有一个很大的限制,那就是高频Key本身的缓存失效时间可能很短,预热时储存的Value在实际被访问到时可能已经被更新或者失效,处理不当会导致出现脏数据,因此实现难度还是有一些大的。 - 历史Hash环保留
一致性Hash的扩容,新增节点后,它所对应的Key在原来的节点还会保留一段时间。因此在扩容的延迟时间段,如果对应的Key缓存在新节点上还没有被加载,可以去原有的节点上尝试读取。
举例,假设原有3个集群,现在要扩展到6个集群,这就意味着原有50%的Key都会失效(被转移到新节点上),如果维护扩容前和扩容后的两个Hash环,在扩容后的Hash环上找不到Key的储存时,先转向扩容前的Hash环寻找一波,如果能够找到就返回对应的值并将该缓存写入新的节点上,找不到时再通过缓存。
这样做的缺点是增加了缓存读取的时间,但相比于直接击穿缓存而言还是要好很多的。优点则是可以随意扩容多台机器,而不会产生大面积的缓存失效。
缩容
- 熔断机制
缩容后,剩余各个节点上的访问压力都会有所增加,此时如果某个节点因为压力过大而宕机,就可能会引发连锁反应。因此作为兜底方案,应当给每个集群设立对应熔断机制来保护服务的稳定性。 - 多集群LB的更新延迟
这个问题在缩容时比较严重,如果你使用一个集群来作为负载均衡,并使用一个配置服务器比如ConfigServer来推送集群状态以构建Hash环,那么在某个集群退出时这个状态并不一定会被立刻同步到所有的LB上,这就可能会导致一个暂时的调度不一致,即请求发送到已下线的节点或节点组。
如果某台LB错误地将请求打到已经退出的集群上,就会导致缓存击穿。解决这个问题主要有以下几种思路:
- 缓慢缩容,等到Hash环完全同步后再操作。可以通过监听退出集群的访问QPS来实现这一点,等到该集群几乎没有QPS时再将其撤下
- 手动删除,如果Hash环上对应的节点找不到,就手动将其从Hash环上删除,然后重新进行调度,这个方式有一定的风险,对于网络抖动等异常情况兼容的不是很好
- 主动拉取和重试,当Hash环上节点失效时,主动从ZK上重新拉取集群状态来构建新Hash环,在一定次数内可以进行多次重试
HashSlot
一致性Hash算法不尽人意的地方:
- 整个分布式缓存需要一个路由服务来做负载均衡,存在单点问题(如果路由服务宕机,整个缓存系统无效)
- Hash环上的节点非常多或更新频繁时,查找性能会比较低下
针对这些问题,Redis在实现自己的分布式集群方案时,设计全新的思路:基于P2P结构的HashSlot算法。类似于Hash环,Redis Cluster采用HashSlot来实现Key值的均匀分布和实例的增删管理。
首先默认分配16384个Slot(正好可使用2kb的空间保存),每个Slot相当于一致性Hash环上的一个节点。接入集群的所有实例将均匀地占有这些Slot,而最终当Set一个Key时,使用CRC16(Key)%16384
来计算出这个Key属于哪个Slot,并最终映射到对应的实例上去。
增删实例时,Slot和实例间的对应要如何进行对应的改动呢?
原本有3个节点A,B,C,创建集群时Slot的覆盖情况是:
节点A 0-5460
节点B 5461-10922
节点C 10923-16383
假设要增加一个节点D,RedisCluster的做法是将之前每台机器上的一部分Slot移动到D上(要对节点D写入的KV储存),成功接入后Slot的覆盖情况将变为如下情况:
节点A 1365-5460
节点B 6827-10922
节点C 12288-16383
节点D 0-1364,5461-6826,10923-1228
删除一个节点,就是将其原来占有的Slot以及对应的KV储存均匀地归还给其他节点。
P2P节点寻找
现在考虑如何实现去中心化的访问,也就是说无论访问集群中的哪个节点,你都能够拿到想要的数据。其实这有点类似于路由器的路由表,具体说来就是:
- 每个节点都保存有完整的HashSlot - 节点映射表,即每个节点都知道自己拥有哪些Slot,以及某个确定的Slot究竟对应着哪个节点
- 无论向哪个节点发出寻找Key的请求,该节点都会通过
CRC(Key)%16384
计算该Key究竟存在于哪个Slot,并将请求转发至该Slot所在的节点
两个要点:映射表和内部转发,通过Gossip协议来实现。
HashSlot + P2P的方案解决去中心化的问题,同时也提供更好的动态扩展性。但相比于一致性Hash而言,其结构更加复杂,实现上也更加困难。
验证
memcached官方使用基于md5的KETAMA算法,下面使用FNV1_32_HASH
算法:
public class HashUtil {
/**
* 计算Hash值, 使用FNV1_32_HASH算法
*/
public static int getHash(String str) {
final int p = 16777619;
int hash = (int) 2166136261L;
for (int i = 0; i < str.length(); i++) {
hash = (hash ^ str.charAt(i)) * p;
}
hash += hash << 13;
hash ^= hash >> 7;
hash += hash << 3;
hash ^= hash >> 17;
hash += hash << 5;
if (hash < 0) {
hash = Math.abs(hash);
}
return hash;
}
}
针对集群负载均衡状态读多写少的状态,很容易联想到使用二叉平衡树的结构去储存,实际上可以使用TreeMap(内部实现是红黑树)来作为Hash环的储存结构。
无虚拟节点的情况:
public class ConsistentHashingWithoutVirtualNode {
/**
* 集群地址列表
*/
private static final String[] GROUPS = {
"192.168.0.0:111", "192.168.0.1:111", "192.168.0.2:111",
"192.168.0.3:111", "192.168.0.4:111"
};
/**
* 用于保存Hash环上的节点
*/
private static final SortedMap<Integer, String> SORTED_MAP = new TreeMap<>();
/*
* 初始化,将所有的服务器加入Hash环中
*/
static {
// 使用红黑树实现,插入效率比较差,但查找效率极高
for (String group : GROUPS) {
int hash = HashUtil.getHash(group);
System.out.println("[" + group + "] launched @ " + hash);
SORTED_MAP.put(hash, group);
}
}
/**
* 计算对应的widget加载在哪个group上
*/
private static String getServer(String widgetKey) {
int hash = HashUtil.getHash(widgetKey);
// 只取出所有大于该hash值的部分而不必遍历整个Tree
SortedMap<Integer, String> subMap = SORTED_MAP.tailMap(hash);
if (subMap.isEmpty()) {
// hash值在最尾部,应该映射到第一个group上
return SORTED_MAP.get(SORTED_MAP.firstKey());
}
return subMap.get(subMap.firstKey());
}
public static void main(String[] args) {
// 生成随机数进行测试
Map<String, Integer> resMap = new HashMap<>();
for (int i = 0; i < 100000; i++) {
int widgetId = (int) (Math.random() * 10000);
String server = getServer(Integer.toString(widgetId));
if (resMap.containsKey(server)) {
resMap.put(server, resMap.get(server) + 1);
} else {
resMap.put(server, 1);
}
}
resMap.forEach(
(k, v) -> System.out.println("group " + k + ": " + v + "(" + v / 1000.0D + "%)")
);
}
}
有虚拟节点的情况:
public class ConsistentHashingWithVirtualNode {
/**
* 集群地址列表
*/
private static final String[] GROUPS = {
"192.168.0.0:111", "192.168.0.1:111", "192.168.0.2:111",
"192.168.0.3:111", "192.168.0.4:111"
};
/**
* 真实集群列表
*/
private static final List<String> REAL_GROUPS = new LinkedList<>();
/**
* 虚拟节点映射关系
*/
private static final SortedMap<Integer, String> VIRTUAL_NODES = new TreeMap<>();
private static final int VIRTUAL_NODE_NUM = 1000;
static {
// 先添加真实节点列表
REAL_GROUPS.addAll(Arrays.asList(GROUPS));
// 将虚拟节点映射到Hash环上
for (String realGroup : REAL_GROUPS) {
for (int i = 0; i < VIRTUAL_NODE_NUM; i++) {
String virtualNodeName = getVirtualNodeName(realGroup, i);
int hash = HashUtil.getHash(virtualNodeName);
System.out.println("[" + virtualNodeName + "] launched @ " + hash);
VIRTUAL_NODES.put(hash, virtualNodeName);
}
}
}
private static String getVirtualNodeName(String realName, int num) {
return realName + "&&VN" + num;
}
private static String getRealNodeName(String virtualName) {
return virtualName.split("&&")[0];
}
private static String getServer(String widgetKey) {
int hash = HashUtil.getHash(widgetKey);
// 只取出所有大于该hash值的部分而不必遍历整个Tree
SortedMap<Integer, String> subMap = VIRTUAL_NODES.tailMap(hash);
String virtualNodeName;
if (subMap.isEmpty()) {
// hash值在最尾部,应该映射到第一个group上
virtualNodeName = VIRTUAL_NODES.get(VIRTUAL_NODES.firstKey());
} else {
virtualNodeName = subMap.get(subMap.firstKey());
}
return getRealNodeName(virtualNodeName);
}
private static void refreshHashCircle() {
// 当集群变动时,刷新hash环,其余的集群在hash环上的位置不会发生变动
VIRTUAL_NODES.clear();
for (String realGroup: REAL_GROUPS) {
for (int i = 0; i < VIRTUAL_NODE_NUM; i++) {
String virtualNodeName = getVirtualNodeName(realGroup, i);
int hash = HashUtil.getHash(virtualNodeName);
System.out.println("[" + virtualNodeName + "] launched @ " + hash);
VIRTUAL_NODES.put(hash, virtualNodeName);
}
}
}
private static void addGroup(String identifier) {
REAL_GROUPS.add(identifier);
refreshHashCircle();
}
private static void removeGroup(String identifier) {
int i = 0;
for (String group: REAL_GROUPS) {
if (group.equals(identifier)) {
REAL_GROUPS.remove(i);
}
i++;
}
refreshHashCircle();
}
public static void main(String[] args) {
Map<String, Integer> resMap = new HashMap<>();
for (int i = 0; i < 100000; i++) {
String group = getServer(Integer.toString(i));
if (resMap.containsKey(group)) {
resMap.put(group, resMap.get(group) + 1);
} else {
resMap.put(group, 1);
}
}
resMap.forEach(
(k, v) -> System.out.println("group " + k + ": " + v + "(" + v / 100000.0D + "%)")
);
}
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
2021-02-03 CentOS 7/8搭建并配置Nexus3记录