面试之Redis

基本数据类型

  • string,值是字符串,可配合json存储对象。
  • hash,值是多个kv对,适合用于存储对象。
  • list,值是一个列表,元素操作类似双端队列。
  • set,值是一个集合,不允许重复元素。
  • zset,值是一个有序集合,不允许重复元素。底层数据结构涉及到跳表。

缓存访问相关

缓存穿透

缓存穿透指查询一个在缓存和数据库中都不存在的数据,这将导致后续每次请求该数据都要到数据库中去查询,增加数据库压力。解决办法一般有:

  • 过滤非法查询条件。
  • 缓存一个虚假空值,配合过期删除使用,使得缓存命中从而减少数据库压力。
  • 布隆过滤器。布隆过滤器本质是一个比特数组,可以快速判断一个元素是否存在于集合中,但当其返回元素“存在”时可能有误差。思想是对每个key进行多次hash(不同的hash函数)得到多个散列值,在数组中将这些位都置为1,当查询时同样对条件key进行多次hash散列,若结果位置在数组中都为1则返回“存在”,有一个为0则返回“不存在”。由于查询一个key时对应的位为1也可能是别的key映射的,故返回“存在”并不意味着元素一定存在,通过设计良好hash函数、增加数组长度、增加hash函数个数可以有效降低误差率。

缓存击穿

缓存击穿指在某个热点key过期时,有大量并发请求访问该key,导致大量并发请求越过缓存直接访问数据库,增加数据库压力。解决办法一般有:

  • 给热点数据设置永不过期。
  • 当访问某个key缓存未命中时加锁,使得仅第一个请求去查询数据库并将数据写入缓存,后续的请求先阻塞后直接在缓存中取。

缓存雪崩

缓存雪崩指Redis服务器宕机或者在某个时间段内,缓存数据集中过期失效,此时如果有大量并发请求将直接访问数据库,增加数据库压力。解决办法一般有:

  • 部署Redis服务器的高可用集群。
  • 均匀设置过期时间,让缓存过期时间点尽量均匀。
  • 数据预热。在可预知的大量并发请求到来之前,手动访问热点数据将其写入缓存。

关于热点key的访问问题

当对某些热点key并发请求数量极大时,可能会造成服务器宕机,解决办法一般有:

  • 部署缓存服务器的高可用集群,做好负载均衡。
  • 使用二级缓存,即jvm本地缓存,如使用hashmap进一步缓存热点数据,从而减少Redis读请求。

过期策略

定时删除

给每个设置了过期时间的key创建一个定时器,到期后直接删除。这种方式对内存友好,但会占用大量CPU资源处理过期key。

定期删除

每隔一段时间(Redis默认100ms,可配置)就去随机抽取部分(Redis默认5个,可配置)设置了过期时间的key,判断是否过期,是则删除。抽取部分的原因是为了保证效率,如果每次都去检测所有设置了过期时间的key,会给CPU带来很大的负担。定期删除会造成部分过期key没有及时删除。

惰性删除

Redis不主动去删除过期key,而是当用户访问某个key时先去判断其是否过期,是则删除并返回缓存未命中,否则直接返回数据。惰性删除同样会造成很多以后不再访问的过期key没有及时删除。

Redis使用的默认方案

定期删除 + 惰性删除。两种方案结合保证了过期key最终一定会被删除,但在某些时间段可能仍然会存在大量过期key未被删除的情况。

内存淘汰策略

当用户set新的key时,如果Redis占用内存超过某个阈值(可配置),则使用内存淘汰策略(可配置,默认为no-eviction策略)淘汰部分key,然后再插入新数据。

策略名称 描述
no-eviction 新写入操作报错,无法写入新数据,一般不用
allkeys-lru 在所有key中移除最久没有使用的key,最常用
allkeys-random 在所有key中随机移除某个key
allkeys-lfu 4.0新增,在所有key中移除使用次数最少且最久没有使用的key
volatile-lru 在设置了过期时间的key中,移除最久没有访问的key
volatile-random 在设置了过期时间的key中,随机移除某个key
volatile-lfu 4.0新增,在设置了过期时间的key中,移除使用次数最少且最久没有使用的key
volatile-ttl 在设置了过期时间的key中,优先移除剩余存活时间最短的key

