封面来源:碧蓝航线 永夜幻光 活动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
2
3
4
5
6
7
8
9
10
11
12
13
daemonize yes
include /home/bigdata/redis.conf
port 6379
pidfile "/var/run/redis_6379.pid"
dbfilename "dump6379.rdb"
dir "/home/bigdata/redis_cluster"
logfile "/home/bigdata/redis_cluster/redis_err_6379.log"
# 打开集群模式
cluster-enabled yes
# 设定节点配置文件名
cluster-config-file nodes-6379.conf
# 设定节点失联时间,超过该时间(毫秒),集群自动进行主从切换。
cluster-node-timeout 15000

然后对 redis6379.conf 文件复制 5 份,每份文件以各自的端口号区分,并对其中涉及到的端口号信息进行修改。

比如针对 redis6380.conf 文件,使用 vim redis6380.conf 打开文件后,使用 Vim 的快捷替换命令 :%s/6379/6380 将配置中涉及到的 6379 都修改为 6380。按照同样的方式,对所有文件都进行修改。

修改完成后,依次使用以下命令启动 6 个 Redis 服务:

1
2
3
4
5
6
redis-server redis6379.conf
redis-server redis6380.conf
redis-server redis6381.conf
redis-server redis6389.conf
redis-server redis6390.conf
redis-server redis6391.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 下的键值是不能使用 MGETMSET 等多键操作。

但也不是没有解决方法:可以通过 {} 来定义组的概念,从而使 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
2
3
4
5
6
7
8
public void testCluster() {
HostAndPort hostAndPort = new HostAndPort("redis", 6379);
JedisCluster jedisCluster = new JedisCluster(hostAndPort);
jedisCluster.set("k4","v4");
String k4 = jedisCluster.get("k4");
Assert.assertEquals("v4", k4);
jedisCluster.close();
}

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

使用 setnxname 的 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
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@GetMapping("testLock")
public void testLock(){
// 上锁,并设置过期时间
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "111", 3, TimeUnit.SECONDS);
// 上锁成功,查询 num 的值
if (lock) {
Object value = redisTemplate.opsForValue().get("num");
if(StringUtils.isEmpty(value)){
return;
}
// 获取到 num 的值后,使其自增 1
int num = Integer.parseInt(value + "");
redisTemplate.opsForValue().set("num", ++num);
// 释放锁,del
redisTemplate.delete("lock");
}else{
// 上锁失败,每隔 0.1 秒再获取
try {
Thread.sleep(100);
testLock();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

2.3 使用 UUID 防止误删

上述实现看似完美,其实在分布式场景下存在问题:释放锁时可能会释放到其他服务器的锁。

假设 Redis 分布式锁的过期时间为 10 秒,现有三台服务器(A、B、C)对数据进行操作,服务器 A 率先拿到锁,然后进行自己的业务逻辑,在执行的时候出现了服务器卡顿,并且在恢复正常之前已经到了分布式锁的过期时间,这时 A 的业务操作还没完,而锁已被自动释放。释放后 B 拿到了锁并执行自己的业务逻辑,B 在执行业务逻辑时 A 服务器恢复正常继续执行剩下的业务逻辑,在 B 还未执行完业务逻辑时,A 执行完剩余的业务逻辑并准备手动释放锁,在这时释放的锁就是 B 拿着的锁。这就相当于 B 没有被锁。

解决方案:在设置锁时设置一个唯一值(比如 UUID),而在释放锁时判断当前的唯一值是否和要释放的锁的唯一值一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@GetMapping("testLock")
public void testLock(){
String uuid = UUID.randomUUID().toString();
// 上锁,并设置过期时间
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 3, TimeUnit.SECONDS);
// 上锁成功,查询 num 的值
if (lock) {
Object value = redisTemplate.opsForValue().get("num");
if(StringUtils.isEmpty(value)){
return;
}
// 获取到 num 的值后,使其自增 1
int num = Integer.parseInt(value + "");
redisTemplate.opsForValue().set("num", ++num);
String lockUuid = (String) redisTemplate.opsForValue().get("lock");
// 唯一值相等才释放
if(uuid.equals(lockUuid)){
// 释放锁,del
redisTemplate.delete("lock");
}
}else{
// 上锁失败,每隔 0.1 秒再获取
try {
Thread.sleep(100);
testLock();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

2.4 保证删除的原子性

引入 UUID 防止误删后依旧存在问题: 删除操作缺乏原子性。

还是以三台服务器(A、B、C)为例,A 在释放锁时,查询到的锁的唯一值和当前的唯一值的确相等,但在 A 还没来得及手动释放锁时,锁的过期时间恰好到了,锁被自动释放了。这时 B 拿到了锁并执行自己的业务逻辑,业务逻辑还未执行完,A 进行手动释放锁,而释放的锁就是 B 的锁。

为了保证删除的原子性,可以 使用 Lua 脚本进行锁的释放。 Lua 脚本有一定的原子性,不会被其他命令插队。

1
2
3
4
5
if redis.call('get', KEYS[1]) == ARGV[1] then 
return redis.call('del', KEYS[1])
else
return 0
end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@GetMapping("testLockLua")
public void testLockLua() {
String uuid = UUID.randomUUID().toString();
String locKey = "lock"
// 上锁,并设置过期时间
Boolean lock = redisTemplate.opsForValue().setIfAbsent(locKey, uuid, 3, TimeUnit.SECONDS);
// 上锁成功,查询 num 的值
if (lock) {
Object value = redisTemplate.opsForValue().get("num");
if (StringUtils.isEmpty(value)) {
return;
}
// 获取到 num 的值后,使其自增 1
int num = Integer.parseInt(value + "");
redisTemplate.opsForValue().set("num", ++num);
// 定义lua 脚本
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
// 使用 Redis 执行 Lua 执行
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(script);
redisScript.setResultType(Long.class);
// 第一个参数表示 Lua 脚本 ,第二个是判断的 key,第三个是 key 所对应的值
redisTemplate.execute(redisScript, Arrays.asList(locKey), uuid);
} else {
// 上锁失败,每隔 0.1 秒再获取
try {
Thread.sleep(1000);
testLockLua();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

2.5 总结

为了确保分布式锁可用,至少要确保锁的实现同时 满足以下四个条件:

1、互斥性。任意时刻,只有一个客户端能持有锁。

2、不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。

3、解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。

4、加锁和解锁必须具有原子性。

3. ACL

3.1 ACL 简介

Redis ACL 是 Access Control List(访问控制列表)的缩写,该功能允许根据可以执行的命令和可以访问的键来限制某些连接。

在 Redis 5 版本之前,Redis 安全规则只有密码控制,还有通过 rename 来调整高危命令,比如 flushdbKEYS*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
2
3
acl whoami
get foo
set cached:1 123

但是执行 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 等等的并发问题。整体的设计大体如下:

Redis的IO多线程架构原理

另外,多线程 IO 默认也是不开启的,需要在配置文件中进行配置:

1
2
io-threads-do-reads yes 
io-threads 4

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 一开始就是一个向编写各种系统开放的平台。