redis汇总笔记

发布于:2025-07-14 ⋅ 阅读:(13) ⋅ 点赞:(0)

语雀完整版:

https://www.yuque.com/g/mingrun/embiys/calwqx/collaborator/join?token=sLcLnqz5Rv8hOKEB&source=doc_collaborator# 《Redis笔记》

Redis

一般问题

  1. Redis内存模型(I/O多路模型)多路复用IO如何解释
    • 为什么Redis要使用单线程?单线程为什么还如此之快?
      首先官方解释说 redis是基于内存的操作,CPU不是redis的瓶颈,限制redis性能的应该是内存的大小和网络宽带。 另外 使用单线程能避免线程切换和竞态消耗。
  1. 高级数据类型(注意bitmaps 在敏感词过滤中的应用)
  1. 删除策略
    • 定时删除:创建一个定时器,时间一到立刻删除,就是对cpu不太友好这样
    • 惰性删除:会在查询的时候检查是否过期如果过期则删除。
    • 定期删除:每隔一段时间检查设置了过期时间Key,如果过期了则删除(是上面两种方案的折中)

上面的删除机制有可能导致内存占满,然后就要走redis的内存删除机制,
另外还有以下情况会对过期键进行处理

    • 新生成RDB文件的时候 会检查过期key,不会生成
    • 生成AOF的时候,会检查过期key,如果过期了就会增加一个DEL指令,记录一下、
    • 主库过期会向从库发送 DEL指令,记录改键已经删除
  1. Redis淘汰策略(也叫逐出算法,内存不够时触发,它和删除策略 保证了redis中都是热点数据)


    注:其中的lru是指淘汰最久未使用的,lfu是指淘汰使用次数最少的
  1. 缓存淘汰的三种策略
    • FIFO
      • 先进先出,可以定义一个双向链表,如果添加数据时满了,就从队头开始清理
    • LRU
      • 淘汰的是使用次数最少的,可以定义一个队列,新来的放到队尾,访问一次就把它的引用计数+1 ,容量不够的时候就从队尾开始清理
    • LFU
      • 刚被访问的数据放到队头,插入时容量不够的话从队尾开始淘汰
    • 上述算法自己如何实现?
  1. Key值命名规范

  1. Redis事务
    • 概述:redis事务就是 将多个命令包装成一个整体,作为一个队列,中间不会被打断
    • 使用:开启multi,结束exec
    • 注意点:中间执行失败了,比如命令没有敲对,是不会回滚的
  1. 生产问题
    • 缓存雪崩
      • 诱因:在一个较短的时间内,大量key过期失效,大量请求无法命中缓存
      • 解决:把过期时间设置的随机一些
    • 缓存击穿
      • 诱因:单个key过期了,然后大量请求到这个key上
      • 解决:
        • 过期时间设置长一些、二级缓存、把这个key对应的value先设置上一个值
        • 在getKey的时候,使用setnx加个分布式锁,然后从数据库里面把这个key的值读出来,放到redis里面,然后再释放锁
    • 缓存穿透
      • 诱因:大量的请求没有命中缓存,直接走数据库了(遇到了攻击)
      • 解决
        • 把这个key对应的value先设置上一个值,并设置上较短的过期时间
        • 布隆过滤器把所有可能存在的key放进去,如果请求过来key不存在就直接过滤掉(布隆过滤器由于哈希冲突,就是别的字段的值会把你的覆盖掉,所以有不一定有,没有的就一定没有)
  1. Redis高可用
    • 主从模式:若master宕机需要手动配置slave转为master,可以设置一主多从,主写从读,提高吞吐量
    • 哨兵模式,该模式下有一个哨兵监视master和slave,若master宕机可自动将slave转为master,但是不能动态扩充;
    • cluster集群模式(3.x):对redis做水平扩展
  1. 数据分发到多个节点上需要用一定的算法来完成
    • 哈希取模:缺点是如果一个节点挂掉所有数据则需要重新计算,原有数据取不到而全部失效。
    • 哈希一致性哈希算法:就是一个数据环,然后服务器作为上面的节点,如果一个服务器挂了,就会顺时针方向,走到下一个节点,这里的缺点也就是会造成热点数据问题
    • 虚拟槽分区算法:

