Redis系列第五章
发表于:2023-12-18 | 分类: 中间件
字数统计: 5.3k | 阅读时长: 22分钟 | 阅读量:

Redis 分布式系统

Redis 分布式系统,官方称为 Redis Cluster,Redis 集群,其是 Redis 3.0 开始推出的分布式解决方案。其可以很好地解决不同 Redis 节点存放不同数据,并将用户请求方便地路由到不同 Redis 的问题。

数据分区算法

分布式数据库系统会根据不同的数据分区算法,将数据分散存储到不同的数据库服务器节点上,每个节点管理着整个数据集合中的一个子集。

常见的数据分区规则有两大类:顺序分区与哈希分区。

顺序分区

顺序分区规则可以将数据按照某种顺序平均分配到不同的节点。不同的顺序方式,产生了不同的分区算法。例如,轮询分区算法、时间片轮转分区算法、数据块分区算法、业务主题分区算法等。

  • 轮询分区算法

每产生一个数据,就依次分配到不同的节点。该算法适合于数据问题不确定的场景。其分配的结果是,在数据总量非常庞大的情况下,每个节点中数据是很平均的。但生产者与数据节点间的连接要长时间保持。

  • 时间片轮转分区算法

将某个固定长度时间内的数据分配到一个节点,时间片结束后切换节点。该算法可能会出现节点数据不平均的情况(每个时间片内产生的数据量可能是不同的)。但生产者与节点间的连接使用完毕后就会立即释放。

  • 数据块分区算法

在整体数据总量确定的情况下,根据各个节点的存储能力,可以将连接的某一整块数据分配到某一节点。

  • 业务主题分区算法

数据可根据不同的业务主题,分配到不同的节点。

哈希分区

哈希分区规则是充分利用数据的哈希值来完成分配,对数据哈希值的不同使用方式产生 了不同的哈希分区算法。几种常见的哈希分区算法:

  • 节点取模分区算法

该算法的前提是,每个节点都已分配好了一个唯一序号,对于 N 个节点的分布式系统,其序号范围为[0, N-1]。然后选取数据本身或可以代表数据特征的数据的一部分作为 key,计算 hash(key)与节点数量 N 的模,该计算结果即为该数据的存储节点的序号。

该算法最大的优点是简单;其存在较严重的不足。如果分布式系统扩容或缩容,已 经存储过的数据需要根据新的节点数量 N 进行数据迁移。生产中扩容一般采用翻倍扩容方式,以减少扩容时数据迁移的比例。

  • 一致性哈希分区算法

一致性 hash 算法通过一个叫作一致性 hash 环的数据结构实现。这个环的起点是 0,终点是 2^32 - 1,并且起点与终点重合。环中间的整数按逆/顺时针分布,故这个环的整数分布范围是[0, 2^32 -1]。

要为数据分配其要存储的节点。该数据对象的 hash(o) 按照逆/顺时针方向距离哪 个节点的 hash(m)最近,就将该数据存储在哪个节点。

该算法的最大优点是,节点的扩容与缩容,仅对按照逆/顺时针方向距离该节点最近的节点有影响,对其它节点无影响。

但当节点数量较少时,非常容易形成数据倾斜问题,且节点变化影响的节点数量占比较大, 即影响的数据量较大。

  • 虚拟槽分区算法

该算法首先虚拟出一个固定数量的整数集合,该集合中的每个整数称为一个 slot 槽。这个槽的数量一般是远远大于节点数量的。然后再将所有 slot 槽平均映射到各个节点之上。

Redis 分布式系统中共虚拟了 16384 个 slot 槽,其范围为[0, 16383]。数据只与 slot 槽有关系,与节点没有直接关系。数据只通过其 key 的 hash(key)映射到 slot 槽:slot = hash(key) % slotNums。这也是该算法的一个优点,解耦了数据与节点

Redis 计算槽点的公式为:slot = CRC16(key) &16383;CRC16()是一种带有校验功能的、具有良好分散功能的、特殊的 hash 算法函数。

如果 b 是 2 的整数次幂,要计算 a % b,那么 a % b = a & (b-1)。

