当前位置 博文首页 > 我没有三颗心脏的博客:妈妈再也不担心我面试被Redis问得脸都绿

    我没有三颗心脏的博客:妈妈再也不担心我面试被Redis问得脸都绿

    作者:[db:作者] 时间:2021-07-28 15:19

    长文前排提醒,收藏向前排提醒,素质三连 (转发 + 在看 + 留言) 前排提醒!

    前言

    Redis 作为一个开源的,高级的键值存储和一个适用的解决方案,已经越来越在构建 「高性能」「可扩展」 的 Web 应用上发挥着举足轻重的作用。

    当今互联网技术架构中 Redis 已然成为了应用得最广泛的中间件之一,它也是中高级后端工程 技术面试 中面试官最喜欢问的工程技能之一,不仅仅要求着我们对 基本的使用 进行掌握,更要深层次地理解 Redis 内部实现 的细节原理。

    熟练掌握 Redis,甚至可以毫不夸张地说已经半只脚踏入心仪的公司了。下面我们一起来盘点回顾一下 Redis 的面试经典问题,就不要再被面试官问得 脸都绿了 呀!

    • Ps: 我把 重要的知识点 都做成了 图片,希望各位 “用餐愉快”(不错记得付餐费… 点个赞留个言…)

    一、基础篇

    什么是 Redis ?

    先解释 Redis 基本概念

    Redis (Remote Dictionary Server) 是一个使用 C 语言 编写的,开源的 (BSD许可) 高性能 非关系型 (NoSQL)键值对数据库

    简单提一下 Redis 数据结构

    Redis 可以存储 不同类型数据结构值 之间的映射关系。键的类型只能是字符串,而值除了支持最 基础的五种数据类型 外,还支持一些 高级数据类型

    一定要说出一些高级数据结构 (当然你自己也要了解… 下面会说到的别担心),这样面试官的眼睛才会亮。

    Redis 小总结

    与传统数据库不同的是 Redis 的数据是 存在内存 中的,所以 读写速度 非常 ,因此 Redis 被广泛应用于 缓存 方向,每秒可以处理超过 10 万次读写操作,是已知性能最快的 Key-Value 数据库。另外,Redis 也经常用来做 分布式锁

    除此之外,Redis 支持事务 、持久化、LUA脚本、LRU驱动事件、多种集群方案。

    Redis 优缺点

    优点

    • 读写性能优异, Redis能读的速度是 110000 次/s,写的速度是 81000 次/s。
    • 支持数据持久化,支持 AOF 和 RDB 两种持久化方式。
    • 支持事务,Redis 的所有操作都是原子性的,同时 Redis 还支持对几个操作合并后的原子性执行。
    • 数据结构丰富,除了支持 string 类型的 value 外还支持 hash、set、zset、list 等数据结构。
    • 支持主从复制,主机会自动将数据同步到从机,可以进行读写分离。

    缺点

    • 数据库 容量受到物理内存的限制,不能用作海量数据的高性能读写,因此 Redis 适合的场景主要局限在较小数据量的高性能操作和运算上。
    • Redis 不具备自动容错和恢复功能,主机从机的宕机都会导致前端部分读写请求失败,需要等待机器重启或者手动切换前端的 IP 才能恢复。
    • 主机宕机,宕机前有部分数据未能及时同步到从机,切换 IP 后还会引入数据不一致的问题,降低了 系统的可用性
    • Redis 较难支持在线扩容,在集群容量达到上限时在线扩容会变得很复杂。为避免这一问题,运维人员在系统上线时必须确保有足够的空间,这对资源造成了很大的浪费。

    为什么要用缓存?为什么使用 Redis?

    提一下现在 Web 应用的现状

    在日常的 Web 应用对数据库的访问中,读操作的次数远超写操作,比例大概在 1:93:7,所以需要读的可能性是比写的可能大得多的。当我们使用 SQL 语句去数据库进行读写操作时,数据库就会 去磁盘把对应的数据索引取回来,这是一个相对较慢的过程。

    使用 Redis or 使用缓存带来的优势

    如果我们把数据放在 Redis 中,也就是直接放在内存之中,让服务端直接去读取内存中的数据,那么这样 速度 明显就会快上不少 (高性能),并且会 极大减小数据库的压力 (特别是在高并发情况下)

    记得是 两个角度 啊… 高性能高并发

    也要提一下使用缓存的考虑

    但是使用内存进行数据存储开销也是比较大的,限于成本 的原因,一般我们只是使用 Redis 存储一些 常用和主要的数据,比如用户登录的信息等。

    一般而言在使用 Redis 进行存储的时候,我们需要从以下几个方面来考虑:

    • 业务数据常用吗?命中率如何? 如果命中率很低,就没有必要写入缓存;
    • 该业务数据是读操作多,还是写操作多? 如果写操作多,频繁需要写入数据库,也没有必要使用缓存;
    • 业务数据大小如何? 如果要存储几百兆字节的文件,会给缓存带来很大的压力,这样也没有必要;

    在考虑了这些问题之后,如果觉得有必要使用缓存,那么就使用它!

    使用缓存会出现什么问题?

    一般来说有如下几个问题,回答思路遵照 是什么为什么怎么解决

    1. 缓存雪崩问题;
    2. 缓存穿透问题;
    3. 缓存和数据库双写一致性问题;

    缓存雪崩问题

    另外对于 “Redis 挂掉了,请求全部走数据库” 这样的情况,我们还可以有如下的思路:

    • 事发前:实现 Redis 的高可用(主从架构 + Sentinel 或者 Redis Cluster),尽量避免 Redis 挂掉这种情况发生。
    • 事发中:万一 Redis 真的挂了,我们可以设置本地缓存(ehcache) + 限流(hystrix),尽量避免我们的数据库被干掉(起码能保证我们的服务还是能正常工作的)
    • 事发后:Redis 持久化,重启后自动从磁盘上加载数据,快速恢复缓存数据。

    缓存穿透问题

    缓存与数据库双写一致问题

    双写一致性上图还是稍微粗糙了些,你还需要知道两种方案 (先操作数据库和先操作缓存) 分别都有什么优势和对应的问题,这里不作赘述,可以参考一下下方的文章,写得非常详细。

    • 面试前必须要知道的Redis面试题 | Java3y - https://mp.weixin.qq.com/s/3Fmv7h5p2QDtLxc9n1dp5A

    Redis 为什么早期版本选择单线程?

    官方解释

    因为 Redis 是基于内存的操作,CPU 不是 Redis 的瓶颈,Redis 的瓶颈最有可能是 机器内存的大小 或者 网络带宽。既然单线程容易实现,而且 CPU 不会成为瓶颈,那就顺理成章地采用单线程的方案了。

    简单总结一下

    1. 使用单线程模型能带来更好的 可维护性,方便开发和调试;
    2. 使用单线程模型也能 并发 的处理客户端的请求;(I/O 多路复用机制)
    3. Redis 服务中运行的绝大多数操作的 性能瓶颈都不是 CPU

    强烈推荐 各位亲看一下这篇文章:

    • 为什么 Redis 选择单线程模型 · Why’s THE Design? - https://draveness.me/whys-the-design-redis-single-thread

    Redis 为什么这么快?

    简单总结:

    1. 纯内存操作:读取不需要进行磁盘 I/O,所以比传统数据库要快上不少;(但不要有误区说磁盘就一定慢,例如 Kafka 就是使用磁盘顺序读取但仍然较快)
    2. 单线程,无锁竞争:这保证了没有线程的上下文切换,不会因为多线程的一些操作而降低性能;
    3. 多路 I/O 复用模型,非阻塞 I/O:采用多路 I/O 复用技术可以让单个线程高效的处理多个网络连接请求(尽量减少网络 IO 的时间消耗);
    4. 高效的数据结构,加上底层做了大量优化:Redis 对于底层的数据结构和内存占用做了大量的优化,例如不同长度的字符串使用不同的结构体表示,HyperLogLog 的密集型存储结构等等…

    二、数据结构篇

    简述一下 Redis 常用数据结构及实现?

    首先在 Redis 内部会使用一个 RedisObject 对象来表示所有的 keyvalue

    其次 Redis 为了 平衡空间和时间效率,针对 value 的具体类型在底层会采用不同的数据结构来实现,下图展示了他们之间的映射关系:(好像乱糟糟的,但至少能看清楚…)

    Redis 的 SDS 和 C 中字符串相比有什么优势?

    先简单总结一下

    C 语言使用了一个长度为 N+1 的字符数组来表示长度为 N 的字符串,并且字符数组最后一个元素总是 \0,这种简单的字符串表示方式 不符合 Redis 对字符串在安全性、效率以及功能方面的要求

    再来说 C 语言字符串的问题

    这样简单的数据结构可能会造成以下一些问题:

    • 获取字符串长度为 O(N) 级别的操作 → 因为 C 不保存数组的长度,每次都需要遍历一遍整个数组;
    • 不能很好的杜绝 缓冲区溢出/内存泄漏 的问题 → 跟上述问题原因一样,如果执行拼接 or 缩短字符串的操作,如果操作不当就很容易造成上述问题;
    • C 字符串 只能保存文本数据 → 因为 C 语言中的字符串必须符合某种编码(比如 ASCII),例如中间出现的 '\0' 可能会被判定为提前结束的字符串而识别不了;

    Redis 如何解决的 | SDS 的优势

    如果去看 Redis 的源码 sds.h/sdshdr 文件,你会看到 SDS 完整的实现细节,这里简单来说一下 Redis 如何解决的:

    1. 多增加 len 表示当前字符串的长度:这样就可以直接获取长度了,复杂度 O(1);
    2. 自动扩展空间:当 SDS 需要对字符串进行修改时,首先借助于 lenalloc 检查空间是否满足修改所需的要求,如果空间不够的话,SDS 会自动扩展空间,避免了像 C 字符串操作中的覆盖情况;
    3. 有效降低内存分配次数:C 字符串在涉及增加或者清除操作时会改变底层数组的大小造成重新分配,SDS 使用了 空间预分配惰性空间释放 机制,简单理解就是每次在扩展时是成倍的多分配的,在缩容是也是先留着并不正式归还给 OS;
    4. 二进制安全:C 语言字符串只能保存 ascii 码,对于图片、音频等信息无法保存,SDS 是二进制安全的,写入什么读取就是什么,不做任何过滤和限制;

    字典是如何实现的?Rehash 了解吗?

    先总体聊一下 Redis 中的字典

    字典是 Redis 服务器中出现最为频繁的复合型数据结构。除了 hash 结构的数据会用到字典外,整个 Redis 数据库的所有 keyvalue 也组成了一个 全局字典,还有带过期时间的 key 也是一个字典。(存储在 RedisDb 数据结构中)

    说明字典内部结构和 rehash

    Redis 中的字典相当于 Java 中的 HashMap,内部实现也差不多类似,都是通过 “数组 + 链表”链地址法 来解决部分 哈希冲突,同时这样的结构也吸收了两种不同数据结构的优点。

    字典结构内部包含 两个 hashtable,通常情况下只有一个 hashtable 有值,但是在字典扩容缩容时,需要分配新的 hashtable,然后进行 渐进式搬迁 (rehash),这时候两个 hashtable 分别存储旧的和新的 hashtable,待搬迁结束后,旧的将被删除,新的 hashtable 取而代之。

    扩缩容的条件

    正常情况下,当 hash 表中 元素的个数等于第一维数组的长度时,就会开始扩容,扩容的新数组是 原数组大小的 2 倍。不过如果 Redis 正在做 bgsave(持久化命令),为了减少内存也得过多分离,Redis 尽量不去扩容,但是如果 hash 表非常满了,达到了第一维数组长度的 5 倍了,这个时候就会 强制扩容

    当 hash 表因为元素逐渐被删除变得越来越稀疏时,Redis 会对 hash 表进行缩容来减少 hash 表的第一维数组空间占用。所用的条件是 元素个数低于数组长度的 10%,缩容不会考虑 Redis 是否在做 bgsave

    跳跃表是如何实现的?原理?

    这是 Redis 中比较重要的一个数据结构,建议阅读 之前写过的文章,里面详细介绍了原理和一些细节:

    • Redis(2)——跳跃表 - https://www.wmyskxz.com/2020/02/29/redis-2-tiao-yue-biao/

    HyperLogLog 有了解吗?

    建议阅读 之前的系列文章:

    • Redis(4)——神奇的HyperLoglog解决统计问题 - https://www.wmyskxz.com/2020/03/02/reids-4-shen-qi-de-hyperloglog-jie-jue-tong-ji-wen-ti/

    布隆过滤器有了解吗?

    建议阅读 之前的系列文章:

    • Redis(5)——亿级数据过滤和布隆过滤器 - https://www.wmyskxz.com/2020/03/11/redis-5-yi-ji-shu-ju-guo-lu-he-bu-long-guo-lu-qi/

    GeoHash 了解吗?

    建议阅读 之前的系列文章:

    • Redis(6)——GeoHash查找附近的人 - https://www.wmyskxz.com/2020/03/12/redis-6-geohash-cha-zhao-fu-jin-de-ren/

    压缩列表了解吗?

    这是 Redis 为了节约内存 而使用的一种数据结构,zsethash 容器对象会在元素个数较少的时候,采用压缩列表(ziplist)进行存储。压缩列表是 一块连续的内存空间,元素之间紧挨着存储,没有任何冗余空隙。

    因为之前自己也没有学习过,所以找了一篇比较好比较容易理解的文章:

    • 图解Redis之数据结构篇——压缩列表 - https://mp.weixin.qq.com/s/nba0FUEAVRs0vi24KUoyQg
    • 这一篇稍微底层稍微硬核一点:http://www.web-lovers.com/redis-source-ziplist.html

    快速列表 quicklist 了解吗?

    Redis 早期版本存储 list 列表数据结构使用的是压缩列表 ziplist 和普通的双向链表 linkedlist,也就是说当元素少时使用 ziplist,当元素多时用 linkedlist。但考虑到链表的附加空间相对较高,prevnext 指针就要占去 16 个字节(64 位操作系统占用 8 个字节),另外每个节点的内存都是单独分配,会家具内存的碎片化,影响内存管理效率。

    后来 Redis 新版本(3.2)对列表数据结构进行了改造,使用 quicklist 代替了 ziplistlinkedlist

    同上…建议阅读一下以下的文章:

    • Redis列表list 底层原理 - https://zhuanlan.zhihu.com/p/102422311

    Stream 结构有了解吗?

    Redis Stream 从概念上来说,就像是一个 仅追加内容消息链表,把所有加入的消息都一个一个串起来,每个消息都有一个唯一的 ID 和内容,这很简单,让它复杂的是从 Kafka 借鉴的另一种概念:消费者组(Consumer Group) (思路一致,实现不同)

    上图就展示了一个典型的 Stream 结构。每个 Stream 都有唯一的名称,它就是 Redis 的 key,在我们首次使用 xadd 指令追加消息时自动创建。我们对图中的一些概念做一下解释:

    • Consumer Group:消费者组,可以简单看成记录流状态的一种数据结构。消费者既可以选择使用 XREAD 命令进行 独立消费,也可以多个消费者同时加入一个消费者组进行 组内消费。同一个消费者组内的消费者共享所有的 Stream 信息,同一条消息只会有一个消费者消费到,这样就可以应用在分布式的应用场景中来保证消息的唯一性。
    • last_delivered_id:用来表示消费者组消费在 Stream 上 消费位置 的游标信息。每个消费者组都有一个 Stream 内 唯一的名称,消费者组不会自动创建,需要使用 XGROUP CREATE 指令来显式创建,并且需要指定从哪一个消息 ID 开始消费,用来初始化 last_delivered_id 这个变量。
    • pending_ids:每个消费者内部都有的一个状态变量,用来表示 已经 被客户端 获取,但是 还没有 ack 的消息。记录的目的是为了 保证客户端至少消费了消息一次,而不会在网络传输的中途丢失而没有对消息进行处理。如果客户端没有 ack,那么这个变量里面的消息 ID 就会越来越多,一旦某个消息被 ack,它就会对应开始减少。这个变量也被 Redis 官方称为 PEL (Pending Entries List)

    Stream 消息太多怎么办?

    很容易想到,要是消息积累太多,Stream 的链表岂不是很长,内容会不会爆掉就是个问题了。xdel 指令又不会删除消息,它只是给消息做了个标志位。

    Redis 自然考虑到了这一点,所以它提供了一个定长 Stream 功能。在 xadd 的指令提供一个定长长度 maxlen,就可以将老的消息干掉,确保最多不超过指定长度,使用起来也很简单:

    > XADD mystream MAXLEN 2 * value 1
    1526654998691-0
    > XADD mystream MAXLEN 2 * value 2
    1526654999635-0
    > XADD mystream MAXLEN 2 * value 3
    1526655000369-0
    > XLEN mystream
    (integer) 2
    > XRANGE mystream - +
    1) 1) 1526654999635-0
       2) 1) "value"
          2) "2"
    2) 1) 1526655000369-0
       2) 1) "value"
          2) "3"
    

    如果使用 MAXLEN 选项,当 Stream 的达到指定长度后,老的消息会自动被淘汰掉,因此 Stream 的大小是恒定的。目前还没有选项让 Stream 只保留给定数量的条目,因为为了一致地运行,这样的命令必须在很长一段时间内阻塞以淘汰消息。(例如在添加数据的高峰期间,你不得不长暂停来淘汰旧消息和添加新的消息)

    另外使用 MAXLEN 选项的花销是很大的,Stream 为了节省内存空间,采用了一种特殊的结构表示,而这种结构的调整是需要额外的花销的。所以我们可以使用一种带有 ~ 的特殊命令:

    XADD mystream MAXLEN ~ 1000 * ... entry fields here ...
    

    它会基于当前的结构合理地对节点执行裁剪,来保证至少会有 1000 条数据,可能是 1010 也可能是 1030

    PEL 是如何避免消息丢失的?

    在客户端消费者读取 Stream 消息时,Redis 服务器将消息回复给客户端的过程中,客户端突然断开了连接,消息就丢失了。但是 PEL 里已经保存了发出去的消息 ID,待客户端重新连上之后,可以再次收到 PEL 中的消息 ID 列表。不过此时 xreadgroup 的起始消息 ID 不能为参数 > ,而必须是任意有效的消息 ID,一般将参数设为 0-0,表示读取所有的 PEL 消息以及自 last_delivered_id 之后的新消息。

    和 Kafka 对比起来呢?

    Redis 基于内存存储,这意味着它会比基于磁盘的 Kafka 快上一些,也意味着使用 Redis 我们 不能长时间存储大量数据。不过如果您想以 最小延迟 实时处理消息的话,您可以考虑 Redis,但是如果 消息很大并且应该重用数据 的话,则应该首先考虑使用 Kafka。

    另外从某些角度来说,Redis Stream 也更适用于小型、廉价的应用程序,因为 Kafka 相对来说更难配置一些。

    推荐阅读 之前的系列文章,里面 也对 Pub/ Sub 做了详细的描述

    • Redis(8)——发布/订阅与Stream - https://www.wmyskxz.com/2020/03/15/redis-8-fa-bu-ding-yue-yu-stream/

    三、持久化篇

    什么是持久化?

    先简单谈一谈是什么

    Redis 的数据 全部存储内存 中,如果 突然宕机,数据就会全部丢失,因此必须有一套机制来保证 Redis 的数据不会因为故障而丢失,这种机制就是 Redis 的 持久化机制,它会将内存中的数据库状态 保存到磁盘 中。

    解释一下持久化发生了什么

    我们来稍微考虑一下 Redis 作为一个 “内存数据库” 要做的关于持久化的事情。通常来说,从客户端发起请求开始,到服务器真实地写入磁盘,需要发生如下几件事情:

    详细版 的文字描述大概就是下面这样:

    1. 客户端向数据库 发送写命令 (数据在客户端的内存中)
    2. 数据库 接收 到客户端的 写请求 (数据在服务器的内存中)
    3. 数据库 调用系统 API 将数据写入磁盘 (数据在内核缓冲区中)
    4. 操作系统将 写缓冲区 传输到 磁盘控控制器 (数据在磁盘缓存中)
    5. 操作系统的磁盘控制器将数据 写入实际的物理媒介(数据在磁盘中)

    分析如何保证持久化安全

    如果我们故障仅仅涉及到 软件层面 (该进程被管理员终止或程序崩溃) 并且没有接触到内核,那么在 上述步骤 3 成功返回之后,我们就认为成功了。即使进程崩溃,操作系统仍然会帮助我们把数据正确地写入磁盘。

    如果我们考虑 停电/ 火灾更具灾难性 的事情,那么只有在完成了第 5 步之后,才是安全的。

    机房”火了“

    所以我们可以总结得出数据安全最重要的阶段是:步骤三、四、五,即:

    • 数据库软件调用写操作将用户空间的缓冲区转移到内核缓冲区的频率是多少?
    • 内核多久从缓冲区取数据刷新到磁盘控制器?
    • 磁盘控制器多久把数据写入物理媒介一次?
    • 注意: 如果真的发生灾难性的事件,我们可以从上图的过程中看到,任何一步都可能被意外打断丢失,所以只能 尽可能地保证 数据的安全,这对于所有数据库来说都是一样的。

    我们从 第三步 开始。Linux 系统提供了清晰、易用的用于操作文件的 POSIX file API20 多年过去,仍然还有很多人对于这一套 API 的设计津津乐道,我想其中一个原因就是因为你光从 API 的命名就能够很清晰地知道这一套 API 的用途:

    int open(const char *path, int oflag, .../*,mode_t mode */);
    int close (int filedes);int remove( const char *fname );
    ssize_t write(int fildes, const void *buf, size_t nbyte);
    ssize_t read(int fildes, void *buf, size_t nbyte);
    
    • 参考自:API 设计最佳实践的思考 - https://www.cnblogs.com/yuanjiangw/p/10846560.html

    所以,我们有很好的可用的 API 来完成 第三步,但是对于成功返回之前,我们对系统调用花费的时间没有太多的控制权。

    然后我们来说说 第四步。我们知道,除了早期对电脑特别了解那帮人 (操作系统就这帮人搞的),实际的物理硬件都不是我们能够 直接操作 的,都是通过 操作系统调用 来达到目的的。为了防止过慢的 I/O 操作拖慢整个系统的运行,操作系统层面做了很多的努力,譬如说 上述第四步 提到的 写缓冲区,并不是所有的写操作都会被立即写入磁盘,而是要先经过一个缓冲区,默认情况下,Linux 将在 30 秒 后实际提交写入。

    但是很明显,30 秒 并不是 Redis 能够承受的,这意味着,如果发生故障,那么最近 30 秒内写入的所有数据都可能会丢失。幸好 PROSIX API 提供了另一个解决方案:fsync,该命令会 强制 内核将 缓冲区 写入 磁盘,但这是一个非常消耗性能的操作,每次调用都会 阻塞等待 直到设备报告 IO 完成,所以一般在生产环境的服务器中,Redis 通常是每隔 1s 左右执行一次 fsync 操作。

    到目前为止,我们了解到了如何控制 第三步第四步,但是对于 第五步,我们 完全无法控制。也许一些内核实现将试图告诉驱动实际提交物理介质上的数据,或者控制器可能会为了提高速度而重新排序写操作,不会尽快将数据真正写到磁盘上,而是会等待几个多毫秒。这完全是我们无法控制的。

    普通人简单说一下第一条就过了,如果你详细地对后面两方面 侃侃而谈,那面试官就会对你另眼相看了。

    Redis 中的两种持久化方式?

    方式一:快照

    Redis 快照 是最简单的 Redis 持久性模式。当满足特定条件时,它将生成数据集的时间点快照,例如,如果先前的快照是在 2 分钟前创建的,并且现在已经至少有 100 次新写入,则将创建一个新的快照。此条件可以由用户配置 Redis 实例来控制,也可以在运行时修改而无需重新启动服务器。快照作为包含整个数据集的单个 .rdb 文件生成。

    方式二:AOF

    快照不是很持久。如果运行 Redis 的计算机停止运行,电源线出现故障或者您 kill -9 的实例意外发生,则写入 Redis 的最新数据将丢失。尽管这对于某些应用程序可能不是什么大问题,但有些使用案例具有充分的耐用性,在这些情况下,快照并不是可行的选择。

    AOF(Append Only File - 仅追加文件) 它的工作方式非常简单:每次执行 修改内存 中数据集的写操作时,都会 记录 该操作。假设 AOF 日志记录了自 Redis 实例创建以来 所有的修改性指令序列,那么就可以通过对一个空的 Redis 实例 顺序执行所有的指令,也就是 「重放」,来恢复 Redis 当前实例的内存数据结构的状态。

    Redis 4.0 的混合持久化

    重启 Redis 时,我们很少使用 rdb 来恢复内存状态,因为会丢失大量数据。我们通常使用 AOF 日志重放,但是重放 AOF 日志性能相对 rdb 来说要慢很多,这样在 Redis 实例很大的情况下,启动需要花费很长的时间。

    Redis 4.0 为了解决这个问题,带来了一个新的持久化选项——混合持久化。将