Redis常见面试题

发布于:2024-06-26 ⋅ 阅读:(39) ⋅ 点赞:(0)

Redis常见面试题

Redis知识框架:

在这里插入图片描述

1. 缓存穿透

在这里插入图片描述

缓存穿透:去redis查询一个不存在的数据,数据库查询不到数据也不会直接写入缓存,就会导致每次请求都查数据库。

1.1 缓存空数据

缓存空数据,查询返回的数据为空,仍把这个空结果进行缓存;

优点:简单;

缺点:消耗内存,可能会导致数据不一致的问题;

1.2 布隆过滤器

布隆过滤器,当一个查询请求过来时,先经过布隆过滤器进行判断,如果判断请求查询值存在,则继续查;如果判断请求查询不存在,直接丢弃;

优点:内存占用较少,没有多余key;

缺点:实现复杂,可能存在误判

布隆过滤器底层是bitmap(位图):相当于是一个以(bit)位为单位的数组,数组中每个单元只能存储二进制数0或1

布隆过滤器作用布隆过滤器可以用于检索一个元素是否在一个集合中

误判率:数组越小误判率就越大,数组越大误判率就越小,但是同时带来了更多的内存消耗。

在这里插入图片描述

在这里插入图片描述

应用Redis的布隆过滤器:

    /**
     * 根据主键id查询
     * Cacheable注解是spring缓存注解,在方法执行前 Spring 会先查看缓存中是否有key,如果有key,则直接返回缓存数据;
     * 若没有key,调用方法并将方法返回值放到缓存中。
     * condition设置当查询结果不为null时生效;
     * cacheNames设置默认前缀,完整key为article:id
     *
     * @param id 主键id
     * @return 文章实体
     */
    @Cacheable(cacheNames = "article", key = "#id", condition = "#result!=null")
    @Override
    public Article getById(Long id) {
        // bloomFilter中不存在该key,为非法访问
        if (!bloomFilter.contains(id)) {
            /**
             * condition = "#result!=null"并在非法访问的时候返回null的目的是不将该次查询返回的null使用
             * 所以我们需要在缓存中添加一个可容忍的短期过期的null或者是其它自定义的值,使得短时间内直接读取缓存中的该值。
             */
            long ttl = new Random().nextInt(500) + 1000;    // 随机过期时间
            Article article = new Article();
            stringRedisTemplate.opsForValue().set("article:" + id, JSONUtil.toJsonStr(article), ttl, TimeUnit.SECONDS);
            return null;
        }
        // 不是非法访问,可以访问mysql数据库
        Article article = this.lambdaQuery().eq(Article::getId, id).one();
        return article;
    }

以下为测试Redisson的布隆过滤器代码:

package com.example.redisson;

import com.example.redisson.domain.po.Article;
import com.example.redisson.service.IArticleService;
import com.example.redisson.util.BloomFilterUtil;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.List;

@SpringBootTest(classes = RedissonDemoApplication.class)
@Slf4j
class RedissonDemoApplicationTests {

    static long expectedInsertions = 2000L;    // 预期插入数量
    static double falseProbability = 0.01;  // 误判率
    @Autowired
    private RedissonClient redissonClient;
    @Autowired
    private BloomFilterUtil bloomFilterUtil;
    @Autowired
    private IArticleService articleService;
    private RBloomFilter<Long> bloomFilter = null;


    /**
     * 初始化布隆过滤器
     */
    public void init() {
        log.info("开始初始化布隆过滤器...");
        // 查询数据库,用于启动项目时初始化bloomFilter
        List<Article> articleList = articleService.list();
        // 创建布隆过滤器,参数依次为:过滤器名称、预测插入数量和误判率
        bloomFilter = bloomFilterUtil.create("idBloomFilter", expectedInsertions, falseProbability);
        for (Article article : articleList) {
            bloomFilter.add(article.getId());
        }
        log.info("初始化布隆过滤器完成...");
    }

    @Test
    void test() {
        init(); // 初始化布隆过滤器
        long count = 0;
        long totalChecks = 2000; // 测试数据共2000个,1000真1000假,计算误判率

        // 测试数据id从1-1000是真实存在的,会初始化到布隆过滤器;id大于1000不存在,检测误判率
        for (long i = 1; i <= totalChecks; i++) {
            if ((i <= 1000 && !bloomFilter.contains(i))
                    || (i > 1000 && bloomFilter.contains(i))) {
                log.info("误判id:{}", i);
                count++;
            }
        }

        double actualFalsePositiveRate = (double) count / (totalChecks - 1000); // 计算实际误判率
        log.info("布隆过滤器误判次数:{}", count);
        log.info(String.format("预期误判率: %.2f%%, 实际误判率: %.2f%%", falseProbability * 100, actualFalsePositiveRate * 100));
    }
}

