前言
很多业务都可能出现同时操作大量Key的情况,比如要同时获取多个用户的信息。
由于 Redis 数据量和访问量的持续增长,造成需要添加大量节点做水平扩容,导致键值分布到更多的节点上,批量操作通常需要从不同节点上获取,相比于单机批量操作只涉及到一次网络操作,Redis Cluster环境下的批量操作会涉及到多次网络时间。大家都知道Redis的性能瓶颈其实不是CPU,而是网络,所以我们要针对这种情况做出优化,尽可能少的减少网络请求的次数。
优化思路
我们以Redis单机批量获取 n 个字符串Key为例,有三种实现方法:
- 客户端 n 次 get:n 次网络 + n 次 get 命令本身。
- 客户端 1 次 pipeline get:1 次网络 + n 次 get 命令本身。
- 客户端 1 次 mget:1 次网络 + 1 次 mget 命令本身。
我们可以发现,在单个节点的情况下,使用mget是性能最好的方法。那么对于集群模式,我们也可以利用这个思路。
下面我们将结合 Redis 集群的一些特性对四种分布式的批量操作方式进行说明。
实践
1.串行IO
由于 n 个 key 是比较均匀的分布在 Redis 集群的各个节点上,因此无法使用 mget 命令一次性获取,所以通常来讲要获取 n 个 key 的值,最简单的方法就是逐次执行 n 次 get 操作, 很明显这种操作时间复杂度较高,它的网络次数是 n,很显然这种方案不是最优的,但是实现起来比较简单:
List<String> serialMGet(List<String> keys) {
// 结果集
List<String> values = new ArrayList<String>();
// n次串行get
for (String key : keys) {
String value = jedisCluster.get(key);
values.add(value);
}
return values;
}
2.归并IO
以 Redis Cluster 为例,Redis Cluster 使用 CRC16 算法计算出散列值,再取对 16384 的余数就可以算出 slot 值,smart 客户端会保存 slot 和节点的对应关系,有了这两个数据就可以对将属于同一个节点的 key 进行归并,得到每个节点的 key 子列表,之后对每个节点执行 mget 或者 pipeline 操作,它的网络次数是 node 的个数,整个过程下图所示,很明显这种方案比第一种要好很多,但是如果节点数足够多,还是有一定的性能问题。
Map<String, String> serialIOMget(List<String> keys) {
// 结果集
Map<String, String> keyValueMap = new HashMap<String, String>();
// 属于各个节点的key列表
Map<JedisPool, List<String>> nodeKeyListMap = new HashMap<JedisPool, List<String>>();
// 遍历所有的key
for (String key : keys) {
// 使用CRC16本地计算每个key的slot
int slot = JedisClusterCRC16.getSlot(key);
// 通过jedisCluster本地slot->node映射获取slot对应的node
JedisPool jedisPool = jedisCluster.getConnectionHandler().getJedisPoolFromSlot(slot);
// 归档
if (nodeKeyListMap.containsKey(jedisPool)) {
nodeKeyListMap.get(jedisPool).add(key);
} else {
List<String> list = new ArrayList<String>();
list.add(key);
nodeKeyListMap.put(jedisPool, list);
}
}
// 从每个节点上批量获取,这里使用mget也可以使用pipeline
for (Entry<JedisPool, List<String>> entry : nodeKeyListMap.entrySet()) {
JedisPool jedisPool = entry.getKey();
List<String> nodeKeyList = entry.getValue();
// 列表变为数组
String[] nodeKeyArray = nodeKeyList.toArray(new String[nodeKeyList.size()]);
// 批量获取
List<String> nodeValueList = jedisPool.getResource().mget(nodeKeyArray);
// 归档
for (int i = 0; i < nodeKeyList.size(); i++) {
keyValueMap.put(nodeKeyList.get(i), nodeValueList.get(i));
}
}
return keyValueMap;
}
3.并行归并IO
此方案是将方案(2)中的最后一步,改为多线程执行,网络次数虽然还是节点个数,但由于使用多线程网络时间变为 o(1),但是这种方案会增加编程的复杂度。它的操作时间 =max_slow(node 网络时间)+n 次命令时间:
Map<String, String> parallelIOMget(List<String> keys) {
// 结果集
Map<String, String> keyValueMap = new HashMap<String, String>();
// 属于各个节点的key列表
Map<JedisPool, List<String>> nodeKeyListMap = new HashMap<JedisPool, List<String>>();
...和前面一样
//多线程mget,最终汇总结果,使用CompletableFuture即可
for (Entry<JedisPool, List<String>> entry : nodeKeyListMap.entrySet()) {
//多线程实现
}
return keyValueMap;
}
4.hash-tag
Redis 集群模式一般都是支持 hash-tag 功能,它可以将多个 key 强制分配到一个节点上,它的操作时间 =1 次网络时间 +n 次命令时间,但是这种方式虽然性能高,但会有一系列不均衡的问题。可能会导致Redis Cluster部分节点负载过高导致集群崩溃。
//注意:集群模式的mget方法操作的Key必须在一个slot内,否则将会得到一个错误信息
List<String> hashTagMget(String[] hashTagKeys) {
return jedisCluster.mget(hashTagKeys);
}
此处简单介绍一下hash-tag这个功能。
Hash tags是Redis官方针对Cluster模式提供的一个功能。Hash tags
There is an exception for the computation of the hash slot that is used in order to implement hash tags. Hash tags are a way to ensure that multiple keys are allocated in the same hash slot. This is used in order to implement multi-key operations in Redis Cluster.
To implement hash tags, the hash slot for a key is computed in a slightly different way in certain conditions. If the key contains a "{...}" pattern only the substring between
{
and}
is hashed in order to obtain the hash slot. However since it is possible that there are multiple occurrences of{
or}
the algorithm is well specified by the following rules:
- IF the key contains a
{
character.- AND IF there is a
}
character to the right of{
.- AND IF there are one or more characters between the first occurrence of
{
and the first occurrence of}
.Then instead of hashing the key, only what is between the first occurrence of
{
and the following first occurrence of}
is hashed.Examples:
- The two keys
{user1000}.following
and{user1000}.followers
will hash to the same hash slot since only the substringuser1000
will be hashed in order to compute the hash slot.- For the key
foo{}{bar}
the whole key will be hashed as usually since the first occurrence of{
is followed by}
on the right without characters in the middle.- For the key
foo{{bar}}zap
the substring{bar
will be hashed, because it is the substring between the first occurrence of{
and the first occurrence of}
on its right.- For the key
foo{bar}{zap}
the substringbar
will be hashed, since the algorithm stops at the first valid or invalid (without bytes inside) match of{
and}
.- What follows from the algorithm is that if the key starts with
{}
, it is guaranteed to be hashed as a whole. This is useful when using binary data as key names.
简单来说,当我们给Key加上了{}之后,Redis只会根据{}里面的内容来进行Hash,从而达到我们想要批量操作的Key强制Hash到同一个Slot上的功能,这样就可以直接使用mget命令了。