搭建完redis集羣后,可以通過jedis的JedisCluster來訪問Redis集羣,這裏列出使用jedisCluster的spring bean配置方式:
<bean id="jedisPoolConfig" class="redis.clients.jedis.JedisPoolConfig">
<property name="maxIdle" value="5" />
<property name="maxTotal" value="1024" />
<property name="maxWaitMillis" value="5000" />
<property name="testOnBorrow" value="true" />
</bean>
<bean id="redisCluster" class="redis.clients.jedis.JedisCluster"
<constructor-arg name=“nodes">
<set>
<ref bean="hostport1" />
<ref bean="hostport2" />
<ref bean="hostport3" />
<ref bean="hostport4" />
<ref bean="hostport5" />
<ref bean="hostport6" />
</set>
</constructor-arg>
<constructor-arg name="timeout" value="6000" />
<constructor-arg name="poolConfig"
<ref bean="jedisPoolConfig" />
</constructor-arg>
</bean>
本質上,JedisCluster中的JedisPool同樣也是實現了apache common pool2的對象池,其中的getResource可以拿到對應的Jedis連接。
JedisCluster是如何構建整個連接池
正如類圖中所呈現的,JedisClusterConnectionHandler中使用了JedisClusterInfoCache作爲緩存初始化容器,將Set<HostAndPort>作爲JedisClusterConnectionHandler的構造函數參數傳遞過去之後,進行初始化slotsCache操作,
public JedisClusterConnectionHandler(Set<HostAndPort> nodes,
final GenericObjectPoolConfig poolConfig, int connectionTimeout, int soTimeout) {
this.cache = new JedisClusterInfoCache(poolConfig, connectionTimeout, soTimeout);
initializeSlotsCache(nodes, poolConfig);
}
private void initializeSlotsCache(Set<HostAndPort> startNodes, GenericObjectPoolConfig poolConfig) {
for (HostAndPort hostAndPort : startNodes) {
Jedis jedis = new Jedis(hostAndPort.getHost(), hostAndPort.getPort());
try {
cache.discoverClusterNodesAndSlots(jedis);
break;
} catch (JedisConnectionException e) {
// try next nodes
} finally {
if (jedis != null) {
jedis.close();
}
}
}
for (HostAndPort node : startNodes) {
cache.setNodeIfNotExist(node);
}
}
在cache.discoverClusterNodesAndSlots中,用到了Jedis.clusterNodes,它可以通過該Redis連接找到其他連接的相關配置,例如可以發現整個集羣的配置,其中三個master,三個slave,並且能夠識別出自身連接,可參考文檔:http://redis.io/commands/cluster-nodes:
./redis-cli -c -h xxx -p 63xx cluster nodes
e54b82fd2b5ab238906cff7fc6250a7bc66c6fec 192.168.1.1xx:6389 master - 0 1469151811362 31 connected 0-5460
166baa38c8ab56339c11f0446257c7a6059a219b 192.168.1.1xx:6389 slave 1609b090dfaaac702449b72d30b2330521ce2506 0 1469151812364 29 connected
1609b090dfaaac702449b72d30b2330521ce2506 192.168.1.1xx:6390 master - 0 1469151811362 29 connected 10923-16383
539627a393aa43e82ca8c16d1e935611fec4e709 192.168.1.1xx:6388 myself,master - 0 0 28 connected 5461-10922
d9b3738ff16e99075242b865a0b6cc137c20d502 192.168.1.1xx:6390 slave 539627a393aa43e82ca8c16d1e935611fec4e709 0 1469151810859 28 connected
101227d3cb13f08a47ad2afe1b348d0efc3cb3b0 192.168.1.1xx:6388 slave e54b82fd2b5ab238906cff7fc6250a7bc66c6fec 0 1469151810357 31 connected
命令返回的結果中,可以看到有3個master,3個slave,而且每個slave都有對應的masterid,每個master都有對應的slot範圍。
在ClusterNodeInformationParser中,去解析每一行並將對應的slot填充進去,因爲只有master上有slot,因此不會填充slave的slot:
public void discoverClusterSlots(Jedis jedis) {
w.lock();
try {
this.slots.clear();
List<Object> slots = jedis.clusterSlots();
for (Object slotInfoObj : slots) {
List<Object> slotInfo = (List<Object>) slotInfoObj;
if (slotInfo.size() <= 2) {
continue;
}
List<Integer> slotNums = getAssignedSlotArray(slotInfo);
// hostInfos
List<Object> hostInfos = (List<Object>) slotInfo.get(2);
if (hostInfos.size() <= 0) {
continue;
}
// at this time, we just use master, discard slave information
HostAndPort targetNode = generateHostAndPort(hostInfos);
setNodeIfNotExist(targetNode);
assignSlotsToNode(slotNums, targetNode);
}
} finally {
w.unlock();
}
}
因此,當我們正常地通過訪問JedisCluster的get/set時,通過計算key的slot來獲取對應的Jedis Connection,根本不會使用到slave,只會訪問master節點。只有一種情況,在tryRandomMode開啓時(此時,正常通過slot無法獲取有效連接時,可能考慮重新排序)。
@Override
public Jedis getConnection() {
// In antirez's redis-rb-cluster implementation,
// getRandomConnection always return valid connection (able to
// ping-pong)
// or exception if all connections are invalid
List<JedisPool> pools = getShuffledNodesPool();
for (JedisPool pool : pools) {
Jedis jedis = null;
try {
jedis = pool.getResource();
if (jedis == null) {
continue;
}
String result = jedis.ping();
if (result.equalsIgnoreCase("pong")) return jedis;
pool.returnBrokenResource(jedis);
} catch (JedisConnectionException ex) {
if (jedis != null) {
pool.returnBrokenResource(jedis);
}
}
}
throw new JedisConnectionException("no reachable node in cluster");
}
但此時拿到一個slave節點的可用Connection是非常危險的,加入當前的操作爲寫操作,將某個字段寫入Redis時,由於master不會從slave節點進行復制,會導致該數據操作沒有被持久化至master上。
開發基於redis key統一批量處理的中間層
根據redis cluster nodes命令來進行,該命令可以識別出當前集羣其餘節點的所有狀態,master/slave,以及檢測的slot位置。
cluster nodes
e54b82fd2b5ab238906cff7fc6250a7bc66c6fec 192.168.1.163:6389 master - 0 1469600305090 31 connected 0-5460
166baa38c8ab56339c11f0446257c7a6059a219b 192.168.1.165:6389 slave 1609b090dfaaac702449b72d30b2330521ce2506 0 1469600304588 29 connected
1609b090dfaaac702449b72d30b2330521ce2506 192.168.1.163:6390 master - 0 1469600305592 29 connected 10923-16383
539627a393aa43e82ca8c16d1e935611fec4e709 192.168.1.163:6388 myself,master - 0 0 28 connected 5461-10922
d9b3738ff16e99075242b865a0b6cc137c20d502 192.168.1.165:6390 slave 539627a393aa43e82ca8c16d1e935611fec4e709 0 1469600305090 28 connected
101227d3cb13f08a47ad2afe1b348d0efc3cb3b0 192.168.1.165:6388 slave e54b82fd2b5ab238906cff7fc6250a7bc66c6fec 0 1469600304088 31 connected
cluster nodes 命令的輸出有點兒複雜, 它的每一行都是由以下信息組成的:
- 節點 ID :例如 3fc783611028b1707fd65345e763befb36454d73 。
- ip:port :節點的 IP 地址和端口號, 例如 127.0.0.1:7000 , 其中 :0 表示的是客戶端當前連接的 IP 地址和端口號。
- flags :節點的角色(例如 master 、 slave 、 myself )以及狀態(例如 fail ,等等)。
- 如果節點是一個從節點的話, 那麼跟在 flags 之後的將是主節點的節點 ID : 例如 127.0.0.1:7002 的主節點的節點 ID 就是 3c3a0c74aae0b56170ccb03a76b60cfe7dc1912e 。
- 集羣最近一次向節點發送 PING 命令之後, 過去了多長時間還沒接到回覆。
- 節點最近一次返回 PONG 回覆的時間。
- 節點的配置紀元(configuration epoch):詳細信息請參考 Redis 集羣規範 。
- 本節點的網絡連接情況:例如 connected 。
- 節點目前包含的槽:例如 127.0.0.1:7001 目前包含號碼爲 5960 至 10921 的哈希槽。
可以看出redis cluster的slot範圍:0-16383,可以採用二分查找的方式,以上面爲例,可以分成3個部分的範圍slot,以其開頭爲標識,通過Collections.binarySearch來進行二分查找搜索:
0——5460,5461——10922,10923——16383;
通過JedisPool/JedisConnection初始化客戶端連接,並建立slotStarts,其中的ClusterNodeObject作爲描述cluster nodes命令返回的行對象:
public void initCluster() {
if (jedis instanceof BinaryJedisCluster) {
BinaryJedisCluster jedisCluster = (BinaryJedisCluster) jedis;
Map<String, JedisPool> clusterNodes = jedisCluster.getClusterNodes();
Map<String, ClusterNodeObject> hpToNodeObjectMap = new HashMap<>(clusterNodes.size());
for (Map.Entry<String, JedisPool> entry : clusterNodes.entrySet()) {
JedisPool jedisPool = entry.getValue();
Jedis jedis = jedisPool.getResource();
String clusterNodesCommand = jedis.clusterNodes();
String[] allNodes = clusterNodesCommand.split("\n");
for (String allNode : allNodes) {
String[] splits = allNode.split(" ");
String hostAndPort = splits[1];
ClusterNodeObject clusterNodeObject =
new ClusterNodeObject(splits[0], splits[1], splits[2].contains("master"), splits[3],
Long.parseLong(splits[4]), Long.parseLong(splits[5]), splits[6],
splits[7].equalsIgnoreCase("connected"), splits.length == 9 ? splits[8] : null);
hpToNodeObjectMap.put(hostAndPort, clusterNodeObject);
}
}
List<Integer> slotStarts = new ArrayList<>();
for (ClusterNodeObject clusterNodeObject : hpToNodeObjectMap.values()) {
if (clusterNodeObject.isConnected() && clusterNodeObject.isMaster()) {
String slot = clusterNodeObject.getSlot();
String[] slotSplits = slot.split("-");
int slotStart = Integer.parseInt(slotSplits[0]);
// int slotEnd = Integer.parseInt(slotSplits[1]);
slotStarts.add(slotStart);
}
}
Collections.sort(slotStarts);
this.slotStarts = slotStarts;
}
}
在拿到各個redis key後,通過getSlotByKey方法,獲得對應的node編號:
private int getSlotByKey(String key) {
int slot = JedisClusterCRC16.getSlot(key);
int slotInsertion = Collections.binarySearch(slotStarts, slot);
if (slotInsertion < 0) {
slotInsertion = Math.abs(slotInsertion + 2);
}
return slotInsertion;
}
最後,當批量查詢的keys數組>2時,再進行批量出,否則,只進行單獨查詢。
if (keys.length > 2 && jedis instanceof JedisCluster) {
//如果批量請求key長度大於2,啓動批量查詢方式
Map<Integer, List<String>> keySlotsMapList = new HashMap<>();
for (String key : keys) {
int slotByKey = getSlotByKey(key);
if (!keySlotsMapList.containsKey(slotByKey)) {
keySlotsMapList.put(slotByKey, new ArrayList<String>());
}
keySlotsMapList.get(slotByKey).add(key);
}
for (Map.Entry<Integer, List<String>> entry : keySlotsMapList.entrySet()) {
List<String> slotSameKeys = entry.getValue();
List<String> mgetValues = ((ZhenJedisCluster) jedis)
.mget(slotSameKeys.toArray(new String[slotSameKeys.size()]));
for (int i = 0; i < slotSameKeys.size(); i++) {
result.set(keyList.indexOf(slotSameKeys.get(i)), mgetValues.get(i));
}
}
} else {
for (String key : keys) {
result.add(jedis.get(key));
}
}
但不能跳過Jedis客戶端的slot key檢查,其中的批量操作依賴slot是否相同:
public T run(int keyCount, String... keys) {
if (keys == null || keys.length == 0) {
throw new JedisClusterException("No way to dispatch this command to Redis Cluster.");
}
// For multiple keys, only execute if they all share the
// same connection slot.
if (keys.length > 1) {
int slot = JedisClusterCRC16.getSlot(keys[0]);
for (int i = 1; i < keyCount; i++) {
int nextSlot = JedisClusterCRC16.getSlot(keys[i]);
if (slot != nextSlot) {
throw new JedisClusterException("No way to dispatch this command to Redis Cluster "
+ "because keys have different slots.");
}
}
}
return runWithRetries(SafeEncoder.encode(keys[0]), this.redirections, false, false);
}
雖然可以通過重寫JedisCluster以及JedisClusterCommand類型(由於有一些依賴包訪問權限的類型,需要將這些重寫的類型同樣放到redis.clients.jedis包中),跳過了JedisCluster的校驗,仍然出現連接錯誤:
Exception in thread "main" redis.clients.jedis.exceptions.JedisDataException: CROSSSLOT Keys in request don't hash to the same slot
at redis.clients.jedis.Protocol.processError(Protocol.java:117)
at redis.clients.jedis.Protocol.process(Protocol.java:151)
at redis.clients.jedis.Protocol.read(Protocol.java:205)
at redis.clients.jedis.Connection.readProtocolWithCheckingBroken(Connection.java:297)
at redis.clients.jedis.Connection.getBinaryMultiBulkReply(Connection.java:233)
at redis.clients.jedis.Connection.getMultiBulkReply(Connection.java:226)
at redis.clients.jedis.Jedis.mget(Jedis.java:355)
at redis.clients.jedis.ZhenJedisCluster$129.execute(ZhenJedisCluster.java:1382)
at redis.clients.jedis.ZhenJedisCluster$129.execute(ZhenJedisCluster.java:1)
at redis.clients.jedis.ZhenJedisClusterCommand.runWithRetries(ZhenJedisClusterCommand.java:119)
at redis.clients.jedis.ZhenJedisClusterCommand.run(ZhenJedisClusterCommand.java:51)
at redis.clients.jedis.ZhenJedisCluster.mget(ZhenJedisCluster.java:1384)
at com.api.pub.cache.JedisClient.batchGet(JedisClient.java:525)
at com.zhen.commons.redis.test.RedisTest.main(RedisTest.java:46)
可以看出,儘管兩個slot在同一個連接上能夠get到值,但是在cluster模式下,是通過slot判斷而非節點node判斷是否可以進行mget操作,不能靠跳過jedis客戶端的方案來完成類似分組操作。
我們可以通過命令行操作,同樣來驗證這一點,注意redis-cli連接時保證在cluster模式下運行,加入-c參數:
redis-cli -c -h 192.168.1.138 -p 6388
192.168.1.138:6388> set key1 "key1"
-> Redirected to slot [9189] located at 192.168.1.137:6390
OK
192.168.1.137:6390> set key2 "key2"
-> Redirected to slot [4998] located at 192.168.1.137:6389
OK
192.168.1.137:6389> set key3 "key3"
OK
192.168.1.137:6389> mget key2 key3
(error) CROSSSLOT Keys in request don't hash to the same slot
192.168.1.137:6389> get key2
"key2"
192.168.1.137:6389> get key3
"key3"
192.168.1.137:6389> get key1
-> Redirected to slot [9189] located at 192.168.1.137:6390
"key1"
192.168.1.137:6390> set {aaa}1 "1"
OK
192.168.1.137:6390> set {aaa}2 "2"
OK
192.168.1.137:6390> mget {aaa}1 {aaa}2
1) "1"
2) "2"
因此,暫且不能在RedisCluster模式下,通過增加中間層來對批量請求進行分組,並處理到對應的slot中,理想很好,但是不能夠實現,因爲服務端會進行一定的限制。
只能通過HASH_TAG來實現cluster模式下的mget/mset批量操作,我們可以在命令行中通過cluster keyslot ${key}來查看某個key對應的slot,可以從Jedis客戶端的源碼查看對應的key slot算法:
public static int getSlot(String key) {
int s = key.indexOf("{");
if (s > -1) {
int e = key.indexOf("}", s + 1);
if (e > -1 && e != s + 1) {
key = key.substring(s + 1, e);
}
}
// optimization with modulo operator with power of 2
// equivalent to getCRC16(key) % 16384
return getCRC16(key) & (16384 - 1);
}
可以看出,keySlot算法中,如果key包含{},就會使用第一個{}內部的字符串作爲hash key,這樣就可以保證擁有同樣{}內部字符串的key就會擁有相同slot。