Redis Cluster 集群一致性原理及slot迁移测试

参考:Redis Cluster原理与管理;Inconsistent slot mapping;Redis中文文档

集群信息一致性问题

主从和slot的一致性是由epoch来管理的. epoch就像Raft中的term, 但仅仅是像. 每个节点有一个自己独特的epoch和整个集群的epoch, 为简化下面都称为node epoch和cluster epoch. node epoch一直递增, 其表示某节点最后一次变成主节点或获取新slot所有权的逻辑时间. cluster epoch则是整个集群中最大的那个node epoch. 我们称递增node epoch为bump epoch, 它会用当前的cluster epoch加一来更新自己的node epoch.

在使用gossip协议中, 如果多个节点声称不同的集群信息, 那对于某个节点来说究竟要相信谁呢? Redis Cluster规定了每个主节点的epoch都不可以相同. 而一个节点只会去相信拥有更大node epoch的节点声称的信息, 因为更大的epoch代表更新的集群信息.
原则上:
(1)如果epoch不变, 集群就不应该有变更(包括选举和迁移槽位)
(2)每个节点的node epoch都是独一无二的

(3)拥有越高epoch的节点, 集群信息越新


Epoch Collision

实际上, 在迁移slot或者使用cluster failover的时候, 如果多个节点同时bump epoch, 就有可能出现多个节点拥有同一个epoch, 违反上述原则(2)和(3). 这个时候拥有较小node id的节点就会自动再一次bump epoch, 以保证原则(3). 而原则(2)实际上因此也并不严格成立, 因为解决epoch collision需要一小段时间.

slot

最大的问题在于slot. 我们遇到过数次迁移slot失败后出现slot不一致的情况. 如果还没搞懂它怎么管slot, 请记住下面这句话:
不要用乱用cluster setslot node.实在要使用 如果此时的节点没有importing flag则必须要给它发一次cluster bumpepoch.
我相信大多数不一致问题都是我们作死用这个命令造成的. 除了它我暂时还没找到有什么大概率的情况会导致不一致.

slot 管理

首先我们搞清楚slot究竟是怎么管的. 每个节点都有一份16384长的表对应每个slot究竟归哪个节点, 并且会保存当前节点所认为的其它节点的node epoch. 这样每个slot实际上绑定了一个节点及其node epoch. 然后由自认为拥有某slot的节点来负责通知其它节点这个slot的归属. 其它节点收到这个消息后, 会对比该slot原先绑定节点的node epoch, 如果收到的是更大的node epoch则更新, 否则不予理睬. 除此之外, 除了使用slot相关命令做变更, 集群没有其它途径修改slot的归属.

     slot x 是我管的, 我的node epoch是 y
node A ------------------------------> node B
          (原来slot x归node C管, 如果 y 比 node C 的node epoch大, 我就更新slot x的归属)

这实际上依赖上述的原则(3), 并且相信slot的旧主人还没有更新epoch.

迁移slot的一致性

下面来看迁移slot如何保证slot归属的一致性.
从node A迁移一个槽位到node B的流程是:
(1) node A调用cluster setslot migrating设置migrating flag, node B调用cluster setslot importing设置importing flag
(2) 调用migrate指令迁移所有该slot的数据到node B
(3) 对两个节点使用cluster setslot node来消除importing和migrating flag, 并且设置槽位

重点在于迁移最后一步消除importing flag使用的cluster setslot node,如果对一个节点使用cluster setslot node的时候节点有importing flag, 节点会bump epoch, 这样这个节点声称slot所有权时别的节点就会认可.

但是这里并没有跑一遍选举中的投票流程. 如果另外一个节点也同时bump epoch, 就出现epoch collision. 这里是一个不完美但又略精妙的地方. 不管这个清importing flag的节点在解决collision后是否获得更高的epoch, 其epoch肯定大于migrating那个节点之前的epoch.

但这里还是有漏洞, 万一node B在广播自己的新node epoch前, node A做了什么变更而获取了一个更大的node epoch呢? 万一发生collision的是node A和node B两个节点呢? 这个时候假如node A的node id更小, node A会拿到更大的新epoch. 只要某个节点收到node A的消息, 这个slot的迁移信息就永远写不进这个节点了, 因为node A的node epoch比node B更大.

