Redis面试总结,助力大厂

Redis简介

Redis 是一种NoSql,非关系型数据库,是字典结构的存储方式,采用 key-value 存储。key 和 value 的最大长度限制 是 512M。

Redis 基本数据类型(注意是数据类型不是数据结构)

String、Hash、Set、List、Zset、Hyperloglog、Geo、Streams
在这里插入图片描述

Redis事务

事务提供了一种将多个命令请求打包,然后一次性、按顺序地执行多个命令的机制,并且在事务执行期间,服务器不会中断事务而改去执行其他客户端的命令请求,他会将事务中的所有命令都执行完毕,然后才去处理其他客户端的请求。
Redis 中的事务是一组命令的集合,是 Redis 的最小执行单位。带有以下三个重要的保证

  • 批量操作在发送 EXEC 命令前被放入队列缓存。
  • 收到 EXEC 命令后进入事务执行,事务中任意命令执行失败,其余的命令依然被执行。
  • 在事务执行过程,其他客户端提交的命令请求不会插入到事务执行命令序列中。

为什么redis事务不具备原子性

单个 Redis 命令的执行是原子性的,但 Redis 没有在事务上增加任何维持原子性的机制,所以 Redis 事务的执行并不是原子性的。 事务可以理解为一个打包的批量执行脚本,但批量指令并非原子化的操作,中间某条指令的失败不会导致前面已做指令的回滚,也不会造成后续的指令不做。

Redis 事务相关命令有哪些?

DISCARD:取消事务,放弃执行事务块内的所有命令。
EXEC:执行所有事务块内的命令。
MULTI:标记一个事务块的开始。
WATCH:Redis Watch 命令用于监视一个(或多个) key ,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断 。
UNWATCH :取消 WATCH 命令对所有 key 的监视。

Redis持久化机制

用一句话可以将持久化概括为:将数据(如内存中的对象)保存到可永久保存的存储设备中。 持久化的主要应用是将内存中的对象存储在数据库中,或者存储在磁盘文件中、 XML 数据文件中等等。
Redis支持两种不同的持久化操作,一种是RDB快照(snapshotting),将 Reids 在内存中的数据库记录定时 dump 到磁盘上。另一种是只追加文件AOF(append only file),将 Redis 的操作日志以追加的方式写入文件。

两种持久化机制的比较

RDB的特性如下:

  • fork一个进程,遍历hash table,利用copy on write,把整个db dump保存下来。
  • save, shutdown, slave 命令会触发这个操作。
  • 粒度比较大,如果save, shutdown, slave 之前crash了,则中间的操作没办法恢复。

AOF的特性如下:

  • 把写操作指令,持续的写到一个类似日志文件里。
  • 粒度较小,crash之后,只有crash之前没有来得及做日志的操作没办法恢复。

两种区别就是:一个是持续的用日志记录写操作,crash后利用日志恢复;一个是平时写操作的时候不触发写,只有手动提交save命令,或者是关闭命令时,才触发备份操作。
选择的标准,就是看系统是愿意牺牲一些性能,换取更高的缓存一致性(aof),还是愿意写操作频繁的时候,不启用备份来换取更高的性能,待手动运行save的时候,再做备份(rdb)。rdb这个就更有些 eventually consistent的意思了。

AOF和RDB的优缺点

RDB优点
  • RDB 是紧凑的二进制文件,比较适合备份,全量复制等场景
  • RDB 恢复数据远快于 AOF
RDB缺点
  • RDB 无法实现实时或者秒级持久化;
  • 新老版本无法兼容 RDB 格式。
AOF优点
  • 可以更好地保护数据不丢失;
  • appen-only 模式写入性能比较高;
  • 适合做灾难性的误删除紧急恢复。
AOF缺点
  • 对于同一份文件,AOF 文件要比 RDB 快照大;
  • AOF 开启后,会对写的 QPS 有所影响,相对于 RDB 来说 写 QPS 要下降;
  • 数据库恢复比较慢, 不合适做冷备。

Redis4.0对持久化的改进