运行结果:

2024-06-18 21:44:14.722  INFO 100564 --- [           main] d.s.w.p.DocumentationPluginsBootstrapper : Skipping initializing disabled plugin bean swagger v2.0
2024-06-18 21:44:14.732  INFO 100564 --- [           main] c.e.r.RedissonDemoApplicationTests       : Started RedissonDemoApplicationTests in 6.621 seconds (JVM running for 7.158)
2024-06-18 21:44:14.922  INFO 100564 --- [           main] c.e.r.RedissonDemoApplicationTests       : 开始初始化布隆过滤器...
2024-06-18 21:44:15.880  INFO 100564 --- [           main] c.e.r.RedissonDemoApplicationTests       : 初始化布隆过滤器完成...
2024-06-18 21:44:17.010  INFO 100564 --- [           main] c.e.r.RedissonDemoApplicationTests       : 误判id:1188
2024-06-18 21:44:17.914  INFO 100564 --- [           main] c.e.r.RedissonDemoApplicationTests       : 布隆过滤器误判次数:1
2024-06-18 21:44:17.915  INFO 100564 --- [           main] c.e.r.RedissonDemoApplicationTests       : 预期误判率: 1.00%, 实际误判率: 0.10%
2024-06-18 21:44:17.933  INFO 100564 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown initiated...
2024-06-18 21:44:17.945  INFO 100564 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown completed.

2. 缓存击穿

**缓存击穿:**给某一个key设置了过期时间,当key过期的时候,恰好这时间点对这个key有大量的并发请求过来,这些并发的请求可能会瞬间把数据库压垮。

在这里插入图片描述

在这里插入图片描述

2.1 互斥锁

互斥锁,当某个线程执行查询请求时,如果未命中缓存,先申请获取互斥锁,获取锁成功后再次检查缓存(避免其他线程已经重建过缓存的情况下再次去重建缓存),如果还是未命中则去查询数据库并进行缓存重建,缓存重建后释放互斥锁;这一过程中如果还有其他线程执行查询请求未命中时,会在申请获取互斥锁时失败,休眠一会后重试。

优点:可保证数据高一致性;

缺点:性能低,可能发生死锁

在这里插入图片描述

实例代码:

    /**
     * 根据主键id查询
     * Cacheable注解是spring缓存注解,在方法执行前 Spring 会先查看缓存中是否有key,如果有key,则直接返回缓存数据;
     * 若没有key,调用方法并将方法返回值放到缓存中。
     * condition设置当查询结果不为null时生效;
     * cacheNames设置默认前缀,完整key为article:id
     *
     * @param id 主键id
     * @return 文章实体
     */
    // @Cacheable(cacheNames = "article", key = "#id", condition = "#result!=null")
    @Override
    public Article solveByMutexLock(Long id) {
        String key = "article:" + id;
        String articleCache = stringRedisTemplate.opsForValue().get(key);
        if (StrUtil.isNotBlank(articleCache)) {  // 命中缓存
            return JSONUtil.toBean(articleCache, Article.class);
        }
        // 未命中缓存,尝试获取互斥锁
        String lockKey = "lock:article:" + id;
        RLock lock = redissonClient.getLock(lockKey);
        Article article = null;
        try {
            boolean isLock = lock.tryLock(500L, 1000L, TimeUnit.MILLISECONDS);
            while (!isLock) {   // 获取锁失败
                // 线程休眠一会儿再重试
                TimeUnit.MILLISECONDS.sleep(500L);
                isLock = lock.tryLock(500L, 1000L, TimeUnit.MILLISECONDS);
            }
            // 获取锁成功,再次校验缓存是否命中(避免其他线程已经重建过缓存的情况下再次去重建缓存)
            articleCache = stringRedisTemplate.opsForValue().get(key);
            if (StrUtil.isNotBlank(articleCache)) {  // 命中缓存
                return JSONUtil.toBean(articleCache, Article.class);
            }
            // 查询数据库
            article = this.lambdaQuery().eq(Article::getId, id).one();
            if (article == null) {   // 缓存空对象
                article = new Article();
            }
            // 缓存重建
            stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(article),
                    RedisConstants.CACHE_ARTICLE_TTL, TimeUnit.MINUTES);
        } catch (InterruptedException e) {
            log.error(e.getMessage());
        } finally {
            // 解锁前检查当前线程是否持有该锁
            if (lock.isHeldByCurrentThread()) {
                // 释放互斥锁
                lock.unlock();
            }
        }
        return article;
    }