分布式系统搭建

系统架构:6个节点,一个 master 配备一个 slave,master 与 slave 的配对关系,在系统搭建成功后会自动分配。

修改 redis.conf

  • dir

指定工作目录,持久化文件、节点配置文件将来都会在工作目录中自动生成。

63238d5cff6e96ab51179eeb8cbff3c1.png

  • cluster-enabled

该属性用于开启 Redis 的集群模式。

07c27129bbb8f37c283b19bfa010e516.png

  • cluster-config-file

该属性用于指定“集群节点”的配置文件。该文件会在第一次节点启动时自动生成,其生成的路径是在 dir 属性指定的工作目录中。在集群节点信息发生变化后(如节点下线、故障转移等),节点会自动将集群状态信息保存到该配置文件中。

  • cluster-node-timeout

用于指定“集群节点”间通信的超时时间阈值,单位毫秒。

a84f93032a79ccaf5579e05b50b43e65.png

修改 redis6380.conf:

1
2
3
4
5
6
7
8
9
include redis.conf
pidfile /var/run/redis_6380.pid
port 6380
dbfilename dump6380.rdb
appendfilename appendonly6380.aof
replica-priority 90
cluster-config-file nodes-6380.conf

# logfile access6380.log

目录中有7个文件

502404198bb0add3695701f00bd22bcc.png

系统启动与关闭:

7a515cab5c4b7dba86090bd3d9753cad.png

我们在启动所有节点后,会自动生成nodes文件

创建系统

此时6个节点仍是独立的 redis,通过 redis-cli –cluster create 命令可将 6 个节点创建成一个分布式系统。

f5356debd3912c6b9e8f63eb4877705f.png

–cluster replicas 1 指定每个 master 会带有一个 slave 作为副本。

这里不能使用 127.0.0.1

fd273db6e37f38bc939e0f9570cd8e77.png

测试系统

通过 cluster nodes 命令可以查看到系统中各节点的关系及连接情况。只要能看到每个节点给出 connected,就说明分布式系统已经成功搭建。

d164e98aa231f8a787b2b9a5489438e9.png

-c:表示这是要连接一个“集群”,而非是一个节点。

关闭系统只需将各个节点 shutdown 即可

集群操作

连接集群

c0b01293ea2dba42e3265953f77c2ab3.png

写入操作和普通 Redis 节点相同,但使用批量写入需要指定统一的 group,让 grop 作为计算 slot 的唯一值

444ef31f738260fc72094a54946b8248.png

  • 查询 Key 的 slot:cluster keyslot key
  • 查询 slot 中 key 的数量:cluster countkeysinslot slot
  • 查询 slot 中的 key:cluster getkeysinslot slot count

c193da4d4ecb70e1e27661c00c6e93d0.png

故障转移

分布式系统中的某个 master 如果出现宕机,那么其相应的 slave 就会自动晋升为 master。 如果原 master 又重新启动了,那么原 master 会自动变为新 master 的 slave。

全覆盖需求

如果某 slot 范围对应节点的 master 与 slave 全部宕机,那么整个分布式系统是否还可以对外提供读服务,就取决于属性 cluster-require-full-coverage 的设置。

16feb19c14892a56b7e8b6442ac9f8b3.png

yes:默认值。要求所有 slot 节点必须全覆盖的情况下系统才能运行;no:slot 节点不全的情况下系统也可以提供查询服务。

集群扩容

动态扩容添加 master 节点:

1
redis-cli -c --cluster add-node 127.0.0.1:6386 127.0.0.1:6380

通过命令 redis-cli --cluster add-node {newHost}:{newPort} {existHost}:{existPort}可以将新 的节点添加到系统中。其中{newHost}:{newPort}是新添加节点的地址,{existHost}:{existPort} 是原系统中的任意节点地址。

添加成功后,通过 redis-cli -c -p 6386 cluster nodes 命令可以看到其它 master 节点都分配有 slot,只有新添加的 master 还没有相应的 slot。

分配 slot

