Loading...
墨滴

jrh

2021/11/11  阅读:30  主题:橙心

Java 面试八股文之中间件篇(三)

前言

这是系列文章【 Java 面试八股文】中间件篇的第三期。

【 Java 面试八股文】系列会陆续更新 Java 面试中的高频问题,旨在从问题出发,带你理解 Java 基础,数据结构与算法,数据库,常用框架等。该系列前几期文章可以通过下方给出的链接进行查看~

按照惯例——首先要做几点说明:

  1. 【 Java 面试八股文】中的面试题来源于社区论坛,书籍等资源;感谢使我读到这些宝贵面经的作者们。
  2. 对于【 Java 面试八股文】中的每个问题,我都会尽可能地写出我自己认为的“完美解答”。但是毕竟我的身份不是一个“真理持有者”,只是一个秉承着开源分享精神的 “knowledge transmitter” & 菜鸡,所以,如果这些答案出现了错误,可以留言写出你认为更好的解答,并指正我。非常感谢您的分享。
  3. 知识在于“融释贯通”,而非“死记硬背”;现在市面上固然有很多类似于“Java 面试必考 300 题” 这类的文章,但是普遍上都是糟粕,仅讲述其果,而不追其源;希望我的【 Java 面试八股文】可以让你知其然,且知其所以然~

那么,废话不多说,我们正式开始吧!

往期文章

Redis 篇(二)

1、Redis 的持久化机制


Redis 提供了三种持久化机制:

  • RDB 持久化
  • AOF 持久化
  • RDB-AOF 混合持久化

RDB 持久化

RDB (Redis DataBase)是 Redis 默认的持久化方式。RDB 方式会按照一定的时间将内存的数据以快照(Snapshot)的形式保存到硬盘中,对应产生的文件为 dump.rdb。

RDB 相关的配置

当 Redis 服务启动时,便会自动加载 redis.conf 文件里的配置信息。

redis.conf 中关于 RDB 持久化相关的配置有以下几项重要内容(Redis 版本为 6.0-1.9):

save 900 1
save 300 10
save 60 10000

stop-writes-on-bgsave-error yes

rdbcompression yes

其中:

save 900 1
save 300 10
save 60 10000

表示在规定的时间内,Redis 发生了多少次写的操作后,便会触发一次 BGSAVE 命令,也就是产生一次快照。在默认的配置下,900 s 内至少发生一次写操作,300 s 内至少发生 10 次写操作 ,60 s 内至少发生 10000 次写操作,只要满足任一条件,均会触发 BGSAVE。关于 BGSAVE 命令,在下文中马上就会介绍。

stop-writes-on-bgsave-error yes

该配置表示当 BGSAVE 子进程出错时,主进程便会停止新的写入操作,这样做是为了保证持久化数据的一致性。

rdbcompression yes

该配置表示将使用 LZF 压缩字符串后再写入到 RDB 文件中。不过 Redis 本身就属于 CPU 密集型服务器,如果开启压缩,便会带来更多的 CPU 消耗。对于 Redis,CPU 的成本比硬盘的成本价值更高,所以,我们可以视自己的项目情况来设置该选项。

RDB 持久化触发机制

RDB 持久化可以手动使用命令触发,也可以自动触发。

先来讲一下如何手动使用命令触发。我们可以输入以下两种命令:

  • SAVE
  • BGSAVE

SAVE 命令最大的缺点是会阻塞 Redis 主进程,所以在线上环境建议禁用;BGSAVE 命令则是会 fork 出一个子进程来完成 RDB 持久化,基本上不会阻塞服务器的主进程(fork 阶段时还是会对主进程产生阻塞)。

而自动触发 RDB 持久化的方式或场景有以下几种:

  • 根据 redis.conf 配置文件中的 SAVE <seconds> <changes> 定时触发
  • 主从复制时,主节点自动触发
  • 执行 debug reload 命令时
  • 执行 shutdown 命令且未开启 AOF 持久化时