2.2 逻辑过期

逻辑过期,为缓存设置一个专门的字段,记录缓存的逻辑过期时间,假设线程1执行查询查询请求,若未命中缓存则直接返回,若命中缓存则继续判断缓存是否过期;若缓存没过期则直接返回,若缓存已过期,就会尝试获取互斥锁,获取锁成功后,再次判断缓存是否过期(防止缓存已被其他线程重建过)。此刻开启新的线程进行缓存重建,新线程重建缓存后会释放锁,线程1返回旧数据,其他线程获取锁失败后返回旧数据。。

优点:性能高

缺点:数据可能不一致,实现复杂

在这里插入图片描述

代码实现:

private final Executor logicalExpireExecutor;   // 自定义线程池

    /**
     * 根据主键id查询(逻辑过期)
     *
     * @param id 主键id
     * @return article实体
     */
    @Override
    public Article solveByLogicalExpire(Long id) {
        // 查询redis有无数据
        String key = "article:" + id;
        String articleCache = stringRedisTemplate.opsForValue().get(key);
        if (StrUtil.isBlank(articleCache)) {  // 未命中缓存
            return null;
        }
        // 命中缓存,校验是否过期
        RedisArticle redisArticle = JSONUtil.toBean(articleCache, RedisArticle.class);
        Article article = BeanUtil.copyProperties(redisArticle, Article.class);
        LocalDateTime expireTime = redisArticle.getExpireTime(); // 获取过期时间
        if (expireTime.isAfter(LocalDateTime.now())) {  // 当前缓存尚未过期
            return article;
        }
        // 当前缓存已过期,需要获取互斥锁,开新线程重建缓存
        String lockKey = "lock:article:" + id;
        RLock lock = redissonClient.getLock(lockKey);
        try {
            // 尝试获取互斥锁
            boolean isLock = lock.tryLock(500L, 1000L, TimeUnit.MILLISECONDS);
            if (!isLock) {  // 获取锁失败
                // 返回过期数据
                return article;
            }
            // 获取锁成功,再次校验缓存是否命中并未过期
            articleCache = stringRedisTemplate.opsForValue().get(key);
            if (StrUtil.isNotBlank(articleCache)) {  // 命中缓存
                redisArticle = JSONUtil.toBean(articleCache, RedisArticle.class);
                expireTime = redisArticle.getExpireTime();
                if (expireTime.isAfter(LocalDateTime.now())) {
                    // 如果缓存数据在锁期间已经被其他线程更新
                    return BeanUtil.copyProperties(redisArticle, Article.class);
                }
            }
            // 使用CompletableFuture异步重建缓存
            CompletableFuture.runAsync(() -> {
                try {
                    // 查询数据库
                    Article newArticle = this.lambdaQuery().eq(Article::getId, id).one();
                    if (newArticle == null) {  // 缓存空对象
                        newArticle = new Article();
                    }
                    log.info("缓存已过期,开始重建缓存");
                    // 重建缓存
                    RedisArticle newRedisArticle = new RedisArticle();
                    newRedisArticle.setExpireTime(LocalDateTime.now().plusSeconds(RedisConstants.CACHE_ARTICLE_TTL));
                    BeanUtil.copyProperties(newArticle, newRedisArticle);
                    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(newRedisArticle));
                    log.info("重建缓存完成");
                } catch (Exception e) {
                    log.error("重建缓存出错", e);
                } finally {
                    // 解锁前检查当前线程是否持有该锁
                    if (lock.isHeldByCurrentThread()) {
                        lock.unlock();
                    }
                }
            }, logicalExpireExecutor);
        } catch (InterruptedException e) {
            log.error(e.getMessage());
            Thread.currentThread().interrupt();
        } catch (Exception e) {
            log.error("获取锁或重建缓存时出错", e);
        }
        // 返回过期数据
        return article;
    }

3. 缓存雪崩

缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。

在这里插入图片描述

解决方案

  • 给不同的Key的TTL添加随机值;
  • 利用Redis集群提高服务的可用性,如哨兵模式集群模式
  • 给缓存业务添加降级限流策略,如Sentinel(不是指Redis集群里的哨兵)
  • 给业务添加多级缓存,如GuavaCaffeine

