Skip to content

Redis

Redis数据结构

跳表

单链表先进行排序,然后针对 有序链表 为了实现高效的查找,可以使用跳表这种数据结构

其根本思想是 二分查找 的思想。

跳表的前提条件是 针对 有序的单链表 ,实现高效地查找,插入,删除。

Redis中的 有序集合 sorted set 就是用跳表实现的。

  • 跳表结合了链表和二分查找的思想
  • 由原始链表和一些通过“跳跃”生成的链表组成
  • 第0层是原始链表,越上层“跳跃”的越高,元素越少
  • 上层链表是下层链表的子序列
  • 查找时从顶层向下,不断缩小搜索范围

image-20220831132054924

image-20220831132156312

Redis 持久化

RDB

RDB持久化是指在指定的时间间隔内将内存中的数据集快照写入磁盘[默认],将内存中数据以快照的方式写入到二进制文件中,默认的文件名为dump.rdb,具体操作是Redis进程执行fork操作创建子进程,RDB持久化过程由子进程负责,完成后自动结束。阻塞只发生在fork阶段,一般时间很短。基本上Redis内部所有的RDB操作都是采用 bgsave 命令

Redis是单线程,RDB阻塞fork耗时问题?

以RDB持久化的过程为例,假设我们在配置上是定时去执行RDB存储。edis有自己的一套事件处理机制,主要处理文件事件(命令请求和应答等等)和时间事件(RDB定时持久化、清理过期的Key等的),线程不停地轮询就绪的事件,发现RDB的事件可执行时,则调用BGSAVE命令,fork出一个子进程来进行完成持久化(生成RDB文件)

AOF

根据配置将写命令存储至日志文件中,顺序写(buffer缓冲区)&&异步刷盘(子线程),重写AOF文件也是需要 fork 子进程。Redis4.0之后支持混合持久化。

AOF持久化策略

  • appendfsync always:每执行一个写操作,立即将该命令持久化到AOF文件中,性能比较差
  • appendfsync everysec:每秒持久化一次
  • appendfsync no:redis根据不同的情况,在一定的时间段中进行持久化

同时开启了RDB和AOF,redis重启之后默认首先执行AOF中的命令,加载数据,之后同步RDB文件中的数据,有可能存在RDB文件中的数据将AOF执行后的数据覆盖

Redis 缓存策略

缓存过期策略

1.定期删除,Redis 默认是每隔 100ms 就随机抽取一些设置了过期时间的 key,检查其是否过期,如果过期就删除。[100ms间隔查看3个key] 2.惰性删除,操作key时redis会检查key的有效期,如果到期则删除key,并返回空值给用户

缓存淘汰机制

