一文教你Redis Cluster批量操作的所有姿势(附代码实操)

1,393 阅读5分钟

前言

很多业务都可能出现同时操作大量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 substring user1000 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 substring bar 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命令了。