4. 双写一致性

双写一致性是指当修改了数据库的数据也要同时更新缓存的数据,缓存和数据库的数据要保持一致。由于引入了缓存,那么在数据更新时,不仅要更新数据库,而且要更新缓存,这两个更新操作存在前后的问题:

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

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

假设我们先删除缓存,再更新数据库,设变量v在redis缓存和mysql数据库都为10,线程1先删除了缓存(v=10),这时候线程2查询缓存未命中,重建缓存(v=10),然后线程1更新数据库(v=20)。这时缓存v=10和数据库v=20就出现了数据不一致的情况。

假设我们先更新数据库,再删除缓存,设变量v在redis缓存和mysql数据库都为10,线程1查询缓存未命中便去查询数据库,此时线程2更新数据库(v=20)并删除缓存,之后线程1重建缓存(v=10)。这时缓存v=10和数据库v=20就出现了数据不一致的情况。

在这里插入图片描述

4.1 强一致性

可以看到,以上两种方式都存在线程安全问题,针对这种情况,第一种解决方法是 延迟双删。延迟双删实现的伪代码如下:

#删除缓存
redis.delKey(X)
#更新数据库
db.update(X)
#睡眠
Thread.sleep(N)
#再删除缓存
redis.delKey(X)

在这里插入图片描述

  • 读操作:缓存命中,直接返回;缓存未命中查询数据库,写入缓存,设定超时时间;

  • 写操作延迟双删,即先删除缓存,在更新数据库,更新数据库后等待一定时间再删除缓存;

    注:第二次删除缓存的原因是避免在第一次删除缓存和更新数据库期间,有其他线程读取了旧数据并重建缓存,确保在缓存重建期间可能被其他操作写入的旧数据被清除延迟删除延时是因为一般数据库都是主从分离,读写分离的。延时是为了让主库有时间通知到从库,确保数据库的更新操作影响到所有节点。延时双删极大程度上避免了脏数据风险,但因为有延时的存在,延时时间不好控制,所以也不能百分百避免脏数据风险。此外,延迟还能够起到避免缓存击穿的作用。

第二种解决方法是分布式锁
直接加互斥锁能保障数据的强一致性,但是性能较低。此时我们就需要优化一下互斥锁。因为存入缓存的数据,一般都是读多写少。为此我们通过Redisson引入两个单独的锁,分别叫共享锁和排他锁。

  • 共享锁/读锁:共享锁,又叫读锁(readLock),加锁之后,其他线程可以共享读操作。
  • 排他锁/独占锁:排他锁,又叫独占锁(writeLock),加锁之后,阻塞其他线程读和写操作。

混合使用的流程和代码

在这里插入图片描述

我们想要拿到共享锁或者排他锁,都需要先拿到读写锁。
通过固定代码可以拿到读写锁。

RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("ITEM_READ_WRITE_LOCK"); 

随后分别拿到共享锁和排他锁。(注意两个锁需要是同一把读写锁)

RLock readLock = readWriteLock.readLock();
RLock writeLock = readWriteLock.writeLock();

读操作的代码

public void getById(Integer id){
  RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("ITEM_READ_WRITE_LOCK");
    // 读之前加锁,读锁的作用就是等待该lockkey释放写锁以后再读
  RLock readLock = readWriteLock.readLock();
  try{
    // 开锁
    readLock.lock();
    System.out.println("readLock...");
    Item item = (Item) redisTemplate.opsForValue().get("item"+id);
    if(item != null){
      return item;
    }
    item = new Item(id, "华为手机", "华为手机", 5999.00);
    // 写入缓存
    redisTemplate.opsForValue().set("item"+id, item);
    return item;
  }finally{
    // 解锁
    readLock.unlock();
  }
}

写操作的代码

public void updateById(Integer id){
  RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("ITEM_READ_WRITE_LOCK");
  // 写之前加锁,写锁加锁成功后读锁只能等待
  RLock writeLock = readWriteLock.writeLock();
  try{
    // 开锁
    writeLock.lock();
    System.out.println("writeLock...");
    Item item = new Item(id, "华为手机", "华为手机", 5299.00);
    try{
      Thread.sleep(10000);
    }catch(InterruptedException e){
      e.printStackTrace();
    }
    // 删除缓存
    redisTemplate.delete("item"+id);
  }finally{
    writeLock.unlock();
  }
}

4.2 延迟一致

