在集群模式下,Redis接收任何键相关命令时首先计算键对应的槽,再根据槽找出所对应的节点,如果节点是自身,则处理键命令;
否则回复MOVED重定向错误,通知客户端请求正确的节点。这个过程称为MOVED重定向,如图10-29所示。
例如,在之前搭建的集群上执行如下命令:
127.0.0.1:6379> set key:test:1 value-1 OK
执行set命令成功,因为键key:test:1对应槽5191正好位于6379节点负责的槽范围内,可以借助cluster keyslot{key}命令返回key所对应的槽,如下所示:
127.0.0.1:6379> cluster keyslot key:test:1 (integer) 5191 127.0.0.1:6379> cluster nodes cfb28ef1deee4e0fa78da86abe5d24566744411e 127.0.0.1:6379 myself,master - 0 0 10 connected 1366-4095 4097-5461 12288-13652 ...
再执行以下命令,由于键对应槽是9252,不属于6379节点,则回复MOVED{slot}{ip}{port}格式重定向信息:
127.0.0.1:6379> set key:test:2 value-2 (error) MOVED 9252 127.0.0.1:6380 127.0.0.1:6379> cluster keyslot key:test:2 (integer) 9252
重定向信息包含了键所对应的槽以及负责该槽的节点地址,根据这些信息客户端就可以向正确的节点发起请求。在6380节点上成功执行之前的命令:
127.0.0.1:6380> set key:test:2 value-2 OK
使用redis-cli命令时,可以加入-c参数支持自动重定向,简化手动发起重定向操作,如下所示:
#redis-cli -p 6379 -c 127.0.0.1:6379> set key:test:2 value-2 -> Redirected to slot [9252] located at 127.0.0.1:6380 OK
redis-cli自动帮我们连接到正确的节点执行命令,这个过程是在redis-cli内部维护,实质上是client端接到MOVED信息之后再次发起请求,并不在Redis节点中完成请求转发,如图10-30所示。
节点对于不属于它的键命令只回复重定向响应,并不负责转发。
熟悉Cassandra的用户希望在这里做好区分,不要混淆。正因为集群模式下把解析发起重定向的过程放到客户端完成,所以集群客户端协议相对于单机有了很大的变化。
键命令执行步骤主要分两步:计算槽,查找槽所对应的节点。下面分别介绍。
Redis首先需要计算键所对应的槽。根据键的有效部分使用CRC16函数计算出散列值,再取对16383的余数,使每个键都可以映射到0~16383槽范围内。
伪代码如下:
def key_hash_slot(key): int keylen = key.length(); for (s = 0; s < keylen; s++): if (key[s] == '{'): break; if (s == keylen) return crc16(key,keylen) & 16383; for (e = s+1; e < keylen; e++): if (key[e] == '}') break; if (e == keylen || e == s+1) return crc16(key,keylen) & 16383; /* 使用{和}之间的有效部分计算槽 */ return crc16(key+s+1,e-s-1) & 16383;
根据伪代码,如果键内容包含{和}大括号字符,则计算槽的有效部分是括号内的内容;否则采用键的全内容计算槽。
cluster keyslot命令就是采用key_hash_slot函数实现的,例如:
127.0.0.1:6379> cluster keyslot key:test:111 (integer) 10050 127.0.0.1:6379> cluster keyslot key:{hash_tag}:111 (integer) 2515 127.0.0.1:6379> cluster keyslot key:{hash_tag}:222 (integer) 2515
其中键内部使用大括号包含的内容又叫做hash_tag,它提供不同的键可以具备相同slot的功能,常用于Redis IO优化。
例如在集群模式下使用mget等命令优化批量调用时,键列表必须具有相同的slot,否则会报错。
这时可以利用hash_tag让不同的键具有相同的slot达到优化的目的。命令如下:
127.0.0.1:6385> mget user:10086:frends user:10086:videos (error) CROSSSLOT Keys in request don't hash to the same slot 127.0.0.1:6385> mget user:{10086}:friends user:{10086}:videos 1) "friends" 2) "videos"
开发提示
Pipeline同样可以受益于hash_tag,由于Pipeline只能向一个节点批量发送
执行命令,而相同slot必然会对应到唯一的节点,降低了集群使用Pipeline的门槛。
Redis计算得到键对应的槽后,需要查找槽所对应的节点。集群内通过消息交换每个节点都会知道所有节点的槽信息,内部保存在clusterState结构中,结构所示:
typedef struct clusterState { clusterNode *myself; /* 自身节点,clusterNode代表节点结构体 */ clusterNode *slots[CLUSTER_SLOTS]; /* 16384个槽和节点映射数组,数组下标代表对应的槽 */ ... } clusterState;
slots数组表示槽和节点对应关系,实现请求重定向伪代码如下:
def execute_or_redirect(key): int slot = key_hash_slot(key); ClusterNode node = slots[slot]; if(node == clusterState.myself): return executeCommand(key); else: return '(error) MOVED {slot} {node.ip}:{node.port}';
根据伪代码看出节点对于判定键命令是执行还是MOVED重定向,都是借助slots[CLUSTER_SLOTS]数组实现。
根据MOVED重定向机制,客户端可以随机连接集群内任一Redis获取键所在节点,这种客户端又叫Dummy(傀儡)客户端,它优点是代码实现简单,对客户端协议影响较小,只需要根据重定向信息再次发送请求即可。
但是它的弊端很明显,每次执行键命令前都要到Redis上进行重定向才能找到要执行命令的节点,额外增加了IO开销,这不是Redis集群高效的使用方式。正因为如此通常集群客户端都采用另一种实现:Smart(智能)客户端。
大多数开发语言的Redis客户端都采用Smart客户端支持集群协议,客户端如何选择见:http://redis.io/clients,从中找出符合自己要求的客户端类库。
Smart客户端通过在内部维护slot→node的映射关系,本地就可实现键到节点的查找,从而保证IO效率的最大化,而MOVED重定向负责协助Smart客户端更新slot→node映射。
我们以Java的Jedis为例,说明Smart客户端操作集群的流程。
127.0.0.1:6379> cluster slots 1) 1) (integer) 0 // 开始槽范围 2) (integer) 1365 // 结束槽范围 3) 1) "127.0.0.1" // 主节点ip 2) (integer) 6385 // 主节点地址 4) 1) "127.0.0.1" // 从节点ip 2) (integer) 6386 // 从节点端口 2) 1) (integer) 5462 2) (integer) 6826 3) 1) "127.0.0.1" 2) (integer) 6385 4) 1) "127.0.0.1" 2) (integer) 6386 ...
public class JedisClusterInfoCache { private Map<String, JedisPool> nodes = new HashMap<String, JedisPool>(); private Map<Integer, JedisPool> slots = new HashMap<Integer, JedisPool>(); ... }
public abstract class JedisClusterCommand<T> { // 集群节点连接处理器 private JedisClusterConnectionHandler connectionHandler; // 重试次数,默认5次 private int redirections; // 模板回调方法 public abstract T execute(Jedis connection); public T run(String key) { if (key == null) { throw new JedisClusterException("No way to dispatch this command to Redis Cluster."); } return runWithRetries(SafeEncoder.encode(key), this.redirections, false, false); } // 利用重试机制运行键命令 private T runWithRetries(byte[] key, int redirections, boolean tryRandomNode, boolean asking) { if (redirections <= 0) { throw new JedisClusterMaxRedirectionsException("Too many Cluster redi rections"); } Jedis connection = null; try { if (tryRandomNode) { // 随机获取活跃节点连接 connection = connectionHandler.getConnection(); } else { // 使用slot缓存获取目标节点连接 connection = connectionHandler.getConnectionFromSlot(JedisClusterCRC16. getSlot(key)); } return execute(connection); } catch (JedisConnectionException jce) { // 出现连接错误使用随机连接重试 return runWithRetries(key, redirections - 1, true/*开启随机连接*/, asking); } catch (JedisRedirectionException jre) { if (jre instanceof JedisMovedDataException) { // 如果出现MOVED重定向错误,在连接上执行cluster slots命令重新初始化slot缓存 this.connectionHandler.renewSlotCache(connection); } // slot初始化后重试执行命令 return runWithRetries(key, redirections - 1, false, asking); } finally { releaseConnection(connection); } } }
键命令执行流程:
整个流程如图10-31所示。
从命令执行流程中发现,客户端需要结合异常和重试机制时刻保证跟Redis集群的slots同步,因此Smart客户端相比单机客户端有了很大的变化和实现难度。
了解命令执行流程后,下面我们对Smart客户端成本和可能存在的问题进行分析:
throw new JedisClusterMaxRedirectionsException("Too many Cluster redirections");
这经常会引起开发人员的疑惑,它隐藏了内部错误细节,原因是节点宕机或请求超时都会抛出JedisConnectionException,导致触发了随机重试,当重试次数耗尽抛出这个错误。 3) 当出现JedisConnectionException时,Jedis认为可能是集群节点故障需要随机重试来更新slots缓存,因此了解哪些异常将抛出JedisConnectionException变得非常重要,有如下几种情况会抛出JedisConnectionException:
private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock(); private final Lock r = rwl.readLock(); private final Lock w = rwl.writeLock(); public void renewSlotCache(Jedis jedis) { try { cache.discoverClusterSlots(jedis); } catch (JedisConnectionException e) { renewSlotCache(); } } public void discoverClusterSlots(Jedis jedis) { // 获取写锁 w.lock(); try { this.slots.clear(); // 执行cluster slots List<Object> slots = jedis.clusterSlots(); for (Object slotInfoObj : slots) { // 初始化slots缓存代码,忽略细节... } } finally { w.unlock(); } } public JedisPool getSlotPool(int slot) { // 获取读锁 r.lock(); try { // 返回slot对应的jedisPool return slots.get(slot); } finally { r.unlock(); } }
从代码中看到,获得写锁后再执行cluster slots命令初始化缓存,由于集群所有的键命令都会执行getSlotPool方法计算槽对应节点,它内部要求读锁。
Reentrant ReadWriteLock是读锁共享且读写锁互斥,从而导致所有的请求都会造成阻塞。
对于并发量高的场景将极大地影响集群吞吐。这个现象称为cluster slots风暴,有如下现象:
def runWithRetries(byte[] key, int attempts) : if (attempts <= 0) : throw new JedisClusterMaxRedirectionsException("Too many Cluster red irections"); Jedis connection = null; try : // 获取连接 connection = connectionHandler.getConnectionFromSlot(JedisClusterCRC16.getSlot(key)); return execute(connection); except JedisConnectionException,jce : if (attempts <= 1) : // 当重试到1次时,更新本地slots缓存 this.connectionHandler.renewSlotCache(); // 抛出异常 throw jce; // 递归执行重试 return runWithRetries(key, attempts - 1); except JedisRedirectionException,jre: // 如果是MOVED异常,更新slots缓存 if (jre instanceof JedisMovedDataException) : this.connectionHandler.renewSlotCache(connection); // 递归,执行重试 return runWithRetries(key, attempts - 1); finally: releaseConnection(connection);
根据代码看出,只有当重试次数到最后1次或者出现MovedDataException时才更新slots操作,降低了cluster slots命令调用次数。
def renewSlotCache(Jedis jedis) : //使用rediscovering变量保证当有一个线程正在初始化slots时,其他线程直接忽略。 if (!rediscovering): try : w.lock(); rediscovering = true; if (jedis != null) : try : // 更新本地缓存 discoverClusterSlots(jedis); return; except JedisException,e: // 忽略异常,使用随机查找更新slots // 使用随机节点更新slots for (JedisPool jp : getShuffledNodesPool()) : try : // 不再使用ping命令检测节点 jedis = jp.getResource(); discoverClusterSlots(jedis); return; except JedisConnectionException,e: // try next nodes finally : if (jedis != null) : jedis.close(); finally : // 释放锁和rediscovering变量 rediscovering = false; w.unlock();
综上所述,Jedis2.8.2之后的版本,当出现JedisConnectionException时,命令发送次数变为5次:4次重试命令+1次cluster slots命令,同时避免了cluster slots不必要的并发调用。
开发提示
建议升级到Jedis2.8.2以上版本防止cluster slots风暴和写锁阻塞问题,但 是笔者认为还可以进一步优化,如下所示:
这里我们用大量篇幅介绍了Smart客户端Jedis与集群交互的细节,主要原因是针对于高并发的场景,这里是绝对的热点代码。
集群协议通过Smart客户端全面高效的支持需要一个过程,因此用户在选择Smart客户端时要重点审核集群交互代码,防止线上踩坑。
必要时可以自行优化修改客户端源码。
(1)JedisCluster的定义
Jedis为Redis Cluster提供了Smart客户端,对应的类是JedisCluster,它的初始化方法如下:
public JedisCluster(Set<HostAndPort> jedisClusterNode, int connectionTimeout, int soTimeout, int maxAttempts, final GenericObjectPoolConfig poolConfig) { ... }
其中包含了5个参数:
例如下面代码展示了一次JedisCluster的初始化过程。
// 初始化所有节点(例如6个节点) Set<HostAndPort> jedisClusterNode = new HashSet<HostAndPort>(); jedisClusterNode.add(new HostAndPort("10.10.xx.1", 6379)); jedisClusterNode.add(new HostAndPort("10.10.xx.2", 6379)); jedisClusterNode.add(new HostAndPort("10.10.xx.3", 6379)); jedisClusterNode.add(new HostAndPort("10.10.xx.4", 6379)); jedisClusterNode.add(new HostAndPort("10.10.xx.5", 6379)); jedisClusterNode.add(new HostAndPort("10.10.xx.6", 6379)); // 初始化commnon-pool连接池,并设置相关参数 GenericObjectPoolConfig poolConfig = new GenericObjectPoolConfig(); // 初始化JedisCluster JedisCluster jedisCluster = new JedisCluster(jedisClusterNode, 1000, 1000, 5, poolConfig); JedisCluster可以实现命令的调用,如下所示。 jedisCluster.set("hello", "world"); jedisCluster.get("key");
对于JedisCluster的使用需要注意以下几点:
(2)多节点命令和操作
Redis Cluster虽然提供了分布式的特性,但是有些命令或者操作,诸如keys、flushall、删除指定模式的键,需要遍历所有节点才可以完成。
下面代码实现了从Redis Cluster删除指定模式键的功能:
// 从RedisCluster批量删除指定pattern的数据 public void delRedisClusterByPattern(JedisCluster jedisCluster, String pattern, int scanCounter) { // 获取所有节点的JedisPool Map<String, JedisPool> jedisPoolMap = jedisCluster.getClusterNodes(); for (Entry<String, JedisPool> entry : jedisPoolMap.entrySet()) { // 获取每个节点的Jedis连接 Jedis jedis = entry.getValue().getResource(); // 只删除主节点数据 if (!isMaster(jedis)) { continue; } // 使用Pipeline每次删除指定前缀的数据 Pipeline pipeline = jedis.pipelined(); // 使用scan扫描指定前缀的数据 String cursor = "0"; // 指定扫描参数:每次扫描个数和pattern ScanParams params = new ScanParams().count(scanCounter).match(pattern); while (true) { // 执行扫描 ScanResult<String> scanResult = jedis.scan(cursor, params); // 删除的key列表 List<String> keyList = scanResult.getResult(); if (keyList != null && keyList.size() > 0) { for (String key : keyList) { pipeline.del(key); } // 批量删除 pipeline.syncAndReturnAll(); } cursor = scanResult.getStringCursor(); // 如果游标变为0,说明扫描完毕 if ("0".equals(cursor)) { break; } } } } // 判断当前Redis是否为master节点 private boolean isMaster(Jedis jedis) { String[] data = jedis.info("Replication").split("\r\n"); for (String line : data) { if ("role:master".equals(line.trim())) { return true; } } return false; }
具体分为如下几个步骤:
例如下面操作每次遍历1000个key,将Redis Cluster中以user开头的key全部删除。
String pattern = "user*"; int scanCounter = 1000; delRedisClusterByPattern(jedisCluster, pattern, scanCounter);
所以对于keys、flushall等需要遍历所有节点的命令,同样可以参照上面的方法进行相应功能的实现。
(3)批量操作的方法
Redis Cluster中,由于key分布到各个节点上,会造成无法实现mget、mset等功能。
但是可以利用CRC16算法计算出key对应的slot,以及Smart客户端保存了slot和节点对应关系的特性,将属于同一个Redis节点的key进行归档,然后分别对每个节点对应的子key列表执行mget或者pipeline操作,具体使用方法可以参考11.5节“无底洞优化”。
(4)使用Lua、事务等特性的方法
Lua和事务需要所操作的key,必须在一个节点上,不过Redis Cluster提供了hashtag,如果开发人员确实要使用Lua或者事务,可以将所要操作的key使用一个hashtag,如下所示:
// hashtag String hastag = "{user}"; // 用户A的关注表 String userAFollowKey = hastag + ":a:follow"; // 用户B的粉丝表 String userBFanKey = hastag + ":b:fans"; // 计算hashtag对应的slot int slot = JedisClusterCRC16.getSlot(hastag); // 获取指定slot的JedisPool JedisPool jedisPool = jedisCluster.getConnectionHandler().getJedisPoolFromSlot(slot); // 在当个节点上执行事务 Jedis jedis = null; try { jedis = jedisPool.getResource(); // 用户A的关注表加入用户B,用户B的粉丝列表加入用户A Transaction transaction = jedis.multi(); transaction.sadd(userAFollowKey, "user:b"); transaction.sadd(userBFanKey, "user:a"); transaction.exec(); } catch (Exception e) { logger.error(e.getMessage(), e); } finally { if (jedis!= null) jedis.close(); }
具体步骤如下:
Redis集群支持在线迁移槽(slot)和数据来完成水平伸缩,当slot对应的数据从源节点到目标节点迁移过程中,客户端需要做到智能识别,保证键命令可正常执行。
例如当一个slot数据从源节点迁移到目标节点时,期间可能出现一部分数据在源节点,而另一部分在目标节点,如图10-32所示。
当出现上述情况时,客户端键命令执行流程将发生变化,如下所示:
ASK与MOVED虽然都是对客户端的重定向控制,但是有着本质区别。
ASK重定向说明集群正在进行slot数据迁移,客户端无法知道什么时候迁移完成,因此只能是临时性的重定向,客户端不会更新slots缓存。
但是MOVED重定向说明键对应的槽已经明确指定到新的节点,因此需要更新slots缓存。
为了支持ASK重定向,源节点和目标节点在内部的clusterState结构中维护当前正在迁移的槽信息,用于识别槽迁移情况,结构如下:
typedef struct clusterState { clusterNode *myself; /* 自身节点 / clusterNode *slots[CLUSTER_SLOTS]; /* 槽和节点映射数组 */ clusterNode *migrating_slots_to[CLUSTER_SLOTS];/* 正在迁出的槽节点数组 */ clusterNode *importing_slots_from[CLUSTER_SLOTS];/* 正在迁入的槽节点数组*/ ... } clusterState;
节点每次接收到键命令时,都会根据clusterState内的迁移属性进行命令处理,如下所示:
例如,手动使用迁移命令让槽4096处于迁移状态,并且数据各自分散在目标节点和源节点,如下所示:
#6379节点准备导入槽4096数据 127.0.0.1:6379>cluster setslot 4096 importing 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756 OK #6385节点准备导出槽4096数据 127.0.0.1:6379>cluster setslot 4096 migrating cfb28ef1deee4e0fa78da86abe5d24566744411e OK # 查看槽4096下的数据 127.0.0.1:6385> cluster getkeysinslot 4096 100 1) "key:test:5028" 2) "key:test:68253" 3) "key:test:79212" # 迁移键key:test:68253和key:test:79212到6379节点 127.0.0.1:6385>migrate 127.0.0.1 6379 "" 0 5000 keys key:test:68253 key:test:79212 OK
现在槽4096下3个键数据分别位于6379和6380两个节点,使用Jedis客户端执行批量操作。mget代码如下:
@Test public void mgetOnAskTest() { JedisCluster jedisCluster = new JedisCluster(new HostAndPort("127.0.0.1", 6379)); List<String> results = jedisCluster.mget("key:test:68253", "key:test:79212"); System.out.println(results); results = jedisCluster.mget("key:test:5028", "key:test:68253", "key:test:79212"); System.out.println(results); }
运行mget测试结果如下:
[value:68253, value:79212] redis.clients.jedis.exceptions.JedisDataException: TRYAGAIN Multiple keys request during rehashing of slot at redis.clients.jedis.Protocol.processError(Protocol.java:127) ...
测试结果分析:
综上所处,当在集群环境下使用mget、mset等批量操作时,slot迁移数据期间由于键列表无法保证在同一节点,会导致大量错误。
Pipeline代码如下:
@Test public void pipelineOnAskTest() { JedisSlotBasedConnectionHandler connectionHandler = new JedisCluster(new Host AndPort ("127.0.0.1", 6379)) { public JedisSlotBasedConnectionHandler getConnectionHandler() { return (JedisSlotBasedConnectionHandler) super.connectionHandler; } }.getConnectionHandler(); List<String> keys = Arrays.asList("key:test:68253", "key:test:79212", "key:test: 5028"); Jedis jedis = connectionHandler.getConnectionFromSlot(JedisClusterCRC16. get Slot(keys.get(2))); try { Pipeline pipelined = jedis.pipelined(); for (String key : keys) { pipelined.get(key); } List<Object> results = pipelined.syncAndReturnAll(); for (Object result : results) { System.out.println(result); } } finally { jedis.close(); } }
Pipeline的代码中,由于Jedis没有开放slot到Jedis的查询,使用了匿名内部类暴露JedisSlotBasedConnectionHandler。
通过Jedis获取Pipeline对象组合3条get命令一次发送。运行结果如下:
redis.clients.jedis.exceptions.JedisAskDataException: ASK 4096 127.0.0.1:6379 redis.clients.jedis.exceptions.JedisAskDataException: ASK 4096 127.0.0.1:6379 value:5028
结果分析:返回结果并没有直接抛出异常,而是把ASK异常JedisAskDataException包含在结果集中。
但是使用Pipeline的批量操作也无法支持由于slot迁移导致的键列表跨节点问题。
得益于Pipeline并没有直接抛出异常,可以借助于JedisAskDataException内返回的目标节点信息,手动重定向请求给目标节点,修改后的程序如下:
@Test public void pipelineOnAskTestV2() { JedisSlotBasedConnectionHandler connectionHandler = new JedisCluster(new Host AndPort("127.0.0.1", 6379)) { public JedisSlotBasedConnectionHandler getConnectionHandler() { return (JedisSlotBasedConnectionHandler) super.connectionHandler; } }.getConnectionHandler(); List<String> keys = Arrays.asList("key:test:68253", "key:test:79212", "key: test:5028"); Jedis jedis = connectionHandler.getConnectionFromSlot(JedisClusterCRC16.get Slot(keys.get(2))); try { Pipeline pipelined = jedis.pipelined(); for (String key : keys) { pipelined.get(key); } List<Object> results = pipelined.syncAndReturnAll(); for (int i = 0; i < keys.size(); i++) { // 键顺序和结果顺序一致 Object result = results.get(i); if (result != null && result instanceof JedisAskDataException) { JedisAskDataException askException = (JedisAskDataException) result; HostAndPort targetNode = askException.getTargetNode(); Jedis targetJedis = connectionHandler.getConnectionFromNode(tar getNode); try { // 执行asking targetJedis.asking(); // 获取key并执行 String key = keys.get(i); String targetResult = targetJedis.get(key); System.out.println(targetResult); } finally { targetJedis.close(); } } else { System.out.println(result); } } } finally { jedis.close(); } }
修改后的Pipeline运行结果以下: value:68253 value:79212 value:5028 根据结果,我们成功获取到了3个键的数据。以上测试能够成功的前提是:
综上所处,使用smart客户端批量操作集群时,需要评估mget/mset、Pipeline等方式在slot迁移场景下的容错性,防止集群迁移造成大量错误和数据丢失的情况。
开发提示
集群环境下对于使用批量操作的场景,建议优先使用Pipeline方式,在客户端实现对ASK重定向的正确处理,这样既可以受益于批量操作的IO优化,又可以兼容slot迁移场景。
Redis集群自身实现了高可用。高可用首先需要解决集群部分失败的场景:
当集群内少量节点出现故障时通过自动故障转移保证集群可以正常对外提供服务。
本节介绍故障转移的细节,分析故障发现和替换故障节点的过程。
当集群内某个节点出现问题时,需要通过一种健壮的方式保证识别出节点是否发生了故障。
Redis集群内节点通过ping/pong消息实现节点通信,消息不但可以传播节点槽信息,还可以传播其他状态如:主从状态、节点故障等。
因此故障发现也是通过消息传播机制实现的,主要环节包括:主观下线(pfail)和客观下线(fail)。
集群中每个节点都会定期向其他节点发送ping消息,接收节点回复pong消息作为响应。如果在cluster-node-timeout时间内通信一直失败,则发送节点会认为接收节点存在故障,把接收节点标记为主观下线(pfail)状态。
流程如图10-34所示。
流程说明:
主观下线简单来讲就是,当cluster-note-timeout时间内某节点无法与另一个节点顺利完成ping消息通信时,则将该节点标记为主观下线状态。
每个节点内的cluster State结构都需要保存其他节点信息,用于从自身视角判断其他节点的状态。结构关键属性如下:
typedef struct clusterState { clusterNode *myself; /* 自身节点 / dict *nodes;/* 当前集群内所有节点的字典集合,key为节点ID,value为对应节点ClusterNode结构 */ ... } clusterState;字典nodes属性中的clusterNode结构保存了节点的状态,关键属性如下: typedef struct clusterNode { int flags; /* 当前节点状态,如:主从角色,是否下线等 */ mstime_t ping_sent; /* 最后一次与该节点发送ping消息的时间 */ mstime_t pong_received; /* 最后一次接收到该节点pong消息的时间 */ ... } clusterNode;
其中最重要的属性是flags,用于标示该节点对应状态,取值范围如下:
// 定时任务,默认每秒执行10次 def clusterCron(): // ... 忽略其他代码 for(node in server.cluster.nodes): // 忽略自身节点比较 if(node.flags == CLUSTER_NODE_MYSELF): continue; // 系统当前时间 long now = mstime(); // 自身节点最后一次与该节点PING通信的时间差 long delay = now - node.ping_sent; // 如果通信时间差超过cluster_node_timeout,将该节点标记为PFAIL(主观下线) if (delay > server.cluster_node_timeout) : node.flags = CLUSTER_NODE_PFAIL;
Redis集群对于节点最终是否故障判断非常严谨,只有一个节点认为主观下线并不能准确判断是否故障。例如图10-35的场景。
节点6379与6385通信中断,导致6379判断6385为主观下线状态,但是6380与6385节点之间通信正常,这种情况不能判定节点6385发生故障。
因此对于一个健壮的故障发现机制,需要集群内大多数节点都判断6385故障时,才能认为6385确实发生故障,然后为6385节点进行故障转移。
而这种多个节点协作完成故障发现的过程叫做客观下线。
当某个节点判断另一个节点主观下线后,相应的节点状态会跟随消息在集群内传播。
ping/pong消息的消息体会携带集群1/10的其他节点状态数据,当接受节点发现消息体中含有主观下线的节点状态时,会在本地找到故障节点的ClusterNode结构,保存到下线报告链表中。结构如下:
struct clusterNode { /* 认为是主观下线的clusterNode结构 */ list *fail_reports; /* 记录了所有其他节点对该节点的下线报告 */ ... };
通过Gossip消息传播,集群内节点不断收集到故障节点的下线报告。
当半数以上持有槽的主节点都标记某个节点是主观下线时。触发客观下线流程。这里有两个问题:
假设节点a标记节点b为主观下线,一段时间后节点a通过消息把节点b的状态发送到其他节点,当节点c接受到消息并解析出消息体含有节点b的pfail状态时,会触发客观下线流程,如图10-36所示。
流程说明:
这里针对维护下线报告和尝试客观下线逻辑进行详细说明。
(1)维护下线报告链表
每个节点ClusterNode结构中都会存在一个下线链表结构,保存了其他主节点针对当前节点的下线报告,结构如下:
typedef struct clusterNodeFailReport { struct clusterNode *node; /* 报告该节点为主观下线的节点 */ mstime_t time; /* 最近收到下线报告的时间 */ } clusterNodeFailReport;
下线报告中保存了报告故障的节点结构和最近收到下线报告的时间,当接收到fail状态时,会维护对应节点的下线上报链表,伪代码如下:
def clusterNodeAddFailureReport(clusterNode failNode, clusterNode senderNode) : // 获取故障节点的下线报告链表 list report_list = failNode.fail_reports; // 查找发送节点的下线报告是否存在 for(clusterNodeFailReport report : report_list): // 存在发送节点的下线报告上报 if(senderNode == report.node): // 更新下线报告时间 report.time = now(); return 0; // 如果下线报告不存在,插入新的下线报告 report_list.add(new clusterNodeFailReport(senderNode,now())); return 1;
每个下线报告都存在有效期,每次在尝试触发客观下线时,都会检测下线报告是否过期,对于过期的下线报告将被删除。
如果在cluster-node-time*2的时间内该下线报告没有得到更新则过期并删除,伪代码如下:
def clusterNodeCleanupFailureReports(clusterNode node) : list report_list = node.fail_reports; long maxtime = server.cluster_node_timeout * 2; long now = now(); for(clusterNodeFailReport report : report_list): // 如果最后上报过期时间大于cluster_node_timeout * 2则删除 if(now - report.time > maxtime): report_list.del(report);
下线报告的有效期限是server.cluster_node_timeout*2,主要是针对故障误报的情况。
例如节点A在上一小时报告节点B主观下线,但是之后又恢复正常。
现在又有其他节点上报节点B主观下线,根据实际情况之前的属于误报不能被使用。
运维提示
如果在cluster-node-time*2时间内无法收集到一半以上槽节点的下线报告,那么之前的下线报告将会过期,
也就是说主观下线上报的速度追赶不上下线报告过期的速度,那么故障节点将永远无法被标记为客观下线从而导致故障转移失败。因此不建议将cluster-node-time设置得过小。
(2)尝试客观下线
集群中的节点每次接收到其他节点的pfail状态,都会尝试触发客观下线,流程如图10-37所示。
流程说明:
使用伪代码分析客观下线的流程,如下所示:
def markNodeAsFailingIfNeeded(clusterNode failNode) { // 获取集群持有槽的节点数量 int slotNodeSize = getSlotNodeSize(); // 主观下线节点数必须超过槽节点数量的一半 int needed_quorum = (slotNodeSize / 2) + 1; // 统计failNode节点有效的下线报告数量(不包括当前节点) int failures = clusterNodeFailureReportsCount(failNode); // 如果当前节点是主节点,将当前节点计累加到failures if (nodeIsMaster(myself)): failures++; // 下线报告数量不足槽节点的一半退出 if (failures < needed_quorum): return; // 将改节点标记为客观下线状态(fail) failNode.flags = REDIS_NODE_FAIL; // 更新客观下线的时间 failNode.fail_time = mstime(); // 如果当前节点为主节点,向集群广播对应节点的fail消息 if (nodeIsMaster(myself)) clusterSendFail(failNode);
广播fail消息是客观下线的最后一步,它承担着非常重要的职责:
需要理解的是,尽管存在广播fail消息机制,但是集群所有节点知道故障节点进入客观下线状态是不确定的。
比如当出现网络分区时有可能集群被分割为一大一小两个独立集群中。
大的集群持有半数槽节点可以完成客观下线并广播fail消息,但是小集群无法接收到fail消息,如图10-38所示。
但是当网络恢复后,只要故障节点变为客观下线,最终总会通过Gossip消息传播至集群的所有节点。
运维提示
网络分区会导致分割后的小集群无法收到大集群的fail消息,因此如果故障节点所有的从节点都在小集群内将导致无法完成后续故障转移,
因此部署主从结构时需要根据自身机房/机架拓扑结构,降低主从被分区的可能性。
故障节点变为客观下线后,如果下线节点是持有槽的主节点则需要在它的从节点中选出一个替换它,从而保证集群的高可用。
下线主节点的所有从节点承担故障恢复的义务,当从节点通过内部定时任务发现自身复制的主节点进入客观下线时,将会触发故障恢复流程,如图10-39所示。
每个从节点都要检查最后与主节点断线时间,判断是否有资格替换故障的主节点。
如果从节点与主节点断线时间超过cluster-node-time*cluster-slave-validity-factor,则当前从节点不具备故障转移资格。
参数cluster-slavevalidity-factor用于从节点的有效因子,默认为10。
当从节点符合故障转移资格后,更新触发故障选举的时间,只有到达该时间后才能执行后续流程。
故障选举时间相关字段如下:
struct clusterState { ... mstime_t failover_auth_time; /* 记录之前或者下次将要执行故障选举时间 */ int failover_auth_rank; /* 记录当前从节点排名 */ }
这里之所以采用延迟触发机制,主要是通过对多个从节点使用不同的延迟选举时间来支持优先级问题。
复制偏移量越大说明从节点延迟越低,那么它应该具有更高的优先级来替换故障主节点。
优先级计算伪代码如下:
def clusterGetSlaveRank(): int rank = 0; // 获取从节点的主节点 ClusteRNode master = myself.slaveof; // 获取当前从节点复制偏移量 long myoffset = replicationGetSlaveOffset(); // 跟其他从节点复制偏移量对比 for (int j = 0; j < master.slaves.length; j++): // rank表示当前从节点在所有从节点的复制偏移量排名,为0表示偏移量最大. if (master.slaves[j] != myself && master.slaves[j].repl_offset > myoffset): rank++; return rank; }
使用之上的优先级排名,更新选举触发时间,伪代码如下:
def updateFailoverTime(): // 默认触发选举时间:发现客观下线后一秒内执行。 server.cluster.failover_auth_time = now() + 500 + random() % 500; // 获取当前从节点排名 int rank = clusterGetSlaveRank(); long added_delay = rank * 1000; // 使用added_delay时间累加到failover_auth_time中 server.cluster.failover_auth_time += added_delay; // 更新当前从节点排名 server.cluster.failover_auth_rank = rank;
所有的从节点中复制偏移量最大的将提前触发故障选举流程,如图10-40所示。
主节点b进入客观下线后,它的三个从节点根据自身复制偏移量设置延迟选举时间,如复制偏移量最大的节点slave b-1延迟1秒执行,保证复制延迟低的从节点优先发起选举。
当从节点定时任务检测到达故障选举时间(failover_auth_time)到达后,发起选举流程如下:
(1)更新配置纪元
配置纪元是一个只增不减的整数,每个主节点自身维护一个配置纪元(clusterNode.configEpoch)标示当前主节点的版本,所有主节点的配置纪元都不相等,从节点会复制主节点的配置纪元。
整个集群又维护一个全局的配置纪元(clusterState.current Epoch),用于记录集群内所有主节点配置纪元的最大版本。
执行cluster info命令可以查看配置纪元信息:
127.0.0.1:6379> cluster info ... cluster_current_epoch:15 // 整个集群最大配置纪元 cluster_my_epoch:13 // 当前主节点配置纪元
配置纪元会跟随ping/pong消息在集群内传播,当发送方与接收方都是主节点且配置纪元相等时代表出现了冲突,nodeId更大的一方会递增全局配置纪元并赋值给当前节点来区分冲突,伪代码如下:
def clusterHandleConfigEpochCollision(clusterNode sender) : if (sender.configEpoch != myself.configEpoch || !nodeIsMaster(sender) || !nodeIsMaster (myself)) : return; // 发送节点的nodeId小于自身节点nodeId时忽略 if (sender.nodeId <= myself.nodeId): return // 更新全局和自身配置纪元 server.cluster.currentEpoch++; myself.configEpoch = server.cluster.currentEpoch;
配置纪元的主要作用:
配置纪元的应用场景有:
开发提示
之前在通过cluster setslot命令修改槽节点映射时,需要确保执行请求的主节点本地配置纪元(configEpoch)是最大值,否则修改后的槽信息在消息传播中不会被拥有更高的配置纪元的节点采纳。
由于Gossip通信机制无法准确知道当前最大的配置纪元在哪个节点,因此在槽迁移任务最后的cluster setslot{slot}node{nodeId}命令需要在全部主节点中执行一遍。
从节点每次发起投票时都会自增集群的全局配置纪元,并单独保存在clusterState.failover_auth_epoch变量中用于标识本次从节点发起选举的版本。
(2)广播选举消息
在集群内广播选举消息(FAILOVER_AUTH_REQUEST),并记录已发送过消息的状态,保证该从节点在一个配置纪元内只能发起一次选举。
消息内容如同ping消息只是将type类型变为FAILOVER_AUTH_REQUEST。
只有持有槽的主节点才会处理故障选举消息(FAILOVER_AUTH_REQUEST),因为每个持有槽的节点在一个配置纪元内都有唯一的一张选票,当接到第一个请求投票的从节点消息时回复FAILOVER_AUTH_ACK消息作为投票,之后相同配置纪元内其他从节点的选举消息将忽略。
投票过程其实是一个领导者选举的过程,如集群内有N个持有槽的主节点代表有N张选票。
由于在每个配置纪元内持有槽的主节点只能投票给一个从节点,因此只能有一个从节点获得N/2+1的选票,保证能够找出唯一的从节点。
Redis集群没有直接使用从节点进行领导者选举,主要因为从节点数必须大于等于3个才能保证凑够N/2+1个节点,将导致从节点资源浪费。
使用集群内所有持有槽的主节点进行领导者选举,即使只有一个从节点也可以完成选举过程。
当从节点收集到N/2+1个持有槽的主节点投票时,从节点可以执行替换主节点操作,例如集群内有5个持有槽的主节点,主节点b故障后还有4个,当其中一个从节点收集到3张投票时代表获得了足够的选票可以进行替换主节点操作,如图10-41所示。
运维提示
故障主节点也算在投票数内,假设集群内节点规模是3主3从,其中有2个主节点部署在一台机器上,当这台机器宕机时,由于从节点无法收集到3/2+1个主节点选票将导致故障转移失败。
这个问题也适用于故障发现环节。因此部署集群时所有主节点最少需要部署在3台物理机上才能避免单点 问题。
投票作废:每个配置纪元代表了一次选举周期,如果在开始投票之后的cluster-node-timeout*2时间内从节点没有获取足够数量的投票,则本次选举作废。
从节点对配置纪元自增并发起下一轮投票,直到选举成功为止。
当从节点收集到足够的选票之后,触发替换主节点操作:
在介绍完故障发现和恢复的流程后,这时我们可以估算出故障转移时间:
主观下线(pfail)识别时间=cluster-node-timeout。
主观下线状态消息传播时间<=cluster-node-timeout/2。
消息通信机制对超过cluster-node-timeout/2未通信节点会发起ping消息,消息体在选择包含 哪些节点时会优先选取下线状态节点,所以通常这段时间内能够收集到半数以上主节点的pfail报告从而完成故障发现。
从节点转移时间<=1000毫秒。由于存在延迟发起选举机制,偏移量最大的从节点会最多延迟1秒发起选举。通常第一次选举就会成功,所以从节点执行转移时间在1秒以内。
根据以上分析可以预估出故障转移时间,如下:
failover-time(毫秒) ≤ cluster-node-timeout + cluster-node-timeout/2 + 1000
因此,故障转移时间跟cluster-node-timeout参数息息相关,默认15秒。
配置时可以根据业务容忍度做出适当调整,但不是越小越好,下一节的带宽消耗部分会进一步说明。
到目前为止介绍了故障转移的主要细节,下面通过之前搭建的集群模拟主节点故障场景,对故障转移行为进行分析。使用kill-9强制关闭主节点6385进程,如图10-42所示。
确认集群状态:
127.0.0.1:6379> cluster nodes 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756 127.0.0.1:6385 master - 0 1471877563600 16 connected 0-1365 5462-6826 10923-12287 15018-16383 40622f9e7adc8ebd77fca0de9edfe691cb8a74fb 127.0.0.1:6382 slave cfb28ef1deee4e0fa78da 86abe5d24566744411e 0 1471877564608 13 connected 8e41673d59c9568aa9d29fb174ce733345b3e8f1 127.0.0.1:6380 master - 0 1471877567129 11 connected 6827-10922 13653-15017 475528b1bcf8e74d227104a6cf1bf70f00c24aae 127.0.0.1:6386 slave 1a205dd8b2819a00dd1e8 b6be40a8e2abe77b756 0 1471877569145 16 connected cfb28ef1deee4e0fa78da86abe5d24566744411e 127.0.0.1:6379 myself,master - 0 0 13 connected 1366-5461 12288-13652 be9485a6a729fc98c5151374bc30277e89a461d8 127.0.0.1:6383 slave 8e41673d59c9568aa9 d29fb174ce733345b3e8f1 0 1471877568136 11 connected
强制关闭6385进程:
# ps -ef | grep redis-server | grep 6385 501 1362 1 0 10:50 0:11.65 redis-server *:6385 [cluster] # kill -9 1362
日志分析如下:
==> redis-6386.log <== # Connection with master lost. * Caching the disconnected master state. * Connecting to MASTER 127.0.0.1:6385 * MASTER <-> SLAVE sync started # Error condition on socket for SYNC: Connection refused
==> redis-6380.log <== * Marking node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756 as failing (quorum reached). ==> redis-6379.log <== * Marking node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756 as failing (quorum reached).
==> redis-6386.log <== # Start of election delayed for 964 milliseconds (rank #0, offset 1822). ·延迟选举时间到达后,从节点更新配置纪元并发起故障选举。 ==> redis-6386.log <== 1364:S 22 Aug 23:12:25.064 # Starting a failover election for epoch 17. ·6379和6380主节点为从节点6386投票,日志如下: 670 ==> redis-6380.log <== # Failover auth granted to 475528b1bcf8e74d227104a6cf1bf70f00c24aae for epoch 17 ==> redis-6379.log <== # Failover auth granted to 475528b1bcf8e74d227104a6cf1bf70f00c24aae for epoch 17
==> redis-6386.log <== # Failover election won: I'm the new master. # configEpoch set to 17 after successful failover
成功完成故障转移之后,我们对已经出现故障节点6385进行恢复,观察节点状态是否正确:
#redis-server conf/redis-6385.conf
# I have keys for slot 4096, but the slot is assigned to another node. Setting it to importing state. # Configuration change detected. Reconfiguring myself as a replica of 475528b1bcf 8e74d227104a6cf1bf70f00c24aae
==> redis-6379.log <== * Clear FAIL state for node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756: master without slots is reachable again. ==> redis-6380.log <== * Clear FAIL state for node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756: master without slots is reachable again. ==> redis-6382.log <== * Clear FAIL state for node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756: master without slots is reachable again. ==> redis-6383.log <== * Clear FAIL state for node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756: master without slots is reachable again. ==> redis-6386.log <== * Clear FAIL state for node 1a205dd8b2819a00dd1e8b6be40a8e2abe77b756: master without slots is reachable again.
==> redis-6385.log <== * MASTER <-> SLAVE sync: Flushing old data * MASTER <-> SLAVE sync: Loading DB in memory * MASTER <-> SLAVE sync: Finished with success
Redis集群由于自身的分布式特性,相比单机场景在开发和运维方面存在一些差异。本节我们关注于常见的问题进行分析定位。
为了保证集群完整性,默认情况下当集群16384个槽任何一个没有指派到节点时整个集群不可用。
执行任何键命令返回(error)CLUSTERDOWNHash slot not served错误。
这是对集群完整性的一种保护措施,保证所有的槽都指派给在线的节点。
但是当持有槽的主节点下线时,从故障发现到自动完成转移期间整个集群是不可用状态,对于大多数业务无法容忍这种情况,因此建议将参数cluster-require-full-coverage配置为no,当主节点故障时只影 响它负责槽的相关命令执行,不会影响其他主节点的可用性。
集群内Gossip消息通信本身会消耗带宽,官方建议集群最大规模在1000以内,也是出于对消息通信成本的考虑,因此单集群不适合部署超大规模的节点。
在之前节点通信小节介绍到,集群内所有节点通过ping/pong消息彼此交换信息,节点间消息通信对带宽的消耗体现在以下几个方面:
例如,一个总节点数为200的Redis集群,部署在20台物理机上每台划分10个节点,cluster-node-timeout采用默认15秒,这时ping/pong消息占用带宽达到25Mb。
如果把cluster-node-timeout设为20,对带宽的消耗降低到15Mb以下。
集群带宽消耗主要分为:读写命令消耗+Gossip消息消耗。
因此搭建Redis集群时需要根据业务数据规模和消息通信成本做出合理规划:
Redis在2.0版本提供了Pub/Sub(发布/订阅)功能,用于针对频道实现消息的发布和订阅。
但是在集群模式下内部实现对所有的publish命令都会向 所有的节点进行广播,造成每条publish数据都会在集群内所有节点传播一次,加重带宽负担,如图10-44所示:
通过命令演示Pub/Sub广播问题,如下所示:
127.0.0.1:6379> subscribe cluster_pub_spread 127.0.0.1:6380> subscribe cluster_pub_spread 127.0.0.1:6382> subscribe cluster_pub_spread 127.0.0.1:6383> subscribe cluster_pub_spread 127.0.0.1:6385> subscribe cluster_pub_spread 127.0.0.1:6386> subscribe cluster_pub_spread
127.0.0.1:6379> publish cluster_pub_spread message_body_1
127.0.0.1:6380> subscribe cluster_pub_spread 1) "message" 2) "cluster_pub_spread" 3) "message_body_1 127.0.0.1:6382> subscribe cluster_pub_spread 1) "message" 2) "cluster_pub_spread" 3) "message_body_1 ...
针对集群模式下publish广播问题,需要引起开发人员注意,当频繁应用Pub/Sub功能时应该避免在大量节点的集群内使用,否则会严重消耗集群内网络带宽。
针对这种情况建议使用sentinel结构专门用于Pub/Sub功能,从而规避这一问题。
集群倾斜指不同节点之间数据量和请求量出现明显差异,这种情况将加大负载均衡和开发运维的难度。因此需要理解哪些原因会造成集群倾斜,从而避免这一问题。
数据倾斜主要分为以下几种:
#redis-trib.rb info 127.0.0.1:6379 127.0.0.1:6379 (cfb28ef1...) -> 33348 keys | 5461 slots | 1 slaves. 127.0.0.1:6380 (8e41673d...) -> 33391 keys | 5461 slots | 1 slaves. 127.0.0.1:6386 (475528b1...) -> 33263 keys | 5462 slots | 1 slaves. [OK] 100002 keys in 3 masters. 6.10 keys per slot on average.
以上信息列举出每个节点负责的槽和键总量以及每个槽平均键数量。
当节点对应槽数量不均匀时,可以使用redis-trib.rb rebalance命令进行平衡:
#redis-trib.rb rebalance 127.0.0.1:6379 ... [OK] All 16384 slots covered. *** No rebalancing needed! All nodes are within the 2.0% threshold.
不同槽对应键数量差异过大。键通过CRC16哈希函数映射到槽上,正常情况下槽内键数量会相对均匀。但当大量使用hash_tag时,会产生不同的键映射到同一个槽的情况。特别是选择作为hash_tag的数据离散度较差时,将加速槽内键数量倾斜情况。通过命令:cluster countkeysinslot{slot}可以获取槽对应的键数量,识别出哪些槽映射了过多的键。再通过命令clustergetkeysinslot{slot}{count}循环迭代出槽下所有的键。从而发现过度使用hash_tag的键。
集合对象包含大量元素。对于大集合对象的识别可以使用redis-cli--bigkeys命令识别,具体使用见12.5节。找出大集合之后可以根据业务场景进行拆分。
同时集群槽数据迁移是对键执行migrate操作完成,过大的键集合如几百兆,容易造成migrate命令超时导致数据迁移失败。
内存相关配置不一致。内存相关配置指hash-max-ziplist-value、setmax-intset-entries等压缩数据结构配置。当集群大量使用hash、set等数据结构时,如果内存压缩数据结构配置不一致,极端情况下会相差数倍的内存,从而造成节点内存量倾斜。
集群内特定节点请求量/流量过大将导致节点之间负载不均,影响集群均衡和运维成本。
常出现在热点键场景,当键命令消耗较低时如小对象的get、set、incr等,即使请求量差异较大一般也不会产生负载严重不均。
但是当热点键对应高算法复杂度的命令或者是大对象操作如hgetall、smembers等,会导致对应节点负载过高的情况。避免方式如下:
集群模式下从节点不接受任何读写请求,发送过来的键命令会重定向到负责槽的主节点上(其中包括它的主节点)。
当需要使用从节点分担主节点读压力时,可以使用readonly命令打开客户端连接只读状态。
之前的复制配置slave-read-only在集群模式下无效。
当开启只读状态时,从节点接收读命令处理流程变为:如果对应的槽属于自己正在复制的主节点则直接执行读命令,否则返回重定向信息。
命令如下:
// 默认连接状态为普通客户端:flags=N 127.0.0.1:6382> client list id=3 addr=127.0.0.1:56499 fd=6 name= age=130 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=0 qbuf-free=32768 obl=0 oll=0 omem=0 events=r cmd=client // 命令重定向到主节点 127.0.0.1:6382> get key:test:3130 (error) MOVED 12944 127.0.0.1:6379 // 打开当前连接只读状态 127.0.0.1:6382> readonly OK // 客户端状态变为只读:flags=r 127.0.0.1:6382> client list id=3 addr=127.0.0.1:56499 fd=6 name= age=154 idle=0 flags=r db=0 sub=0 psub=0 multi=-1 qbuf=0 qbuf-free=32768 obl=0 oll=0 omem=0 events=r cmd=client // 从节点响应读命令 127.0.0.1:6382> get key:test:3130 "value:3130"
readonly命令是连接级别生效,因此每次新建连接时都需要执行readonly开启只读状态。
执行readwrite命令可以关闭连接只读状态。
集群模式下的读写分离,同样会遇到:复制延迟,读取过期数据,从节点故障等问题,具体细节见6.5复制运维小节。
针对从节点故障问题,客户端需要维护可用节点列表,集群提供了cluster slaves{nodeId}命令,返回 nodeId对应主节点下所有从节点信息,数据格式同cluster nodes,命令如下:
// 返回6379节点下所有从节点 127.0.0.1:6382> cluster slaves cfb28ef1deee4e0fa78da86abe5d24566744411e 1) "40622f9e7adc8ebd77fca0de9edfe691cb8a74fb 127.0.0.1:6382 myself,slave cfb28e f1deee4e0fa78da86abe5d24566744411e 0 0 3 connected" 2) "2e7cf7539d076a1217a408bb897727e5349bcfcf 127.0.0.1:6384 slave,fail cfb28ef1 deee4e0fa78da86abe5d24566744411e 1473047627396 1473047622557 13 disconnected"
解析以上从节点列表信息,排除fail状态节点,这样客户端对从节点的故障判定可以委托给集群处理,简化维护可用从节点列表难度。
开发提示
集群模式下读写分离涉及对客户端修改如下:
集群模式下读写分离成本比较高,可以直接扩展主节点数量提高集群性能,一般不建议集群模式下做读写分离。
集群读写分离有时用于特殊业务场景如:
以上场景也可以在不同机房独立部署Redis集群解决,通过客户端多写来维护,读命令直接请求到最近机房的Redis集群,或者当一个集群节点故障时客户端转向另一个集群。
Redis集群提供了手动故障转移功能:指定从节点发起转移流程,主从节点角色进行切换,从节点变为新的主节点对外提供服务,旧的主节点变为它的从节点,如图10-45所示。
在从节点上执行cluster failover命令发起转移流程,默认情况下转移期间客户端请求会有短暂的阻塞,但不会丢失数据,流程如下:
运维提示
主从节点转移后,新的从节点由于之前没有缓存主节点信息无法使用部
分复制功能,所以会发起全量复制,当节点包含大量数据时会严重消耗CPU和网络资源,线上不要频繁操作。
Redis4.0的Psync2将有效改善这一问题。手动故障转移的应用场景主要如下:
主节点迁移:运维Redis集群过程中经常遇到调整节点部署的问题,
如节点所在的老机器替换到新机器等。由于从节点默认不响应请求可以安全下线关闭,但直接下线主节点会导致故障自动转移期间主节点无法对外提供服务,影响线上业务的稳定性。
这时可以使用手动故障转移,把要下线的主节点安全的替换为从节点后,再做下线操作操作,如图10-46所示。
强制故障转移。当自动故障转移失败时,只要故障的主节点有存活的从节点就可以通过手动转移故障强制让从节点替换故障的主节点,保证集群的可用性。自动故障转移失败的场景有:
图中Redis集群分别部署在2个同城机房,机房A部署节点:master-1、master-2、master-3、slave-4。机房B部署节点:slave-1、slave-2、slave-3、master-4。
综上所述,在集群可以自动完成故障转移的情况下,不要使用cluster failover takeover强制干扰集群选举机制,该操作主要用于半数以上主节点故障时采取的强制措施,请慎用。
运维提示
手动故障转移时,在满足当前需求的情况下建议优先级:
cluster failver>cluster failover force>cluster failover takeover。
应用Redis集群时,常需要把单机Redis数据迁移到集群环境。redistrib.rb工具提供了导入功能,用于数据从单机向集群环境迁移的场景,命令如下:
redis-trib.rb import host:port --from <arg> --copy --replace
redis-trib.rb import命令内部采用批量scan和migrate的方式迁移数据。这种迁移方式存在以下缺点:
正因为这些问题,社区开源了很多迁移工具,这里推荐一款唯品会开发的redis-migrate-tool,该工具可满足大多数Redis迁移需求,特点如下:
支持单机、Twemproxy、Redis Cluster、RDB/AOF等多种类型的数据迁移。
工具模拟成从节点基于复制流迁移数据,从而支持在线迁移数据,业务方不需要停写。
采用多线程加速数据迁移过程且提供数据校验和查看迁移状态等功能。
本文作者:Eric
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!