背景
隨著業務系統越來越大,我們需要對API的訪問進行更多的緩存,使用Redis是一個很好的解決方案.
但是單臺Redis性能不足夠且遲早要走向集群的,那么怎么才能良好的利用Redis集群來進行緩存呢?
當一個請求到來,我們如何決定將這個請求的內容緩存在那臺Redis服務器上?我們一一道來.
分配方法
隨機分配
假設我們有X臺服務器,當一個請求來到的時候,我們獲取一個0-X的隨機數,然后將內容緩存在該服務器上.
這明顯是不可選的,想要查詢的時候我們自己也不知道在哪,只能逐個遍歷服務器,知道拿到為止.
hash取模
還有一種常見的方式就是對集群數量進行hash取模.比如我們現在有3臺服務器,那么對請求的key進行hash,之后拿到的hashcode對3進行取模,得到的數字就是該key應該存儲的服務器.
這樣雖然解決了上面的獲取問題,但是擴展性極其差,設想一下現在我們需要新添加一臺機器,也就是機器數量來到了4,那么對4取模的結果和對3取模的結果基本上全部不一樣,也就是說我們需要對所有的key進行一次重新的hash計算并重新存儲.
一致性hash
這也是我們今天的重點,它于1997年由麻省理工學院提出.我們在下面單獨講解一下他.
一致性hash原理
其實本質上,一致性hash也是hash取模,只是是永遠的對2的32次方-1取模.
一致性hash引入了一個叫做一致性hash環的概念,即將(0-2^32-1)中間的所有整數首尾相接連接成一個環.如下圖:
然后將所有的節點映射到環上,假設我們有3個節點,N1,N2.N3.那么如下圖:
之后我們將要存儲的所有key也都映射到環上,假設我們有6個key.
這樣之后,順時針旋轉key,將其存儲在遇到的第一個服務器上,這樣有什么好處呢?
那就是擴展性,當新插入一個節點時,只會影響到少部分key,需要重新計算的key很少,我們添加一個節點試試:
可以發現,只有N3數據需要從N2節點遷移到N4.
是不是看起來挺美滋滋的,啥好處都有,有啥缺點呢?
缺點當然有.
1上面的圖是一種理想狀態,基本算是均勻的分布了,但是實際使用中,你用一個集群中的機器名(有很大的可能性很類似)去hash,拿到的結果可能很相近,也就是說,并不是像圖中這樣分散的,而是聚集在一起,而key是分散的,這樣會導致,大量的key命中了其中一個或者多個服務器,而有一部分卻空閑.總之,負載不均衡.
2redis的key都是字符串,而字符串的hashcode方法是可能會返回負值的,而一致性hash環是只有正值的,因此需要我們使用別的hash算法.(淡然你也可以粗暴的進行取絕對值).
使用虛擬節點解決hash不均勻的問題
hash不均勻主要出現在節點很少的時候,那么我們可以手動模擬一些節點出來,也就是所謂的虛擬節點,比如我們只有3個節點,但是我們定義一個規則,比如A-1,A-2,A-3,這三個節點都可以被映射到環上,但是在真正存儲的時候我們都存儲在A上.
只要我們的虛擬節點足夠多,我們就可以讓其盡可能的均勻分布在環上.
總結
一致性hash算法是使用虛擬的環狀數據結構,解決了簡單hash算法中擴展性差的問題,在分布式緩存以及負載均衡中有許多的應用.
Java實現一致性hash算法緩存客戶端
1、Java中提供了ConcurrentSkipListMap類,可以很好的使用在這里,不僅可以輕松的模擬環狀結構,并發安全且使用跳表結構的ConcurrentSkipListMap可以提供很好的并發性能.
2、對于虛擬節點的多少,其實是可以大概估算出來的,因此在下面的代碼中,我將其作為一個變量,在初始化的時候由當前節點的數量計算得到,當然我沒有具體實現計算方法.這么設計是出于什么考慮呢,想讓虛擬節點的數量盡量的剛剛好,萬一節點很多,還是用固定的虛擬節點,對均勻性提升不會很大,反而會造成性能損耗等.
3、代碼中主要提供了一下幾個方法:
初始化,用一個redis配置的字符串
添加和刪除節點,會將其虛擬節點一起操作.
jedis的get和set操作,當然在實際情況下不會只有這兩個方法,這里只做模擬,對更多的方法沒有做一個實現.
好了,廢話不多說了,都在注釋里面了!
package util;
import redis.clients.jedis.Jedis;
import java.util.concurrent.ConcurrentNavigableMap;
import java.util.concurrent.ConcurrentSkipListMap;
/**
* Created by pfliu on 2019/05/19.
*/
public class ConsistentHashRedis {
// 用跳表模擬一致性hash環,即使在節點很多的情況下,也可以有不錯的性能
private final ConcurrentSkipListMap<Integer, String> circle;
// 虛擬節點數量
private final int virtual_size;
public ConsistentHashRedis(String configs) {
this.circle = new ConcurrentSkipListMap<>();
String[] cs = configs.split(",");
this.virtual_size = getVirtualSize(cs.length);
for (String c : cs) {
this.add(c);
}
}
/**
* 將每個節點添加進環中,并且添加對應數量的虛擬節點
*/
private void add(String c) {
if (c == null) return;
for (int i = 0; i < virtual_size; ++i) {
String virtual = c + "-N" + i;
int hash = getHash(virtual);
circle.put(hash, virtual);
}
}
// 根據字符串獲取hash值,這里使用簡單粗暴的絕對值.
private int getHash(String s) {
return Math.abs(s.hashCode());
}
// 計算當前需要多少個虛擬節點,這里沒有計算,直接使用了150.
private int getVirtualSize(int length) {
return 150;
}
/**
* 對外提供的set方法
*/
public void set(String key, String v) {
getJedisFromCircle(key).set(key, v);
}
public String get(String k) {
return getJedisFromCircle(k).get(k);
}
/**
* 從環中取到適合當前key的jedis.
*/
private Jedis getJedisFromCircle(String key) {
int keyHash = getHash(key);
ConcurrentNavigableMap<Integer, String> tailMap = circle.tailMap(keyHash);
String config = tailMap.isEmpty() ? circle.firstEntry().getValue() : tailMap.firstEntry().getValue();
// 注意,由于使用了虛擬節點,所以這里要做 虛擬節點 -> 真實節點的映射
String[] cs = config.split("-");
return new Jedis(cs[0]);
}
/**
* 對外暴露的添加節點接口
*/
public boolean addJedis(String cs) {
add(cs);
return true;
}
/**
* 對外暴露的刪除節點節點
*/
public boolean deleteJedis(String cs) {
delete(cs);
return true;
}
/**
* 從環中刪除一個節點極其虛擬節點
*/
private void delete(String cs) {
if (cs == null) return;
for (int i = 0; i < virtual_size; ++i) {
String virtual = cs + "-N" + i;
int hash = getHash(virtual);
circle.remove(hash, virtual);
}
}
}
作者:呼延十
鏈接:https://juejin.im/post/5cfdf4e5f265da1bd260e04c
來源:掘金
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。