Redis4.0开始支持RDB和AOF的混合持久化(默认关闭,可通过aof-use-rdb-preamble开启)。
如果把混合持久化打开,AOF重写时就直接把RDB的内容写到AOF文件开头。这样做的好处是可以结合RDB和AOF的优点,快速加载的同时避免丢失过多的数据。缺点是AOF里面RDB部分是压缩格式,不再是AOF格式,可读性较差。

Redis内存淘汰机制(如何保证Redis中的数据都是热点数据)

先从算法来看:

  • LRU,Least Recently Used:最近最少使用。判断最近被使用的时间,目前最远的 数据优先被淘汰。
  • LFU,Least Frequently Used,最不常用,4.0 版本新增。
  • random,随机删除。

淘汰策略:

  • no-eviction:当内存不足以容纳新写入数据时,新写入操作会报错。
  • allkeys-lru:根据 LRU 算法删除键,不管数据有没有设置超时属性,直到腾出足够内存为止。
  • allkeys-lfu :在所有的键中选择最不常用的,不管数据有没有设置超时属性。
  • allkeys-random:随机删除所有键,直到腾出足够内存为止。
  • volatile-lru:根据 LRU 算法删除设置了超时属性(expire)的键,直到腾出足够内存为止。
  • volatile-ttl:根据键值对象的 ttl 属性,删除最近将要过期数据。
  • volatile-random:在带有过期时间的键中随机选择。
  • volatile-lfu:在带有过期时间的键中选择最不常用的。

缓存失效策略

定时过期(主动淘汰)

每个设置过期时间的key都需要创建一个定时器,到过期时间就会立即清除。该策略可以立即清除过期的数据,对内存很友好;但是会占用大量的CPU资源去处理过期的数据,从而影响缓存的响应时间和吞吐量。

惰性过期(被动淘汰)

只有当访问一个key时,才会判断该key是否已过期,过期则清除。该策略可以最大化地节省CPU资源,却对内存非常不友好。极端情况可能出现大量的过期key没有再次被访问,从而不会被清除,占用大量内存。

定期过期

每隔一定的时间,会扫描一定数量的数据库的expires字典中一定数量的key,并清除其中已过期的key。该策略是前两者的一个折中方案。通过调整定时扫描的时间间隔和每次扫描的限定耗时,可以在不同情况下使得CPU和内存资源达到最优的平衡效果。

Redis中同时使用了惰性过期和定期过期两种过期策略。

获取 key 的时候,如果此时 key 已经过期,就删除,不会返回任何东西。但是实际上这还是有问题的,如果定期删除漏掉了很多过期 key,然后你也没及时去查,也就没走惰性删除,此时会怎么样?如果大量过期 key 堆积在内存里,导致 redis 内存块耗尽了,咋整?答案是:走内存淘汰机制

Redis缓存异常方案

在这里插入图片描述

缓存预热

什么是缓存预热

缓存预热就是系统上线后,将相关的缓存数据直接加载到缓存系统。这样就可以避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题。用户直接查询事先被预热的缓存数据。
如果不进行预热, 那么 Redis 初始状态数据为空,系统上线初期,对于高并发的流量,都会访问到数据库中, 对数据库造成流量的压力。

解决方案

  1. 数据量不大的时候,工程启动的时候进行加载缓存动作;
  2. 数据量大的时候,设置一个定时任务脚本,进行缓存的刷新;
  3. 数据量太大的时候,优先保证热点数据进行提前加载到缓存。

缓存击穿

什么是缓存击穿?

缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取据,引起数据库压力瞬间增大,造成过大压力。

解决方案

  1. 使用互斥锁(mutex key)
    这种解决方案思路比较简单,就是只让一个线程构建缓存,其他线程等待构建缓存的线程执行完,重新从缓存获取数据就可以了。 如果是单机,可以用synchronized或者lock来处理,如果是分布式环境可以用分布式锁就可以了。
    在这里插入图片描述
  2. “永远不过期”
  • 从redis上看,确实没有设置过期时间,这就保证了,不会出现热点key过期问题,也就是“物理”不过期。
  • 从功能上看,如果不过期,那不就成静态的了吗?所以我们把过期时间存在key对应的value里,如果发现要过期了,通过一个后台的异步线程进行缓存的构建,也就是“逻辑”过期。
    在这里插入图片描述
  1. 缓存屏障
    该方法类似于方法一:使用countDownLatch和atomicInteger.compareAndSet()方法实现轻量级锁。