注:实现LRU算法需使用hashmap + 一个双向链表,通过双向链表维护访问顺序(例尾结点永远是最久未访问的结点);实现LFU算法需要使用hashmap + 多个双向链表(可用列表或map),每个双向链表还需要关联一个引用次数,当需要移除元素时先定位到具体的双向链表(关联的引用次数最少的那个),再删除最久未访问的结点(头或尾)。

持久化策略

Redis可以通过持久化将内存数据写入硬盘进行保存,从而在重启或故障恢复时重新恢复数据。Redis默认使用RDB模式,不开启AOF模式。同时Redis4.0支持同时开启RDB和AOF,此时Redis在恢复数据时会先检查AOF文件是否存在,如果不存在就尝试加载RDB文件。

Redis Database Backup (RDB)

将内存数据的快照保存在名字为dump.rdb的二进制文件中,通过配置“save N M”使得Redis在“N秒内至少有M个改动”时触发一次持久化操作,每次生成rdb文件时会覆盖之前的rdb文件。同时可以在Redis客户端敲命令save或者bgsave手动触发RDB持久化,通过配置文件自动触发的持久化属于bgsave类型。

  • save命令会阻塞Redis主进程,直到rdb文件写完,阻塞期间无法响应其他任何客户端请求,故基本不使用。
  • bgsave命令会由Redis主进程fork()一个子进程(fork期间会短暂阻塞),由子进程负责写rdb文件,Redis主进程继续处理客户端请求。子进程在写rdb文件时,如果主进程更新了某些key,并不会对正在写的rdb文件有任何影响,即rdb文件记录的是子进程创建时的内存快照,此处利用的是fork()的特性。

fork()函数是linux操作系统提供的api,用于创建子进程,操作系统会将父进程的已使用内存页设为read-only,作为共享内存。当父进程或子线程修改共享内存时(在redis中这个角色肯定是主进程,因为子进程只负责写rdb文件),由于内存页是只读的,内核会先将该内存页拷贝到对应的进程中作为副本,然后再修改,这种方式称为写时复制(Copy-On-Write)。

RDB持久化的优缺点如下:

  • 优点。存储的是二进制文件,文件小,Redis重启或故障恢复时速度快,故适用于大量数据的全量复制场景。
  • 缺点。用的是“save N M”的模式,无法做到实时持久化。

Append-Only File (AOF)

所有的写命令会追加到内存的AOF缓冲区中,然后使用不同的策略将AOF缓冲区中的内容同步写入AOF文件,当AOF文件太大时,开启bgrewriteaof子进程对其进行重写以压缩文件大小。当Redis每次将写命令追加到AOF缓冲区后,会调用一个函数判断是否需要将缓冲区内容同步写入到AOF文件中,判断依据是配置文件中的策略,共有三种策略:

  • always,每执行一个写命令就执行一次,慢但最安全。
  • everysec,每秒执行一次,折中选项,同时也是默认选项
  • no,使用操作系统的默认方式刷新缓冲区,最快。

AOF文件重写并不会依赖于现有的AOF文件,而是根据当前内存中的数据状态,对某个key而言,直接用一条写命令代替旧文件中所有对于该key的操作,当value包含多个元素且数量大于64时才使用多条写命令记录。同时,由于AOF文件重写是由子进程负责(同样利用了写时复制技术),在子进程重写AOF文件时,主进程可能执行了客户端命令修改了数据,存在数据不一致的问题。故Redis还有一个AOF重写缓冲区,在开启AOF子进程之后会将写命令同时追加到AOF缓冲区和AOF重写缓冲区,当子进程重写结束后,主进程再将AOF重写缓冲区中的内容追加写入新的AOF文件。