延迟一致是指只保证数据的最终一致性,过程中允许数据不一致,采用的解决方案为异步通知,常见的如MQ(消息队列)、Canal( MySQL 数据库增量日志解析)等。

使用MQ消息队列作为中间件,更新数据之后,通知缓存删除。

在这里插入图片描述

利用Canal中间件,不需要修改业务代码,伪装为MySQL的一个从节点,canal通过读取binlog数据更新缓存。

在这里插入图片描述

5. Redis持久化

Redis中自身存在两种方案,分别叫RDB和AOF,来保障数据的持久化。其中前者默认开启,后者默认关闭。
Redis是基于内存的,redis持久化的意思就是将redis数据,即内存数据写入磁盘等持久化存储设备当中。

5.1 RDB

RDB全称Redis Database Backup file(Redis数据备份文件),也被叫做Redis数据快照。简单来说就是把内存中的所有数据都记录到磁盘中。当Redis实例故障重启后,从磁盘读取快照文件,恢复数据。

  • 主动备份

​ 在redis客户端可以通过以下两个命令手动开启redis数据快照。

save(由redis主进程来执行RDB,会阻塞其他所有命令,此时新增缓存会被拒绝);

bgsave(开启子进程执行RDB,避免主进程受到影响)。

  • 被动触发

​ 在redis.conf中有被动触发的相应条件,且默认是bgsave。

# 900秒内,如果至少有1个key被修改,则执行bgsave 
save 900 1  
save 300 10  
save 60 10000 

​ redis的主进程并不直接和真实物理地址打交道。它会维护一个页表的东西,页表记录着虚拟地址和物理内存中真实的物理地址之间的映射关系。子进程就是直接复制该页表,以此达到和主进程共享数据的效果。这个复制过程很快,是纳秒级别的,所以可以忽略不记。当子进程复制完页表后会读取内存数据并写入RDB文件。

当子进程在写入RDB文件时,主进程那依旧可以接受用户的请求并写数据。但是此时物理内存中的数据是只读模式,只是会拷贝一份数据出来让主进程可以在上面修改。主进程修改完后页表也会指向这个拷贝出来的新数据。这样就防止了子进程写入RDB,主进程那同时修改数据,从而产生的脏数据的问题。

在这里插入图片描述

5.2 AOF

AOF全称为Append Only File(追加文件)。Redis处理的每一个写命令都会记录在AOF文件,可以看做是命令日志文件。

在redis.conf文件里修改以下配置:

# 是否开启AOF功能,默认是no
appendonly yes
# AOF文件的名称
appendfilename "appendonly.aof"

# 表示每执行一次写命令,立即记录到AOF文件
appendfsync always 
# 写命令执行完先放入AOF缓冲区,然后表示每隔1秒将缓冲区数据写到AOF文件,是默认方案
appendfsync everysec 
# 写命令执行完先放入AOF缓冲区,由操作系统决定何时将缓冲区内容写回磁盘
appendfsync no

AOF命令记录频率有三种模式:

配置项 刷盘时机 优点 缺点
Always 同步刷盘 可靠性高,几乎不丢数据 性能影响大
everysec 每秒刷盘 性能适中 最多丢失1秒数据
no 操作系统控制 性能最好 可靠性较差,可能丢失大量数据

重写功能:因为是记录命令,AOF文件会比RDB文件大的多。而且AOF会记录对同一个key的多次写操作,但只有最后一次写操作才有意义。通过执行bgrewriteaof命令,可以让AOF文件执行重写功能,用最少的命令达到相同效果。
在这里插入图片描述

Redis也会在触发阈值时自动去重写AOF文件。阈值也可以在redis.conf中配置:

# AOF文件比上次文件 增长超过多少百分比则触发重写
auto-aof-rewrite-percentage 100
# AOF文件体积最小多大以上才触发重写 
auto-aof-rewrite-min-size 64mb

5.3 RDB和AOF的对比

RDB和AOF各有自己的优缺点,如果对数据安全性要求较高,在实际开发中往往会结合两者来使用。

RDB AOF
持久化方式 定时对整个内存做快照 记录每一次执行的命令
数据完整性 不完整,两次备份之间会丢失 相对完整,取决于刷盘策略
文件大小 会有压缩,文件体积小 记录命令,文件体积很大
宕机恢复速度 很快
数据恢复优先级 低,因为数据完整性不如AOF 高,因为数据完整性更高
系统资源占用 高,大量CPU和内存消耗 低,主要是磁盘IO资源 但AOF重写时会占用大量CPU和内存资源
使用场景 可以容忍数分钟的数据丢失,追求更快的启动速度 对数据安全性要求较高常见

