前言
今天我们来说说Redis如何实现高可扩展?如何实现分布式锁?
Redis集群提高了Redis的可扩展性,Redis的集群方案主要有Redis Cluster和Codis。
Redis Cluster
Redis Cluster是Redis官方提供的去中心化集群方案,通过分片来进行数据共享,并提供复制和故障转移功能。
Redis Cluster如何分片?
Redis集群通过分片的方式来保存数据库中的键值对。集群的整个数据库被分为16384个槽,数据库中的每个键都属于这16384个槽的其中一个,集群中的每个节点可以负责0~16384个槽。
当数据库中的16384个槽都有节点在处理时,集群处于上线状态;如果有任何一个槽没有节点处理时,集群处于下线状态。
槽指派命令
通过向节点发送cluster addslots命令可以将一个或多个槽指派给该节点。
如何维护槽信息?
每个节点的clusterNode结构slots属性和numslot属性记录节点负责的槽信息。
clusterNode.slots是一个二进制数组,长度为16384/8=2048字节,共包含16384个二进制位。Redis对clusterNode.slots数组中的16384个二进制位进行编号,根据索引i上的二进制位值来判断节点负责哪些槽(二进制位值=1,表示节点负责处理槽i)。 clusterNode.numslot记录该节点负责的槽数量。
节点之间如何共享槽指派信息?
集群中的每个节点都知道16384个槽都分别被指派给了集群中的哪些节点。
每个节点会将自己的clusterNode.slots数组发送给集群中的其他节点,告知其他节点自己负责处理哪些槽。收到槽指派信息的节点会在自己的clusterState.nodes字典中查找该节点对应的clusterNode结构,并对该clusterNode.slots数组进行更新。
但是只是将槽指派信息保存在每个节点的clusterNode.slots数组中,想要知道某个槽是否被指派或者指派给了那个节点,都需要遍历clusterState.nodes字典中的所有clusterNode.nodes数组,时间复杂度为O(n)。
所以每个节点clusterState结构会使用一个slots数组会记录集群中16384个槽的指派信息,每个数组项指向一个clusterNode结构,表示槽i由该节点负责。想要知道某个槽的指派信息时,只需要访问clusterState.slots[i]即可,时间复杂度为O(1)。
集群中执行命令的过程
客户端向节点发送命令,接收命令的节点会计算出命令要处理的key属于哪个槽,并检查这个槽是否由自己负责,如果这个槽不是由自己负责,则向客户端返回一个MOVED错误,指引客户端请求正确的节点,并再次发送命令。
1. 如何计算键属于哪个槽 计算key的哈希值,并将哈希值对16384个槽取模,即可得到key所在的槽位。 2. 如何判断槽是否由自己负责呢? 根据槽位检查自己的clusterState.slots[i]是否等于clusterState.myself,如果是,则表示该槽位由自己负责,如果不是,根据clusterState.slots[i]指向的clusterNode记录的节点IP地址和端口号指引客户端向处理该槽的节点发送命令请求。 3. MOVED错误
重新分片
Redis集群的重新分片操作可以将任意数量已经指派给某个节点的槽改为指派给另一个节点,并且移动相关槽位负责的键值对。
重新分片过程
重新分片操作由Redis集群管理软件redis-trib负责执行,redis-trib通过向源节点和目标节点发送命令来进行重新分片操作。
Redis 重新分片的单位是槽,一个槽一个槽进行迁移,当一个槽正在迁移时,这个槽就处于中间过渡状态。这个槽在原节点的状态为migrating,在目标节点的状态为importing,表示数据正在从源流向目标。
- redis-trib 首先会在源和目标节点设置好中间过渡状态;
- 然后一次性获取源节点槽位的所有key列表(keysinslot指令,可以部分获取),再挨个key进行迁移,每个key的迁移过程是以原节点作为目标节点的客户端: (1) 原节点对当前的key执行dump指令得到序列化内容; (2) 源节点向目标节点发送指令restore携带序列化的内容作为参数; (3) 目标节点进行反序列化将内容恢复到目标节点的内存中,然后返回源节点OK; (4) 原节点收到后再把当前节点的key删除;
(1) ~ (4) 执行期间主线程阻塞,如果key的内容过大会导致源节点和目标节点卡顿,影响集群稳定性,所以需要尽可能避免大key产生。
ASK错误
在进行重新分片期间,可能出现被迁移槽的一部分键值对在源节点,一部分键值对在目标节点。当客户端请求的键属于被迁移的槽时:
- 源节点在自己的数据库中查找指定的键,如果找到,则直接执行命令;
- 如果源节点中找不到指定的键,则说明该键不存在或已经被迁移到了目标节点,源节点将向客户端返回一个ASK错误(槽、节点IP地址、端口号);
- 客户端收到回复的ASK错误,将向返回的节点IP地址、端口号发送一个不带参数的asking命令(槽迁移没完成前,目标节点还不认为该节点由自己负责,会重定向回源节点,形成重定向循环,所以需要向目标节点发送一个acking指令,告知目标节点需要处理下一条指令);
- 客户端请求目标节点,并再次发送命令;
Codis
Codis是一个国内开源的代理中间件,当客户端向Codis发送命令时,Codis负责将命令转发到Redis服务器来执行,并将结果再返回给客户端。
Codis上挂接的所有Redis服务器构成一个集群,当集群空间不足时,可以通过动态增加Redis服务器来实现扩容需求。
Codis是无状态的,只是一个转发代理中间件,可以启动多个Codis实例供客户端使用,每个Codis节点都是对等的。
Codis如何分片?
Codis将默认划分1024个槽,每个key都属于这1024个槽的其中一个。当客户端发送命令时,首先计算key的哈希值,再将哈希值对1024取模,即可得到key对应的槽位。
Codis会在内存维护槽位和Redis实例的映射关系,每个槽位都会唯一映射到一个Redis实例。
槽位同步
Codis会使用一个分布式配置存储数据库专门用来持久化槽位关系,如ZooKeeper。
Codis将槽位关系存储在ZooKeeper,并且提供了一个DashbBoard来观察和修改槽位关系,当槽位发生变化时,CodisProxy会监听到变化并重新同步槽位关系。
扩容
Codis增加了slotsscan命令,可以遍历某个槽下所有的key。Codis通过slotsscan命令获取待迁移槽位的所有key,然后挨个迁移每个key到新的Redis节点。
但是Codis无法判断迁移中的key存在哪个实例。当Codis接收到正在迁移槽位中的key后,会立即强制对当前的单个key进行迁移,迁移完成后再将请求转发到新的Redis节点。
比较Redis Cluster和Codis
- 分布式事务:
- Codis是代理中间件,所有的key都分散在不同的Redis实例中,无法实现分布式事务;
- Redis Cluster支持分布式事务;
- 槽位划分:
- Redis Cluster划分16384个槽位;
- Codis默认划分1024个槽位;
- 槽位迁移:
- Redis Cluster每个节点都保存槽位关系,通过发送消息来通知槽位变更;
- Codis将槽位关系维护在ZooKeeper,当Codis监听到槽位变更则重新同步;
- 目标节点定位:
- Codis需要经过Proxy来定位目标节点;
- Redis Cluster可以直接定位目标节点;
继续
如何加锁?
- setnx + del
setnx key value # 如果key不存在,则set;如果key存在,则set失败
del key # 删除key
setnx是set if not exist的缩写,通过set key获得锁,使用完后通过del命令释放锁。
如果某个客户端获得锁期间故障了del命令没有被调用怎么办?
- setnx + expire
setnx key value # 如果key不存在,则set;如果key存在,则set失败
expire key 5 # 设置key过期时间
del key # 删除key
使用setnx命令set key之后给key设置个过期时间,这样即使客户端在获得锁期间异常也可以保证锁会在过期时间到来时自动释放锁。
但是setnx + expire是两个操作,不是原子性的,如果在setnx和expire期间客户端异常了,还是会导致锁不会被释放,如何解决呢?
expire是依赖于setnx的执行结果,如果setnx没抢到锁,expire是不应该执行的。事务里没有if-else分支逻辑,要么全部执行要么一个都不执行,所以此处不能使用Redis事务解决。
- set扩展参数
set key value [EX seconds|PX milliseconds] [NX|XX] [KEEPTTL] #setkey并设置过期时间
del key # 删除key
EX seconds:设置key的过期时间为seconds秒;
PX milliseconds:设置key的过期时间为milliseconds毫秒;
NX:只有key不存在才会进行set操作;
XX:只有key已存在才会对key进行操作;
对set命令加上扩展参数,使得setnx + expire操作能一起执行。如果客户端在获得锁期间故障,那么也将会在超时时间到来时释放锁。
- Redisson
setnx + lua加锁只作用在一个Redis节点上。
在分布式情况下,客户端A在主节点set key成功获得锁,主节点还未将锁同步到从节点,主节点就故障了,此时会选举一个从节点升级为新的主节点,但是这个新的主节点并没有存在客户端A获取成功的这个锁,此时客户端B请求获得锁成功,导致系统中同时有两个客户端持有锁,如何解决呢?
RedLock算法
加锁时,它会向集群中其他主节点发送set(key, value, nx=True, ex=xxx)命令,只要超过n/2 + 1个节点 set 成功,那就认为加锁成功。释放锁时,需要向所有节点发送del指令。
Redisson提供了对RedLock算法的封装,使用Lua脚本实现原子性的加锁和释放锁操作。
如何释放锁?
1. del
客户端加锁是set一个key,那释放锁直接使用del命令将该key删除即可。
假设线程A在获得锁操作期间,锁超时释放了,此时线程B获得了这个锁,当线程A操作完成时进行了一个del操作将锁释放了,线程B操作完成去释放锁时发现锁不存在或者也释放了别的线程的锁,怎么办呢?
2. get + del
在set key的时候,将value设置为一个唯一的客户端标识或UUID,客户端在释放锁时,先get key的value校验加锁线程是否为自己,如果是,则释放锁。
但是get + del操作并不是原子性的,还是有线程安全问题,怎么解决呢?
3. Lua脚本
使用Lua脚本通过eval/evalsha命令执行get + del操作,Lua脚本保证原子性操作。