主从和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代表更新的集群信息.(3)拥有越高epoch的节点, 集群信息越新
实际上, 在迁移slot或者使用cluster failover的时候, 如果多个节点同时bump epoch, 就有可能出现多个节点拥有同一个epoch, 违反上述原则(2)和(3). 这个时候拥有较小node id的节点就会自动再一次bump epoch, 以保证原则(3). 而原则(2)实际上因此也并不严格成立, 因为解决epoch collision需要一小段时间.
最大的问题在于slot. 我们遇到过数次迁移slot失败后出现slot不一致的情况. 如果还没搞懂它怎么管slot, 请记住下面这句话:
不要用乱用cluster setslot node.实在要使用 如果此时的节点没有importing flag则必须要给它发一次cluster bumpepoch.
我相信大多数不一致问题都是我们作死用这个命令造成的. 除了它我暂时还没找到有什么大概率的情况会导致不一致.
首先我们搞清楚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归属的一致性.
从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.
假设现在, 我们有 A 和 B 两个节点, 并且我们想将槽 8 从节点 A 移动到节点 B , 于是我们:
每当客户端向其他节点发送关于哈希槽 8 的命令请求时, 这些节点都会向客户端返回指向节点 A 的转向信息:
这种机制将使得节点 A 不再创建关于槽 8 的任何新键。
从客户端的角度来看, ASK 转向的完整语义(semantics)如下:
一旦节点 A 针对槽 8 的迁移工作完成, 节点 A 在再次收到针对槽 8 的命令请求时, 就会向客户端返回 MOVED 转向, 将关于槽 8 的命令请求长期地转向到节点 B 。
注意, 即使客户端出现 Bug , 过早地将槽 8 映射到了节点 B 上面, 但只要这个客户端不发送 ASKING 命令, 客户端发送命令请求的时候就会遇上 MOVED 错误, 并将它转向回节点 A 。
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); }控制台输出:
String key = "newkey{TEST_ASK}";控制台输出:
if (askHostAndPortMap.size()>0){ askHostAndPortMap.clear();//清空askMap //jedis.asking();//上次返回了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(); } }