6. 数据删除策略

Redis对数据设置数据的有效时间,数据过期以后,就需要将数据从内存中删除掉。可以按照不同的规则进行删除,这种删除规则就被称之为数据删除策略数据过期策略)。

6.1 惰性删除

惰性删除:设置该key过期时间后,我们不去管它,当需要该key时,我们在检查其是否过期,如果过期,我们就删掉它,反之返回该key

优点 :对CPU友好,只会在使用该key时才会进行过期检查,对于很多用不到的key不用浪费时间进行过期检查
缺点 :对内存不友好,如果一个key已经过期,但是一直没有使用,那么该key就会一直存在内存中,内存永远不会释放。

6.2 定期删除

定期删除:每隔一段时间,我们就对一些key进行检查,删除里面过期的key(从一定数量的数据库中取出一定数量的随机key进行检查,并删除其中的过期key)。

定期清理有两种模式:
SLOW模式是定时任务,执行频率可以通过修改配置文件redis.conf 来调整;
FAST模式执行频率不固定,但两次间隔不低于2ms,每次耗时不超过1ms。

优点:可以通过限制删除操作执行的时长和频率来减少删除操作对 CPU 的影响。另外定期删除,也能有效释放过期键占用的内存。
缺点:难以确定删除操作执行的时长和频率。

Redis的过期删除策略惰性删除 + 定期删除两种策略进行配合使用。

7. 数据淘汰策略

当Redis中的内存不够用时,此时在向Redis中添加新的key,那么Redis就会按照某一种规则将内存中的数据删除掉,这种数据的删除规则被称之为内存的淘汰策略。

分类
Redis支持8种不同策略来选择要删除的key:
noeviction默认策略, 不淘汰任何key,但是内存满时不允许写入新数据;
volatile-ttl: 对设置了TTL的key,比较key的剩余TTL值,TTL越小越先被淘汰;
allkeys-random:对全体key ,随机进行淘汰;
volatile-random:对设置了TTL的key ,随机进行淘汰;
allkeys-LRU: 对全体key,基于LRU算法进行淘汰;
volatile-LRU: 对设置了TTL的key,基于LRU算法进行淘汰;
allkeys-LFU: 对全体key,基于LFU算法进行淘汰;
volatile-LFU: 对设置了TTL的key,基于LFU算法进行淘汰;

注:

  • LRU(Least Recently Used)最近最少使用。用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高。例如:key1是在3s之前访问的, key2是在9s之前访问的,删除的就是key2。
  • LFU(Least Frequently Used)最少频率使用。会统计每个key的访问频率,值越小淘汰优先级越高。例如:key1最近5s访问了4次, key2最近5s访问了9次, 删除的就是key1

淘汰策略选用建议

  1. 数据有明显冷热数据区分

​ 如果业务有明显的冷热数据区分,建议使用 allkeys- LRU 策略,把最近最常访问的数据留在缓存中。为什么allkeys-LFU策略不一定能把最近最常访问的数据留下来呢?因为可能存在在某个时间段访问频率很高的数据,这部分数据不是最近最常访问的数据,但是会被LFU策略留下来。

  1. 数据没有明显冷热数据区分

    如果业务中数据访问频率差别不大,没有明显冷热数据区分,建议使用 allkeys-random,随机选择淘汰。

  2. 数据有置顶的需求
    如果业务中有置顶的需求,可以使用 volatile-LRU 策略,同时置顶数据不设置过期时间,这些数据就一直不被删除,会淘汰其他设置过期时间的数据。

  3. 短时高频访问数据
    如果业务中有短时高频访问的数据,可以使用 allkeys-LFUvolatile-LRU 策略。

8. 分布式锁

JavaSE的synchronized加锁方式在分布式情景下不再适用,我们需要分布式锁。

8.1 基于SETNX命令的分布式锁

Redis实现分布式锁主要利用Redis的SETNX命令。SETNXSET if not exists(如果不存在,则 SET)的简写。

获取锁:

# 添加锁,NX是互斥、EX是设置超时时间
SET lock value NX EX 10

释放锁:

# 释放锁,删除即可
DEL key

流程图如图所示:

在这里插入图片描述

但这种方式存在一些问题,比如说:如果因为网络拥挤,拿到锁的线程在执行业务的过程中可能因为还没执行完锁就被释放了,性能不太好。

8.2 基于Redisson的分布式锁

