Redis 集群与分布式锁
封面来源:碧蓝航线 永夜幻光 活动CG
本文参考:【尚硅谷】Redis 6 入门到精通 超详细 教程
1. 集群
1.1 问题
容量不够,Redis 如何进行扩容?
并发写操作,Redis 如何分摊?
另外,主从模式,薪火相传模式,主机宕机,导致 ip 地址发生变化,应用程序中配置需要修改对应的主机地址、端口等信息。
之前通过代理主机来解决,但是 Redis3.0 中提供了解决方案,就是 无中心化集群 配置。
代理主机
客户端的请求都发送到代理主机上,代理主机通过一些策略将不同的请求分发到不同的 Redis 上。以电商为例,现有三台 Redis,分别存放了用户、订单和商品信息,当客户端的请求发送到代理主机上时,代理主机通过一些策略,将不同的请求分发到不同的 Redis 上。
假设某台 Redis 宕机了怎么办?为了保证可用性,还需要对每台 Redis 搭建从机。
那如果代理主机宕机了呢?因此还需要对代理主机搭建从机。
每台主机只搭建一台从机?这可不行,得一主多从。
可见使用这种方式,无论是从部署,还是后期维护等方面来看,都不太行。
无中心化集群
这种模式下取消代理主机的存在。假设依旧是三台主机,它们也有各自的从机,并组成一个无中心化集群。当请求打到集群上时,请求自动转移到目标服务器。
比如现有一个订单请求打到集群上存放了用户信息的 Redis 主机上,由于这台主机存放的并不是订单信息,因此将请求移交给另一台 Redis 主机,依次类推,直到请求打到存放了订单信息的 Redis 主机上。
简单来说就是一种“踢皮球”。
1.2 什么是集群
Redis 集群实现了对 Redis 的水平扩容,即启动 N 个 Redis 节点,将整个数据库分布存储在这 N 个节点中,每个节点存储总数据的 1/N。
Redis 集群通过分区(partition)来提供一定程度的可用性(availability), 即使集群中有一部分节点失效或者无法进行通讯, 集群也可以继续处理命令请求。
1.3 模拟集群的搭建
在搭建前删除 RDB 和 AOF 产生的持久化文件,避免造成混淆。
我们将制作 6 个实例(当然了,都是在一台 Redis 上进行模拟操作,真正的 6 个实例对我来说过于奢侈),它们的访问端口号分别为 6379、6380、6381、6389、6390、6391。
启动 6 个 Redis 实例
首先拷贝一份 redis.conf 文件,拷贝出的文件名为 redis6379.conf:
1 | cp redis.conf redis6379.conf |
redis6379.conf 配置文件中的部分配置修改成以下形式:
1 | daemonize yes |
然后对 redis6379.conf 文件复制 5 份,每份文件以各自的端口号区分,并对其中涉及到的端口号信息进行修改。
比如针对 redis6380.conf 文件,使用 vim redis6380.conf
打开文件后,使用 Vim 的快捷替换命令 :%s/6379/6380
将配置中涉及到的 6379 都修改为 6380。按照同样的方式,对所有文件都进行修改。
修改完成后,依次使用以下命令启动 6 个 Redis 服务:
1 | redis-server redis6379.conf |
为了确定服务正常启动,使用以下命令查看:
1 | ps -ef | grep redis |
如果发现 6 个 Redis 都启动成功了,我们已经达成了一个阶段性胜利。
再使用 ll
命令,查看是否生成了 6 个节点配置文件,它们类似 nodes-6379.conf
的形式。
接下来需要将 6 个节点合并成一个集群,在此之前,必须确保 6 个 Redis 实例已经启动,它们各自的节点配置文件也以生成。
节点合并成集群
进入 Redis 安装目录下的 src 目录,查看是否存在 redis-trib.rb 文件,如果已经存在,表示当前版本的 Redis 已内置 Ruby 环境,否则还需要安装 Ruby 环境。
我使用的是 Redis 6.0.8 版本,它已经内置了 Ruby 环境。
然后在 Redis 安装目录的 src 目录中执行以下命令:
1 | redis-cli --cluster create --cluster-replicas 1 192.168.11.101:6379 192.168.11.101:6380 192.168.11.101:6381 192.168.11.101:6389 192.168.11.101:6390 192.168.11.101:6391 |
当然了,上述命令中出现的 IP 仅做实例,实际操作中使用每个实例真实的 IP(也不要用 127.0.0.1)。
对了,redis6379.conf 等各个配置文件中的 bind
属性也记得修改为真实 IP。
如果 Redis 设置了密码,在上述命令后加上 -a 密码
并执行。
执行命令后出现以下字样:
Can I set the above configuration? (type 'yes' to accept):
这表示是否接受默认的集群配置策略,输入 yes 即可,按照默认的分配。
命令中使用了 --replicas 1
表示采用最简单的方式配置集群,一台主机搭配一台从机,六台实例按照默认配置正好三组。
运行完成后,最后会出现如下字样:
[OK] All 16384 slots covered.
记住 16384
这个值后面有用。
连接集群
可以使用以下命令以普通方式登录,但在存储数据时,会出现 MOVED 重定向错误,所以应当以集群方式登录:
1 | redis-cli -p 6379 |
在上述命令上加个 -c
就可以采用集群策略连接,存储数据时会自动切换到对应的写主机。
1 | redis-cli -c -p 6379 |
还可以使用 cluster nodes
命令查看集群信息。
1.4 集群操作
节点的分配
已经使用 6 台 Redis 实例搭建起一个集群,那么 Redis 集群是如何分配这 6 个节点的呢?
一个集群至少要有三个主节点。
节点合并成集群的命令的选项 --cluster-replicas 1
表示为集群中的每个主节点创建一个从节点。
分配原则尽量保证每个主数据库运行在不同的 IP 地址,每个从库和主库不在一个 IP 地址上。
什么是 slots
当所有节点合并成一个集群时,最后有这样一句信息:
[OK] All 16384 slots covered.
这个 16384 是什么?
一个 Redis 集群包含 16384 个插槽(hash slot), 库中的每个键都属于这 16384 个插槽的其中一个。
在向集群中录入值时,集群使用公式 CRC16(key) % 16384
来计算 key 属于哪个槽,然后将这个 key 放入那个槽中。集群中的每个节点负责处理一部分插槽。
公式 CRC16(key) % 16384
中的 CRC16(key)
语句用于计算键 key 的 CRC16 校验和 。
我们配置的集群中有三个主节点,那么:节点 A 负责处理 0 号至 5460 号插槽,节点 B 负责处理 5461 号至 10922 号插槽,节点 C 负责处理 10923 号至 16383 号插槽。
向集群中录入值
在 Redis 集群中录入、查询键值时,Redis 都会计算出该 key 应该送往哪个插槽,如果不是当前客户端对应服务器的插槽,Redis 就会报错,并告知应前往的 Redis 实例地址和端口。
这也是为什么我们在前面以普通方式登录 Redis 后再录入值,客户端会产生 MOVED 重定向错误的原因。
redis-cli 客户端提供 -c
参数实现自动重定向。
因此以 redis-cli -c -p 6379
登录后,再进行录入、查询操作时,客户端可以自动重定向,而不是报错。
我们知道使用 MSET
指令可以一次设置多个键值,但这在集群模式下似乎不行,比如执行以下命令就会产生错误:
192.168.32.123:6381> mset k1 v1 k2 v2 k3 v3 (error) CROSSSLOT Keys in request don't hash to the same slot
这是因为不在同一个 slot 下的键值是不能使用 MGET
、MSET
等多键操作。
但也不是没有解决方法:可以通过 {}
来定义组的概念,从而使 key 中 {}
内相同内容的键值放到同一个 slot 中:
192.168.32.123:6381> mset k1{user} v1 k2{user} v2 k3{user} v3
查询集群中的值
查询为 k1 的 key 在集群中的哪个插槽:CLUSTER KEYSLOT k1
查询 12706 插槽中 key 的数量(注意,12706 插槽必须在当前客户端中,否则 12706 中就算有 key,查得的结果也是 0):CLUSTER COUNTKEYSINSLOT 12706
获取 5474 插槽中的前 10 个 key(不足 10 个显示所有):CLUSTER GETKEYSINSLOT 5474 10
1.5 故障恢复
在集群中,如果某个主节点宕机,从节点能否自动升为主节点?
答案是肯定的。
宕机的主节点再次恢复后,主从节点的关系如何?
宕机的主节点恢复后变成原来从节点升为的主节点的从节点。这里有个 15 秒超时,也就是说主节点如果在宕机后 15 秒内重启成功,那么它就还是主节点,否则重启回来后就是从节点。
如果所有某一段插槽的主从节点都宕机,Redis 服务是否正常?
这取决于 redis.conf (配置文件)中 cluster-require-full-coverage
参数的值:
1、如果值为 yes
,那么整个集群都会挂掉;
2、如果值为 no
,仅仅是无法向那段插槽录入或读取数据。
1.6 使用 Jedis 操作集群
使用 Jedis 操作集群与操作单机类似,由于 Redis 集群是无中心化主从集群,因此此时连接的不是主机,集群也会自动切换主机存储。
1 | public void testCluster() { |
1.7 优点与不足
优点
1、实现扩容
2、分摊压力
3、无中心配置相对简单
不足
1、不支持多键操作(可以在定义组后进行多键操作)
2、多键的 Redis 事务是不被支持的,Lua 脚本也不被支持
3、由于 Redis Cluster 方案出现较晚,很多公司已经采用了其他的集群方案,而代理或者客户端分片的方案想要迁移至 Redis Cluster,需要整体迁移而不是逐步过渡,迁移的复杂度较大
2. 分布式锁
2.1 遇到的问题
随着业务发展的需要,原单体单机部署的系统被演化成分布式集群系统后,由于分布式系统多线程、多进程并且分布在不同机器上,使得原单机部署情况下的并发控制锁策略失效,而单纯的 Java API 又不能提供分布式锁的能力。
为了解决这个问题就需要一种跨 JVM 的互斥机制来控制共享资源的访问,这也就是分布式锁要解决的问题。
分布式锁的主流实现方案有以下三种:
1、基于数据库实现分布式锁
2、基于缓存(Redis 等)
3、基于 Zookeeper
而每一种分布式锁解决方案都有各自的优缺点:
1、从性能角度出发:Redis 性能最高
2、从可靠性角度出发:Zookeeper 可靠性最高
在此,我们基于 Redis 实现分布式锁。
2.2 简单的实现
可以使用 Redis 命令简单实现分布式锁,即:使用 setnx
上锁,使用 del
释放锁。
使用 setnx
上锁:
192.168.32.123:6381> setnx name mofan (integer) 1 192.168.32.123:6381> setnx name mo (integer) 0 192.168.32.123:6381> setnx name fan (integer) 0
使用 setnx
为 name
的 key 上锁后,无法再进行修改,除非释放锁。
使用 del
释放锁:
192.168.32.123:6381> del name (integer) 1 192.168.32.123:6381> setnx name mo (integer) 1 192.168.32.123:6381> setnx name fan (integer) 0
设置过期时间
上锁后必须释放锁才能对 key 继续操作,那如果一直不释放锁就一直无法操作?可以使用 expire
为锁设置过期时间。
192.168.32.123:6381> del name (integer) 1 192.168.32.123:6381> setnx name mofan (integer) 1 192.168.32.123:6381> expire name 10 (integer) 1 192.168.32.123:6381> ttl name (integer) 5 192.168.32.123:6381> setnx name mo (integer) 0 192.168.32.123:6381> ttl name (integer) -1 192.168.32.123:6381> setnx name mo (integer) 1
上述示例中,使用 expire
为 name 设置了 10 秒的过期时间,使用 ttl
查询剩余的过期时间,当过期时间为 -1 时,就可以成功对 name 进行操作。
原子操作
上述示例中上锁和设置过期时间不是原子操作。假设在上锁后突然出现异常无法设置过期时间了,那岂不是要一直锁着?
针对这个问题可以 在设置值时一并设置过期时间, 比如:
192.168.32.123:6381> del name (integer) 1 192.168.32.123:6381> set name mofan nx ex 10 OK 192.168.32.123:6381> ttl name (integer) 8
解析 set name mofan nx ex 10
:为 name 设置值为 mofan,并上锁,过期时间为 10 秒。
参数解析:
EX second
:设置键的过期时间为 second 秒。SET key value EX second
效果等同于 SETEX key second value
。
PX millisecond
:设置键的过期时间为 millisecond 毫秒。SET key value PX millisecond
效果等同于 PSETEX key millisecond value
。
NX
:只在键不存在时,才对键进行设置操作。SET key value NX
效果等同于 SETNX key value
。
XX
:只在键已经存在时,才对键进行设置操作。
Java 代码实现
1 |
|
2.3 使用 UUID 防止误删
上述实现看似完美,其实在分布式场景下存在问题:释放锁时可能会释放到其他服务器的锁。
假设 Redis 分布式锁的过期时间为 10 秒,现有三台服务器(A、B、C)对数据进行操作,服务器 A 率先拿到锁,然后进行自己的业务逻辑,在执行的时候出现了服务器卡顿,并且在恢复正常之前已经到了分布式锁的过期时间,这时 A 的业务操作还没完,而锁已被自动释放。释放后 B 拿到了锁并执行自己的业务逻辑,B 在执行业务逻辑时 A 服务器恢复正常继续执行剩下的业务逻辑,在 B 还未执行完业务逻辑时,A 执行完剩余的业务逻辑并准备手动释放锁,在这时释放的锁就是 B 拿着的锁。这就相当于 B 没有被锁。
解决方案:在设置锁时设置一个唯一值(比如 UUID),而在释放锁时判断当前的唯一值是否和要释放的锁的唯一值一样。
1 |
|
2.4 保证删除的原子性
引入 UUID 防止误删后依旧存在问题: 删除操作缺乏原子性。
还是以三台服务器(A、B、C)为例,A 在释放锁时,查询到的锁的唯一值和当前的唯一值的确相等,但在 A 还没来得及手动释放锁时,锁的过期时间恰好到了,锁被自动释放了。这时 B 拿到了锁并执行自己的业务逻辑,业务逻辑还未执行完,A 进行手动释放锁,而释放的锁就是 B 的锁。
为了保证删除的原子性,可以 使用 Lua 脚本进行锁的释放。 Lua 脚本有一定的原子性,不会被其他命令插队。
1 | if redis.call('get', KEYS[1]) == ARGV[1] then |
1 |
|
2.5 总结
为了确保分布式锁可用,至少要确保锁的实现同时 满足以下四个条件:
1、互斥性。任意时刻,只有一个客户端能持有锁。
2、不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
3、解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。
4、加锁和解锁必须具有原子性。
3. ACL
3.1 ACL 简介
Redis ACL 是 Access Control List(访问控制列表)的缩写,该功能允许根据可以执行的命令和可以访问的键来限制某些连接。
在 Redis 5 版本之前,Redis 安全规则只有密码控制,还有通过 rename
来调整高危命令,比如 flushdb
,KEYS*
,shutdown
等。Redis 6 则提供 ACL 的功能以便对用户进行更细粒度的权限控制,比如:
1、接入权限:用户名和密码
2、可以执行的命令
3、可以操作的 KEY
3.2 命令
1、使用 acl list
命令显示用户的权限列表:
127.0.0.1:6379> acl list 1) "user default on nopass ~* +@all"
其中:default
表示用户名,on
表示此用户被启用(off
则是未启用),nopass
表示此用户没密码,~*
表示此用户可以操作所有 key,+@all
表示此用户可以执行所有命令。
2、使用 acl cat
命令查看添加权限指令类别;
3、使用 acl cat string
命令查看 string
命令下具体可添加权限的指令;
4、使用 acl whoami
命令查看当前用户
5、使用 aclsetuser
命令创建用户并编辑用户的访问权限列表
3.3 ACL 规则
下面是有效ACL规则的列表。某些规则只是用于激活或删除标志,或对用户ACL执行给定更改的单个单词。其他规则是字符前缀,它们与命令或类别名称、键模式等连接在一起。
启动和禁用用户
参数 | 说明 |
---|---|
on | 激活某用户账号 |
off | 禁用某用户账号。注意,已验证的连接仍然可以工作。如果默认用户被标记为 off,则新连接将在未进行身份验证的情况下启动,并要求用户使用 AUTH 选项发送 AUTH 或 HELLO,以便以某种方式进行身份验证。 |
权限的添加和删除
参数 | 说明 |
---|---|
+<command> |
将指令添加到用户可以调用的指令列表中 |
-<command> |
从用户可执行指令列表移除指令 |
+@<category> |
添加该类别中用户要调用的所有指令,有效类别为 @admin、@set、@sortedset… 等,通过调用 ACL CAT 命令查看完整列表。特殊类别 @all 表示所有命令,包括当前存在于服务器中的命令,以及将来将通过模块加载的命令。 |
-@<actegory> |
从用户可调用指令中移除类别 |
allcommands |
+@all 的别名 |
nocommand |
-@all 的别名 |
可操作键的添加或删除
参数 | 说明 |
---|---|
~<pattern> |
添加可作为用户可操作的键的模式。例如 ~* 允许所有的键 |
3.4 简单的示例
1、创建新用户并设置默认权限
1 | acl setuser mofan1 |
上述指令没有给 mofan 用户指定任何规则。如果用户不存在,这将使用j ust created 的默认属性来创建用户。如果用户已经存在,则上面的命令将不执行任何操作。
2、为用户设置用户名、密码、ACL 权限并启用用户:
1 | acl setuser mofan2 on >password ~cached:* +get |
上述指令表示新建了 mofan2
用户并启用了他,该用户的密码为 password
,该用户只对 cached:
开头的 key 有操作权限,并且对这些 key 只有 get
权限。
3、查看当前用户:
1 | acl whoami |
4、切换用户:
1 | auth mofan2 password |
切换为 mofan2
用户后, 执行以下命令都没有权限:
1 | acl whoami |
但是执行 get cached:1
是可以的:
127.0.0.1:6379> get cached:1 (nil)
4. IO 多线程
4.1 简介
Redis 6 支持多线程了,并不是说 Redis 就告别单线程了。
IO 多线程其实指 客户端交互部分 的 网络IO 交互处理模块 多线程,而非 执行命令多线程。Redis 6 执行命令依然是单线程。
4.2 原理架构
Redis 6 加入多线程,但跟 Memcached 这种从 IO 处理到数据访问多线程的实现模式有些差异。Redis 的多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程。
之所以这么设计是不想因为多线程而变得复杂,需要去控制 key、Lua、事务、LPUSH/LPOP 等等的并发问题。整体的设计大体如下:
另外,多线程 IO 默认也是不开启的,需要在配置文件中进行配置:
1 | io-threads-do-reads yes |
5. 其他新特性
5.1 工具支持 Cluster
之前老版本 Redis 想要搭集群需要单独安装 Ruby 环境,Redis 5 将 redis-trib.rb 的功能集成到 redis-cli。另外官方 redis-benchmark 工具开始支持 Cluster 模式了,通过多线程的方式对多个分片进行压测。
5.2 其他
1、RESP3 新的 Redis 通信协议:优化服务端与客户端之间通信
2、Client Side Caching 客户端缓存:基于 RESP3 协议实现的客户端缓存功能。为了进一步提升缓存的性能,将客户端经常访问的数据 Cache 到客户端,减少TCP网络交互。
3、Proxy 集群代理模式:Proxy 功能,让 Cluster 拥有像单实例一样的接入方式,降低大家使用 Cluster 的门槛。不过需要注意的是代理不改变 Cluster 的功能限制,不支持的命令还是不会支持,比如跨 Slot 的多 Key 操作。
4、Modules API:Redis 6 中模块 API 开发进展非常大,因为 Redis Labs 为了开发复杂的功能,从一开始就用上 Redis 模块。Redis 可以变成一个框架,利用 Modules 来构建不同系统,而不需要从头开始写然后还要 BSD 许可。Redis 一开始就是一个向编写各种系统开放的平台。