Redis--发布订阅、key 的过期删除策略、Jedis的使用、SpringBoot整合Redis、Redis的事务、持久化、Redis集群、Redis做缓存中间件

六、发布和订阅

1、什么是发布、订阅

Redis的发布和订阅(pub/sub)是一种消息通信模式发送者(pub)发送消息,订阅者(usb)接收消息

Redis 客户端可以订阅任意数量的频道。

2、Redis的发布和订阅

image-20211031151747455

3、命令实现

用两个客户端,演示一下。

  1. 在第一个客户端,订阅 channel 1

    image-20211031152945568

    此时它正在等待接收消息。

  2. 在另一个客户端,给channel 1发布消息

    image-20211031153034458

    返回的1是订阅者数量

  3. 在第一个客户端,就收到了发送的消息

    image-20211031153111808

注意:发布的消息没有持久化,如果是这条消息发出之后才订阅此频道的客户端,不会收到之前的消息。

八、key 的过期删除策略

1、给key设置过期时间

设置key过期时间的命令有4个:

区别就是精确到秒或毫秒,精确到某一时间戳还是几秒之后

  • expire :设置key在n秒后过期
  • pexpire :设置key在n毫秒后过期
  • expireat :设置key在某个时间戳后过期,n是时间戳,精确到秒
  • pexpireat :设置key在某个时间戳后过期,n是时间戳,精确到毫秒

也可以在插入key时就设置过期时间:

区别就是精确到秒或毫秒

  • set ex :指定过期时间,精确到秒
  • set px :指定过期时间,精确到毫秒
  • setex :指定过期时间,精确到秒

查看某个key的剩余存活时间:

  • ttl

取消某个key的过期时间:

  • persist

2、如何知道key已经过期了

Redis有一个过期字典(expires dict),保存了所有key的过期时间。

每次对某个key设置了过期时间,就会把这个key连带它的过期时间,存入过期字典中。

过期字典的数据结构

字典实际上是哈希表,可以做到快速查找。

  • key 是一个指针,指向某个键对象
  • value 是一个 long long 类型的整数,保存了 key 的过期时间

判断key过期的流程

  • 获取key时,先检查这个key是否包含在过期字典中
    • 如果不在,说明不会过期,正常读取键值
    • 如果在,就获取该key的过期时间,和当前的系统时间比较。如果过期时间小于当前系统时间,那么该key过期。

3、过期删除策略

Redis的过期策略需要考虑两个问题:

  • 释放宝贵的内存资源
  • 避免过多占用CPU资源,影响吞吐率

1、定时删除

做法:

  • 在设置key的过期时间时,同时创建一个定时事件,到时间之后,由事件处理器去执行key的删除工作。

优点:

  • 可以保证过期的key被及时删除,内存及时被释放,对内存很友好

缺点:

  • 如果过期的key较多,删除key这个动作会占用CPU。如果当时内存不紧张,其实这部分CPU资源用来服务于存储和查询更好一些
  • 这样会降低服务器的吞吐率,增加响应时间,对CPU不友好

2、懒惰删除

做法:

  • 在获取某个key时,Redis会先检查它是否过期,如果过期了就立即删除,平时不会去主动删除过期的key,除非它被访问到。

优点:

  • 不会专门删除过期key,对CPU友好

缺点:

  • 如果只有懒惰删除策略,很可能造成大量已过期但未被访问的key堆积在内存中,相当于内存泄漏。

3、定期删除

做法:

  • 每隔默认的 100 ms ,随机从过期字典中抽取20个key,挨个检查是否过期,并删除其中的过期key
  • 如果本轮检查找到的过期key超过1/4,也就是5个以上,就会再执行一次,直到发现的过期key数量少于这个阈值
  • Redis 为了保证定期删除不会一直循环导致线程卡死,为此增加了定期删除循环流程的时间上限,默认不会超过 25ms

优点:

  • 操作不会频繁执行,尽量减少对系统的影响
  • 每次操作时,涉及到的键比较少,是随机的一些设置了过期时间的Key,而不是全体Key,这样每次操作的耗时就较少
  • 很大程度上,过期的Key不会在内存中保留太长时间,提高了系统的存储能力。

缺点:

  • 如果间隔时间设置得太频繁,或者每次操作的耗时太长,就会导致CPU时间被过多地耗费在这个活动上
  • 如果间隔时间设置得太稀疏,或者每次删除的Key太少,就也会导致大量已经过期的Key在内存积压

4、Redis 实际选择的过期策略

三种策略都有优缺点,没有孰优孰劣。

Redis的选择是,组合使用 “懒惰删除” + “定期删除”,以求在内存占用和CPU占用之间取得平衡。

九、Jedis的使用

1、开发步骤

1.1、导入依赖

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>3.2.0</version>
</dependency>

1.2、linux设置

禁用Linux的防火墙:Linux(CentOS7)里执行命令

systemctl stop/disable firewalld.service

redis.conf中注释掉bind 127.0.0.1 ,然后 protected-mode no

1.3、VirtualBox设置

直接连虚拟机的ip,是连不上的,所以还是需要做端口转发。

image-20211031161158646

之后,访问127.0.0.1的2334端口,就能访问到Redis服务器。

1.4、测试

编写一个测试程序

public class JedisDemo1 {
    public static void main(String[] args) {
        //创建一个Jedis对象,传入redis主机的ip地址和端口号
        Jedis jedis = new Jedis("127.0.0.1", 2334);
        String pong = jedis.ping();
        System.out.println("pong = " + pong);
        jedis.close();
    }
}

启动后,收到pong,证明连接正常。

2、测试数据类型API

2.1、Key

public class jedisTest1 {

    Jedis jedis;

    @Before
    public void before(){
        jedis = new Jedis("127.0.0.1", 2334);
    }

    @After
    public void close(){
        jedis.close();
    }

    @Test
    //操作key
    public void test1(){
        //添加数据
        jedis.set("k1", "v1");
        jedis.set("k2", "v2");
        jedis.set("k3", "v3");

        //查看所有key
        Set<String> keys = jedis.keys("*");
        for (String key : keys) {
            System.out.println("key = " + key);
        }

        //根据键获取值
        String k2 = jedis.get("k2");
        System.out.println("k2 = " + k2);

        //判断键是否存在
        Boolean k1 = jedis.exists("k1");
        System.out.println("k1 = " + k1);
    }
}

3、示例 模拟验证码发送

要求:

1、输入手机号,点击发送后随机生成6位数字码,2分钟有效

2、输入验证码,点击验证,返回成功或失败

3、每个手机号每天只能获取3次验证码

分析

随机六位数字,可以用random实现。

验证码两分钟内有效,可以将生成的数字码放入redis中,设置过期时间为两分钟(120秒)

验证码是否正确:获取输入,与redis中的进行比较

每个手机号每天只能获取3次验证码,利用redis的incr操作,每次存入验证码后,值+1。当值>2,就提示不能继续发送。

代码实现

/**
 * 模拟验证码
 * @Author: Crucis_chen
 * @Date: 2021/10/31 16:39
 */
public class checkCode {
    public static void main(String[] args) {
        System.out.println("请输入手机号:");
        Scanner scanner = new Scanner(System.in);
        String tel = scanner.nextLine();
        while (true){
            System.out.println("------------------");
            System.out.println("获取到的验证码是:"+check(tel));
            System.out.println("------------------");
            System.out.println("请输入验证码:");
            String code = scanner.nextLine();
            System.out.println("------------------");
            if (verify(code)){
                System.out.println("输入正确");
            }else{
                System.out.println("输入错误");
            }
        }

    }

    //生成一个六位的数字验证码
    public static String getCode() {
        Random random = new Random();
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < 6; i++) {
            sb.append(random.nextInt(10));
        }
        return sb.toString();
    }

    //每天只能发送三次,验证码存入redis,并设置过期时间
    public static String check(String tel) {
        //连接redis
        Jedis jedis = new Jedis("127.0.0.1", 2334);

        //拼接key
        //存储发送次数的key
        String countKey = "CheckCode" + tel + ":count";
        //存储验证码的key
        String codeKey = "CheckCode";

        //限制每个tel每天只能发送三次
        String count = jedis.get(countKey);
        if (count == null) {
            //没有获取过
            //设置发送次数为1,过期时间为1天
            jedis.setex(countKey, 24 * 60 * 60, "1");
        } else if (Integer.parseInt(count) <= 2) {
            //发送次数加1
            jedis.incr(countKey);
        } else {
            //已经发送了三次,不再发送
            System.out.println("警告:已经发送了三次,请一天后再尝试获取");
            jedis.close();
        }

        //将验证码存入redis
        String vcode = getCode();
        jedis.setex(codeKey, 60 * 2, vcode);
        jedis.close();
        return vcode;
    }

    //校验验证码
    public static boolean verify(String code){
        //连接redis
        Jedis jedis = new Jedis("127.0.0.1", 2334);
        String checkCode = jedis.get("CheckCode");
        return checkCode.equals(code);
    }

}

十、SpringBoot整合Redis

1、整合步骤

  1. 引入redis的starter
<!-- redis -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<!-- springboot2.X集成redis所需common-pool2-->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
    <version>2.6.0</version>
</dependency>
  1. 在springboot配置文件中,添加对redis的配置
spring:
  redis:
    host: 127.0.0.1 #Redis服务器地址
    port: 2334 #Redis服务器连接端口
    database: 0 #Redis数据库索引(默认为0)
    timeout: 1800000 #连接超时时间(毫秒)
    lettuce:
      pool:
        max-active: 20 #连接池最大连接数(使用负值表示没有限制)
        max-wait: -1 #最大阻塞等待时间(负数表示没限制)
        max-idle: 5 #连接池中的最大空闲连接
        min-idle: 0 #连接池中的最小空闲连接
  1. 编写redis配置类
@EnableCaching
@Configuration
public class RedisConfig extends CachingConfigurerSupport {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        RedisSerializer<String> redisSerializer = new StringRedisSerializer();
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(om);
        template.setConnectionFactory(factory);
        //key序列化方式
        template.setKeySerializer(redisSerializer);
        //value序列化
        template.setValueSerializer(jackson2JsonRedisSerializer);
        //value hashmap序列化
        template.setHashValueSerializer(jackson2JsonRedisSerializer);
        return template;
    }

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory factory) {
        RedisSerializer<String> redisSerializer = new StringRedisSerializer();
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
        //解决查询缓存转换异常的问题
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(om);
        // 配置序列化(解决乱码的问题),过期时间600秒
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofSeconds(600))
            .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer))
            .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jackson2JsonRedisSerializer))
            .disableCachingNullValues();
        RedisCacheManager cacheManager = RedisCacheManager.builder(factory)
            .cacheDefaults(config)
            .build();
        return cacheManager;
    }
}