第一种方式便是通过在 redis.conf 配置文件中的 SAVE <seconds> <changes>。该方式会定时触发 RDB 持久化,使用的命令是 BGSAVE

第二个场景是在主从同步时,主节点自动触发的:

  1. 首先 Slave 从服务器会连接到主服务器,并发送 SYNC 命令
  2. Master 主服务器接收到 SYNC 命令后便会执行 BGSAVE 命令生成 RDB 文件,并使用缓冲区记录在此后执行的所有写命令
  3. 在 RDB 文件生成后,Master 向所有的 Slave 发送快照文件,并在发送期间继续记录被执行的写命令
  4. Slave 收到快照文件后,便会丢弃所有的旧数据,载入新的快照
  5. Master 在快照发送完毕后,向 Slave 发送缓冲区中的写命令
  6. Slave 执行来自 Master 缓冲区的写命令完成全量同步

第三个自动触发 RDB 持久化的场景是执行 debug reload 命令时。

第四个自动触发 RDB 持久化的场景是执行 shutdown 命令关闭服务器,且并没有开启 AOF 持久化功能时。此时会执行一次 BGSAVE 命令,完成 RDB 持久化。

BGSAVE 的实现原理

当 Redis 主进程执行了 BGSAVE 命令,首先会检查是否存在 AOF/RDB 子进程,如果存在则直接返回;如果不存在便会调用 rdbSaveBackground() 方法,该方法会调用操作系统的 fork 函数创建出一个子进程。

Linux 的 fork 函数使用了 COW(Copy On Write)即:写时复制技术。传统的复制技术会将主进程的内存数据整份拷贝到子进程中,这两个进程持有的数据段与堆栈都是相互独立的。而 COW 的原理是为子进程创建一个虚拟空间,实际上则是与主进程共享相同的物理空间。

在 fork 完成后,系统会将内存页权限都设置为 read-only。当两个进程都对内存进行读操作时,则相安无事;一旦某个进程收到了对内存写的请求,便会触发页异常中断(page-fault),系统便会将发生异常的页复制一份专用副本给调用进程,而其他的进程所见到的最初资源保持不变。

在子进程将内存数据写入到一个临时的 RDB 文件后,主进程会将原来的旧文件替换掉,即完成了一次 RDB 持久化。在此期间,主进程可以通过操作副本继续处理客户端的写请求,不会出现阻塞的情况。

大家再来思考一个问题:BGSAVE 命令发生阻塞的点在哪里?

其实很简单,在上文中我也提到了,BGSAVE 发生阻塞的点在 fork 子进程时。

RDB 持久化的优缺点

先来谈一下优点:

  1. 执行效率高,因为是全量同步,所以适用于大规模数据的备份恢复

    RDB 持久化使用 BGSAVE 命令,该命令的原理是 fork 出一个子进程,并使用 COW 技术,使得主进程不会发生阻塞。

  2. 备份的文件占用空间小

    RDB 备份的是数据快照,相对于 AOF 文件来说要更小一些。

RDB 持久化的缺点:

  1. 内存数据的全量同步,数据量大时,由于磁盘 I/O 而严重影响到性能

    每次 RDB 持久化都是将内存数据完整地写入到磁盘一次,这是全量同步,并不是增量同步。所以,当数据量大或是写操作频繁时,必然会引起大量的磁盘 I/O 操作而影响到性能。

  2. 数据安全性低

    因为 RDB 持久化是在一定间隔时间才会触发一次,所以,一旦 Redis 发生故障,就有可能因为没有及时执行 RDB 持久化而导致近期的数据丢失。

AOF 持久化

AOF (Append-Only-File)持久化是以独立日志的方式,记录除了查询以外的所有变更数据库状态的指令。与 RDB 不同,RDB 持久化方式相当于是全量同步,而 AOF 则是以 append 的形式追加保存到 AOF 文件中(appendonly.aof),是一种增量同步的策略。

AOF 相关的配置

