Redis总结

186 阅读11分钟

一、 Redis基础

1. 数据类型

基本: String、List、Hash、Set、Zset

其他: HyperLogLog、Pub/Sub

2. keys和scan区别

keys: Redis是单线程的,keys指令会阻塞式的去扫指定的key列表,一般线上禁止使用。

scan: 可以无阻塞增量式获取指定的key列表,但是有一定的重复率,需要服务端去重,整体花费的时长较高。

二、 缓存雪崩、缓存击穿、缓存穿透

1. 缓存雪崩

描述:

  同一时刻,缓存大量失效,导致大量请求直接访问数据库,造成数据库压力急剧增大。

解决:

1. 缓存的过期时间设置随机值。
2. 热点数据不过期,异步更新缓存,适用于不严格要求的场景。
3. 服务限流、熔断,降级。

2. 缓存击穿

描述:

 某一时刻,单个热点key缓存失效,导致请求直接打到数据库,持续的高并发直接请求数据库。

解决:

1. 分布式锁,获取锁成功才执行数据库和写缓存操作。
2. 热点数据不过期。

3. 缓存穿透

描述:

  访问的数据,既不在缓存中,也不在数据库中,导致每次请求都直接打到数据库。

解决:

1. 缓存空值或者默认值。
2. 业务逻辑前置。
3. bloom filter。

三、持久化

1. RDB(Redis Data Base)

1. 定义

        RDB也就是内存快照,在指定的时间间隔内将内存中的数据集快照写入磁盘,RDB是以内存快照的形式持久化的,
   每次都是从Redis中生成一个快照进行全量备份。

2. 备份过程

    Redis将内存快照保存在dump.rdb二进制文件中。

(1)文件结构

RDB文件结构由五部分组成:

  • 长度5字节的常量字符串。

  • 4字节的version,标识RDB文件版本。

  • database:不定长度。

  • 1字节的EOF常量,表示文件正文内容结束。

  • check_sum:8字节,保存校验和。

image.png

例:

以下是数据库0和数据库3有数据的情况:

image.png

(2)文件创建

可以通过save指令或者bgsave指令手动触发RDB持久化
  • save:会阻塞redis其他的操作,导致无法响应客户端,不建议使用。

  • bgsave:创建子线程,进行快照保存操作,不阻塞其他操作。

默认情况下,可以进行redis设置,让其“N秒内至少有M次改动”满足时自动保存数据。

save 60 10 
    : 60秒内至少有10个键被改动

Redis默认设置:

save 60 10000
save 300 10
save 900 1

(3)过程

    Redis会fork一个子进程进行持久化,将数据写入一个临时文件中,持久化完成后替换旧的RDB文件。整个持久化过程中,
主进程(为客户端提供服务的进程)不参与IO操作,确保Redis的高性能。

整体流程图:

image.png

步骤:

  • 父进程fork创建子进程,这个过程父进程是阻塞的。父进程fork后,bgsave返回命令信息不在阻塞父进程。

  • 子进程对内存数据生成快照。

  • 父进程期间内接收新的写操作,使用COW机制写入。

  • 子进程文成快照写入,替换旧RDB文件,随后子进程退出。

fork:
    - linux系统中,fork会产生一个和父进程完全相同的子进程,子进程与父进程所有的数据均一致。
    - linux系统使用COW(Copy On Write)写时复制机制,fork子进程与父进程一般情况共同使用一段物理内存,
    只有在进程空间中发生修改时,内存空间才会复制一份出来。
    Redis持久化就是利用这项技术,在持久化时调用glibc函数fork子进程,在持久化过程中,当客户端的请求对内存中的数据进行
修改时,此时就会通过COW机制对数据段页面进行分离,也就是复制一块内存给主进程修改。父进程对内存的修改对于子进程是不可见
的,两者互不影响。

3. 优缺点

(1)优点

  • 恢复速度快,适合全量备份的场景。
  • 存储紧凑(二进制文件),节省空间。

(2)缺点

  • 容易丢失数据,两次快照之间的数据容易丢失。
  • RDB通过fork子进程对内存快照进行备份,是一个重量级操作,成本高。

2. AOF(Append Only File)

1. 定义

    AOF是把所有对内存进行修改的指令(写操作)以独立日志文件的方式进行记录,当Redis重启时通过执行AOF文件中的命令
来恢复数据。类似binlog的原理,AOF可以解决数据持久化实时性问题。