AOF持久化的优缺点如下:

  • 优点。持久化频率高,故安全性更高,存储的是操作日志,可读性高。
  • 缺点。日志文件相比RDB的二进制文件更大, 数据恢复速度也更慢。

部署模式

单机模式

默认情况下Redis启动就是单机模式,一个结点负责执行客户端的读写、持久化等。

主从模式

部署多台Redis服务器,一台服务器作为master节点,负责客户端的读写。其他的多台服务器作为slave节点,数据全部来自master节点,仅负责客户端的读请求。在Redis的配置文件中默认当前服务器为master节点,需手动修改配置文件来设置slave节点。slave节点复制master节点的数据时有两种方式:

  • 全量复制。slave节点给master节点发送一个sync同步命令,master节点开启子进程执行RDB持久化,同时将持久化期间接收到的客户端写命令追加到复制缓冲区。master节点在持久化结束后将rdb文件发送给slave节点。slave节点接收到rdb文件后,先将其保存在磁盘上,然后加载进内存。再然后master节点会发送复制缓冲区中的命令发给slave节点,slave节点再执行这些命令。
  • 增量复制。master节点和slave节点各自维护一个复制缓冲区的偏移量offset(便于描述将master结点的offset称为m_offset,slave结点的offset称为s_offset),master节点在接收到客户端的写命令后会增加自己的m_offset,slave节点在接收到master节点发送的写命令后会增加自己的s_offset。slave节点给master节点发送psync命令时会携带自己的s_offset,此时master节点仅发送本地复制缓冲区中的从s_offset到m_offset区间内的内容(即还没同步完成的写命令),slave节点接收到后会执行写命令并返回给master节点自己最新的s_offset。

Redis2.8之后使用psync命令代替了sync命令,当slave节点是第一次连接master节点时使用全量复制,其他情况使用增量复制(例如slave节点断线重连,网络异常丢包等)。当搭建了正常的主从架构之后,master节点接收到客户端的命令后会判断该命令是否会导致数据的更新,是则向所有slave节点发送该命令进行增量复制,从而保证数据一致性。值得注意的是,如果增量复制时某个slave节点的s_offsett到m_offset区间内的内容被覆盖了,会变为全量复制。

哨兵模式

主从模式中一旦master节点宕机,需要手工选择slave节点成为新的master节点,而哨兵模式可以实现自动推选master节点。哨兵是用于监测所有master节点和slave节点状态的工具,当某个master节点下线后就会将其下面的slave节点之一升级为新的master节点继续服务(这个过程称为自动故障转移),从而保证集群的高可用。哨兵也可以有多个,从而避免单点故障,哨兵之间也是互相监测的状态。

哨兵通过不断向其监测的对象发送ping请求实现可用性监测,当某个被监测对象在规定时间内仍未回复时,该哨兵认为其处于主观下线状态。当有足够多的哨兵(少数服从多数)认为某个被监测对象属于主观下线状态时,则认为该被监测对象处于客观下线状态,如果该对象是master节点则需进行自动故障转移。在这个过程中,哨兵们会通过投票机制让下线的master节点的某个slave节点晋级为新的master节点(修改配置文件),并通知其他slave节点改为复制新的master节点,当客户端试图连接下线的master节点时也会向其返回新的master节点地址。

集群模式

哨兵模式中仍仅一个master结点,存在高并发写瓶颈,且所有结点中的数据都是完整的,浪费内存,而集群模式实现了Redis的分布式存储,对数据进行分片后在不同的节点中存储。集群中的每个节点又是一个主从复制模型,且至少一主一从。对数据的分片采用hash槽的方式(并非一致性hash),Redis集群共有16384个hash槽,集群中的每个节点各自负责一部分的hash槽。当添加或删除某个节点时只需要将部分hash槽从一个节点移动到另一个节点(本质是key的复制)即可,此时整个集群依然是可供服务的。当某个节点中的master节点和slave节点都挂掉时,整个集群也会挂掉。