上面提到的cluster setslot node的问题在于, 如果节点没有importing flag, 它会直接设置槽位, 但不会增加自己的node epoch.这样当他告诉别的节点对这个槽位的所有权时, 其他节点并不认可. 这实际上违反了上述原则(1). 详细见这里.所以实在要在迁移slot以外的地方用这个命令, 必须要给它发一次cluster bumpepoch.

注意的地方

cluster setslot node在源节点和目标节点都须要执行,因此cluster bumpepoch也须要执行两次

思考

当slot处于migrating或者importing状态时,客户端该如何访问该slot所属的key

(1)当一个槽被设置为 MIGRATING  状态时, 原来持有这个槽的节点仍然会继续接受关于这个槽的命令请求, 但只有命令所处理的键仍然存在于节点时, 节点才会处理这个命令请求。如果命令所使用的键不存在与该节点, 那么节点将向客户端返回一个 -ASK 转向(redirection)错误, 告知客户端, 要将命令请求发送到槽的迁移目标节点。

(2)当一个槽被设置为 IMPORTING 状态时, 节点仅在接收到 ASKING 命令之后, 才会接受关于这个槽的命令请求。如果客户端没有向节点发送 ASKING 命令, 那么节点会使用 -MOVED 转向错误将命令请求转向至真正负责处理这个槽的节点。

假设现在, 我们有 A 和 B 两个节点, 并且我们想将槽 8 从节点 A 移动到节点 B , 于是我们:

  • 向节点 B 发送命令 CLUSTER SETSLOT 8 IMPORTING A
  • 向节点 A 发送命令 CLUSTER SETSLOT 8 MIGRATING B

每当客户端向其他节点发送关于哈希槽 8 的命令请求时, 这些节点都会向客户端返回指向节点 A 的转向信息:

  • 如果命令要处理的键已经存在于槽 8 里面, 那么这个命令将由节点 A 处理。
  • 如果命令要处理的键未存在于槽 8 里面(比如说,要向槽添加一个新的键), 那么这个命令由节点 B 处理。

这种机制将使得节点 A 不再创建关于槽 8 的任何新键。


关于ASK转向

当节点需要让一个客户端长期地(permanently)将针对某个槽的命令请求发送至另一个节点时, 节点向客户端返回  MOVED  转向,
另一方面, 当节点需要让客户端仅仅在下一个命令请求中转向至另一个节点时, 节点向客户端返回 ASK 转向。

比如说, 在我们上一节列举的槽 8 的例子中, 因为槽 8 所包含的各个键分散在节点 A 和节点 B 中, 所以当客户端在节点 A 中没找到某个键时, 它应该转向到节点 B 中去寻找, 但是这种转向应该仅仅影响一次命令查询, 而不是让客户端每次都直接去查找节点 B :在节点 A 所持有的属于槽 8 的键没有全部被迁移到节点 B 之前, 客户端应该先访问节点 A , 然后再访问节点 B

因为上述原因,如果我们要在查找节点 A 之后, 继续查找节点 B , 那么客户端在向节点 B 发送命令请求之前, 应该先发送一个 ASKING命令, 否则这个针对带有 IMPORTING 状态的槽的命令请求将被节点 B 拒绝执行接收到客户端 ASKING 命令的节点将为客户端设置一个一次性的标志(flag), 使得客户端可以执行一次针对 IMPORTING 状态的槽的命令请求。

从客户端的角度来看, ASK 转向的完整语义(semantics)如下:

  • 如果客户端接收到 ASK 转向, 那么将命令请求的发送对象调整为转向所指定的节点。
  • 先发送一个 ASKING 命令,然后再发送真正的命令请求。
  • 不必更新客户端所记录的槽 8 至节点的映射: 槽 8 应该仍然映射到节点 A , 而不是节点 B 。

一旦节点 A 针对槽 8 的迁移工作完成, 节点 A 在再次收到针对槽 8 的命令请求时, 就会向客户端返回 MOVED 转向, 将关于槽 8 的命令请求长期地转向到节点 B 。

注意, 即使客户端出现 Bug , 过早地将槽 8 映射到了节点 B 上面, 但只要这个客户端不发送 ASKING 命令, 客户端发送命令请求的时候就会遇上 MOVED 错误, 并将它转向回节点 A 。


测试

环境模拟:
Redis Cluster 集群一致性原理及slot迁移测试_第1张图片