为新的 master 分配的 slot 来自于其它节点,总 slot 数量并不会改变。所以 slot 分配过 程本质是一个 slot 的移动过程。

通过 redis-cli –c --cluster reshard {existIP}:{existPort}命令可开启 slot 分配流程。其中地址{existIP}:{existPort}为分布式系统中的任意节点地址。

添加 slave 节点

通过 redis-cli --cluster add-node {newHost}:{newPort} {existHost}:{existPort} --cluster-slave --cluster-master-id masterID 命令可将新添加的节点直接添加为指定 master 的 slave。

集群缩容

删除 slave 节点

对于 slave 节点,可以直接通过 redis-cli --cluster del-node <delHost>:<delPort> delNodeID 命令删除。

移出 master 的 slot

在删除一个 master 之前,必须要保证该 master 上没有分配有 slot。

1
redis-cli -c --cluster reshard ip:port

仅能指定一个接收节点

在移出 slot 后,我们可以像删除 slave 节点一样删除 master。

分布式系统的限制

  • 仅支持 0 号数据库
  • 批量 key 操作支持有限
  • 分区仅限于 key
  • 事务支持有限
  • 不支持分级管理

Redis 缓存

Jedis 客户端

Jedis 是一个基于 java 的 Redis 客户端连接工具,旨在提升性能与易用性。其 github 上的官网地址为:https://github.com/redis/jedis。

添加依赖:

1
2
3
4
5
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>4.2.0</version>
</dependency>

Jedis 提供了非常丰富的操作 Redis 的方法,且方法名几乎与 Redis 命令相同。在每次使用时直接创建 Jedis 实例即可,Jedis 底层实际会创建一个到指定 Redis 服务器的 Socket 连接。

使用实例:

1
2
3
4
5
6
7
8
@Test
public void test(){
Jedis jedis = new Jedis("192.168.64.100",6379);
jedis.set("name","John");
String name = jedis.get("name");
System.out.println(name);
jedis.close();
}

记得关防火墙,不然连接不上

使用 JedisPool

非常频繁地创建和销毁 Jedis 实例,虽然节省了系统资源与网络带宽,但创建和销毁 Socket 连接是比较耗时的,会大大降低系统性能。此时可以使用 Jedis 连接池来解决该问题。

1
2
3
4
5
6
7
8
9
JedisPool jp=new JedisPool("192.168.64.100",6379);
@Test
public void test1(){
Jedis jedis = jp.getResource();
jedis.sadd("cities","fs","sc","sz","bj");
Set<String> cities = jedis.smembers("cities");
System.out.println(cities);
jedis.close();
}

使用 JedisPooled

每次关闭资源比较麻烦,JedisPooled 可以自动释放资源

1
2
3
4
5
6
JedisPooled jpd=new JedisPooled("192.168.64.100",6379);
@Test
public void test2(){
Set<String> cities = jpd.smembers("cities");
System.out.println(cities);
}

连接 Sentinel 高可用集群

对于 Sentinel 高可用集群的连接,直接使用 JedisSentinelPool 即可。在该客户端只需注册所有 Sentinel 节点及其监控的 Master 的名称即可,无需出现 master-slave 的任何地址信息。其采用的也是 JedisPool,使用完毕的 Jedis 也需要通过 close()方法将其返回给连接池。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
JedisSentinelPool jsp;
{
Set<String> sentinel=new HashSet<>();
sentinel.add("192.168.64.100:26380");
sentinel.add("192.168.64.100:26381");
sentinel.add("192.168.64.100:26382");
jsp=new JedisSentinelPool("mymaster",sentinel);
}
@Test
public void test3(){
Jedis jedis = jsp.getResource();
jedis.hset("emp","name","John");
String name = jedis.hget("emp", "name");
System.out.println(name);
jedis.close();
}

这里代码没什么问题,但运行时容易报错:1. 连接不上;2. 连接的是slave不能写入

连接分布式系统

对于 Redis 的分布式系统的连接,直接使用 JedisCluster 即可。其底层采用的也是 Jedis 连接池技术。