集群中所有master节点和slave节点都是互相监测的状态,以实现故障发现。与哨兵模式一样存在主观下线与客观下线,如果是持有hash槽的节点下线(即某个master节点),则需进行故障转移。由下线的master节点的slave节点发起选举,由其他master节点进行投票,达到多数条件则让slave节点晋升。

注:当客户端向集群查找某个key时,先定位其所属的hash槽,然后找到负责该hash槽的节点,接下来的步骤与主从模式一致。

分布式锁

分布式锁用于在分布式场景下多个系统的进程访问共享资源时进行访问控制。实现分布式锁的方式有数据库,Redis和Zookeeper。

mysql实现分布式锁

新建一个lock表,里面只有一个字段status默认为0,表示锁空闲。不同的进程通过查询lock表获取锁状态,如果为0则修改为1,表示锁被占有,然后执行业务逻辑,最后将status修改为0释放锁。但这种方式存在死锁,效率低等问题。

Redis实现分布式锁

  • setnx + expire,setnx全称set if not exists,当key不存在时才插入成功,expire用于设置过期时间。客户端伪代码如下:

    while (!获取锁超时) {
        if (setnx(key, val) == 1) {
            expire(key, 100);
            //业务逻辑...
            delete(key);
        }
    }
    

    这样实现的问题有setnx与expire并非原子操作可能出错,delete释放锁时可能key已过期被其他进程获取导致误删。

  • set key value NX PX expiretime,NX选项等同于if not exists,PX选项用于设置过期时间。客户端伪代码如下:

    while (!获取锁超时) {
        if (set(key, val, "NX", "PX", 100) == 1) {
            //业务逻辑...
            if (get(key) == val)	//1
                delete(key);		//2
        }
    }
    

    代码1通过比对val是否是自己设置的值来防止释放他人的锁(要求所有竞争者设置的val都不一样),而代码1和2并不是一个原子操作,故需将代码1和2写入Lua脚本再执行(Redis保证Lua脚本操作的原子性)。此时还有一个问题是可能业务处理未结束,但锁已过期释放,此时需要回滚业务,通常可以对业务处理时间进行预估,设置一个合理的过期时间,但这并不能保证业务处理时锁一定未被释放。

  • 在上一种方法的基础上,设置一个守护线程,每当分布式锁设定的过期时间过了三分之一时,就去重置过期时间,这样就可以保证业务执行时锁一定不会被释放。Redis的第三方工具Redisson(Java编写)中就提供了这种方式实现的分布式锁。

zookeeper实现分布式锁

新建永久结点/lock,不同进程想要获取锁时在/lock下新建临时有序结点,例如/lock/t1。如果自己是最左侧的结点(即序号最小),则获取锁执行业务逻辑,否则监听/lock的子节点删除事件。当监听到删除事件后,再次判断自己能否获取到锁,如此循环直到获取到锁。获取到锁的进程在执行业务逻辑后需删除自己创建的节点,由于临时有序节点的特性,当获取到锁的进程的服务器宕机后,zookeeper会自动删除该结点,从而避免死锁的发生。而在Redis实现的分布式锁中通过设置过期时间避免了死锁。

缓存与数据库的一致性问题

一般来说,在进行对数据库的写请求时,我们可以选择对缓存进行更新或者删除。前者可以增加缓存命中率,而后者可以尽量避免缓存与数据库的数据不一致的问题。删除缓存可以使得下次读请求未命中,从而直接读取数据库的最新值并重新写入缓存,但即使如此,也会出现数据不一致的问题。此外,一致性问题还涉及到更新缓存和更新数据库的执行顺序问题。如果真的想彻底杜绝缓存与数据库的数据不一致的问题,只能通过加锁同步的方式解决,但这样会使得缓存的效率大大降低。

更新缓存