redis.conf 中关于 AOF 持久化相关的配置有以下几项重要内容(Redis 版本为 6.0-1.9):

appendonly no

# appendfsync always
appendfsync everysec
# appendfsync no

auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb

Redis 默认的情况下,AOF 持久化是关闭的:

appendonly no

我们需要将 appendonly 设置为 yes,并重启服务器使 AOF 开启生效。

AOF 追加文件的方式有以下三种:

# appendfsync always
appendfsync everysec
# appendfsync no

always 表示,一旦缓存区的内容发生变化,就将缓存区的变更追加到 AOF 文件中;everysec 表示每隔一秒,便将缓存区的内容追加到 AOF 文件中;no 选项表示将 AOF 的追加操作交给操作系统处理,一般情况下,操作系统会等待缓存区被填满,才会同步数据到磁盘文件。默认的情况下,AOF 的追加机制是 everysec。我推荐也是使用默认的追加机制,这样可以兼顾到性能与安全性。

auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb

我们知道,AOF 持久化是以 append 形式追加到 AOF 文件中的,为了避免 AOF 日志变得越来越大,Redis 设置了重写 AOF 日志的机制以达到对 AOF 日志瘦身的效果:

auto-aof-rewrite-percentage 指的是当前 AOF 文件比上次重写后的增长百分比,一旦达到这个比率就会进行 AOF 重写;auto-aof-rewrite-min-size 表示 AOF 文件最小的重写大小,只有当 AOF 文件大小大于该值时才可能发生重写操作。除了在 redis.conf 配置文件中指定 AOF 自动重写的时机,我们还可以直接输入 bgrewriteaof 命令手动触发 AOF 重写。

AOF 重写机制

在没有触发 AOF 重写时,Redis 客户端会将写命令 append 到缓存区 aof_buf 中,然后再从 aof_buf 同步到 AOF 文件中。等到 Redis 重启服务,要进行数据恢复时,会优先加载 AOF 日志进行指令重放以恢复数据,如果没有 AOF 文件才会去加载 RDB 文件。

如果触发了 bgrewriteaof 命令,首先系统会判断当前是否有正在执行的 AOF 重写子进程以及正在执行的 BGSAVE 子进程,如果有正在执行的 AOF 重写子进程则直接返回;如果有正在执行的 BGSAVE 子进程则会推迟。

如果没有正在执行的子进程,主进程便会 fork 一个子进程用于 AOF 重写,同理地,fork 阶段会阻塞主进程一段时间。在 fork 子进程完毕后,主进程仍然会处理客户端的写请求,它会将这些写操作记录到 aof_buf 与 aof_rewrite_buf 这两个缓冲区,而子进程则会采用类似 RDB 快照的方式,基于 COW 技术,全量遍历内存中的数据,然后逐页序列化到新的 AOF 文件中。

在子进程将快照数据全部写入到新的 AOF 文件后,便会通知主进程。主进程将 aof_rewrite_buf 缓冲区的新的写操作同步到新的 AOF 文件,并替换掉旧的 AOF 文件,这样便完成了一次 AOF 重写的过程。

AOF 持久化的优缺点

AOF 持久化的优点:

  1. 增量同步,对服务器性能影响小,速度比 RDB 要快,消耗内存少
  2. 可读性高,数据不易丢失

AOF 持久化的缺点:

  1. AOF 文件的体积大
  2. AOF 使用日志重放的方式来恢复数据,恢复的时间比 RDB 长

RDB-AOF 混合持久化

在 Redis 4.0 后,引入了一种新的持久化方式——RDB-AOF 混合持久化。

在 Redis 4.0 以前,AOF 持久化在进行重写时,子进程会先写一份全量的 AOF 日志。而在混合持久化的机制下,这里面的 AOF 日志便不再是全量日志了,而是自持久化开始到持久化结束的这段时间发生的增量 AOF 日志——子进程会通过管道获取主进程的增量数据。在 Redis 重启时,会先加载 RDB 文件的内容,然后再重放增量的 AOF 日志来代替之前 AOF 全量日志的重放,因此重启 Redis 恢复数据的效率便得到了大幅度提升。