![image-20210601212630310](https://mrbucket-2.oss-cn-hangzhou.aliyuncs.com/typora/image-20210601212630310.png)

  1. 连接redis的java客户端
    • jedis/jedisCluster
      • 全媒体用了jedisCluster,他使用条件注解开启,以此来达到可配置化
@ConditionalOnProperty(name = {"spring.redis.config.cluster"}, havingValue = "true")

    • Spring提供的
    • redission(推荐)
  1. Redis底层数据结构
    • 列表(list)
      • 分两种实现方式,压缩列表和双向循环列表
      • 压缩列表
        • 压缩指的是对内存的压缩,要满足以下两个条件才会使用压缩列表,否则就会使用双向链表
          • 列表中保存的单个数据(有可能是字符串类型的)小于64字节:
          • 列表中数据个数少于512个。
        • 压缩列表做的优化就是 因为数组的长度是固定的,字符串的长度不固定,放到这些数组里面就会造成浪费,所以就保存每个字符串的长度,去除空闲的空间

      • 双向链表
    • 字典(hash)
      • 他也有两种实现方式,压缩列表和散列表
      • 压缩列表:要使用压缩列表需要满足下面两个条件
        • 字典中保存的键和值的大小都要小于64字节;
        • 字典中键值对的个数要小于512个。
      • 散列表:使用的MurmurHash2作为哈希函数,对于哈希冲突使用的是链表法来解决,对于动态因子,大于一时会触发扩容两倍,小于0.1时就会缩容
    • 集合(set)
      • 也有两种实现,有序数组和散列表
      • 有序数组:要使用有序数组要满足一下两个条件
        • 存储的数据都是整数;
        • 存储的数据元素个数不超过512个。
      • 散列表
    • 有序集合(sortedset)
      • 也有两种实现,压缩列表和跳表
      • 压缩列表:使用压缩列表的条件如下:
        • 所有数据的大小都要小于64字节;
        • 元素个数要小于128个。
      • 跳表
  1. 数据持久化的两种方式
    • 清楚原有的数据结构,直接将数据存储到磁盘(redis采用的这个)
      • 优点未知(redis不经常重新加载数据从磁盘,就是随机选一个方案)
        缺点是 比如对于散列表这种数据,恢复的时候就要重新计算散列值,如果数据量比较大就比较耗费时间
    • 保留原来的存储格式
      • 可以解决上面的缺点

redis分布式锁

单机实现

  1. 加锁
    • 其中key设置本业务名称,value设置成thradId,thradId的生成方式如下
//可以看出,这个就是在Thread中自增的,这样干在集群的时候肯定不行
private static long threadSeqNumber;
private static synchronized long nextThreadID() {
    return ++threadSeqNumber;
}

    • 另外因为redis setnx和expire指令不是原子性的,所以采用如下方式进行设置,返回的结果是是否加锁成功(非阻塞锁),如果成功就继续执行操作,如果失败就设置循环获取锁,每次循环休眠一段时间
jedis.set(lockKey, Thread.currentThread().getId(), "nx", "ex", 100);

  1. 解锁:要解决get 和del指令的原子性
// 解锁的时候 get 指令和 del 指令不是原子性的,所以采用 LUA 脚本执行删除 lockKey 的逻辑
// 解锁的 LUA 脚本 lock.lua:if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
public  boolean unLock(String lockKey, String threadId) {
    Jedis jedis = jedisPool.getResource();
    // 加载 LUA 脚本
    DefaultRedisScript<Number> script = new DefaultRedisScript<>();
    script.setScriptSource(new ResourceScriptSource(new ClassPathResource("lock.lua")));
    script.setResultType(Number.class);
    // 执行 LUA 脚本
    Object result = jedis.eval(script.getScriptAsString(), Collections.singletonList(lockKey), Collections.singletonList(threadId));
    if(UNLOCK_SUCCESS.equals(result)) {
        return true;
    }
    return false;
}

  1. 执行时间过长导致过期问题
    • 假如过期时间是30秒,A线程执行时间超过了30秒,导致锁失效,B线程就进来了,解决这种情况就可以用守护线程为当前锁续命
    • B线程进来后开始执行,这个时候如果A线程执行完毕了要删除锁,按照之前的写法是直接删除key,不校检value,这就相当于在B还运行着的时候,A就直接把锁给删除了
  1. 集群锁丢失问题
    • 假设加锁加在了 master节点上,加完锁master正好挂了,故障转移之后slave变成了master,这个锁就丢失了,这个问题可以用RedLock解决,而Redission就对RedLock做了一套实现,就是MultiLock,创建多个分组,每个分组可能是一个cluster、一个主从复制,然后加锁和解锁要在这上面大多数成功才行

redis集群版加锁:Redisson

案例

  1. 代码实现
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.8.1</version>
</dependency>

原理

  1. 找哪台机器加锁

    • 其中第一步就对应代码RLock lock = redisson.getLock("anyLock");,主要就是通过RedissionLock类获取锁对象
  1. 客户端线程首次加锁
    • 调用处 就是在上面的第二步流程中,具体实现为LUA脚本
      其中 KEYS[1]为上面设置的key值 "anyLock",
      ARGV[1]的值为internalLockLeaseTime=30 * 1000ms ,这个时间也是LockWatchdogTimeout
      ARGV[2]的值为ThreadId

    • 上面前三行LUA脚本解释
      • 先判断这个key存在不存在
      • 存在的话就设置值,相当于命令 hset anyLock UUID:ThreadId 1
      • 设置过期时间,pexpire anyLock 30000
    • 流程进度

  1. 加锁成功后如何维持加锁
    • 获取锁成功后会走下面的步骤

    • 接下来就会添加一个定时器,定时时间为 internalLockLeaseTime/3,也就是10秒钟就去执行一次,执行的是下面这一段脚本


      主要就是判断线程是否还持有该锁,如果还持有就为该key续期30s,这样就保证了只要这个key还在 就一直维持下去,这就是看门狗机制,其中的key值得是 'hexists', KEYS[1],ARGV[2]) == 1;
  1. 可重入锁的加锁机制
    • 依然是上面的LUA脚本,第二次加锁会进入到第二个If分支,他判当前传进来的key和线程id是否存在,存在的话就 加一(相当于 增加相同线程的持有次数,可重入锁),加锁成功后也会开启一个watchDog线程
    • 当前的流程如下

  1. 其它线程重复加锁阻塞
    • 其它线程过来时 会执行LUA脚本的最后一行,pttl anyLock,返回当前key的过期剩余时间 ttl
    • 然后在循环中,他就会等待ttl这么久的时间,然后再去获取锁
  1. 可重入锁释放场景
    • 客户端宕机释放
    • 客户端调用 unlock方法主动释放,底层执行的脚本如下,其中KEYS[1]=anyLock,
      ARGV[2]=30000,ARGV[3]=UUID:ThreadId

      • 其中直接可以看第8行,对重入锁的次数减一,然后在判断是否大于一,如果大于一就说明还不能解锁,而且还会顺便把过期时间 蓄满,小于一就直接把key删掉释放锁

  1. 尝试获取锁超时
    • 上面都是对 lock.lock()方法的分析,他如果没获得锁就会一直阻塞在那,不断尝试,而对于lock.tryLock(30, 10, TimeUnit.MILLISECONDS);则可以设置阻塞时间
    • 每个阶段都会扣减时间,直到传进来的 time扣到0以内,然后就可以返回加锁失败了
  1. 超时自动释放锁
    • 使用 lock.tryLock  底层不会维持看门狗机制,直接到点就自动释放锁了

