请求路由
目前我们已经搭建好Redis集群并且理解了通信和伸缩细节,但还没有使用客户端去操作集群。Redis集群对客户端通信协议做了比较大的修改,为了追求性能最大化,并没有采用代理的方式而是采用客户端直连节点的方式。因此对于希望从单机切换到集群环境的应用需要修改客户端代码。本节我们关注集群请求路由的细节,以及客户端如何高效的操作集群。
-
请求重定向
在集群模式下,Redis接收任何键相关命令时首先计算对应的槽,再根据槽找出所对应的节点,如果节点是自身,则处理键命令;否则回复MOVED重定向错误,通知客户端请求正确的节点。这个过程称为MOVED重定向。
redis-cli自动帮我们连接到正确的节点执行命令,这个过程是在redis-cli内部维护,实质上是client端接到MOVED信息之后再次发起请求,并不在Redis节点中完成转发。节点对于不属于它的键命令只回复重定向响应,并不负责转发。熟悉Cassandra的哟农户希望在这里做好区分,不要混淆。正因为集群模式下把解析发起重定向的过程放到客户端完成,所以集群客户端协议相对于单机有了很大的变化。
键命令执行步骤主要分为两步:计算槽,查找槽对应的节点。节点对于判定键命令是执行还是MOVED重定向,都是借助slots [CLUSTER_SLOTS]数组实现。根据MOVED重定向机制,客户端可以随机连接集群内任意Redis获取键所在节点,这种客户端又叫Dummy(傀儡)客户端,它优点是代码实现简单,对客户端协议影响较小,只需要根据重定向信息再次发送请求即可。但是它的弊端很明显,每次执行键命令前都要到Redis上进行重定向才能找到要执行命令的节点,额外增加了IO开销,这不是Redis集群高效的使用方式。正因为如此通常集群客户端都采用另一种实现:Smart(智能)客户端。
-
Smart客户端
-
客户端原理
大多数开发语言的Redis客户端都采用Smart客户端支持集群协议,从中找出符合自己要求的客户端类库。Smart客户端通过在内部维护slot->node的映射关系,本地就可实现键到节点的查找,从而保证IO效率的最大化,而MOVED重定向负责协助Smart客户端更新slot->node映射。Smart客户端操作集群的流程如下:
1)首先在JedisCluster初始化是会选择一个运行节点,初始化槽和节点映射关系,使用cluster slots命令完成。
2)Jedis Cluster解析cluster slots结果缓存到本地,并为每个节点创建唯一的JedisPool连接池。
3)JedisCluster执行键命令的过程有些复杂,但是理解这个过程对于开发人员分析定位问题非常有帮助。键命令执行流程:
计算slot并根据slots缓存获取目标节点连接,发送命令。
如果出现连接错误,使用随机连接重新执行键命令,每次命令重试对redi-rections参数减1.
捕获到MOVED重定向错误,使用cluster slots命令更新slots缓存(renewSlotCache方法)。
重复执行第一步和第三步,知道命令执行成功,或者当redirections<=0时抛出JedsiClusterMaxRedirectionsException异常。
从上面流程中发现,客户端需要结合异常和重试机制时刻保证跟Redis集群的slots同步,因此Smart客户端相比单机客户端有了很大的变化和实现难度。了解命令执行流程后,下面我们对Smart客户端成本和可能存在的问题进行分析:
1)客户端内部维护slots缓存表,并且针对每个节点维护连接池,当集群规模非常大时,客户端会维护非常多的连接并消耗更多的内存。
2)使用Jedis凑走集群是最常见的错误是:
throw new JedisClusterMaxRedirectionsExceptions("Too many Cluster redirections?");
这经常会引起开发人员的疑惑,它隐藏了内部错误细节,原因是节点宕机或请求超时都会抛出JedisConnectionException,导致触发了随机重试,当重试次数耗尽抛出这个错误。
3)当出现JedisConnectionException时,Jedis认为可能是集群节点故障需要随机重试来更新slots缓存,因此了解哪些异常将抛出JedisConnectionException变得非常重要,有如下几种情况会抛出JedisConnectionException:
Jedis连接节点发生socket错误时抛出。
所有命令/Lua囧爱本读写超时抛出。
JedisPool连接池获取可用Jedis对象超时抛出。
前两点都可能是节点故障需要通过JedisConnectionException来更新slots缓存,但是第三点没有必要,因此Jedis2.8.1版本之后对于连接池的超时抛出JedisException,从而避免触发随机重试机制。
4)Redis集群支持自动故障转移,但是从故障发现到完成转移需要一定的时间,节点宕机期间所有指向这个节点的名都会触发随机重试,每次收到MOVED重定向后会调用JedisClusterInfoCache类的renewSlotCache方法。获得写锁后再执行cluster slots命令初始化缓存,由于集群所有的键命令都会执行getSlotPool方法方法计算槽对应节点,它内部要求读锁。ReentrantReadWriteLock是读锁共享且读写锁互斥,从而导致所有的请求都会造成阻塞。对于并发量高的场景将极大地影响集群吞吐。这个现象称为cluster slots风暴,有如下现象:
重试机制导致IO通信放大问题。比如默认重试5次的情况,当抛出JedisClusterMaxRedirectionsException异常时,内部最少需要9次IO通信:5次发送命令+2次ping命令保证随机节点正常+2次cluster slots命令初始化slots缓存。导致异常判定时间变长。
个别节点操作异常导致频繁的更新slots缓存,多次调用cluster slots命令,高并发是将过度消耗Redis节点资源,如果集群slot<->映射庞大则cluster slots返回信息越多,问题越严重。
频繁触发更新本地slots缓存操作,内部使用了写锁,阻塞对集群所有的键命令调用。
针对以上问题在Jedis2.8.2版本做了改进:
当接收到JedisConnectionException时不再轻易初始化slots缓存,大幅降低内部IO次数。逻辑为只有当重试次数到最后一次或者出现MovedDataException时才更新slots操作,降低了cluster slots命令代用次数。
当更新slots缓存时,不再使用ping命令检测节点活跃度,并且使用redis covering变量保证同一时刻只有一个线程更新slots缓存,其他线程忽略,优化了写锁阻塞和cluster slots调用次数。
综上所述,当出现JedisConnectionException时,命令发送次数变为5次:4次重试命令+1次cluster slots命令,同时避免了cluster slots不必要的并发调用。
开发提示:
执行cluster slots的过程不需要加入任何读写锁,因为cluster slots命令执行不需要做并发控制,值由修改本地slots时才需要控制并发,这样降低了写锁持有时间。
当获取新的slots映射后使用读锁跟老slots比对,只有新老slots不一致时再加入写锁进行更新。防止集群slots映射没有变化时进行不必要的加写锁行为。
-
Smart客户端——JedisCluster
(1)JedisCluster的定义
Jedis为Redis Cluster提供了Smart客户端,对应的类是JedisCluster,它的初始化方法如下:
public JedisCluster (Set<HostAndPort> jedisClusterNode, int connectionTiemout, int soTimeout, int maxAttempts, final GenericObjectPoolConfig poolConfig) { ... }
其中包含了5个参数:
Set<HostAndPort> jedisClusterNode:所有Redis Cluster节点信息(也可以是一部分,因为客户端可以通过cluster slots自动发现)。
int connectionTimeout:连接超时。
int soTimeout:读写超时。
int maxAttempts:重试次数。
GenericObjectPoolConfig poolConfig:连接池参数,JedisCluster会为Redis Cluster的每个节点创建连接池。对于JedisCluster的使用需要注意以下几点:
JedisCluster包含了所有节点的连接池(JedisPool),所以建议JedisCluster使用单例。
JedisCluster每次操作完成后,不需要管理连接池的借还,它在内部已经完成。
JedisCluster一般不要执行close(),它会将所有JedisPool执行destroy操作。
(2)多节点命令和操作。
Redis Cluster虽然提供了分布式的特性,但是有些命令或者操作,诸如keys、flushall、删除 指定模式的键,需要遍历所有节点才可以完成。具体分为如下几个步骤:
通过jedisCluster.getClusterNodes()获取所有节点的连接池。
使用info replication筛选上一步中的主节点。
比那里主节点,使用scan命令找到指定模式的key,使用Pipeline机制删除。
(3)批量操作的方法
Redis Cluster中,由于key分布到各个节点上,会造成无法实现mget、mset等功能。但是可以利用CRC16算法计算出key对应的slot,以及Smart客户端保存了slot和节点对应关系的特性,将属于同一个Redis节点的key进行归档,然后分别对每个节点对应的子key列表执行mget或者pipeline操作。
(4)使用Lua、事务等特性的方法
Lua和事务需要所操作的key,必须在一个节点上,不过Redis Cluster提供了hashtag,如果开发人员确实需要使用Lua或者事务,可以将所要操作的key使用一个hashtag。具体操作步骤如下:
将事务中所有的key添加hashtag。
使用CRC16计算hashtag对应的slot。
获取指定slot对应的节点连接池JedisPool。
在JedisPool上执行事务。
-
-
ASK重定向
-
客户端ASK重定向流程
Redis集群支持在线迁移槽(slot)和数据来完成水平伸缩,当slot对应的数据从源节点到目标节点迁移过程中,客户端需要做到只能识别,保证键命令可正常执行。例如当一个slot数据从源节点迁移到目标节点时,期间可能出现一部分数据在源节点,而另一部分在目标节点。
当出现上述情况时,客户端键命令执行流程将发生变化:
客户端根据本地slots缓存发送命令道源节点,如果存在键对象则直接执行并返回结果给客户端。
如果键对象不存在,则可能存在于目标节点,这时源节点会恢复ASK重定向异常。格式如下:(error) ASK {slot} {targetIP} : {targetPort}.
客户端从ASK重定向异常提取出目标节点信息,发送asking命令道目标节点打开客户端连接标识,再执行键命令。如果存在则执行,不存在则返回不存在信息。
ASK与MOVED虽然都是对客户端的重定向控制,但是有着本质区别。ASK重定向说明集群正在进行slot数据迁移,客户端无法知道什么时候迁移完成,因此只能是临时性的重定向,客户端不会更新slots缓存。但是MOVED重定向说明键对应的槽已经明确指定到新的节点,因此需要更新slots缓存。
-
节点内部处理
为了支持ASK重定向,源节点和目标节点在内部的clusterState结构中维护当前正在迁移的槽信息,用于识别槽迁移情况。节点每次接收到键命令是,都会根据clusterState内的迁移属性进行命令处理,如下所示:
如果键所在的槽由当前节点负责,但键不存在则查找migrating_slots_to数组查看槽是否正在迁出,如果是返回ASK重定向。
如果客户端发送asking命令打开了CLIENT_ASKING标识,则该客户端下次发送键命令时查找importing_slots_from数组获取clusterNode,如果指向自身则执行命令。需要注意的是,asking命令时一次性命令,每次执行完后客户端标识都会修改回原状态,因此每次客户端接收ASK重定向后都需要发送asking命令。
批量操作。ASK重定向对单键命令支持的很完善,但是,在开发送我们经常使用批量操作,如mget或pipeline。当槽处于迁移状态是,批量操作会受到影响。
使用smart客户端批量操作集群时,需要评估mget/mset、Pipeline等方式在slot迁移场景下的容错性,防止集群迁移造成大量错误和数据丢失的情况。
开发提示:集群环境下对于使用批量操作的场景,建议优先使用Pipeline方式,在客户端实现对ASK重定向的正确处理,这样既可以受益于批量操作的IO优化,又可以兼容slot迁移场景。
-