oldkey{TEST_ASK}是slot迁移前set的,属于8003节点,new key{TEST_ASK}是slot迁移后set的,属于8001节点,以上过程
也间接印证了一旦原节点slot处于migrating状态,不再处理关于迁移的slot的任意新的键

客户端测试:
一:客户端使用单机Jedis实例

(1)测试对迁移前的key读取情况
public static void main(String[] args) {
        //key-redis节点映射的map,解决ASK 只应该是一次性的,因此将map定义在方法内部
        Map<String,HostAndPort> askHostAndPortMap = new HashMap<String, HostAndPort>();
        Boolean returnFlag;
        String key = "oldkey{TEST_ASK}";
        HostAndPort hp;
        Jedis jedis = null;
        String host = BaseConfig.HOST;
        int port = BaseConfig.PORT;//8001
        do {
            returnFlag = Boolean.FALSE;
            hp = askHostAndPortMap.get(key);//从缓存中取出HostAndPort
            if (hp == null)
                hp = movedHostAndPortMap.get(key);//从缓存中取出HostAndPort
            try {
                if (hp != null){
                    host = hp.getHost();
                    port = hp.getPort();
                } else {
                    port = BaseConfig.PORT;
                    host = BaseConfig.HOST;
                }
                jedis = jedisUtils.getJedis(host,port);
                if (askHostAndPortMap.size()>0){
                    askHostAndPortMap.clear();//清空askMap
                    jedis.asking();//上次返回了ask
                }
                String value = jedis.get(key);
                System.out.println(key+":"+value);
            } catch (JedisMovedDataException e) {
                //rediscluster:当前key所在的slot不是当前连接的redis节点,jedis抛出moved,要求客户端重定向到正确的redis节点
                returnFlag = Boolean.TRUE;
                movedHostAndPortMap.put(key, e.getTargetNode());//更新缓存的map
                System.out.println("我进行了一次moved:"+e.getTargetNode().getHost()+":"+e.getTargetNode().getPort());
            } catch (JedisAskDataException e) {
                //rediscluster:当前key所在的slot处于migrating/importing状态,jedis抛出ask,要求客户端重定向到正确的redis节点
                returnFlag = Boolean.TRUE;
                askHostAndPortMap.put(key, e.getTargetNode());//更新缓存的map
                System.out.println("我进行了一次ask:"+e.getTargetNode().getHost()+":"+e.getTargetNode().getPort());
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                if (jedis != null)
                jedisUtils.closeJedis(jedis,host,port);//释放连接
            }
        } while (returnFlag);
    }
控制台输出:
Redis Cluster 集群一致性原理及slot迁移测试_第2张图片
moved之后从8003上读取到了value,测试通过

(2)测试对迁移后的新key读取情况,将key替换为新的key
String key = "newkey{TEST_ASK}";
控制台输出:
Redis Cluster 集群一致性原理及slot迁移测试_第3张图片
move到8003尝试读取,服务端返回ask定向,最终在8001上读取到了value,测试通过

(3)测试客户端未发送asking的情况 注释asking发送
if (askHostAndPortMap.size()>0){
      askHostAndPortMap.clear();//清空askMap
      //jedis.asking();//上次返回了ask
   }
控制台输出:
Redis Cluster 集群一致性原理及slot迁移测试_第4张图片
由于8001没有接收到asking指令,请求被拒绝,节点使用 -MOVED 转向错误将命令请求转向至真正负责处理这个槽的节点8003
由于客户端始终不会发送asking指令,进入了死循环,测试通过

(4)测试slot迁移完毕后的情况
迁移slot
Redis Cluster 集群一致性原理及slot迁移测试_第5张图片
控制台输出:


Redis Cluster 集群一致性原理及slot迁移测试_第6张图片
发现slot迁移完毕后,直接在8001上读取到了value,默认连接8001所以无须重定向,测试通过。

二:客户端使用JedisCluster实例
经测试当客户端使用JedisCluster实例时不须要考虑MOVED和ASK转向,内部已经封装了对其的一些处理,直接调用即可
@Test
public void testJedisCluster() {
    try {
        String key = "newkey{TEST_ASK}";
        System.out.println(key+":"+jedisCluster.get(key));
        jedisCluster.close();
    } catch (IOException e) {
        e.printStackTrace();
    }
}

至此,slot迁移完毕,迁移中间状态的读取测试完毕。

你可能感兴趣的:(redis)