RDB-AOF 混合持久化方式也是当前较为推荐的一种持久化方式。它使用 BGSAVE 做全量持久化,使用 AOF 日志做增量持久化,提升了持久化与数据恢复时的性能。

2、Redis 缓存的淘汰策略


Redis 过期的 Key 的删除策略

我们先来看一下 Redis 过期的 Key 的删除策略是怎样的?

首先,我们可以通过 expire 命令来设置一个 key 的过期时间:

expire key time

而 Redis 使用了两种删除过期数据的策略:

  • 惰性删除
  • 定期删除

所谓的惰性删除是指,Redis 并不会主动判断一个 Key 是否过期,只有客户端访问了一个 Key 时,Redis 才会检查它的过期时间,如果发现过期则立即删除。惰性删除这种机制虽然可以最大化地节省 CPU 资源,但对内存却是不友好的。在极端的情况下,很有可能出现大量的 Key 发生过期,但因没有访问到而不被删除,导致占用大量内存的情况。

第二种删除策略为定期删除。Redis 会将设置了 expire 过期时间的 key 放入到一个独立字典 expires 中。然后每隔一定的时间对该字典进行扫描,每一次扫描并不会对整个字典进行扫描,而是会随机出 20 个 Key,并删除扫描到这 20 个 Key 中已过期的 Key,如果发现已过期的比例超过了 1/4,则再次随机出 20 个 Key ,并重复该过程。定期删除弥补了惰性删除的不足,使得 CPU 和内存资源达到了一个平衡的效果。

Redis 缓存淘汰策略

Redis 的缓存淘汰策略是指在 Redis 用于缓存的内存不足时,该如何处理需要新写入到内存的数据以及如何释放内存空间。

Redis 提供了以下 8 种指定的策略:

  1. noeviction

    该策略对于写请求将不再提供服务,会直接返回错误;这也是 Redis 默认的缓存淘汰策略

  2. volatile-ttl

    从设置了过期时间的 Key 中,选择过期时间最小的 Key,进行淘汰

  3. volatile-random

    从设置了过期时间的 Key 中,随机选择 Key,进行淘汰

  4. volatile-lru

    从设置了过期时间的 Key 中,使用 LRU 算法选择 Key,进行淘汰

  5. volatile-lfu

    从设置了过期时间的 Key 中,使用 LFU 算法选择 Key,进行淘汰

  6. allkeys-random

    从所有的 Key 中,随机选择 Key,进行淘汰

  7. allkeys-lru

    从所有的 Key 中,使用 LRU 算法选择 Key,进行淘汰

  8. allkeys-lfu

    从所有的 Key 中,使用 LFU 算法选择 Key,进行淘汰

我们可以通过命令查看与设置当前 Redis 使用的缓存淘汰策略:

config get maxmemory-policy    
config set maxmemory-policy valatile-lru  

接下来,我们再来看一下 LRU 与 LFU 这两种内存淘汰算法。

LRU

LRU(Least Recently Used)会按照最近最少使用的原则来筛选数据,即最不常用的数据会被筛选出来。

我将使用最简单的方式来带你理解下该算法的设计:

首先,对于 LRU 底层存储的数据结构,我将使用双向链表 + 哈希表的实现方式。

双向链表按照被使用的顺序存储这些键值对,靠近链表头部的节点是最近使用的数据,而链表尾部的节点则是最久未使用过的数据。

哈希表代表缓存 cache,Key 存储双向链表节点对应的 Key,Value 用来存储节点本身,用来定位到该节点在双向链表中的位置。

当我们向内存中写数据时,也就是向 cache 执行 put 操作。首先会判断 cache 中是否存在 Key,如果存在就重写 Value 值,并且,我们需要将该节点挪动到双向链表的头部位置;如果 cache 中不存在 Key,那么就新增节点到双向链表的头部,同时我们还需要判断内存空间是否已满,如果满了,则删除链表尾部的节点。