更新缓存在高并发下会存在明显的一致性问题,下面举例(假设数据库数据为K,对应的缓存数据为K'):

          先缓存后数据库                先数据库后缓存
time    线程A       线程B             线程A       线程B
 |      K' = 1                       K = 1
 |                  K'= 2                        K = 2
 |                  K = 2                        K'= 2
 |      K = 1                        K'= 1
 v    ---------------------        ---------------------
	     K = 1,K' = 2                 K = 2,K'= 1

可以看到,如果使用更新缓存的策略,只要出现两个线程并发写冲突时就会出现数据不一致的问题。故对于经常发生写操作的数据,或者一致性要求较高的数据不推荐使用更新缓存。

删除缓存

删除缓存,虽然在极端情况下仍有数据不一致的问题,但相比更新缓存要好得多,下面举例(假设数据库数据为K = 3,对应的缓存数据为K' = K = 3):

          先缓存后数据库        先数据库后缓存(假设此时缓存K'已过期)
time    线程A       线程B             线程A       线程B
 |      删除K'                     读取K'miss
 |               读取K'miss        读取K = 3        
 |               读取K = 3                       K = 2
 |                 K'= 3                        删除K'
 |      K = 1                        K'= 3  
 v    ---------------------        ---------------------
	     K = 1,K' = 3                 K = 2,K'= 3

考虑到对数据库的读操作要比写操作快得多,故上图右侧的数据不一致的情况出现的概率其实是很低的。综合考虑,在数据一致性要求较高的场景下,推荐使用先更新数据库后删除缓存

延迟双删

思路很简单:删除缓存 --> 更新数据库 --> 等待1秒(具体可以是读业务预计时间+几百ms)再删除缓存。延迟双删最大的好处是保证了数据的最终一致性,但在延迟期间仍可能有其他读请求向缓存写入脏数据,造成短期数据不一致的问题。另一个问题时第二次删除缓存时可能失败,此时可以采取失败重试的方式直到删除成功为止(或者传入消息队列并确保被消费)。

Redis事务机制

涉及到四个基础命令:

  • MULTI,开启事务,实质上是将后面的命令入队,先不执行。
  • WATCH,监听某些key,如果在事务执行前被修改则不执行事务。
  • EXEC,执行事务,一次性执行队列中的所有命令,如果WATCH监视的key被修改则不执行事务。
  • DISCARD,放弃此次事务。

但Redis中并没有事务回滚的概念,如果队列中的某个命令执行出错,并不会影响其他命令的执行。

zset底层数据结构跳表的原理

为了让链表也能实现快速查找,结合了二分查找的思想,典型的空间换时间。原始有序链表放在最底层,依次向上扩展层级,每一层相比下一层减少约一半的结点数。每一层都是一个链表,每一个结点都有上下左右四个结点指向。典型的如:

1 9

1 5 9

1 3 5 7 9

查找时从最左上结点往右下查找,先向右遍历找到最接近且小于等于查找值的结点,然后向下一级延伸,如此循环直到最后一层结束。一般的代码实现并不能保证上一层结点数是下一层的一半(即上面的例子),插入结点时会以掷硬币的方式(1/2概率)将该结点向上级扩展。跳表空间复杂度为:O(n+n/2+n/4+...),约为O(2n),去掉常数,即O(n)。下面记录常见数据结构的时间复杂度:

# 二叉查找树 平衡二叉树 红黑树 跳表
查找 O(n) O(logn) O(logn) O(logn)
插入 O(n) O(logn) O(logn) O(logn)
删除 O(n) O(logn) O(logn) O(logn)

自己写的JAVA实现跳表:https://blog.csdn.net/qq_40246487/article/details/118764289?spm=1001.2014.3001.5501

其他问题

为什么快

  • 基于内存操作。
  • 优秀的底层数据结构设计。
  • 单线程 + IO多路复用。

应用场景

  • 缓存热点数据。
  • Session共享。
  • 分布式锁。

单线程or多线程

Redis主线程负责处理所有客户端的请求,是一个单线程,但持久化、主从复制等场景下会使用多线程。这样做的原因是Redis的瓶颈主要来自于内存限制和网络IO限制,所以将耗时长的IO操作交给多线程完成。

posted @ 2023-09-27 20:59  万里阳光号船长  阅读(4)  评论(0编辑  收藏  举报