image.png

2. 备份过程

(1)文件结构

    写入AOF文件的所有命令都是以RESP格式进行保存的,是以纯文本的格式保存在AOF文件中。Redis客户端和服务端之间使用RESP的二进制文本协议进行通信。

(2)AOF过程

    AOF持久化进行备份时,客户端所有的写命令都会被追加到AOF缓冲区中,缓冲区中的数据根据Redis配置的同步策略同步到磁盘上的AOF文件中,追加保存每次写操作到文件末尾。当AOF文件达到重写策略的阈值时,Redis会对AOF日志进行重写。Redis重启时,通过加载AOF文件来恢复数据。

image.png

文件写入(write)&文件同步(sync)

    Linux系统为了提升性能,使用了页缓存。当我们将AOF缓冲区中的内容写到磁盘上时,此时没有真正的落盘,
而是在页缓存中,执行fsync/fdatasync命令来强制刷盘。

同步策略:

  • always:命令写入AOF缓冲区后立即调用系统write操作和fsync操作同步AOF文件,fsync完成后线程返回。这种情况下,每次写命令都会同步AOF文件,硬盘IO成为性能瓶颈。

  • no:命令写入缓冲区后,调用系统write操作,不对AOF进行fsync同步;同步由操作系统负责,通常30秒为一个周期。这种情况下,文件同步的时间不可控,且缓冲区中堆积的数据会更多。

  • everysec:命令写入缓存后调用write操作,write完成后线程返回;fsync同步文件操作由专门的线程每秒调用一次。这种方案。

(3)AOF重写

    通过fork一个子进程,重写一个新的AOF文件,该次重写不是读取旧的AOF文件进行复制,而是读取内存中的Redis数据库,
重写一份AOF文件。

能够压缩AOF文件的原因:

  • 过期的数据不再写入文件。

  • 无效的命令不再写入文件,例如被重复设置的值或者被删除的。

  • 多条命令合并为一条,防止单条命令过大造成缓存区溢出,会将命令拆分为多条。

问题一:为什么AOF重写时读取的是内存中的数据不是从现有的AOF文件中复制呢?

    答:AOF文件是以追加写命令的形式进行保存的,这就导致大量冗余的指令存储,从而AOF文件过大。例如一个key被写入10000次,
最终却被删除了,这种情况不仅占用内存,而且导致恢复速度较慢。因此复制AOF文件的形式并不能的到最大限度的压缩文件结果。

(4)重写时机

  • 当前AOF文件空间大于运行AOF重写时文件的最小体积,默认mb。

  • 当前AOF相比上次AOF的增长率达到一定值时。

(5)AOF重写流程

  1. bgrewriteaof触发重写,判断是否增在重写,存在等待其执行结束再执行;

  2. 主进程fork子进程,防止主进程阻塞影响服务;

  3. 子进程遍历Redis内存快照中数据写入临时AOF文件,同时将新的写指令写入AOF缓冲区&AOF重写缓冲区,前者是了写回旧的AOF文件,后者是为了后续刷新到临时AOF文件中,防止快照内存遍历时新的写入操作丢失;

  4. 子进程结束临时AOF文件写入后,通知主进程;

  5. 主进程将AOF重写缓冲区的数据写入临时AOF文件中;

  6. 主进程使用临时AOF文件替换旧AOF文件,完成重写过程;

image.png

3. 优缺点

(1)优点

  • 数据备份完整,丢失数据的概率较低,适用于对数据完整性较高的场景。

  • 日志文件可读,AOF可操作性强,通过操作日志进行修复。

(2)缺点

  • AOF日志文件较大,恢复速度较慢。

  • 同步写操作频繁会带来性能压力。

四、过期策略&内存淘汰机制

    Redis作为一个高性能数据库,可以直接从内存中获取数据,但是当数据量较大时,达到上限,想存入新的值时就会出现问题。
Redis提供了两种机制来解决:过期策略和内存淘汰机制。

1. 过期策略

我们在设置key时,可以为key指定一个过期时间,这个key是何时被删除的呢?

(1)定期删除

    Redis默认每个100ms随机抽取一些设置过期时间的key,检查key是否过期,如果过期就将其删除。因为存储的key太多,
如果全盘扫描的话,十分消耗性能,因此通过随机的方式。可能存在大部分漏删的情况,通过惰性删除解决。