当我们向内存中读数据时,也就是向 cache 执行 get 操作。我们会先判断 cache 中是否存在 Key,如果存在,则 Key 对应的节点就是最近被使用的节点,我们需要将该节点挪动到双向链表的头部位置,最后返回其 Value 值。

关于 LRU 缓存机制的设计,在 LeetCode 上有相关的题目,链接如下:

https://leetcode-cn.com/problems/lru-cache/

我的 Java 代码实现:

class DoubleLinkedListNode {
    int key;
    int value;
    DoubleLinkedListNode prev;
    DoubleLinkedListNode next;
    
    public DoubleLinkedListNode(){}
    
    public DoubleLinkedListNode(int key,int value){
        this.key = key;
        this.value = value;
    }
}

class LRUCache {
    
    private Map<Integer,DoubleLinkedListNode> cache = new HashMap<>();
    private int size;
    private int capacity;
    private DoubleLinkedListNode head;
    private DoubleLinkedListNode tail;
    
    public LRUCache(int capacity) {
        this.size = 0;
        this.capacity = capacity;
        head = new DoubleLinkedListNode();
        tail = new DoubleLinkedListNode();
        head.next = tail;
        tail.prev = head;
    }
    
    public int get(int key) {
        DoubleLinkedListNode node = cache.get(key);
        if(node == null)
            return -1;
        
        moveToHead(node);
        return node.value;
    }
    
    public void put(int key, int value) {
        DoubleLinkedListNode node = cache.get(key);
        if(node == null) {
            // key 不存在,则创建一个新的节点
            DoubleLinkedListNode newNode = new DoubleLinkedListNode(key,value);
            cache.put(key,newNode);
            // 添加到双向链表的头部
            addToHead(newNode);
            size++;
            if(size > capacity){
                DoubleLinkedListNode retNode = removeTail();
                cache.remove(retNode.key);
                size--;
            }
        }else {
            // 如果 key 存在,则修改 value  并移动到头部
            node.value = value;
            moveToHead(node);
        }
    }
    
    private void moveToHead(DoubleLinkedListNode node){
        removeNode(node);
        addToHead(node);
    }
    
    private void removeNode(DoubleLinkedListNode node){
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }
    
    private void addToHead(DoubleLinkedListNode node){
        node.prev = head;
        node.next = head.next;
        head.next.prev = node;
        head.next = node;
    }
    
    private DoubleLinkedListNode removeTail(){
        DoubleLinkedListNode retNode = tail.prev;
        removeNode(retNode);
        return retNode;
    }
}

LFU

LFU(Least Frequently Used)是 Redis 4.0 引入的一个新的缓存淘汰策略。LFU 策略会先统计访问的次数,将访问次数最低的数据淘汰;如果访问次数相同,则比较访问时间,将访问时间更早的数据淘汰。这种内存淘汰策略要比 LRU 更精确。

我们同样使用最简单的方式来理解下 LFU 缓存淘汰算法的设计。

对于 LFU 底层存储的数据结构,我将使用优先队列 + 哈希表的实现方式。在 Java 中,PriorityQueue 默认的实现为小根堆。我们将使用优先队列存储节点 Node。节点 Node 的比较规则设置为首先按照访问频次 freq 排序,然后按照最后一次访问时间 updateTime 排序。

哈希表代表缓存 cache,Key 存储优先队列中的节点对应的 Key,Value 用来存储节点本身,用来定位到该节点在优先队列中的位置。

当我们向内存中写数据时,也就是向 cache 执行 put 操作。首先会判断 cache 中是否存在 Key,如果存在就重写 Value 值,并且,我们需要更新该节点的 freq 与 updateTime 信息。优先队列会帮助我们调整该节点的位置;如果 cache 中不存在 Key,那么就要新增一个节点。我们首先要判断内存空间是否已满,如果满了,则将小根堆的堆顶元素弹出,然后将新的节点添加到优先队列中。