基于Redisson实现的分布式锁,有以下集中特性:

看门狗机制

基于Redisson实现的分布式锁,能够通过看门狗机制实现锁的续期,提高分布式锁的性能。其在加锁时,如果不自己定义参数中的leaseTime(锁自动释放时间),或者定义的leaseTime为-1,那么就会开启看门狗机制。(不定义的话,这个线程默认的leaseTime就是30s)

看门狗机制:当一个线程A加锁成功,且达到看门狗机制的触发条件时,会额外开一个线程,一般命名为Watch dog,这个线程每隔(releaseTime/3)的时间就为A续期时间,即重置过期时间。一般这个续期时间为锁的有效时间的一半,也可以自己额外设置。在该锁有效期间,如果有其他线程想要试图加锁,那么就会不断while循环重试。整个执行流程如图所示:

在这里插入图片描述

而采用了看门狗机制的锁的释放,是需要手动完成的,释放完成后还需要通知看门狗线程。需要注意的是,基于Redisson实现的分布式锁的加锁、设置过期时间等操作都是基于lua脚本完成的,因为能保证操作的原子性。

代码示例:

public void redisLock() throws InterruptedException{
  // 获取可重入锁
  RLock lock = redissonClient.getLock("heimalock");
  try{
    // 申请加锁
    boolean isLock = lock.tryLock(10, TimeUnit.SECONDS);
    if(isLock){	// 加锁成功
      System.out.println("加锁成功,开始执行业务……");
    }
  }finally{
    // 释放锁
    lock.unlock();
  }
}

可重入性
锁的可重入性指的是同一个线程在持有锁的情况下,能够多次获取该锁而不会发生死锁或阻塞的情况。可重入锁的一个典型应用场景是在一个方法中调用另一个加锁的方法,譬如以下代码。如果方法A在获取锁后调用了方法B,而方法B也需要获取同一个锁,那么如果锁是可重入的,方法B可以直接获取到锁,而不会因为锁已被方法A持有而发生死锁。