面试题

  1. 客户端线程在底层是如何实现加锁的?
    • 先定位master节点
      通过key计算出CRC16值,再CRC16值对16384取模得hash slot,通过这个hash slot定位rediscluster集群中的master节点
    • 加锁
      加锁逻辑底层是通过lua脚本来实现的,如果客户端线程第⼀次去加锁的话,会在key对应的hash
      数据结构中添加线程标识UUID:ThreadId 1,指定该线程当前对这个key加锁⼀次了。
  1. 客户端线程是如何维持加锁的?
    当加锁成功后,此时会对加锁的结果设置⼀个监听器,如果监听到加锁成功了,也就是返回的结果为空,此时就会在后台通过watchdog看⻔狗机制、启动⼀个后台定时任务,每隔10s执⾏⼀次,检查如果key当前依然存在,就重置key的存活时间为30s。维持加锁底层就是通过后台这样的⼀个线程定时刷新存活时间维持的。
  1. 相同客户端线程是如何实现可重⼊加锁的?
    第⼀次加锁时,会往key对应的hash数据结构中设置 UUID:ThreadId 1,表示当前线程对key加锁⼀次;
    如果相同线程来再次对这个key加锁,只需要将UUID:ThreadId持有锁的次数加1即可,就为:
    UUID:ThreadId 2 了,Redisson底层就是通过这样的数据结构来表示重⼊加锁的语义的。
  1. 其他线程加锁失败时,底层是如何实现阻塞的?
    线程加锁失败了,如果没有设置获取锁超时时间,此时就会进⼊⼀个while的死循环中,⼀直尝试加
    锁,直到加锁成功才会返回。
  1. 客户端宕机了,锁是如何释放的?
    客户端宕机了,相应的watchdog后台定时任务当然也就没了,此时就⽆法对key进⾏定时续期,那
    么当指定存活时间过后,key就会⾃动失效,锁当然也就⾃动释放了。
  1. 客户端如何主动释放持有的锁?
    客户端主动释放锁,底层同样也是通过执⾏lua脚本的⽅式实现的,如果判断当前释放锁的key存在,并且在key的hash数据结构中、存在当前线程的加锁信息,那么此时就会扣减当前线程对这个key的重⼊锁次数。
    扣减线程的重⼊锁次数之后,如果当前线程在这个key中的重⼊锁次数为0,此时就会直接释放锁,如果当前线程在这个key中的重⼊锁次数依然还⼤于0,此时就直接重置⼀下key的存活时间为30s。
  1. 客户端尝试获取锁超时的机制在底层是如何实现的?
    如果在加锁时就指定了尝试获取锁超时的时间,如果获取锁失败,此时就不会⽆⽌境的在while死循环中⼀直获取锁,⽽是根据你指定的获取锁超时时间,在这段时间范围内,要是获取不到锁,就会标记为获取锁失败,然后直接返回false。
  1. 客户端锁超时⾃动释放机制在底层⼜是如何实现的?
    如果在加锁时,指定了锁超时时间,那么就算你获取锁成功了,也不会开启watchdog的定时任务了,此时直接就将当前持有这把锁的过期时间、设置为你指定的超时时间,那么当你指定的时间到了之后,key失效被删除了,key对应的锁相应也就⾃动释放了。