对于 JedisCluster 常用的构造器有两个。一个是只需集群中的任意一个节点,但该构造器存在一个风险:其指定的这个节点在连接之前恰好宕机,那么该客户端将无法连接上集群。所以, 推荐将集群中所有节点全部罗列出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
JedisCluster jc;
{
Set<HostAndPort> cluster=new HashSet<>();
cluster.add(new HostAndPort("192.168.64.100",6380));
cluster.add(new HostAndPort("192.168.64.100",6381));
cluster.add(new HostAndPort("192.168.64.100",6382));
cluster.add(new HostAndPort("192.168.64.100",6383));
cluster.add(new HostAndPort("192.168.64.100",6384));
cluster.add(new HostAndPort("192.168.64.100",6385));
jc=new JedisCluster(cluster);
}
@Test
public void test4(){
jc.rpush("hobby","music","game","dance");
List<String> hobby = jc.lrange("hobby", 0, -1);
System.out.println(hobby);
}

操作事务

Jedis 提供了 multi()、watch()、unwatch()方法来对应 Redis 中的 multi、watch、unwatch 命令。Jedis的 multi()方法返回一个 Transaction 对象,其 exec()与 discard() 方法用于执行和取消事务的执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Test
public void test(){
Jedis jedis = new Jedis("192.168.64.100",6379);
jedis.set("name","John");
jedis.set("age","20");
Transaction multi = jedis.multi();
try {
multi.set("name","Jerry");
multi.incrBy("name",5);
multi.exec();
} catch (Exception e) {
multi.discard();
}
System.out.printf("%s,%s",jedis.get("name"),jedis.get("age"));
jedis.close();
}

输出结果为修改过的,即 Redis 运行时抛出的异常不会影响 Java 代码的执行。

Spring Boot 整合 Redis

Spring Boot 中可以直接使用 Jedis 实现对 Redis 的操作,但一般使用 Redis 操作模板 RedisTemplate 类的实例来操作 Redis。

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

这里以一个精简的金融产品交易平台为例示范:

我们跳过 ssm 结构直接构建 ssrm 工程

添加依赖:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<!-- MySQL 连接驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- 数据库连接池 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.2.8</version>
</dependency>
<!-- redis 模板 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.4</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>

修改配置文件:

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
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
username: root
password: 123456
url: jdbc:mysql://localhost:3306/test
# 连接 redis
redis:
host: 192.168.64.100
port: 6379
# 开启 redis 缓存
cache:
cache-names: product
type: redis
# 整合 mybatis
mybatis:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.redis.demo3.pojo
configuration:
map-underscore-to-camel-case: on
# SpringBoot 日志设置
logging:
pattern:
console: level-%-5level-%msg%n
# 指定不同的日志级别,显示sql语句
level:
root: warn
com.redis.demo3.mapper: debug

JavaBean

由于要将查询的实体类对象缓存到 Redis,Redis 要求实体类必须序列化。

e242ced47944b7ef2f8856417cc7792e.png

redis缓存的使用

@EnableCaching

用于开启当前应用的缓存功能。

1c2fc93b6d011d52dfae16e2201ac6fa.png

在 ServiceImpl 中开启缓存减轻数据库压力

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
34
35
36
37
38
39
40
41
@Service
public class ProductServiceImpl implements ProductService {

@Autowired(required = false)
private ProductMapper pm;

@Autowired(required = false)
private RedisTemplate<Object,Object> rt;

@Override
@Cacheable(value = "product",key="'product_all'")
public List<Product> getAll() {
return pm.getAll();
}

@Override
@Cacheable(value = "product",key="'product_'+#name")
public Product getByName(String name) {
return pm.getByName(name);
}

@Override
public Double getSum() {
BoundValueOperations<Object, Object> bvo = rt.boundValueOps("raised");
Object o = bvo.get();
Double raised = null;
if (o == null) {
Date date = new Date();
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
raised = pm.getRaised(sdf.format(date));
bvo.set(raised, 1, TimeUnit.HOURS);
}
return raised;
}

@Override
@CacheEvict(value="product",allEntries = true)
public int insert(Product product) {
return pm.insert(product);
}
}