class MyCache{

    private ConcurrentHashMap<String, String> map;

    private CountDownLatch countDownLatch;

    private AtomicInteger atomicInteger;

    public MyCache(ConcurrentHashMap<String, String> map, CountDownLatch countDownLatch,
                   AtomicInteger atomicInteger) {
        this.map = map;
        this.countDownLatch = countDownLatch;
        this.atomicInteger = atomicInteger;
    }

    public String get(String key){

        String value = map.get(key);
        if (value != null){
            System.out.println(Thread.currentThread().getName()+"\t 线程获取value值 value="+value);
            return value;
        }
        // 如果没获取到值
        // 首先尝试获取token,然后去查询db,初始化化缓存;
        // 如果没有获取到token,超时等待
        if (atomicInteger.compareAndSet(0,1)){
            System.out.println(Thread.currentThread().getName()+"\t 线程获取token");
            return null;
        }

        // 其他线程超时等待
        try {
            System.out.println(Thread.currentThread().getName()+"\t 线程没有获取token,等待中。。。");
            countDownLatch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 初始化缓存成功,等待线程被唤醒
        // 等待线程等待超时,自动唤醒
        System.out.println(Thread.currentThread().getName()+"\t 线程被唤醒,获取value ="+map.get("key"));
        return map.get(key);
    }

    public void put(String key, String value){

        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        map.put(key, value);

        // 更新状态
        atomicInteger.compareAndSet(1, 2);

        // 通知其他线程
        countDownLatch.countDown();
        System.out.println();
        System.out.println(Thread.currentThread().getName()+"\t 线程初始化缓存成功!value ="+map.get("key"));
    }

}

class MyThread implements Runnable{

    private MyCache myCache;

    public MyThread(MyCache myCache) {
        this.myCache = myCache;
    }

    @Override
    public void run() {
        String value = myCache.get("key");
        if (value == null){
            myCache.put("key","value");
        }

    }
}

public class CountDownLatchDemo {
    public static void main(String[] args) {

        MyCache myCache = new MyCache(new ConcurrentHashMap<>(), new CountDownLatch(1), new AtomicInteger(0));

        MyThread myThread = new MyThread(myCache);

        ExecutorService executorService = Executors.newFixedThreadPool(5);
        for (int i = 0; i < 5; i++) {
            executorService.execute(myThread);
        }
    }
}

缓存穿透

什么是缓存穿透?

缓存穿透是指用户查询数据,在数据库没有,自然在缓存中也不会有。这样就导致用户查询的时候,在缓存中找不到对应key的value,每次都要去数据库再查询一遍,然后返回空(相当于进行了两次无用的查询)。这样请求就绕过缓存直接查数据库。

解决方案

  1. 缓存空对象
    简单粗暴的方法,如果一个查询返回的数据为空(不管是数据不存在,还是系统故障),我们仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过五分钟。

  2. 布隆过滤器
    占用内存空间很小,位存储;性能特别高,使用key的hash判断key存不存在,将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。

缓存雪崩

什么是缓存雪崩?

如果缓存集中在一段时间内失效,所有的查询都落在数据库上,从而对数据库CPU和内存造成巨大压力,严重的会造成数据库宕机。

解决方案

  1. 数据预热
    缓存预热就是系统上线后,将相关的缓存数据直接加载到缓存系统。这样就可以避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题。用户直接查询事先被预热的缓存数据。可以通过缓存reload机制,预先去更新缓存,再即将发生大并发访问前手动触发加载缓存不同的key。
  2. redis高可用
    既然redis有可能挂掉,那我多增设几台redis,这样一台挂掉之后其他的还可以继续工作,其实就是搭建的集群。
  3. 设置热点数据永远不过期。
  4. 限流降级
    这个解决方案的思想是,在缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数量。比如对某个key只允许一个线程查询数据和写缓存,其他线程等待。
  • 4
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值