面试题

  1. Redis为什么是单线程,单线程是如何处理并发请求的
    • Redis的性能瓶颈就是内存 和网络带宽,所以顺利成章就单线程了(这里的单线程指处理网络请求的时候,其它模块还是多线程的)
    • 单线程是如何处理的
      • 如果使用阻塞 I/O的话,从读数据到处理数据 都是阻塞的,这期间不能处理其它读写请求
      • 所以redis使用的是I/O多路复用,可以解决一个线程处理多个连接的问题(使用 select、poll、epoll函数库)
      • 使用 Reactor多路复用线程模型,就是redis有一个I/O多路复用模块,用来监听事件的发生,有如下两种,如果多路复用器监听到了一个 文件事件的话就会交给文件事件分发器,文件事件分发器再交给具体的 处理器
文件事件:Redis 客户端通过 socket 与 Redis 服务器连接,例如 get 命令请求就是一个文件事件;
时间事件:Redis 服务器周期或者定期执行的事件,例如定期的 RDB 持久化命令就是一个时间事件。

  1. 如何保证redis和数据库的一致性
    • 方案一:先更新数据库在更新删除缓存
      • 为什么要删除缓存而不是更新?
        如果更新的频率大于读的频率,那就直接删除掉,读的时候再更新进缓存,效率更高
      • 删除放到 更新缓存前
        在删除的过程中,会有其它请求读数据库,发现命中不了又返回缓存
      • 有什么缺点
        服务器需要同时连接 Redis 和 database,需要大量的连接资源导致连接数过多。
    • 方案二:更新数据库同时消息队列异步更新 Redis
      • 就是使用队列,让更新数据库和更新缓存解耦
      • 缺点
        消息乱序可能会导致更新错误,而且又加了mq
    • 方案三:订阅BinLog更新redis
      • 从bingLog解析出更新操作,然后在更新缓存,适合db压力小的场景
  1. Sort set 的数据结构
    • 它是用跳表实现的,对于插、删、查这几个操作 他和红黑树的时间复杂度是一样的, 就是根据范围查,比如[20,30],它可以从底层链表 只需要找到第一个值 就可以顺着差了,效率较高