其中 RedisTemplate 必须指定泛型,否则会报错

BoundValueOperations就是一个绑定key的对象,我们可以通过这个对象来进行与key相关的操作。

@Cacheable

用于指定将查询结果使用指定的 key 缓存到指定缓存空间。如果再有对该 查询数据的访问,则会先从缓存中查看。

@CacheEvict

用于实现 value 指定缓存空间中缓存数据的清空。allEntries 为 true 指定清空该缓存空间所有数据。如果不想清空所有,则需通过 key 属性指定要清理的 key 数据。

高并发问题

缓存穿透

当用户访问的数据既不在缓存也不在数据库中时,就会导致每个用户查询都会“穿透”缓存“直抵”数据库。这种情况就称为缓存穿透。

当高并发的访问请求到达时,缓存穿透不仅增加了响应时间,而且还会引发对 DBMS 的高并发查询,这种高并发查询很可能会导致 DBMS 的崩溃。

缓存穿透产生的主要原因有两个:一是在数据库中没有相应的查询结果,二是查询结果为空时,不对查询结果进行缓存。

解决方案:

  • 对非法请求进行限制
  • 对结果为空的查询给出默认值

缓存击穿

对于某一个缓存,在高并发情况下若其访问量特别巨大,当该缓存的有效时限到达时,可能会出现大量的访问都要重建该缓存,即这些访问请求发现缓存中没有该数据,则立即到 DBMS 中进行查询,那么这就有可能会引发对 DBMS 的高并发查询,从而接导致 DBMS 的崩溃。这种情况称为缓存击穿,而该缓存数据称为热点数据。

解决方案,较典型的是使用“双重检测锁”机制。

优化交易额的查询:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Override
public Double getSum() {
BoundValueOperations<Object, Object> bvo = rt.boundValueOps("raised");
Object raised = bvo.get();
if (raised == null) {
synchronized (this){
raised=bvo.get();
if(raised==null){
Date date = new Date();
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
raised = pm.getRaised(sdf.format(date));
bvo.set(raised, 1, TimeUnit.HOURS);
}
}
}
return Double.parseDouble(raised+"");
}

缓存雪崩

对于缓存中的数据,很多都是有过期时间的。若大量缓存的过期时间在同一很短的时间段内几乎同时到达,那么在高并发访问场景下就可能会引发对 DBMS 的高并发查询,而这将可能直接导致 DBMS 的崩溃。这种情况称为缓存雪崩。

最好的解决方案就是预防,即提前规划好缓存的过期时间。如果 DBMS 采用的是分布式部署,则将热点数据均匀分布在不同数据库节点中,将可能到来的访问负载均衡开来。

数据库缓存双写不一致

以上三种情况都是针对高并发读场景中可能会出现的问题,而数据库缓存双写不一致问题,则是在高并发写场景下可能会出现的问题。

“修改 DB 更新缓存”场景

c8bbccda699ecbc1a4994ee355832cda.png

“修改 DB 删除缓存”场景

7829ea02d03a0038d9baf709cc0c7e90.png

解决方案:

延迟双删

延迟双删方案是专门针对于“修改 DB 删除缓存”场景的解决方案。但该方案并不能彻底解决数据不一致的状况,其只可能降低发生数据不一致的概率。

两次删除中间的间隔时长,要大于一次缓存写操作的时长。

981d8e2f351371168d84a50621d55d4c.png

队列

以上两种场景中,只所以会出现数据库与缓存中数据不一致,主要是因为对请求的处理出现了并行。只要将请求写入到一个统一的队列,只有处理完一个请求后才可处理下一个请求,即使系统对用户请求的处理串行化,就可以完全解决数据不一致的问题。

分布式锁

使用队列的串行化虽然可以解决数据库与缓存中数据不一致,但系统失去了并发性,降低了性能。使用分布式锁可以在不影响并发性的前提下,协调各处理线程间的关系,使数据库与缓存中的数据达成一致性。

只需要对数据库中的这个共享数据的访问通过分布式锁来协调对其的操作访问即可。

上一篇:
Redis系列第六章
下一篇:
Redis系列第四章