固定化的写法,可以直接copy

测试

@Controller
public class RedisController {

    @Autowired
    RedisTemplate redisTemplate;


    @GetMapping("/redis")
    @ResponseBody
    public String redisTest(){
        redisTemplate.opsForValue().set("msg", "hello,redis!");
        String msg = (String) redisTemplate.opsForValue().get("msg");
        return msg;
    }

}

自动注入一个RedisTemplate对象,利用它操作Redis。

image-20211101103747050

十一、Redis的事务

1、Redis中的事务

Redis中的事务和MySQL中的不一样。

Redis事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。

Redis事务的主要作用就是:串联多个命令,防止别的命令插队。即确保命令的执行顺序

2、Multi、Exec、discard

Multi、Exec、discard是操作事务的三个命令。

Multi:从输入Multi命令开始,输入的命令都会依次进入命令队列中,但不会执行;(开启事务

Exec:直到输入Exec后,Redis会将之前的命令队列中的命令依次执行。(提交事务

discard:组队的过程中可以通过discard来放弃组队。 (取消事务,化整为零

image-20211101104559020

Multi之后,输入的命令会按顺序排好,暂不执行。

Exec之后,之前的命令依次执行。

在Multi之后,如果想退出组队,可以输入discard

3、事务的使用示例

1. 正常的提交事务操作

image-20211101105029420

2. 开启事务后关闭

image-20211101105121148

3. 组队阶段出错

image-20211101105327434

如果组队阶段有错误,提交时整个命令队列都不会被执行。

这种错误通常是语法错误,一般不常见

4. 提交阶段报错

image-20211101105547665

如果执行时有错误,正确的指令依然会被提交,错误指令执行不成功。

4、事务的错误处理

组队时,某个命令出现了报告错误,执行时整个的所有队列都会被取消。

image-20211101110652369

执行时,某个命令报出了错误,则只有报错的命令不会被执行,而其他的命令都会执行,不会回滚

image-20211101110707519

5、悲观锁

悲观锁(Pessimistic Lock),顾名思义,就是很悲观,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会block,直到它拿到锁。

传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁

6、乐观锁

乐观锁(Optimistic Lock),顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。

乐观锁适用于多读的应用类型,这样可以提高吞吐量

Redis就是利用这种check-and-set机制实现事务的。

7、watch与 unwatch

watch

Redis利用watch保证了隔离性。

在执行multi之前,先执行watch key1 [key2],可以监视一个(或多个) key 。

如果在事务执行之前,这个(或这些) key 被其他命令所改动,那么事务将被打断。

比如一个事务要对k1操作,执行了watch k1,再执行了multi开启事务。在提交事务之前,另一个客户端对key1进行了修改,那么这个事务就不会执行。

这里就是一个“乐观锁”的操作。被watch的key,每次修改都会检查版本号。

unwatch

取消 WATCH 命令对所有 key 的监视

如果在执行 WATCH 命令之后,EXEC 命令或DISCARD 命令先被执行了的话,那么就不需要再执行UNWATCH 了。

8、Redis事务的特性

Redis 的事务具备如下特点:

  • 保证隔离性;
  • 无法保证持久性;
  • 具备了一定的原子性,但不支持回滚;
  • 一致性的概念有分歧,假设在一致性的核心是约束的语意下,Redis 的事务可以保证一致性。

9、详解Redis事务

1、事务的三个阶段

  • MULTI,开始事务组队阶段
  • 命令入队,此时命令不会执行,但也会检查语法错误
  • EXEC,顺序执行事务的全部命令,期间不会穿插执行其他的命令。如果组队阶段执行DISCARD,则丢弃本次事务

2、原子性

原子性:事务的操作要么全部成功,要么全部失败,不会出现执行一半的情况。

Redis并不具备严格的原子性,它只在某些特定条件下表现出原子性:

  • 事务的组织阶段,如果命令语法报错,那么事务不会执行,保证了原子性
  • 事务的执行阶段,正确的命令会执行,如果某个命令出错,那么已经执行的命令也不会回滚,不保证原子性

3、隔离性

隔离性:多个事务并发操作数据时的数据一致问题。

Redis并没有事务隔离级别的概念,所以只考虑在并发场景下,多个尚未提交的事务和单条命令之间会不会产生干扰。

事务组织阶段:

  • 如果不做任何操作,在开启事务之后,在事务提交之前,事务中操作的Key依然可以被其他命令修改,不保证隔离性
    • 因为开启事务只是一个客户端的行为,其他客户端依然可以去执行命令修改Key,那么之前的客户端上还没执行的事务就被影响了
  • 可以在事务开始之前,执行watch key,这样只要在事务提交之前该key被修改过,事务就不会正常提交,相当于乐观锁的思想。

事务提交阶段:

  • Redis是单线程的,并且事务会顺序执行,不会穿插,可以保证隔离性

4、持久性

持久性:事务提交之后,对数据做出的修改是永久保存的,不会因为任何故障而丢失数据。

Redis有两种持久化策略:

  • RDB,间隔太长,期间可能丢失数据
  • AOF,可以设置为no、everysec,都会丢数据,设置成always可以保证持久性,但效率太低

5、一致性

从满足约束的角度来说,Redis可以保证一致性的。

  • 事务组队阶段:
    • 客户端传来错误命令,事务不会成功提交,保证一致性
  • 事务提交阶段:
    • 命令不合法,比如操作的数据类型不同,命令不会成功执行,但是正确的语句执行后不会回滚,也保证一致性
    • 中途Redis宕机,如果配置了持久化,Redis能从文件中恢复数据,保证一致性

十二、Redis的持久化

Redis 提供了两种不同形式的持久化方式:

  • AOF(Append Of File)日志
  • RDB(Redis DataBase)快照

1、AOF 日志

1、概述

Redis 每执行一条写操作命令,就把该命令以追加的方式写入到一个文件里。AOF 文件的内容是操作命令。

每次重启Redis,都会先读取AOF日志,逐行执行命令来恢复数据。这就实现了数据的持久化。

在 Redis 中 AOF 持久化功能默认是不开启的,需要我们修改 redis.conf 配置文件中的以下参数:

img

注意,Redis是先执行写命令,执行成功后才记录AOF日志的,日志会先存放在内存缓冲区中,根据配置的写回策略持久化进磁盘

这样有两个好处:

  • 避免额外的检查开销
    • 因为如果该写命令能够执行,说明语法没有问题,就可以直接写入AOF日志,不需要做额外的检查。
    • 如果先写日志,那么就必须额外做语法检查。如果不做检查,那么Redis下次读取日志执行该语句,就会报错
  • 不会阻塞当前写操作的正常执行

这样做也有弊端:

  • 如果已经执行了写操作,还没来得及写日志,Redis就宕机了,那么这条操作就没有记录到日志中,也就无法恢复
  • 虽然写日志的操作不会阻塞当前的写操作,但是可能会阻塞下一个写操作,因为Redis是单线程的。
    • 由于日志是直接写入磁盘,如果当时磁盘IO压力大,就会导致写入的速度很慢,导致后续Redis的操作被阻塞。

2、写AOF的过程

Redis 写入 AOF 日志的过程,如下图:

img

简单来说,写AOF日志分为三个阶段:

  • Redis执行完写操作命令,会把命令追加到 server.aof_buf 缓冲区

  • 之后会通过系统调用,将aof_buf 缓冲区的数据写入AOF文件。

    不过此时数据还没有写入磁盘,而是拷贝到了内核缓冲区 page cache,等待内核将数据写入硬盘

  • 具体内核缓冲区的数据什么时候写入到硬盘,由配置的写回策略决定。

3、三种写回策略

在 redis.conf 配置文件中的 appendfsync 配置项可以有以下 3 种参数可选:

  • Always:每次写操作命令执行完后,同步将 AOF 日志数据写到硬盘
  • Everysec(默认):每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,然后每隔一秒将缓冲区里的内容写到硬盘
  • No:Redis不控制AOF写入硬盘的时机,每次都会把AOF数据放在内核的缓冲区,由内核自行将数据写入磁盘

AOF的写回需要考虑两点:

  • 对主进程的阻塞
  • 数据的丢失风险

Redis提供了多种写回策略,因为每种策略的考量点不一样。

  • Always策略可以最大程度保证数据不丢失,但它是每执行一条写操作就写入一次磁盘,所以会阻塞主进程,影响性能
  • No策略可以最大程度保证主程序的性能,但是内核将缓冲区的数据写入磁盘的间隔是不可知的,相对久一点,所以数据丢失的风险较高
  • Everysec策略是一种权衡,每隔一秒写入一次数据,既避免了频繁写入磁盘,又避免一次丢失太多数据。

写回策略是如何实现的

调用 fsync() 函数后,内核会立即将缓冲区的内容写入磁盘。

所以不同的写回策略,其实是在不同的时机调用了 fsync() 函数。

4、AOF重写机制

随着写操作越来越多,AOF的文件体积也会越来越大。

如果AOF体积太大,就会出现性能问题。比如Redis重启后需要读取AOF的内容来恢复数据,这个耗时就会很久。

因此,Redis为了避免AOF文件太大,设计了 AOF 重写机制,来压缩 AOF 文件。

AOF重写的发生时机:

由两个参数共同控制。

// 当前AOF文件比上次重写后的AOF文件大小的增长比例超过100
auto-aof-rewrite-percentage 100 

// 当前AOF文件的文件大小大于64MB
auto-aof-rewrite-min-size 64mb

AOF重写机制的细节:

  • 发生AOF重写时,读取当前数据库的所有键值对。(注意不是读取旧的AOF文件)
  • 将每一个键值对用一条命令记录到一个新的 AOF 文件
  • 等到全部记录完后,就将新的 AOF 文件替换掉现有的 AOF 文件

思想:

  • 当前数据库的每一个键值对,都是通过写操作保存进来的,所以旧的AOF肯定都包含了对这些键值对的写操作
  • 旧的AOF中,可能包含对相同键的多次写操作,只需要保存最新的即可,因为AOF反正不需要提供回滚操作。
  • 这样,新的AOF文件中就没有针对相同键的多次写操作的记录,体积自然就比旧的AOF文件要小。

注意,在AOF重写时,选择了发起一个新的AOF文件开始重写,重写完成后再替换原有的AOF文件。

这种做法的考量是,如果直接操作旧的AOF,那么一旦重写失败或出现问题,旧的AOF文件就被污染了,无法用于之后的数据恢复工作。

采用新发起一个AOF文件的方式,如果重写失败,那么删除当前新创建的这个AOF文件,下次重写时再创建一个即可。

在AOF重写时,新的AOF由后台子进程生成,主进程依然会去追加旧的AOF文件,目的是如果新的AOF出现问题,旧的AOF也体现了当前最新的Redis状态。

5、AOF后台重写

写入AOF时,由于写入的内容不太多,所以可以由主进程执行。

但是在进行重写AOF时,涉及到的数据比较多,整个操作非常耗时。如果由主进程来完成,那么会造成严重的阻塞。

所以,Redis 的重写 AOF 过程是由后台子进程 bgrewriteaof 来完成的,有两个好处:

  • 子进程进行 AOF 重写期间,主进程可以继续处理命令请求,从而避免阻塞主进程

  • 这里使用的是子进程而不是线程。

    • 因为如果是使用线程,多线程之间会共享内存,那么在修改共享内存数据的时候,需要通过加锁来保证数据的安全,而这样就会降低性能。

    • 而使用子进程,创建子进程时,父子进程是共享内存数据的,不过这个共享的内存只能以只读的方式。

      当父子进程任意一方修改了该共享内存,就会发生「写时复制」,于是父子进程就有了独立的数据副本,就不用加锁来保证数据安全,性能更好。

子进程如何拥有和主进程一样的数据副本?

主进程在通过 fork 系统调用生成 bgrewriteaof 子进程时,操作系统会把主进程的「页表」复制一份给子进程。

而页表记录着虚拟地址和物理地址映射关系,相当于它们使用的是同一块物理内存。

这样一来,子进程就共享了父进程的物理内存数据,这样能够节约物理内存资源,页表对应的页表项的属性会标记该物理内存的权限为只读。

当父进程或者子进程向这个物理内存发起写操作时,CPU 就会触发缺页中断。这个缺页中断是由于违反权限导致的。

之后操作系统会在「缺页异常处理函数」里进行物理内存的复制,并重新设置其内存映射关系,将父子进程的内存读写权限设置为可读写,最后才会对内存进行写操作,这个过程被称为 写时复制(Copy On Write)。

写时复制,顾名思义,在发生写操作的时候,操作系统才会去复制物理内存。这样是为了防止 fork 创建子进程时,由于物理内存数据的复制时间过长而导致父进程长时间阻塞的问题。

但是,父进程依然有两个节点会被阻塞:

  • 创建子进程的过程中,需要复制父进程的页表,此时父进程会被阻塞。页表越大,阻塞的时间也越长。
  • 创建完子进程后,如果子进程或者父进程修改了共享数据,就会发生写时复制,这期间会拷贝物理内存。如果内存越大,阻塞的时间也越长

回到AOF的后台重写问题。一旦触发AOF的重写,主进程就会创建一个重写子进程,此时父子进程共享物理内存。

重写子进程只会对这个内存进行读操作,重写 AOF 子进程会读取数据库里的所有数据,并逐一把内存数据的键值对转换成一条命令,再将命令记录到一个新的 AOF 文件。

如果在重写AOF时,主进程修改了某个键值对,此时就会发生写时复制,不过只会复制主进程修改的这一部分物理内存,没修改的部分依然是共享状态。所以如果此时修改的是一个bigkey,那么写时复制会比较久,并且写时复制会阻塞主进程。

还有一个问题。在AOF重写时产生的这个新的AOF文件,必须保证它能反映出当前数据库的最新状态。

如果主进程修改了已经存在的键值对,就会发生写时复制,使得该键值对在主进程和子进程的内存中数据不一致

为了解决这个问题,Redis 设置了一个 AOF 重写缓冲区,这个缓冲区在创建 bgrewriteaof 子进程之后开始使用。

在重写 AOF 期间,Redis 执行完一个写命令之后,它会同时将这个写命令写入到 「AOF 缓冲区」和 「AOF 重写缓冲区」

在这里插入图片描述

这个AOF重写缓冲区的作用是:

  • 当子进程完成了全部的键值对扫描工作,写好了新的AOF日志,会向主进程发送一条信号。信号是进程间通讯的一种方式,它是异步的
  • 主进程收到该信号后,会调用一个信号处理函数,该函数主要做以下工作:
    • 将 AOF 重写缓冲区中的所有内容追加到新的 AOF 的文件中,使得新旧两个 AOF 文件所保存的数据库状态一致
    • 使用新的 AOF 的文件覆盖现有的 AOF 文件
  • 信号处理函数执行完毕,说明AOF重写已经完成。
  • 注意,执行信号处理函数时也会短暂阻塞主进程。

重写缓冲区的内容是由主进程写入新的AOF文件的。如果重写缓冲区很大,主进程就会被阻塞很久。

Redis的优化:

  • 进行 AOF 后台重写时,Redis 会创建一组用于父子进程间通信的管道

    同时会新增一个文件事件,该文件事件会将写入 AOF 重写缓冲区的内容通过管道发送到子进程

  • 在重写结束后,子进程会通过该管道尽量从父进程读取更多的数据

    每次等待可读取事件1ms,如果一直能读取到数据,则这个过程最多执行1000次,也就是1秒。

    如果连续20次没有读取到数据,则结束这个过程

  • 思想是,子进程尽量完成大部分工作,以减小重写缓冲区的数据量,这样主进程只需要将很少的数据写入AOF文件,阻塞时间不会太长。

2、RDB 快照

1、概述

RDB 文件的内容是二进制数据,即某一时刻数据库的实际内存情况。

RDB 恢复数据的效率比AOF高一些。因为它在恢复数据时是将快照文件直接读到内存里,而AOF还需要额外执行命令。

2、RDB的工作方式

1、两个命令

Redis提供了两个命令来生成RDB文件,分别是:

  • save:会由主进程生成RDB文件,如果写入RDB耗时很长,会阻塞主进程
  • bgsave:会创建一个子进程来生成RDB文件,不会阻塞主进程

image-20211101143420203

至于读取,每次Redis的服务启动会自动读取RDB文件,没有专门提供加载命令。

2、定期保存

Redis也可以通过配置“save point”来实现定期保存RDB文件:

save 900 1
save 300 10
save 60 10000
  • 只要满足任意一个条件,就会自动执行 bgsave
  • 含义是:900 秒之内,对数据库进行了至少 1 次修改、300 秒之内,对数据库进行了至少 10 次修改。。。
  • RDB是默认开启的,如果删除所有的 save point 配置,就相当于关闭了RDB。
3、全量快照

注意,RDB是全量快照,每次执行都会把内存中的所有数据记录到磁盘中,属于比较重的操作。

  • 如果保存频率高,会频繁创建子进程和写入磁盘,性能开销大
  • 如果保存频率低,就会造成两次保存数据的间隔长,一旦发生错误,会丢失更多数据

通常设置至少 5 分钟才保存一次快照。

3、RDB和AOF的区别

  • 记录的内容不同:

    • AOF记录的是每次写操作的命令
    • RDB记录的是某一时刻上内存的实际二进制数据
  • 恢复数据的耗时不同:

    • 从AOF中恢复数据,需要逐行执行命令,效率较低
    • 从RDB中恢复数据,只需要直接将快照文件读取到内存中,效率较高
  • 数据的丢失风险不同:

    • 在服务器发生故障时,RDB丢失的数据会比AOF更多

      因为RDB属于全量快照,生成的耗时长,所以不能频繁生成,一般设置为5分钟一次

      而AOF一般配置成每秒持久化一次,所以丢失的数据更少。

  • 文件体积与可读性不同:

    • AOF文件体积较大,不过如果太大会自动触发AOF重写操作。AOF的可读性一般,但也能够阅读
    • RDB文件是经过压缩的二进制数据,所以相同的数据集下,文件体积比AOF小。但RDB的可读性差,完全不可读

4、执行快照时的修改问题

在执行bgsave时,主进程依然可以正常处理操作命令,也就是说记录快照时数据依然可以被修改。

  • 具体的实现方式也是“写时复制”。
  • 执行bgsave时,会通过fork()创建子进程,子进程和父进程共享同一块物理内存空间。
  • 这样做的目的是,为了减少创建子进程的时间消耗,进而减少对父进程的阻塞。毕竟复制内存区域很耗时。

快照肯定需要一个具体的时间点,进而获得一个具体的内存状态。Redis的策略是,以子进程创建后的时刻作为快照的时间点。

  • 也就是说,发生写时复制后,子进程记录的还是原本时刻的内存状态
  • RDB文件不会保存最新修改后的状态,这些状态得等到下次保存RDB时才能被记录
  • 如果主进程在执行bgsave后修改了数据,RDB写入磁盘,之后服务崩溃,那么Redis会丢失主进程在快照期间的所有修改。

另外,由于记录快照期间存在写时复制,如果主进程修改的共享数据过多,极端情况下它修改了所有数据,那么Redis占用的内存就会暴涨一倍

所以在写操作频繁的场景下,要注意记录快照时的内存变化,防止内存被打满。

5、RDB的相关配置

修改快照文件名称:

在redis.conf中配置文件名称,默认为dump.rdb

这个设置项在“快照”的区域。

image-20211101143713277

RDB文件的保存路径,也可以修改。默认为Redis启动时命令行所在的目录下

image-20211101143836555

可以修改,例如dir “/myredis/”

6、总结

RDB的优点:

  • 恢复数据快
  • 文件体积小

RDB的缺点:

  • 记录日志慢。虽然是发起子进程来完成记录日志的,不会阻塞主进程,但还是存在两个问题:
    • 主进程fork子进程时会发生阻塞。不过利用写时复制技术可以免去复制内存内容,只需要复制页表,相对减少了阻塞时间
    • 另外,虽然记录快照期间主进程依然可以正常执行操作,但是Redis的性能肯定会受影响,因为子进程在宿主机上做额外工作。

3、AOF和RDB混合持久化

1、概述

AOF的优点是丢失数据少,RDB的优点是恢复数据快。

Redis 4.0提出了“混合使用 AOF 日志和内存快照”,也叫混合持久化

开启的方式:修改配置文件项 aof-use-rdb-preamble为yes

aof-use-rdb-preamble yes

这个配置项翻译过来叫,aof使用rdb附加。

2、工作细节

混合持久化的工作时机是AOF日志的重写过程时。

当开启了混合持久化时:

  • AOF重写子进程会先将和主进程共享的内存数据以RDB的方式写入新的AOF文件
  • 之后主进程的写操作会记录在AOF重写缓冲区中,之后会被追加写入新的AOF文件
  • 写入完成后,AOF文件包含两部分:前半部分是RDB的全量数据,后半部分是追加的AOF数据

图片

3、优势

为什么这样的效率会更高?

  • 首先,这种方式相当于利用RDB给AOF文件加速,比起之前的纯RDB,AOF的记录更频繁,所以丢失数据更少
  • 其次,这种方式记录的混合文件,恢复数据更快,因为前半部分是RDB文件,只需要读取到内存中即可,需要执行的AOF命令较少

为什么这种方式可以频繁记录RDB快照?

之前不能频繁创建RDB的原因有两个:

  • 频繁创建子进程存在开销
  • RDB属于全量快照,每次记录耗时长

混合持久化方式,记录RDB的时机是AOF重写时,它有两个特点:

  • 已经创建了AOF重写子进程,不需要额外创建子进程来保存RDB
  • AOF的重写相对不会太频繁

总之,如果关闭所有的持久化功能,Redis的性能肯定是最好的。

要按照实际场景需要来考虑是否允许冒着丢失一定数据的风险来换取性能,合理配置持久化策略。

4、如何通过日志恢复数据

1、使用哪个日志恢复数据

  • 如果手动开启了AOF,Redis就用AOF进行恢复。因为AOF的更新频率通常较高,这样可以少丢数据。
  • 没开启AOF就用RDB恢复,RDB是默认开启的

只会用一种日志来恢复数据

2、RDB的载入过程

Redis服务启动时会自动载入RDB文件,载入过程中Redis服务被阻塞。

Redis没有专门提供加载RDB文件的命令。

3、AOF的载入过程

AOF文件不能直接载入,因为它保存的是一堆写命令,需要依次执行一下才能恢复数据。

但是Redis的命令只能在客户端上下文中执行,也就是说,必须由客户端来执行AOF文件保存的这些命令,后台不能直接去执行。

所以Redis服务使用了一个没有网络连接的“伪客户端 fake client”来执行AOF文件保存的写命令,这个客户端执行命令的效果和普通的网络客户端完全一样。

十三、Redis集群

1、读写分离

Redis 读并发 110000 次 /s, 写并发 81000 次 /s 。如果要支撑起更高的读并发,单机很难实现。

使用读写分离的思想来支持更高的读并发,这种设计适合写操作较少的场景。

做法:

  • 主从架构,一主带多从
  • 主服务器只负责应对写操作,把数据同步到从服务器
  • 从服务器只负责并发读操作,可以很容易地通过横向扩容来提升整体的并发能力

主从架构和读写分离的关系

读写分离是一种思想,使用主从架构的方式来实现读写分离,主写从读

2、主从架构

1、单点故障

如果Redis只部署在一台服务器上,可能会出现这些单点架构的问题:

  • Redis服务崩溃,重新启动需要读取持久化数据,需要一定的时间。这期间缓存服务相当于直接挂掉了,造成缓存雪崩
  • 如果服务器的硬盘出现故障损坏,所有数据直接丢失了

主从架构的优势:

  • 将一份数据同时保存在多个实例上,避免单点故障造成服务挂掉
  • 即使有一台服务器出现了故障,在它维护重启的期间,其他服务器依然可以继续提供服务

2、集群部署的问题

  • 多台服务器之间如何保证数据的一致性
  • 服务器是否做读写分离处理

3、主从复制概述

Redis提供了主从复制模式。

这个模式可以保证数据一致性,并且主从服务器之间是采用读写分离方式的。

  • 主服务器进行读写操作。在发生写操作时,将命令同步给从服务器
  • 从服务器一般是只读,接收主服务器发来的写命令,保证数据一致性。

4、第一次同步

多台服务器之间,如何确定谁是主服务器、谁是从服务器?

可以使用 replicaof(Redis 5.0 之前使用 slaveof)命令,手动声明主服务器和从服务器的关系

# 服务器 B 执行这条命令
replicaof <服务器 A 的 IP 地址> <服务器 A 的 Redis 端口号>

这样,服务器 B 就会变成服务器 A 的“从服务器”,然后与主服务器进行第一次同步

主从服务器间的第一次同步的过程可分为三个阶段:

  • 第一阶段:建立连接、协商同步
  • 第二阶段:主服务器同步数据给从服务器(全量复制)
  • 第三阶段:主服务器发送新的写操作命令给从服务器(命令传播)

图片

第一个阶段 建立连接、协商同步

执行了 replicaof 命令后,从服务器就会给主服务器发送 psync 命令,表示要进行数据同步。

psync 命令包含两个参数,分别是主服务器的 runID 和复制进度 offset。

  • runID:每个 Redis 服务器在启动时都会自动产生一个随机的 ID ,来唯一标识一台Redis实例。
    • 当从服务器和主服务器第一次同步时,因为不知道主服务器的 run ID,所以将其设置为 “?”。
  • offset:表示复制的进度
    • 第一次同步时,其值为 -1。

主服务器收到 psync 命令后,会用 FULLRESYNC 作为响应命令返回给对方。

  • 这个响应命令会带上两个参数:主服务器的 runID 和主服务器目前的复制进度 offset。从服务器收到响应后,会记录这两个值。
  • FULLRESYNC 响应命令的意图是采用全量复制的方式,也就是主服务器会把所有的数据都同步给从服务器。

第一阶段的工作是为了全量复制做准备。

第二阶段 主服务器同步数据给从服务器

  • 主服务器会执行 bgsave 命令来生成 RDB 文件,然后把文件发送给从服务器
  • 从服务器收到 RDB 文件后,会先清空自己当前的数据,然后载入 RDB 文件

注意:

  • 生成RDB快照时,主服务器的主进程依然可以处理写操作,这期间的写操作不会记录到RDB文件中
  • 这就造成,根据之前RDB构造好数据的从服务器,和主服务之间可能存在数据不一致的问题。

不过这个问题很好解决。主服务器的主进程会把开始生成RDB文件之后执行的所有写操作保存到AOF重写缓冲区中,后续发给从服务器即可。

第三阶段 主服务器发送新的写操作命令给从服务器

主服务器将AOF重写缓冲区中记录的所有写操作发送给从服务器,从服务器执行这些命令。

在现在这个时刻,主从服务器之间的数据就完全一致了。此时第一次同步完成。

5、命令传播

主从服务器在完成第一次同步后,双方之间就会维护一个 TCP 连接。

这个TCP连接是长连接,目的是避免频繁的 TCP 连接和断开带来的性能开销

后续,主服务器就一直通过这个TCP连接,向从服务器传播写命令,从服务器收到后就执行这些命令。

上面的这个过程被称为基于长连接的命令传播,通过这种方式来保证第一次同步后的主从服务器的数据一致性。

注意,是主服务器的AOF重写缓冲区会被同步给从服务器。

6、增量复制

如果在第一次同步后,建立的TCP长连接断开了,怎么办?

  • 此时“失联”的从服务器无法获得最新的数据,造成数据不一致
  • 如果此时有客户端访问了这些服务器,就会错误读取到旧的数据

如果后来网络恢复了,连接重新建立,如何恢复主从之间的数据一致性?

  • 在 Redis 2.8 之前,如果主从服务器在命令同步时出现了网络断开又恢复的情况,从服务器就会和主服务器重新进行一次全量复制。很明显这样的开销太大了。
  • 从 Redis 2.8 开始,网络断开又恢复后,从主从服务器会采用增量复制的方式继续同步,也就是只会把网络断开期间主服务器接收到的写操作命令同步给从服务器。

增量复制主要有三个步骤:

  • 从服务器在恢复网络后,会发送 psync 命令给主服务器。此时的 psync 命令里的 offset 参数不是 -1,而是从服务器的复制偏移量
  • 主服务器收到该命令后,用 CONTINUE 响应命令告诉从服务器,接下来采用增量复制的方式同步数据
  • 主服务将主从服务器断线期间,所执行的写命令发送给从服务器,从服务器执行这些命令,数据一致。

增量复制的问题在于:主服务器如何得知从服务器当前的数据状态,即哪些数据它没有接收到。

设置到两个区域:

  • repl_backlog_buffer:它是一个环形缓冲区,保存在主服务器中。主从服务器断连后,可以从中找到差异的数据
  • replication offset:表示repl_backlog_buffer的同步进度。主从服务器都有各自的偏移量
    • 主服务器使用 master_repl_offset 来记录自己「写」到的位置
    • 从服务器使用 slave_repl_offset 来记录自己「读」到的位置

这就产生了两个问题:

  • repl_backlog_buffer被写入的时机?

    • 在主服务器进行命令传播时,不仅会将写命令发送给从服务器,还会将写命令写入到 repl_backlog_buffer 缓冲区里。因此这个缓冲区里会保存着最近传播的写命令。
  • repl_backlog_buffer为什么设计成环形缓冲区?

    • 这个缓冲区记录的是,发给从服务器的写操作。

      它肯定不能无限记录,因为它的用途只是在恢复连接后支持增量复制。所以它是循环写入的,会覆盖之前的值

    • 它的默认大小是 1M,所以在主服务器写入速度远大于从服务器读取速度时,这个缓冲区的内容将很快被覆盖。

      如果发生断线重连后,环形缓冲区内找不到从服务器需要的写操作,就会触发非常耗时的全量复制。

    • 所以应该适当调大这个缓冲区的容量,尽量避免全量复制。参考的公式是:

      图片

      • second :服务器断线后重新连接上主服务器所需的平均时间(单位:秒)。
      • write_size_per_second:主服务器平均每秒产生的写命令数据量大小

      比如,主服务器每秒产生1MB数据,平均5秒恢复连接,环形缓冲区就至少应该设置为5MB。

      一般会设置成这个值的2倍,应对一些突发极端情况。

      设置的方法:修改配置文件中这个项的值

       repl-backlog-size 1mb
      

增量复制的细节:

  • 网络断开后,当从服务器重新连上主服务器时,从服务器会通过 psync 命令,将自己的复制偏移量 slave_repl_offset 发送给主服务器。
  • 主服务器根据自己的 master_repl_offset 和 slave_repl_offset 之间的差距,然后来决定对从服务器执行哪种同步操作:
    • 如果判断出从服务器要读取的数据还在 repl_backlog_buffer 缓冲区里,那么主服务器将采用增量同步的方式
    • 如果判断出从服务器要读取的数据已经不存在 repl_backlog_buffer 缓冲区里,那么主服务器将采用全量同步的方式
  • 如果 repl_backlog_buffer 缓冲区中保留着从服务器需要的数据,主服务器就会将这些数据写入重写缓冲区,同步给从服务器。

7、如何保持连接

主节点和从节点之间,通过心跳监测的机制,来知晓对方是否还活着。

主节点会每隔10秒ping一次从节点,从节点接收到ping后会响应。

网络环境复杂,如何知道复制的数据是否成功送达?

从节点会每隔1秒,将自身的复制偏移量发送给主节点。相当于一个ACK

如果丢失数据,主节点会重新补发数据。

这个补发数据和增量复制还不一样:

  • 补发数据发生在网络正常连接时
  • 增量复制发生在网络断开后来又重连时

8、总结

Redis的主从复制共有三种模式:

  • 全量复制
    • 即主服务器生成RDB快照文件,通过网络发送给从服务器
    • 发生时机:第一次同步时、断线重连后 repl_backlog_buffer 中数据被覆盖时
  • 基于长连接的命令传播
    • 连接正常时的策略
    • 主服务器会将执行的写操作命令放入重写缓冲区,通过已经建立的TCP长连接发送给从服务器
  • 增量复制
    • 断线重连后专用的策略
    • 断线重连后 repl_backlog_buffer 中数据还保留时,主服务器向从服务器发送它掉线后更新的值

9、三种主从架构拓扑关系

1、一主一从架构

一个主节点,一个从节点。

主节点可读可写,从节点只接收读请求。常用于主节点出现故障时,从节点能够快速顶上

2、一主多从架构

一个主节点,多个从节点。

对于读命令较大的场景,可以把读命令分摊到多个从节点,增加能处理的读并发量

一主多从还有一个好处:可以使用一个从节点专门执行一些比较耗时的读命令,避免慢查询对主节点造成阻塞,而影响服务的稳定性

3、树状主从架构

在与从服务器的第一次同步中,主服务器会做两件耗时的操作:

  • 生成RDB快照文件
  • 传输RDB快照文件

虽然可以由子进程来完成,主进程可以继续执行操作,不过如果从服务器非常多,那么对主服务器的性能影响也是非常显而易见的:

  • 主服务器是通过bgsave命令来生成RDB文件的,这个命令会利用fork()创建一个子进程,此时是会阻塞主进程的
    • 如果频繁使用fork(),就会导致Redis时不时被阻塞,影响正常的访问
  • 传输RDB文件会占用主服务器的网络带宽,会对主服务器响应命令请求产生影响。

Redis的做法是:

  • 从服务器也可以有自己的从服务器,它可以从主服务器接收同步数据,并把这些数据同步给自己的从服务器。

这样做的好处是:

  • 可以由主服务器的从服务器扩展自己的从服务器,由它来发起第一次同步,这样就不影响主服务器
  • 在后续的同步时,主服务器不用向太多从服务器同步数据,只需要发布给自己的少数几个从服务器,就相当于发布给了整个集群
  • 有效降低主节点负载和需要传送给从节点的数据量

10、一些常见问题

为什么主从库之间的复制要用RDB,不用AOF?

  • RDB文件是压缩的二进制文件,而AOF是命令文本。RDB的文件体积更小,磁盘IO效率、网络传输效率都更高
  • 利用RDB进行数据恢复的速度更快
  • RDB的缺点就是生成比较慢,但综合性能更好。所以要合理配置环形缓冲区大小,尽量减少全量复制的次数。

主从架构的高可用问题

  • 主从架构只有一个主节点,如果主节点宕机,影响整个系统运行
  • Redis最好的方案是集群部署,即将多个主从架构进行横向扩容+分片存储+哨兵
  • 使用哨兵监控主节点
  • 使用Redis集群增加主节点

主从读写分离的问题

虽然设计思想是主节点读写、从节点只读,不过需要在业务中手动配置,比较麻烦。

不过如果试图对Redis的从节点进行写操作,就会报错,不会执行。

3、数据分片

1、概述

如果写操作的并发很大,单个master的架构就会成为写并发的瓶颈。

可以使用分布式DB的思想来解决,通过数据分片来降低master单机的写并发。

具体做法:主从模式组+多个组集群,每个组都是一套主从架构,不同组存储不同的数据分片,构成集群。

2、需要考虑的问题

  • key的映射,即如何知道某个数据保存在哪个机器上
  • 集群的扩缩容
  • 服务器状态管理

3、实践方案

目前的数据分片技术方案有三种:

客户端实现数据分片

客户端自己去计算数据的key在哪台机器上。多数redis客户端库实现了此功能,也叫sharding。

好处:

  • 降低了服务器集群的复杂性,不需要管理数据分片的细节

缺点:

  • 客户端需要知道集群当前的所有节点的信息
  • 当集群扩缩容时,机器发生变更,客户端要支持动态sharding,多数客户端实现不支持此功能,需要重启redis。
  • Redis的高可用(HA)需要重新考虑

服务器实现数据分片

客户端直接与集群通信,无需考虑数据的存储细节,由集群来计算key对应的机器。

具体流程:

  • 当客户端访问某台机器时,服务器计算对应的key应该存储在哪个机器,然后把结果返回给客户端。
  • 客户端再去对应的节点操作key,是一个重定向的过程。

此方式是redis3.0正在实现,Redis 3.0的集群同时支持HA功能,某个master节点挂了后,其slave会自动接管。

通过代理服务器实现数据分片

客户端直接与代理服务器通信,由代理服务器维护集群节点信息,计算出key对应的机器,访问对应节点获取数据,返回给客户端。

4、哨兵

1、为什么需要哨兵

在Redis的主从架构中,一般是读写分离的,只有一台master负责写操作。

如果master挂了,会发生两件事:

  • 集群无法处理客户端的写操作
  • master无法继续给slave同步数据

此时如果要恢复集群服务,需要做几件事情:

  • 必须手动重新配置一个从节点成为新的主节点,然后让其他的从节点指向新的主节点
  • 通知上游使用集群的所有客户端,将原先主节点的ip地址修改为新的主节点的ip地址

什么是哨兵:

  • Redis 在 2.8 版本以后提供了哨兵(Sentinel)机制
  • 它的作用是实现主从节点故障转移,目的就是处理主节点挂掉的情况

哨兵的作用:

它会监测主节点是否存活,如果发现主节点挂了,它就会选举一个从节点成为新的主节点,并且把新主节点的信息通知给从节点和客户端。

2、哨兵机制如何工作

哨兵是一个运行在特殊模式下的 Redis 进程,它也是一个节点。它相当于是“观察者节点”,观察的对象是主从节点。

哨兵节点主要负责三件事情:

  • 监控
  • 选主
  • 通知

涉及到三个问题:

  • 哨兵节点如何监控节点?如何判断主节点是否真的故障了?
  • 根据什么规则选择一个从节点切换为主节点?
  • 怎么把新主节点的相关信息通知给从节点和客户端?
1、监控

哨兵节点如何监控节点

  • 哨兵每隔1秒,向所有的主从节点发送PING命令。
  • 正常主从节点收到PING命令后,会返回给哨兵一个响应。
  • 哨兵收到响应,得知每个节点是否正常运行
  • 如果节点在规定时间内没有响应,哨兵就将它标记为“主观下线”。这个规定时间的配置参数是down-after-milliseconds,单位毫秒。

主观下线与客观下线

为了避免误判主节点的存活状态,给主节点设计了两种状态:主观下线、客观下线

客观下线只适用于主节点,从节点没有这种状态

主观下线与客观下线如何减少误判?

主节点的系统压力较大,可能它并没有故障,但是由于系统压力大或者网络拥塞,导致没有按时回应。如果主节点一次不回应就换掉它,属于误判,会带来资源的浪费。

为了减少误判的情况,哨兵在部署的时候不会只部署一个节点,而是用多个节点部署成哨兵集群(最少需要三台机器来部署哨兵集群)

通过多个哨兵节点一起判断,就可以避免单个哨兵因为自身网络状况不好,而误判主节点下线的情况。

同时,多个哨兵的网络同时不稳定的概率较小,由它们一起做决策,误判率也能降低。

主观下线升级为客观下线

  • 当一个哨兵判断主节点为「主观下线」后,就会向其他哨兵发起命令
  • 其他哨兵收到这个命令后,就会根据自身和主节点的网络状况,做出赞成投票或者拒绝投票的响应。
  • 当这个哨兵的赞同票数达到哨兵配置文件中的 quorum 配置项设定的值后,这时主节点就会被该哨兵标记为「客观下线」
2、选主

通过哨兵集群投票之后,如果判断主节点客观下线了,就需要从哨兵集群中选出一个leader,来执行主从切换。

这个选举leader也是一个投票的过程,投票就需要有候选人。

  • 最先将主节点标记为主观下线的哨兵,会收集其他哨兵的投票,如果它将主节点标记为了客观下线,这个哨兵就是一个leader候选人。
  • 候选人会向其他哨兵发送命令,表示希望成为leader,让其他哨兵给它投票。只要哨兵节点正常,它第一次收到投票命令后就会投出赞成票。
  • 每个哨兵只有一次投票机会,可以投给自己或投给别人,但是只有候选者才能把票投给自己,而且候选者会立即给自己投票。
  • 候选人成为leader,必须同时满足两个条件:
    • 拿到半数以上的赞成票
    • 得到的票数必须大于等于哨兵配置文件中的 quorum 值

如果有两个哨兵都标记主节点为客观下线?

它们都是候选人,各自投了自己一票。

比如A先发出投票命令,那么C就会给A投票。后续C收到B的投票命令后,由于没有投票机会,就会拒绝投票。

只会有一个哨兵满足条件,当选为leader,保证不会出现多个leader的情况,即不会脑裂。

为什么哨兵集群至少需要3个节点?

如果集群只有2个哨兵,那么当选为leader就需要2票,并不是1票。因为候选人会投给自己一票,这样可能出现多个leader的情况。

那么,如果一个哨兵挂掉,剩下的另一个哨兵永远无法成为leader,导致哨兵集群无法进行主从切换。

所以,通常至少设置3个哨兵节点,此时当选为leader就需要2票,就算其中一个哨兵挂掉,集群还能正常选举leader。

如果挂了2个,那依然存在问题。此时可以考虑增加哨兵集群的数量,但是需要合理设置quorum,避免产生多个leader。

如何设置哨兵个数和quorum大小

如果quorum太小:

  • 一个哨兵判断主节点主观下线,需要获得quorum个赞成票,才能标记主节点为客观下线(它自己也会投赞成票)
  • 如果哨兵挂掉了很多,那么选举leader时就可能出现票数达到了quorum,但是没有超过哨兵数量一半的情况
  • 这样,前面投票标记客观下线的操作就是无效的,因为最终无法选举出leader去做主从替换

合理策略:

  • 哨兵个数应该为奇数
  • quorum 的值建议设置为哨兵个数的二分之一加1
3、通知

主从故障转移的细节

主从故障转移包括四个过程:

  • 在原先主节点的从节点中选择一个从节点,成为主节点
  • 让原先主节点下其他所有的从节点修改复制目标,改为复制新的主节点
  • 将新主节点的IP地址信息,通过发布/订阅机制,通知给客户端
  • 继续监测原先的主节点,在它重新上线后,设置为新的主节点的从节点

如何在从节点中选出一个作为主节点?

涉及到两个问题:

  • 选取的标准
  • 如何让该从节点变成主节点

选取新主节点的标准

首先要过滤网络不好的节点,避免由于网络拥塞频繁替换新产生的主节点。

做法是,过滤掉当前已经下线的节点,并且过滤掉以往网络连接不好的节点。

如何知道哪些节点以往网络连接不好?

  • Redis 有个叫 down-after-milliseconds * 10 配置项,其down-after-milliseconds 是主从节点断连的最大连接超时时间。
  • 如果在 down-after-milliseconds 毫秒内,主从节点都没有通过网络联系上,我们就可以认为主从节点断连了。
  • 如果发生断连的次数超过了 10 次,就说明这个从节点的网络状况不好,不适合作为新的主节点。

剩下的网络好的节点都是可以作为主节点的,进行三轮筛选,只要有一轮胜出,该从节点就直接作为新的主节点。

三轮筛选涉及到:优先级、复制进度、ID号

  • 首先根据优先级排序,优先级越高排名越靠前
    • Redis 的 slave-priority 配置项,可以给从节点设置优先级
    • 可以根据宿主机的性能,来给节点设置优先级。比如某台机器的物理内存很大,就设置高优先级,这样它就会在优先级筛选中胜出,成为新的主节点。
  • 如果优先级相同,则查看复制的下标,哪个从节点接收的复制数据多,哪个就靠前
  • 如果优先级和下标都相同,就选择从节点 ID 较小的那个
    • 这个规则没有特殊原因,只是因为必须选出一个

如何让选定的从节点变为主节点

  • 哨兵leader向这个从节点发送SLAVEOF no one 命令,效果是解除该节点的从节点身份,升级为主节点
  • 在发送 SLAVEOF no one 命令之后,哨兵 leader 会以每秒一次的频率向被升级的从节点发送 INFO 命令,目的是验证该节点的角色信息
  • 观察命令回复中的角色信息,当被升级节点的角色信息从原来的 slave 变为 master 时,哨兵 leader 就知道被选中的从节点已经顺利升级为主节点了。

将从节点指向新主节点

哨兵leader向其余的从节点发送SLAVEOF<新主节点ip><新主节点port>命令,让它们成为新主节点的从节点。

通知客户端主节点已更换

通过Redis的发布订阅机制实现,客户端从哨兵订阅消息。

哨兵规定了很多消息订阅频道,不同频道包含了主从节点切换过程中的不同关键事件,几个常见的事件如下:

img

主从切换完成后,哨兵就会向 +switch-master 频道发布新主节点的 IP 地址和端口的消息

客户端就可以收到这条信息,然后用这里面的新主节点的 IP 地址和端口进行通信了。

通过订阅指定频道,客户端还可以得知主从节点切换期间的重大事件,便于得知切换进度。

将旧主节点变为从节点

哨兵监测原先的主节点,在它重新上线后,发送SLAVEOF<新主节点ip><新主节点port>命令,让它成为新主节点的从节点。

3、哨兵集群如何组成

组成哨兵集群时,也使用了发布订阅机制。

1、如何配置哨兵集群

只需要设置主节点的名称、IP、端口和quorum,而不需要提供其他哨兵的任何信息。

sentinel monitor <master-name> <ip> <redis-port> <quorum>

那么哨兵之间是如何感知对方,组成集群的?

2、组成集群的原理

在主从集群中,主节点上有一个名为_sentinel_:hello的频道,不同哨兵就是通过它来相互发现、相互通信的。

哨兵都订阅了该频道,新的哨兵上线后,把自己的IP地址、端口号信息发布到该频道中,其他哨兵就能获取到,进而建立网络连接。

3、哨兵如何知道从节点信息

主节点维护了所有它的从节点的信息,所以哨兵会每隔10秒向主节点发送 INFO 命令,来获取所有「从节点」的信息。

哨兵知道所有从节点的连接信息后,就和每个从节点建立连接,对从节点进行持续监控。

十四、作为缓存中间件使用

1、缓存概述

1、缓存的适用场景

适合放入缓存的数据:

  • 访问量大且更新频率不高的数据。可以从缓存中读取,支撑起更大的并发
  • 更新频率特别高的数据。可以先写入缓存,之后定期刷入数据库,减少对数据库的频繁访问
  • 数据一致性要求不高的数据。可以使用同步更新的方式,提高系统性能

缓存的目的:

  • 减少和磁盘数据库的交互,避免因为磁盘IO效率低带来的性能瓶颈

2、本地缓存

将数据缓存在服务器本地的内存中。

以前常用Google的Guava Cache,现在常用Caffine,性能更好

优点:

  • 性能最好,因为没有网络传输的速度损失。

缺点:

  • 占用宿主机的内存资源
  • 宿主机宕机后,数据丢失
  • 缓存和数据库的最新数据可能存在不一致
  • 同一台机器上的多个服务之间可能缓存的数据不一致
  • 集群场景下无法分布式共享缓存数据

3、分布式缓存

一般使用Redis作为分布式缓存

2、缓存的三大问题

1、起因

引入Redis作为缓存后,可以支撑起更大的并发数据访问量。

不过,引入了Redis作为缓存,可能引发三大问题:缓存雪崩、缓存击穿、缓存穿透。

2、缓存雪崩

1、原因

为了保证缓存数据和数据库数据的一致性,一般会给Redis中的数据设置过期时间。

  • 如果键值对数据过期,就会从Redis中移除。
  • 如果访问的数据不在缓存中,就会访问数据库,获取最新的数据,重新生成缓存。之后访问该数据时就能命中缓存,直接返回数据了。

那么,如果出现两种情况的其中一种:

  • 大量缓存数据在同一时间内过期,导致热点数据频繁未命中
  • Redis故障宕机,整个缓存系统失效

那么,就会有大量的请求直接访问数据库,导致数据库的压力骤增,很可能数据库会崩溃宕机,进而造成整个系统崩溃。

这种现象称为“缓存雪崩”。

可见,发生缓存雪崩有两个可能的原因,所以解决方案也不同。

2、解决大量数据同时过期

针对大量数据同时过期,常见的策略有:

  • 通用做法:均匀设置过期时间
  • 如果数据更新不频繁:使用分布式互斥锁,或者本地互斥锁。
  • 如果key不大:双 key 策略
  • 如果数据更新频繁,或者刷新缓存的耗时较长:后台更新缓存
  • 识别热点Key+本地缓存

均匀设置过期时间

避免将大量数据设置成同样的过期时间。应该在设置过期时间时,加上一个随机数,保证数据不会在同一时间过期

这样做可以分散请求,保证不会出现超级高的并发去直接访问数据库

分布式互斥锁

业务线程在处理访问请求时,如果发现需要的数据不在Redis中,就尝试获取互斥锁。

  • 只有持有互斥锁的线程能够初始化缓存,即访问数据库,然后将数据存入缓存中,之后释放锁
  • 如果有其他线程已经持有互斥锁,当前线程可以选择等待锁释放,也可以先返回空值,之后正常读取缓存即可
  • 实现互斥锁时,最好设置超时时间,防止因为某个请求被阻塞而导致整个系统无响应的情况

这种做法其实也很好,因为只要有一个线程访问了数据库,它就可以构建缓存,后面的请求就可以正常读取缓存了。

双 key 策略

对缓存数据设置两个key,一个作为主key设置过期时间,而另一个备key永不过期。

  • 这两个key的value都是一样的,相当于做了冗余
  • 这样,如果主key过期,还可以使用备key来获取数据。
  • 在更新缓存时,同时更新主key和备key的值,保证它们的value始终相等。

后台更新缓存

缓存的键值对不再设置有效期,使其永远不会自然过期,由后台线程来定时更新缓存。

但是这样做有一个问题:

  • 不设置有效期,也不代表该键值对数据会一直留在内存中。当系统内存紧张时,有些缓存数据就会被淘汰

    而数据被淘汰,到下一次后台线程更新缓存这期间,读取缓存会返回空值,此时缓存处于失效状态。

有两种解决方案:

  • 后台线程不仅负责定时更新缓存,也负责频繁检查缓存是否有效。
    • 一旦发现缓存失效,就立即从数据库中读取最新值,初始化缓存
    • 这种做法不是太好。就算检测的频率是毫秒级的,也必然会存在某些时刻,缓存处于淘汰状态,所以用户体验一般
  • 在业务线程发现缓存失效后,通过消息队列发送一条消息给后台线程,说是哪个缓存失效了。
    • 后台线程收到消息,判断缓存是否存在,如果不存在就查询数据库,初始化缓存。
    • 这种做法比起第一种,初始化缓存的行为变成了业务线程主动请求,所以更及时,用户体验也更好。

这种方案比较灵活,也可以给键设置过期时间,有两种方案:

  • 可以在缓存即将过期前通知后台线程,使其提前重新初始化缓存,或者更新缓存的过期时间

同时,在业务刚上线时,就应该利用后台线程提前把缓存都构建好,这种行为称为“缓存预热”。

3、解决Redis故障宕机

有两种思路来避免Redis的宕机:

  • 服务熔断或请求限流机制
  • 构建Redis的高可用集群

服务熔断或请求限流机制

如果Redis已经宕机,可以启动服务熔断机制,暂停业务对缓存服务的访问,直接返回错误信息。

  • 这样可以避免大量请求直接访问数据库,保证系统的正常运行。
  • 等到Redis恢复正常,再允许业务访问缓存服务。

如果该业务比较重要,不能被直接熔断,也必须采取请求限流的措施。

  • 开启请求限流后,只会允许少量的请求去直接访问数据库来获取数据
  • 大多数的请求会直接在入口处拒绝服务
  • 这样既可以保证业务不完全瘫痪,又保护了数据库

构建Redis的高可用集群

可以通过主从节点的方式来构建高可用的Redis集群,从而最大程度避免Redis宕机。

因为一个Redis集群有很多台宿主机,如果 Redis 缓存的主节点故障宕机,从节点可以切换成为主节点,继续提供缓存服务。

这样就避免了由于 Redis 单点故障宕机而导致缓存雪崩问题,毕竟这么多台主机全部挂掉的概率较低

3、缓存击穿

业务中通常会有一些热点数据,称为hotKey。

如果缓存中的某个热点数据过期了,此时又正好有大量的请求访问该热点数据,这些请求就会直接访问数据库,数据库就很有可能挂掉。

这种现象称为“缓存击穿”。

缓存击穿问题属于缓存雪崩问题的子集,所以有一些雪崩的解决方案同样适用于击穿:

  • 分布式互斥锁方案,同一时刻只能有一个线程访问数据库,并初始化缓存,相当于击穿的洞被补上了
  • 物理不过期,逻辑过期。后台线程更新缓存,热点数据不会主动过期,只会由后台线程更新缓存,不会有大量请求访问数据库
  • 识别热点Key+本地缓存

缓存击穿也有自己的特点,即一般是由热点Key过期,正好遇到高并发访问而导致的,可以这样解决:

  • 如果可行,可以把hotKey设置成永不过期

4、缓存穿透

缓存穿透是最严重的。

因为缓存雪崩或者穿透时,只是缓存中没有数据,数据库中是有数据的。

只要数据库还没挂掉,有一个线程成功查到数据,就能初始化缓存,问题结束。

但是,如果数据库中没有对应的数据,那就无法构建缓存,大量的请求会持续涌入数据库,数据库几乎必死无疑。

这种现象称之为“缓存穿透”。

缓存穿透一般有两种情况会发生:

  • 业务误操作,业务会请求不存在的数据,导致数据库和缓存都查不到
  • 黑客恶意攻击,使某些正常的业务大量访问不存在的数据

常见的缓存穿透解决方案有三种:

  • 限制非法请求
  • 缓存空值或者默认值
  • 使用布隆过滤器快速判断数据是否存在,避免通过查询数据库来判断数据是否存在

限制非法请求

应该在API的入口处,优先判断参数是否合理,比如不能存在非法值、请求字段是否存在。如果发现是恶意请求就直接返回,不访问数据库

缓存空值或者默认值

对于线上业务,如果发现确实有些请求会访问不存在的数据,可以暂且在缓存中针对该数据设置一个空值或默认值,避免它访问数据库

可以在下一次版本更新或维护时处理这一问题

快速判断数据是否存在

可以在写入数据库数据时,使用布隆过滤器做个标记。

在用户请求到来时,如果业务线程确认缓存失效,可以通过查询布隆过滤器快速判断数据是否存在。

  • 如果存在,再去查数据库,或者执行别的策略
  • 如果不存在,就不用通过查询数据库来判断数据是否存在,进而避免了缓存穿透

5、布隆过滤器

布隆过滤器由两部分组成:初始值都为0的位图数组、N个哈希函数

布隆过滤器会通过3个操作标记1个数据:

  • 第一步,使用 N 个哈希函数,分别对数据做哈希计算,得到 N 个哈希值
  • 第二步,将第一步得到的 N 个哈希值,对位图数组的长度取模,得到每个哈希值在位图数组的对应位置
  • 第三步,将每个哈希值在位图数组的对应位置的值设置为 1

比如下面的布隆过滤器:

图片

  • 它有三个哈希函数
  • 对一个数据X,计算了三次哈希值,分别是1、4、6,把位图数组对应的值标记为1
  • 要快速检查某个数据,只需要利用相同的规则,计算3个哈希函数,然后检查对应的位图数组是否全为1即可。

布隆过滤器的特点:

  • 虽然使用了多个哈希函数,但依然存在哈希冲突的可能性。
    • 可能两个不同的数据经过三个哈希函数都算出了三个一样的值,不过概率较小
  • 布隆过滤器说一个数据存在,它不一定存在。但如果布隆过滤器说一个值不存在,那它肯定是不存在。

3、缓存与锁

1、上锁解决的问题

初始化缓存的方法,是在缓存未命中时被调用。它会去查找数据库,获得当前的值,并构建缓存。

一旦发生了缓存雪崩或缓存击穿,就有可能有海量的请求由于缓存未命中而调用这个初始化缓存的方法,进而导致并发访问数据库,导致数据库瞬间被冲垮。

所以,可以给初始化缓存的方法上锁,来保证同一时刻只能有一个请求线程去调用初始化缓存,访问数据库,进而构建缓存。

有两种上锁的思路:

  • 在Java层面,给读取缓存的方法上锁,比如synchronized或lock
  • 在更高的层面,访问Redis之前,加上分布式锁的逻辑

2、本地锁方案

1、适用场景
  • 只有一台机器,而且只部署一个服务
  • 只有这一个服务会操作Redis缓存和数据库
2、关于锁的粒度

锁的粒度有两种情况:

  • 如果使用“更新数据库,更新缓存”的方案,就必须给整个通过缓存获取数据的方法都上锁,即包括查询缓存、删除缓存、构建缓存的步骤。
    • 查询缓存也必须使用同一把锁,否则会导致数据不一致
    • 举个例子:
      • 比如此时数据为100
      • 请求A要将数据修改为99,它就获取锁,更新缓存,修改数据库
      • 请求B要查询数据。如果查询缓存没有上锁,它就可能读到旧的缓存值。如果上了锁,等它获取到锁之后,缓存肯定已经被更新了。
  • 如果使用“更新数据库,删除缓存”的方案,就可以只给构建缓存的方法上锁。
    • 举个例子:
      • 比如此时数据为100
      • 请求A要将数据修改为99,它就删除缓存,获取锁,修改数据库
      • 请求B要查询数据,缓存中没有数据,它想要构建缓存,就必须等待锁被释放
3、本地锁的缺陷

单机部署的场景,虽然不存在逻辑问题,但严重限制了性能。

  • 因为获取锁比较耗时,而缓存是为了提高访问性能。
  • 如果访问缓存要先获取锁,整个系统的效率就非常低。

而且在分布式部署服务时会存在问题:

  • 本地锁只能锁定当前服务进程的线程,而不能实现数据库级别的上锁
  • 如果一台机器上部署了多个服务,而且它们都会访问同一套缓存服务和数据库
  • 那么在高并发场景下,就会存在数据出错的问题,比如超卖问题

超卖问题:

  • 比如当前库存是100,缓存在Redis中
  • 此时服务A获取了本地锁,将库存修改为99
  • 此时服务B获取了本地锁,将库存修改为99(因为服务A的锁不会影响服务B)
  • 结果就是,库存被扣减了两次,但此时值为99,发生了超卖问题。

3、分布式锁方案

1、缓存与锁的本质

构建缓存的环节被并发执行,是很多服务访问Redis查不到所需的键值对数据,从而自发地访问MySQL。

由于服务之间的本地锁不共享,所以要构建针对服务之间的锁,应该从服务中抽离出来,放在外部来实现,比如利用Redis。

使用这样的外部规则,就可以使得多个服务之间共享一把锁的状态,进而锁住整个分布式服务系统,所以成为“分布式锁”。

2、分布式锁的实现思路

抛开使用锁的具体时机,首先来研究分布式锁的实现。

基本的思想是,构造一个普通的RedisKey作为锁。

  • 如果当前Redis中不存在这个RedisKey,含义是,没有线程持有分布式锁
  • 当前线程可以通过构造出这个RedisKey,来持有该分布式锁
  • 等当前线程执行完任务,就删除这个RedisKey,来释放该分布式锁

存在的问题:

  • 在并发场景下,无法保证“判断RedisKey是否存在”和“构造一个RedisKey”之间的原子性
  • 这就有可能导致,两个线程都判断RedisKey不存在,然后都成功持有了锁,各自同时操作Redis,就会出现错误情况。

解决方案:

Redis提供了一个命令:setNX,含义是set … if not exist,如果不存在就创建。

  • 返回 OK表示设置成功。返回 nil表示设置失败

Redis的命令是这样的:

set <key> <value> NX
3、基本的实现方案
  • 多个线程都使用setNX命令去尝试获取锁,如果没有获取到就等待100ms后再次尝试获取。(它们都使用同一个key,比如叫clock)
  • 线程A成功获取到了锁(向Redis插入了clock键值对)
  • 线程A执行任务
  • 线程A执行完任务,删除clock键值对
  • 另一个线程有机会获取锁。。。

代码实现

public void getRedisDistributedLock() {
    // 尝试获取锁
    Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "123");
    System.out.println(lock);
    while (!lock) {
        // 没有获取到锁
        System.out.println(Thread.currentThread().getName() + "没有获取到锁...");
        // 延迟一段时间,再次尝试获取锁
        try {
            sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    if (lock) {
        // 如果获取到锁,执行任务
        System.out.println(Thread.currentThread().getName() + "执行了任务...");
        // 释放锁
        redisTemplate.delete("lock");
    }
}

缺陷:

  • 作为锁的RedisKey没有设置过期时间,有死锁风险
4、设置key的过期时间

作为锁的RedisKey应该设置过期时间。

因为如果一个线程持有锁后,业务出现问题或者Redis宕机,那么别的线程只能一直等待,造成死锁

注意,应该在获取锁时(插入键时)就设置过期时间,而不应该在获取锁之后再去设置过期时间。

因为如果“获取锁”和“设置过期时间”之间出现异常,依然会导致死锁。

5、使用不同的value

在给锁的key设置过期时间后,如果所有服务的线程都使用同一个key,存在以下问题:

  • 如果key过期了,相当于锁被释放。

    而如果当前持有锁的线程还没有执行完任务,而有别的线程请求插入key持有锁,就会导致并发执行任务,出现并发安全问题

    而之前的线程在执行完之后,会删除key,相当于释放了别的线程创建的锁,继续导致并发安全问题

要解决这个问题,有两种思路:

  • 每次设置不同的key。不可行,因为Redis没有类似的命令,势必会导致“判断键是否存在”和“插入键”之间不连续。
  • 每次获取锁时,给key设置不同的value。可行,在释放锁时,首先判断锁是否由自己创建,是的话才能释放。

代码实现

public void getRedisDistributedLock() {
    // 1.生成唯一 id
    String uuid = UUID.randomUUID().toString();
    // 尝试获取锁
    Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 10, TimeUnit.SECONDS);
    System.out.println(lock);
    while (!lock) {
        // 没有获取到锁
        System.out.println(Thread.currentThread().getName() + "没有获取到锁...");
        // 延迟一段时间,再次尝试获取锁
        try {
            sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    if (lock) {
        // 如果获取到锁,执行任务
        System.out.println(Thread.currentThread().getName() + "执行了任务...");
        // 获取当前锁的值
        String lockValue = redisTemplate.opsForValue().get("lock");
        // 如果和当前的uuid相同,就可以释放锁
        if(uuid.equals(lockValue)) {
            // 释放锁
            redisTemplate.delete("lock");
        }

    }
}
6、使用Lua脚本

上面的方案逻辑上已经很优秀了,但还存在一个问题:“获取当前锁的值”和“释放锁”之间不是连续的,可能出现问题。

举个例子:

  • 线程A获取锁
  • 线程A准备释放锁,尝试获取当前锁的值,发现是自己创建的。
  • 但线程A还没来得及删除锁,锁自动过期
  • 线程B获取锁
  • 线程A释放锁。但此时的锁其实是线程B创建的

如何把“获取当前锁的值”和“释放锁”这两个操作做成原子性的?

可以编写一段Lua脚本来实现。因为脚本在Redis内部是连续执行的,不会被中断。

if redis.call("get",KEYS[1]) == ARGV[1]
then
    return redis.call("del",KEYS[1])
else
    return 0
end

然后重构解锁的代码即可:

// 脚本解锁
String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class), Arrays.asList("lock"), uuid);

KEYS[1] 对应“lock”,ARGV[1] 对应 “uuid”,含义就是如果 lock 的 value 等于 uuid ,则删除 lock。

7、Redisson

之前设计的分布式锁方案已经比较合理了,但只是能最低限度满足需求,并不成熟。

Redisson 是一个在 Redis 的基础上实现的 Java 驻内存数据网格(In-Memory Data Grid)

4、数据库和缓存如何保证一致性

一个系统,使用MySQL作为DB,使用Redis作为缓存服务。

一个请求会先查询Redis缓存。如果没有查到,就去MySQL查询,初始化缓存。解决了数据库瓶颈

但是,如果要更新数据库的数据,就有可能涉及到更新缓存和数据库的双写,那么双写之间肯定存在一个先后关系。

双写时,是应该先更新缓存,还是先更新数据库?

1、先更新数据库,后更新缓存

这种做法在并发更新同一条数据时,会有数据不一致的风险

比如请求A和请求B同时更新同一条数据:

  • A把数据库更新为2
  • B把数据库更新为1
  • B把缓存更新为1
  • A把缓存更新为2

此时就出现了数据不一致,因为数据库的真实值是1

2、先更新缓存,后更新数据库

同样,在并发更新同一条数据时,会有数据不一致的风险

所以,只要涉及到同时更新缓存和数据库,就必然存在先后关系。

  • 不能保证针对同一请求的两者在执行更新数据期间,没有别的请求修改数据。
  • 所以当多个请求并发修改某个数据时,可能会出现缓存与实际数据不一致的问题。

3、旁路缓存

在更新数据时,写入数据库,删除缓存。这样,下次读取数据时,发现缓存没有数据,就会从数据库读取数据,初始化缓存。

这个策略叫“Cache Aside”,旁路缓存策略。

这个策略可以细分为“读策略”和“写策略”两个阶段:

图片

其中,旁路缓存的写操作阶段,也存在写入数据库和删除缓存的先后问题。

4、先删除缓存,再更新数据库

这种做法也不好。在读写并发的场景下,会出现数据不一致问题

举个例子:

  • 一个请求要修改数据,它先删除了缓存,还没来得及修改数据库
  • 另一个请求要访问该数据,发现缓存没有值,就查询数据库,初始化了缓存,读到了旧值
  • 此时前一个请求才执行更新数据库的操作

产生这种问题的场景

如果并发很低,特别是读并发很低,出现这种问题的几率比较小。

只有在读写操作的并发量较高时会出现

解决方案:延迟双删

针对这种方案在读写并发场景产生的数据不一致问题,有一个解决方案:延迟双删。

这种方案更新数据的思路是:

  • 删除缓存
  • 更新数据库
  • 延迟一段时间,比如sleep 100ms
  • 再删除一次缓存

延迟这一段时间的目的是:

  • 如果别的请求在“删除缓存”和“更新数据库”期间初始化了缓存,就有可能是错误根据旧值构建的缓存
  • 这第二次删除就有可能可以及时删除掉它

不过这个方案的延迟时间不好把控,只能说整体上减少了数据不一致的时间,但是没办法非常精准有效地避免数据不一致。

5、先更新数据库,再删除缓存

理论上来说,这个方案也是有问题的:

  • 某个请求查询的数据不在缓存中,它从数据库查出一个值,还没初始化缓存
  • 另一个请求修改了数据库的数据,还没删除缓存
  • 此时前一个请求成功初始化缓存,缓存的值是旧值,数据不一致。

但实际情况下,这种问题出现概率并不高。

  • 因为Redis缓存的写入速度比数据库快很多,基本不会出现一个请求修改完数据库,另一个请求还没有初始化完缓存的情况。
  • 如果每个请求在缓存未命中时,都能快速地查询数据库,初始化缓存,那么后续的修改请求都会在修改完数据库后立马删除缓存,下次会重新初始化缓存为数据库最新的值,所以没有问题。

这个方案相对比较好地解决了数据一致性问题

6、旁路缓存的数据不一致问题

最佳实践是:先更新数据库,再删除缓存。

但是存在一个问题:假如说更新数据库成功了,但删除缓存失败了,那么之后的请求查询到的都是缓存中的旧值。

简单的解决方法是,给缓存加上一个过期时间:

  • 这样,就算有一段时间内出现了数据不一致,等到该缓存数据过期后,下次请求在缓存中查不到数据,就会用数据库的最新值来初始化缓存,数据不一致消除了。

但是这种做法存在一个问题:如果遇上了删除缓存失败的问题,所有的请求必须等到该缓存过期才能获取到最新值,用户体验不好。

有两种解决方案:

  • 消息队列重试
  • 订阅 MySQL binlog,再操作缓存

重试机制

引入消息队列,将要删除缓存的key放入消息队列中,由消费者来执行删除动作

  • 如果删除缓存失败,消息不会从消息队列中被移除,可以尝试再次删除缓存。如果失败达到一定次数,就向业务层发送报错信息
  • 如果删除缓存成功,消息从队列中被移除,不会引发重试

订阅 MySQL binlog,再操作缓存

如果更新MySQL成功,MySQL就会产生一条binlog日志。

可以通过订阅 binlog 日志,拿到具体要操作的数据,然后再执行缓存删除。阿里巴巴开源的 Canal 中间件就是基于这个实现的

Canal的工作原理:

  • Canal 模拟 MySQL 主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向 MySQL 主节点发送 dump 请求
  • MySQL 收到请求后,就会开始推送 Binlog 给 Canal
  • Canal 解析 Binlog 字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用

这两种方案都是可行的,消息队列重试的方案更容易理解。

本质上,它们都是异步操作缓存的,所以有能力保证缓存最终成功被删除。

因为如果是同步删除缓存,如果引入重试机制,就可能导致业务被重试机制一直阻塞,显然不可取。

7、总结:保证一致性的最佳实践

旁路缓存的好处

更新数据时删除缓存,是一种懒加载的思想。因为这样缓存只有在下次用到时才会更新。

哪些场景下删除缓存是一种好的解决方案?

  • 有些业务场景,缓存的值并不直接对应某张表的某个字段,而是需要利用多张表的多个字段计算出来,此时更新缓存就很困难

  • 有些情况,缓存的值计算量比较大,更新的代价较高。

    此时应该考虑,这个频繁更新维护的缓存是否会被频繁访问。如果不会,不如改成删除策略,需要时再计算。

旁路缓存的缺陷

如果要严格保证数据一致性,那么“先更新数据库,后删除缓存”的做法最好。其他做法都存在高并发下的数据不一致问题,这种做法出问题的概率最小。

但是,频繁删除缓存会导致频繁的缓存未命中,对于热点业务会成为性能瓶颈。如果要优化,还是要回到同时更新缓存的问题上。

为什么同时更新数据库和缓存会存在并发问题,归根结底:

  • 这是两个独立的操作,如果没有做额外的并发控制,多个线程并发更新同一条数据,就会由于写入顺序不一致导致最终的数据不一致。

保证双写一致性常用的解决方案

  • 读请求和写请求串行化。
    • 如果系统不是严格要求“缓存和数据库的数据必须完全一致”,最好不要做这个方案
    • 串行化可以保证一定不会出现不一致的情况,但是它也会导致系统的吞吐量大幅度降低
  • 在更新缓存之前,使用分布式锁。
    • 这样就能保证同一时间只能有一个请求更新缓存,就不会产生并发问题了
    • 当然引入了锁后,对于写入的性能就会带来影响
  • 在更新完缓存后,加上一个较短的过期时间。
    • 如果业务允许短暂的数据不一致,就可以使用这种做法
    • 可以减少数据不一致的时间,就算不一致,缓存也会很快过期
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值