(2)惰性删除

    惰性删除不再是Redis主动删除,而是客户端获取key的时候,检查key的是否已经过期,如果已经过期会删除这个key,
否则返回客户端。

    惰性删除可以解决一些问题,但是哪些没有被随机到,也没有被客户端访问的key就会一直保留在数据库中,
长期会导致内存耗尽,因此需要内存淘汰机制处理。

2. 内存淘汰机制

当内存使用达到某个阈值时,就会触发内存淘汰,Redis提供了几种策略。
  • noeviction:当内存不足以容纳写入数据时,新写入会报错。(默认策略)

  • allkeys-lru:当内存不足以容纳新数据时,移除最近最少使用的key。

  • allkeys-random:当内存不足以容纳新数据时,随机移除某个key。

  • volatile-lru:当内存不足以容纳新数据时,在设置过期时间的key中,移除最近最少使用的key。

  • volatile-random:当内存不足以容纳新数据时,在设置过期时间的key中,随机移除某个key。

  • volatile-ttl:当内存不足以容纳新数据时,在设置过期时间的key中,移除最早过期的key。

根据自己的业务需求,如果使用Redis仅作为缓存,不进行DB持久化,推进allkeys-lru;如果作为缓存和DB持久化,推荐volatile-lru。

3. LRU算法

    LRU(Least Recently Used),即最近最少使用,是一种缓存置换算法。核心思想是:如果一个数据在最近一段时间没有被使用到,
那么将来被使用到的概率也很小,所以选择淘汰掉。

代码实现:

public class LRUCache<k, v> {
    //容量
    private int capacity;
    //当前有多少节点的统计
    private int count;
    //缓存节点
    private Map<k, Node<k, v>> nodeMap;
    private Node<k, v> head;
    private Node<k, v> tail;

    public LRUCache(int capacity) {
        if (capacity < 1) {
            throw new IllegalArgumentException(String.valueOf(capacity));
        }
        this.capacity = capacity;
        this.nodeMap = new HashMap<>();
        //初始化头节点和尾节点,利用哨兵模式减少判断头结点和尾节点为空的代码
        Node headNode = new Node(null, null);
        Node tailNode = new Node(null, null);
        headNode.next = tailNode;
        tailNode.pre = headNode;
        this.head = headNode;
        this.tail = tailNode;
    }

    public void put(k key, v value) {
        Node<k, v> node = nodeMap.get(key);
        if (node == null) {
            if (count >= capacity) {
                //先移除一个节点
                removeNode();
            }
            node = new Node<>(key, value);
            //添加节点
            addNode(node);
        } else {
            //移动节点到头节点
            moveNodeToHead(node);
        }
    }

    public Node<k, v> get(k key) {
        Node<k, v> node = nodeMap.get(key);
        if (node != null) {
            moveNodeToHead(node);
        }
        return node;
    }

    private void removeNode() {
        Node node = tail.pre;
        //从链表里面移除
        removeFromList(node);
        nodeMap.remove(node.key);
        count--;
    }

    private void removeFromList(Node<k, v> node) {
        Node pre = node.pre;
        Node next = node.next;

        pre.next = next;
        next.pre = pre;

        node.next = null;
        node.pre = null;
    }

    private void addNode(Node<k, v> node) {
        //添加节点到头部
        addToHead(node);
        nodeMap.put(node.key, node);
        count++;
    }

    private void addToHead(Node<k, v> node) {
        Node next = head.next;
        next.pre = node;
        node.next = next;
        node.pre = head;
        head.next = node;
    }

    public void moveNodeToHead(Node<k, v> node) {
        //从链表里面移除
        removeFromList(node);
        //添加节点到头部
        addToHead(node);
    }

    class Node<k, v> {
        k key;
        v value;
        Node pre;
        Node next;

        public Node(k key, v value) {
            this.key = key;
            this.value = value;
        }
    }
}

LRU在Redis中的实现:

    Redis使用近似LRU算法,通过随机采样淘汰数据,每次随机选择5key,从中淘汰掉最近最少使用的key。Redis为了实现近似LRU
算法,给每个key额外增加一个24bit的字段,用来存储该key最后一次被访问的时间。

五、常见面试问题

1. Redis的同步机制,哨兵机制?

敬请期待!

2. Redis为什么这么快?

敬请期待!

3. Redis是单线程的?

敬请期待!

4. Bigkey问题的解决思路?

敬请期待!

5. 缓存和数据库一致性如何保证?

敬请期待!