当我们向内存中读数据时,也就是向 cache 执行 get 操作。我们会先判断 cache 中是否存在 Key,如果存在,我们就需要更新该节点的 freq 与 updateTime 信息,然后再返回该节点的 Value。

关于 LFU 缓存机制的设计,在 LeetCode 上也有相关的题目,链接如下:

https://leetcode-cn.com/problems/lfu-cache/

我的 Java 代码实现:

class Node implements Comparable<Node{
    int key;
    int value;
    int freq;
    long updateTime;
    
    public Node(){}
    
    public Node(int key,int value,long updateTime){
        this.key = key;
        this.value = value;
        this.updateTime = updateTime;
        this.freq = 1;
    }
    
    public int compareTo(Node node){
        int diff = this.freq - node.freq;
        return diff != 0? diff: (int)(this.updateTime - node.updateTime);
    }
}
class LFUCache {
    
    Map<Integer,Node> cache;
    PriorityQueue<Node> pq;
    int size;
    int capacity;
    
    public LFUCache(int capacity) {
        cache = new HashMap<>();
        if(capacity > 0)
            pq = new PriorityQueue<>(capacity);
        this.capacity = capacity;
    }
    
    public int get(int key) {
        Node node = cache.get(key);
        if(node == null)
            return -1;
        
        pq.remove(node);
        node.freq++;
        node.updateTime = System.nanoTime();
        pq.offer(node);
        return node.value;
    }
    
    public void put(int key, int value) {
        if(capacity <= 0)
            return;
        Node node = cache.get(key);
        if(node != null) {
            // 更新
            pq.remove(node);
            node.value = value;
            node.freq++;
            node.updateTime = System.nanoTime();
            pq.offer(node);
        }else {
            // 新增
            if(size == capacity) {
                cache.remove(pq.peek().key);
                pq.poll();
                size--;
            }
            Node newNode = new Node(key,value,System.nanoTime());
            cache.put(key,newNode);
            pq.offer(newNode);
            size++;
        }
    }
}

3、Redis Pipeline


Redis 是一个基于 TCP 协议的 NoSql 数据库,而客户端与 Redis 服务端之间的交互过程可以分为以下几个简略的步骤:

  1. 客户端向 Redis 服务端发起一个请求(request)
  2. Redis 服务端收到请求,等待处理
  3. Redis 服务端处理请求
  4. Redis 服务端将结果响应给客户端(response)

通过上图,我们可以知道,Redis 服务端需要等待上一条命令响应完成后再去执行本次的命令。如果此时需要执行大量的命令,通过传统的 Redis 请求/响应模型便会严重影响效率,这中间不仅仅多了 RTT(Round Time Trip)即客户端与服务端来回交互的时间,而且还会频繁调用系统 IO 来发送网络请求。

为了解决这样的问题,Redis 为我们提供了 Pipeline(管道)技术:

Pipeline 允许客户端一次发送多条命令,Redis Server 则会对这些命令进行“批处理”,执行完毕后将结果一次性发送给客户端。Pipeline 可以将多次 IO 往返时间缩减为一次,前提是 Pipeline 执行的指令之间没有依赖的相关性,如果指令之间有依赖,则还是建议分批去发送指令。

总结

在今天的文章中,我总结了 Redis 一部分常考的面试题。其中涵盖的知识点有:

  • Redis 三种持久化机制
  • Redis 缓存淘汰策略
  • LRU,LFU 缓存淘汰算法的基本实现
  • Redis Pipeline

希望本篇文章能够对你有所帮助~

好啦,至此为止,这篇文章就到这里了,感谢您的阅读与支持~~欢迎大家关注我的公众号,在这里希望你可以收获更多的知识,我们下一期再见!

jrh

2021/11/11  阅读:30  主题:橙心

作者介绍

jrh