public void add1(){
    RLock lock = redissonClient.getLock(“heimalock");
    boolean isLock = lock.tryLock();    
    //执行业务  
    add2();
    //释放锁
    lock.unlock();
}
public void add2(){
    RLock lock = redissonClient.getLock(“heimalock");
    boolean isLock = lock.tryLock();
    //执行业务
    //释放锁
    lock.unlock();
}

基于redisson实现的分布式锁是拥有可重入性的,它利用Hash结构记录线程id
重入次数,其中的key为分布式锁名称,field是线程id这个唯一标识,value记录着当前线程重入的次数。

KEY VALUE
field value
heimalock thread1 1

主从一致性
对于多线程的主从一致性,实际上redisson没办法很好地保障。
像是原本如下运行,一个主库,两个从库,且java应用程序对主库进行加锁。

在这里插入图片描述

因为某种原因,主库短暂宕机,此时根据哨兵模式选举了下面的从库为新的主库。
此时又有个新的java应用来对这个新的主库进行加锁。那么就会出现两个线程同时持有一把锁的情况。为此,redisson提出了一个叫RedLock,红锁。
RedLock(红锁):不能只在一个redis实例上创建锁,应该是在多个redis实例上创建锁(n / 2 + 1,n为redis集群的节点数),避免在一个redis实例上加锁。即,在超过一半的实例上进行加锁。但是这种机制实现复杂,性能较差,运维起来也很繁琐。所以实际开发中很少用。

9. 集群方案

在Redis中提供的集群方案总共有三种(一般一个redis节点不超过10G内存)

  • 主从复制
  • 哨兵模式
  • 分片集群

9.1 主从复制

单节点Redis的并发能力是有上限的,要进一步提高Redis的并发能力,就需要搭建主从集群,实现读写分离。

在这里插入图片描述

主从全量同步过程:首次同步

在这里插入图片描述

注:
Replication Id:简称replid,是数据集的标记,id一致则说明是同一数据集。每一个master都有唯一的replid,slave则会继承master节点的replid
offset:偏移量,随着记录在repl_baklog中的数据增多而逐渐增大。slave完成同步时也会记录当前同步的offset。如果slave的offset小于master的offset,说明slave数据落后于master,需要更新。

主从增量同步过程:slave节点重启或后期数据变化

在这里插入图片描述

优点:解决了系统的高并发读的问题
缺点:无法保证系统的高可用,所以哨兵模式出现了。

9.2 哨兵模式

Redis提供了**哨兵(Sentinel)**机制来实现主从集群的自动故障恢复。哨兵(Sentinel)实际上也是redis节点,它的具体功能如下:

  • 监控:Sentinel 会不断检查您的master和slave是否按预期工作;
  • 自动故障恢复:如果master故障,Sentinel会将一个slave提升为master。当故障实例恢复后也以新的master为主;
  • 通知:Sentinel充当Redis客户端的服务发现来源,当集群发生故障转移时,会将最新信息推送给Redis的客户端。

在这里插入图片描述

哨兵Sentinel基于心跳机制监测服务状态,每隔1秒向集群的每个实例发送ping命令:

  • 主观下线:如果某sentinel节点发现某实例未在规定时间响应,则认为该实例主观下线。

  • 客观下线:若超过指定数量(quorum)的sentinel都认为该实例主观下线,则该实例客观下线。quorum值最好超过Sentinel实例数量的一半。一旦发现主节点客观下线了,哨兵会推举新的主节点。

    在这里插入图片描述

哨兵选主规则

  • 判断主与从节点断开时间长短,如超过指定值就排除该从节点;
  • 然后判断从节点的slave-priority值,越小优先级越高;
  • 如果slave-prority一样,则判断slave节点的offset值,越大优先级越高(数据更全);
  • 最后是判断slave节点的运行id大小,越小优先级越高。

如果此时原本的主节点(暂时称为A)因为网络问题,没有回应心跳,那么哨兵便会进行选举出一个新的主节点(暂时称为B),这样就存在了两个主节点,像是大脑分两列了一样。等A节点网络恢复之后才会由主节点降为从节点,这个过程称为脑裂

在这里插入图片描述

但是注意,这个选主并切换的过程需要一定时间,此时A节点还是可以被写入数据的(暂时称这段数据为message,因为A节点实际上没有宕机,只是因为网络分区等问题联系不上从节点和哨兵了)当A节点被降为从节点时,A节点会清空自己的数据,复制B节点的数据,此时message就丢失了。

它的解决方案是修改redis的两个配置参数,达不到要求就拒绝请求,就可以避免大量的数据丢失

min-replicas-to-write 1  # 表示最少的slave节点为1个
min-replicas-max-lag 5  # 表示数据复制和同步的延迟不能超过5秒

优点:解决了系统高可用的问题
缺点:无法解决海量数据存储还有高并发写的问题,此时分片集群就出现了。

9.3 分片集群

主从和哨兵分别解决了高并发读、高可用的问题。但是依然有两个问题没有解决:

  • 海量数据存储问题
  • 高并发写的问题

使用分片集群可以解决上述问题,分片集群特征:

  • 集群中有多个master,每个master保存不同数据
  • 每个master都可以有多个slave节点
  • master之间通过ping监测彼此健康状态
  • 客户端请求可以访问集群任意节点,最终都会被转发到正确节点

分片集群的结构如下:

在这里插入图片描述

不再需要哨兵,直接master之间通过ping监测彼此健康状态。只要超过一定数量的master节点认为某个master节点宕机了,那么那个节点就客观下线了,相当于变形的哨兵模式。客户端请求可以访问集群任意节点,经过一定的路由规则,最终都会被转发到正确节点。
路由规则
Redis 分片集群引入了哈希槽的概念,Redis 集群有 16384 个哈希槽,每个 key通过 CRC16 校验后对 16384 取模来决定放置哪个槽,集群的每个节点负责一部分 hash 槽。这样能保证客户端请求不冲突地正确转发到redis的某个master节点上。

在这里插入图片描述

注:也可以使用如下命令来指定CRC16校验

set {aaa}name itheima  # 对aaa进行CRC16校验后取模计算槽的位置
set name itheima  # 对key(即itheima)进行CRC16校验后取模计算槽的位置

优点:解决了系统的海量数据存储、高可用、高并发读写的问题
缺点:集群维护很麻烦,而且集群之间的通信和心跳检测消耗大量的网络带宽,无法使用lua脚本和事务。

10. Redis是单线程却很快

Redis是单线程的,但是为什么还那么快?

  • Redis是纯存操作,执行速度非常快;
  • 采用单线程,避免不必要的上下文切换可竞争条件,多线程还要考虑线程安全问题;
  • 使用I/O多路复用模型,非阻塞IO方式。

关于I/O多路复用模型,可以去搜索相关的计算机操作系统知识。

参考链接:黑马程序员 Redis面试题


网站公告

今日签到

点亮在社区的每一天
去签到