Redis内存已经存满的情况下,再添加一个新的key的数据,执行淘汰机制

  • volatile-lru 在内存不足的时,Redis会将设置了过期时间的可以中干掉一个最近最少使用的key
  • allkeys-lru 在内存不足时,Redis会从所有的key中干掉一个最近最少使用的key(最常用
  • volatile-lfu 在内存不足的时,Redis会将从设置了过期时间的key中干掉一个使用频次最少的key
  • allkeys-lfu 在内存不足的时,Redis会将从所有的key中干掉一个使用频次最少的key
  • volatile-random 在内存不足的时,Redis会随机的从设置了过期时间的key中干掉一个
  • allkeys-random 在内存不足的时,Redis会随机的从所有的key中干掉一个
  • volatile-ttl 在内部不足时,Redis将有效期最短的key干掉
  • noeviction 内存不足时,直接报错(Redis默认的策略

LRU算法实现

image-20230211194643871

java
public class LRUCache<K,V> extend LinkedHashMap<K,V> {
    private int capacity;
    
    /**
     * 构造方法
     *
     * @param capacity 缓存大小
     */
    public LRUCache(int capacity){
        super(capacity,0.75f,true);
        this.capacity = capacity;
    }
    
    /**
     * 如果map中的数据量大于设定的最大容量,返回true,再新加入对象时删除最老的数据
     *
     * @param eldest 最老的数据项
     * @return true则移除最老的数据
     */
    @Overrrite
    public boolean removeEldestEntry(Map.Entry<K,V> eldest){
        // 当map中的数据量大于指定缓存容量时,自动移除最老的数据
        return size() > capacity;
    }
}
public class LRUCache<K,V> extend LinkedHashMap<K,V> {
    private int capacity;
    
    /**
     * 构造方法
     *
     * @param capacity 缓存大小
     */
    public LRUCache(int capacity){
        super(capacity,0.75f,true);
        this.capacity = capacity;
    }
    
    /**
     * 如果map中的数据量大于设定的最大容量,返回true,再新加入对象时删除最老的数据
     *
     * @param eldest 最老的数据项
     * @return true则移除最老的数据
     */
    @Overrrite
    public boolean removeEldestEntry(Map.Entry<K,V> eldest){
        // 当map中的数据量大于指定缓存容量时,自动移除最老的数据
        return size() > capacity;
    }
}

Redis 缓存问题

缓存穿透

缓存穿透是指长时间流量缓存无法命中Key,换而言之Key值不存在。假如一个查询条件无查询结果,并且业务对无结果的Key执行不缓存操作,每次查询流量都会打入数据库,当流量中有较多的Key长时间无法命中,缓存穿透现象发生。

使用布隆过滤器

使用BitMap(需要精确判断的场景,优先选择BitMap)

使用BitMap动态维护一个集合,当访问数据库前,先查询数据的主键是否存在集合中,以此作为是否访问数据库的依据。

BitMap新增数据或者移除数据属于轻量级操作,检查操作的准确度依赖于动态集合维护的闭环的完整性。比如向数据库增加数据时需要向BitMap中添加数据,从数据库中删除数据需要从BitMap中移除数据。如果要求严格的检查可靠性,则可以单独维护一个分布式定时任务,定期更新BitMap数据。

java
/**
 * 查询订单详情
 *
 * @param orderId 订单ID
 * @return BuOrder
 */
@Override
public BuOrder getOrder(Integer orderId) {
    /* 如果不存在,则快速返回,流量不走DB */
    if (!RedisUtils.getBit(ORDER_BITMAP_KEY, orderId)) {
        return null;
    }
    return getById(orderId);
}
/**
 * 查询订单详情
 *
 * @param orderId 订单ID
 * @return BuOrder
 */
@Override
public BuOrder getOrder(Integer orderId) {
    /* 如果不存在,则快速返回,流量不走DB */
    if (!RedisUtils.getBit(ORDER_BITMAP_KEY, orderId)) {
        return null;
    }
    return getById(orderId);
}

缓存击穿

缓存击穿是指某个Key在访问流量较高的情况下,缓存突然失效,瞬间无缓冲流量全部打到数据库,数据库压力瞬间剧增。缓存击穿的特点是发生概率小、危害大。

  • 适当延长热点Key缓存过期时间

  • 更新缓存采用互斥锁来实现,只有获得锁的请求方能连接数据库查询数据

缓存雪崩

存在某时刻大面积缓存Key失效的情况,当Key失效数据未更新,并且同时有流量请求时,可能会发生雪崩现象(数据库因查询连接过多宕机或者响应缓慢)。

  • 不同的业务缓存采用不同的过期时间

  • 相同的业务在原来恒定过期时间基础上增加随机数,尽可能减少缓存Key在同一时刻失效的数量。

缓存倾斜

json
问题:热点数据可能在某台redis服务器上,这样会导致这台redis服务器需要接收大量的请求!
    1.扩展主从结构,扩展大量的从服务器,减轻主服务器的压力
    2.将热点数据备份到JVM内存中
问题:热点数据可能在某台redis服务器上,这样会导致这台redis服务器需要接收大量的请求!
    1.扩展主从结构,扩展大量的从服务器,减轻主服务器的压力
    2.将热点数据备份到JVM内存中

缓存与数据库双写一致性

Cache Aside Pattern

  • 读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。
  • 更新的时候,先更新数据库,然后再删除缓存

初级缓存不一致问题及解决方案

问题:先更新数据库,再删除缓存。如果删除缓存失败了,那么会导致数据库中是新数据,缓存中是旧数据,数据就出现了不一致。

image-20230211193245438

解决思路 1:先删除缓存,再更新数据库。如果数据库更新失败了,那么数据库中是旧数据,缓存中是空的,那么数据不会不一致。因为读的时候缓存没有,所以去读了数据库中的旧数据,然后更新到缓存中。

解决思路 2:延时双删。依旧是先更新数据库,再删除缓存,唯一不同的是,我们把这个删除的动作,在不久之后再执行一次,比如 5s 之后。

java
public void set(key, value) {
    putToDb(key, value);
    deleteFromRedis(key);

    // ... a few seconds later
    deleteFromRedis(key);
}
public void set(key, value) {
    putToDb(key, value);
    deleteFromRedis(key);

    // ... a few seconds later
    deleteFromRedis(key);
}

删除的动作,可以有多种选择,比如:1. 使用 DelayQueue,会随着 JVM 进程的死亡,丢失更新的风险;2. 放在 MQ,但编码复杂度为增加。总之,我们需要综合各种因素去做设计,选择一个最合理的解决方案。

复杂的数据不一致问题分析

数据发生了变更,先删除了缓存,然后要去修改数据库,此时还没修改。一个请求过来,去读缓存,发现缓存空了,去查询数据库,查到了修改前的旧数据,放到了缓存中。随后数据变更的程序完成了数据库的修改。完了,数据库和缓存中的数据不一样了...

为什么上亿流量高并发场景下,缓存会出现这个问题?

只有在对一个数据在并发的进行读写的时候,才可能会出现这种问题。其实如果说你的并发量很低的话,特别是读并发很低,每天访问量就 1 万次,那么很少的情况下,会出现刚才描述的那种不一致的场景。但是问题是,如果每天的是上亿的流量,每秒并发读是几万,每秒只要有数据更新的请求,就可能会出现上述的数据库+缓存不一致的情况

解决方案如下:

更新数据的时候,根据数据的唯一标识,将操作路由之后,发送到一个 jvm 内部队列中。读取数据的时候,如果发现数据不在缓存中,那么将重新执行“读取数据+更新缓存”的操作,根据唯一标识路由之后,也发送到同一个 jvm 内部队列中。

一个队列对应一个工作线程,每个工作线程串行拿到对应的操作,然后一条一条的执行。这样的话,一个数据变更的操作,先删除缓存,然后再去更新数据库,但是还没完成更新。此时如果一个读请求过来,没有读到缓存,那么可以先将缓存更新的请求发送到队列中,此时会在队列中积压,然后同步等待缓存更新完成。

Redis 事务

一个事务中存在多个命令,要么一起执行,要么一起失败!但是redis中的事务不具有原子性! 开启事务,执行一系列命令,但是这些命令不会立即执行,而是放在一个队列中,如果要执行事务,队列中的命令全部执行!如果取消事务,这个队列中的所有命令全部作废!

  • 开启事务:multi
  • 执行事务:exec
  • 取消事务:discard

1.在命令执行的时候出错了,其他正常的命令还是会继续执行 2.命令在加入队列时出错了,在执行事务时会全部取消执行

Redis事务一般和watch命令配置使用:

watch主要用来监控一个key是否被修改,如果在事务执行过程中watch监控的key被修改了,事务自动取消执行(乐观锁),事务取消后,监控也就自动被取消。

Redis 高可用

2.6.1、Redis 主从架构

image-20230211184927946

主从复制

「复制」也叫「同步」,在Redis使用的是「PSYNC」命令进行同步,该命令有两种模型:完全重同步和部分重同步。

如果是第一次「同步」,从服务器没有复制过任何的主服务器,或者从服务器要复制的主服务器跟上次复制的主服务器不一样,那就会采用「完全重同步」模式进行复制。

如果只是由于网络中断,只是「短时间」断连,那就会采用「部分重同步」模式进行复制;假如主从服务器的数据差距实在是过大了,还是会采用「完全重同步」模式进行复制。

image-20230211185054861

完全同步

主服务器要复制数据到从服务器,首先是建立Socket「连接」,这个过程干一些信息校验啊、身份校验等事情,

然后从服务器就会发「PSYNC」命令给主服务器,要求同步(这时会带「服务器ID」RUNID和「复制进度」offset参数,如果从服务器是新的,那就没有)

主服务器发现这是一个新的从服务器(因为参数没带上来),就会采用「完全重同步」模式,并把「服务器ID」(runId)和「复制进度」(offset)发给从服务器,从服务器就会记下这些信息。

随后,主服务器会在后台生成RDB文件,通过前面建立好的连接发给从服务器。

从服务器收到RDB文件后,首先把自己的数据清空,然后对RDB文件进行加载恢复。

这个过程中,主服务器也没闲着(继续接收着客户端的请求),主服务器把生成RDB文件「之后修改的命令」会用「buffer」记录下来,等到从服务器加载完RDB之后,主服务器会把「buffer」记录下的命令都发给从服务器,这样一来,主从服务器就达到了数据一致性了(复制过程是异步的,所以数据是『最终一致性』)。

部分同步

靠「offset」来进行部分重同步。每次主服务器传播命令的时候,都会把「offset」给到从服务器,主服务器和从服务器都会将「offset」保存起来(如果两边的offset存在差异,那么说明主从服务器数据未完全同步)。

从服务器断连之后,就会发「PSYNC」命令给主服务器,同样也会带着RUNID和offset(重连之后,这些信息还是存在的)。

主服务器收到命令之后,看RUNID是否能对得上,对得上,说明这可能以前就复制过一部分了,接着检查该「offset」是否在主服务器记录的offset还存在(因为主服务器记录offset使用的是一个环形buffer,如果该buffer满了,会覆盖以前的记录)

image-20230211185350907

如果找到了,那就把从缺失的一部分offer开始,把对应的修改命令发给从服务器;如果从环形buffer没找到,那只能使用「完全重同步」模式再次进行主从复制了

image-20230211185413705

2.6.2、Redis 哨兵模式

「哨兵」干的事情主要就是:监控(监控主服务器的状态)、选主(主服务器挂了,在从服务器选出一个作为主服务器)、通知(故障发送消息给管理员)和配置(作为配置中心,提供当前主服务器的信息)

可以把「哨兵」当做是运行在「特殊」模式下的Redis服务器,为了「高可用」,哨兵也是集群架构的。

image-20230211185527710

首先它需要跟Redis主从服务器创建对应的连接(获取它们的信息),每个哨兵不断地用ping命令看主服务器有没有下线,如果主服务器在「配置时间」内没有正常响应,那当前哨兵就「主观」认为该主服务器下线了;其他「哨兵」同样也会ping该主服务器,如果「足够多」(还是看配置)的哨兵认为该主服务器已经下线,那就认为「客观下线」,这时就要对主服务器执行故障转移操作。

「哨兵」之间会选出一个「领头」,选出领头的规则也比较多,总的来说就是先到先得(哪个快,就选哪个),由「领头哨兵」对已下线的主服务器进行故障转移。

首先要在「从服务器」上挑选出一个,来作为主服务器(这里也挑选讲究,比如:从库的配置优先级、要判断哪个从服务器的复制offset最大、RunID大小、跟master断开连接的时长…) 然后,以前的从服务器都需要跟新的主服务器进行「主从复制」,已经下线的主服务器,再次重连的时候,需要让他成为新的主服务器的从服务器

Redis在主从复制的和故障转移的过程中数据丢失问题

1.「主从复制」流程来看,这个过程是异步的(在复制的过程中:主服务器会一直接收请求,然后把修改命令发给从服务器),假如主服务器的命令还没发完给从服务器,自己就挂掉了。这时候想要让从服务器顶上主服务器,但从服务器的数据是不全的

2.有可能哨兵认为主服务器挂了,但真实是主服务器并没有挂( 网络抖动),而哨兵已经选举了一台从服务器当做是主服务器了,此时「客户端」还没反应过来,还继续写向旧主服务器写数据,等到旧主服务器重连的时候,已经被纳入到新主服务器的从服务器了…所以,那段时间里,客户端写进旧主服务器的数据就丢了(脑裂问题

两种情况(主从复制延迟&&脑裂),都可以通过配置来「尽可能」避免数据的丢失(达到一定的阈值,直接禁止主服务器接收写请求,企图减少数据丢失的风险

2.6.3、Redis分片集群

Redis集群配置问题

  • 1.Redis集群无中心节点
  • 2.集群中各个节点之间有个互访机制,ping-pong选举机制,Redis集群中节点必须是2N+1
  • 3.Redis集群中总共分16384个槽位(slot),在存储数据时,就将key采用CRC16算法计算hash值,然后将该(hash值%16384)得到具体的槽位值,这个槽位值就是当前key存储的节点位置
  • 4.为了保证redis集群中节点的可靠性,每个主节点配置一个从节点
  • 5.当集群中半数以上节点宕机的话,整个集群就会宕机