聊聊 Redis 的一些有趣的特性(上)
一、持久化
Redis 是内存数据库,数据全部保存在内存中。如果服务器发生宕机,内存中的数据将会全部丢失。为防止系统崩溃后数据丢失,Redis 提供了持久化功能,可将内存中的数据保存到磁盘。
Redis 对持久化的需求
- 作为缓存时 :系统更注重性能,缓存数据如果丢失,可以从数据库中重新查询获取,因此对 Redis 持久化的要求并不高,可以容忍短时间的数据丢失。
- 作为数据库时 :数据量大,且对数据完整性和一致性要求较高,此时 Redis 持久化就显得尤为重要,需要确保数据不丢失或尽可能减少数据丢失。
Redis 提供的两种持久化方式
- RDB 持久化 :基于快照的持久化方式,将内存中的数据以快照的形式写入磁盘。适用于数据完整性和一致性要求不高的场景。
- AOF 持久化 :基于日志的持久化方式,将内存中的数据以日志的形式写入磁盘。适用于数据完整性和一致性要求较高的场景。
我们可以选择开启其中一种持久化方式,也可以选择不开启(如果对数据完整性和一致性要求不高,可以关闭持久化)。
AOF 持久化相较于 RDB 更加可靠而完整,但会带来更大的磁盘占用和性能开销,属于更高级别的持久化,当同时开启两种持久化机制时 Redis 会优先使用 AOF 持久化,当 AOF 持久化文件过大时会自动切换到 RDB 持久化。
(一) RDB 持久化
RDB 是 Redis 默认的持久化方式,它将 Redis 内存中的数据以快照的方式写入磁盘。
- 特点 :RDB 是基于快照的持久化方式,持久化的是某一个时间点的 Redis 内存全量数据,并不是实时的,会造成一定程度的数据丢失。例如,假如每隔一个小时进行一次快照持久化,当 Redis 在 10:00 进行过一次快照持久化时,从 10:00 到 11:00 这一小时内 Redis 发生的任何修改都不会被持久化到磁盘,从而造成部分数据的丢失。
- 优点 :RDB 是基于全量数据进行的持久化,数据文件非常紧凑,适合用于数据备份和灾难恢复。恢复时,可以直接加载数据文件到 Redis 内存中。
- 实现机制 :进行 RDB 持久化时,Redis 会 fork 一个子进程,由子进程负责执行 IO 操作,将内存中的数据写入到临时文件中,待持久化过程结束后,再替换之前的快照文件;主进程继续处理命令请求,不会受到影响。
RDB 持久化的性能优化
- copy-on-write 策略 :从主进程 fork 子进程时采用 copy-on-write 策略,父进程和子进程共享同一份数据,只有在修改数据时,父进程才会复制需要变化的那部分数据。这样可以大大减少 fork 子进程的时间和内存空间占用,提高 Redis 性能。
(二) AOF 持久化
AOF 持久化是 Redis 另一种持久化方式,它将 Redis 内存中的数据以日志的方式写入磁盘。
- 特点 :AOF 会记录每一笔针对 Redis 数据库的写入操作,并将这些命令以日志的形式保存到磁盘。当 Redis 重启时,会从日志中重新执行写命令,从而达到恢复数据的目的。AOF 弥补了 RDB 持久化的不足,能够做到实时持久化,实现更可靠更耐久的数据持久化。
- 实现机制 :基于日志的持久化方式是一种追加的方式,不会进行随机写入磁盘寻址操作,而是将每一个写命令都追加到日志末尾,是一种顺序写的机制,尽管针对磁盘的读写操作效率总是比较低,但是相较于随机的写入方式还是能一定程度上提高写操作的效率。
AOF 持久化的性能与策略
- 刷盘策略配置 :事实上,AOF 为数据操作记录日志时,也不会立即将数据写入磁盘,而是先写入内存中日志文件的缓冲区。至于什么时候真正写入磁盘,实现持久化,可以进行配置。默认是每秒钟将缓冲区中的日志写入磁盘,同时提供了不主动控写入磁盘时机(由操作系统决定什么时候写入磁盘)、以及每次写的时候都强制写入磁盘的选项。
- 综合考虑性能与数据完整性 :我们必须清楚一点,基于 AOF 的数据持久化是以性能为代价的,如果我们要求每次写入数据都进行 fsync 操作,那么每次写入数据都会导致磁盘 IO 开销,尽管这样做会保证数据不丢失,但将导致效率非常低下。正确的做法是综合衡量性能要求和数据完整性要求,选择合适的刷盘策略。
AOF 日志重写机制
日志文件膨胀问题 :AOF 记录的是日志而非数据文件,日志文件体积相较于紧凑的数据文件更大;另一方面,由于 AOF 记录的是每一笔写操作,因此 AOF 日志文件中会包含大量远古的无用的命令,例如对已经过期的键的操作,和重复的添加删除操作,这些命令对数据完整性没有任何意义。
例如,执行以下操作:redis> set a 1 es 1 redis> set b 2 es 2 redis> set c 3 es 3 redis> set d 4 redis> del d redis> set d 4 redis> del d redis> set d 4 redis> del d redis> set d 4
这个例子中,我们执行了若干条操作,并通过 es 选项设置了过期时间,这些操作都会被记录到日志文件当中,然而,最后 redis 中的数据只有 d -> 4 这一个键值对,因此除了最后一条命令,其余的命令对数据完整性都没有任何意义。
长此以往,日志文件的体积会越来越大,最后,内存中的数据可能很少,而日志文件却记录了若干年积累下来的命令。这时一旦发生宕机,重启时 redis 将从几年前的第一条数据开始恢复,我们无法想象恢复数据需要多少时间。
重写机制实现 :AOF 持久化机制提供了重写的机制。重写操作也会 fork 一个子进程,将内存中的数据翻译成最小命令,并以日志的形式写入到临时文件中,待持久化过程结束后,再将原来的日志文件替换为临时文件。整个过程是增量的,不会影响到现有的数据,因此绝对安全。上面的例子中,重写时 Redis 会将 d -> 4 这一个键值对翻译成 set d 4 命令,并写入到临时文件中,替换之前的日志文件,从而减少日志文件体积,提高恢复数据的效率。
(三) RDB 和 AOF 的配合使用
Redis 4.0 版本引入了 RDB 和 AOF 持久化的配合使用,即可以同时开启 RDB 和 AOF 持久化。
在这种情况下,重写 AOF 文件就不需要将数据集翻译成指令,而是直接基于 RDB 进行增量记录,结合了两种持久化方式的优势,既提高了数据持久化的效率,又保证了数据的完整性。
缓存的设计模式
Redis 缓存的设计模式包括:
- Write-Through 直写缓存模式。
- Write-Behind 后写缓存模式。
- Cache-Aside 旁路缓存模式。
- Delay-Double-Delete 延迟双删缓存模式。
(一) Write-Through 直写缓存模式
直写缓存模式是指读写数据时 发生 读穿透 Read-Through 和 写穿透 Write-Through
- Read-Through 和 Write-Through 是两种与缓存相关的策略,它们主要用于缓存系统与持久化存储之间的数据交互,旨在确保缓存与底层数据存储的一致性。
Read-Through 是一种在缓存中找不到数据时,自动从持久化存储中加载数据并回填到缓存中的策略。具体执行流程如下:
- 客户端发起读请求到缓存系统。
- 缓存系统检查是否存在请求的数据。
- 如果数据不在缓存中,缓存系统会透明地向底层数据存储(如数据库)发起读请求。
- 数据库返回数据后,缓存系统将数据存储到缓存中,并将数据返回给客户端。
- 下次同样的读请求就可以直接从缓存中获取数据,提高了读取效率。
- 在缓存未命中的情况下,Read-Through 策略会自动隐式地从数据库加载数据并填充到缓存中,而无需应用程序显式地进行数据库查询。
- 缓存系统承担了更多的职责,实现了更紧密的缓存与数据库集成,从而简化了应用程序的设计和实现。
- 对于用户来说,读取数据的过程就是在和缓存系统交互,用户感受不到数据库的存在。
Write-Through 是一种在缓存中更新数据时,同时将更新操作同步到持久化存储的策略。具体流程如下:
- 当客户端向缓存系统发出写请求时,缓存系统首先更新缓存中的数据。
- 同时,缓存系统还会把这次更新操作同步到底层数据存储(如数据库)。
- 当数据在数据库中成功更新后,整个写操作才算完成。
- 这样,无论是从缓存还是直接从数据库读取,都能得到最新一致的数据。
Read-Through 和 Write-Through 的共同目标是确保缓存与底层数据存储之间的一致性,并通过自动化的方式隐藏了缓存与持久化存储之间的交互细节,简化了客户端的处理逻辑。
- 这两种策略经常一起使用,以提供无缝且一致的数据访问体验,特别适用于那些对数据一致性要求较高的应用场景。
- 这种缓存设计模式在每次写入时,不论数据是否真正活跃都会写入到缓存中,因此这种模式适用于全量数据活跃度较高的场景。
- 由于内存中存储了全量的数据,因此直写缓存策略会带来更高的内存消耗,因此在数据量较大的情况下不建议使用。
- 虽然它们有助于提高数据一致性,但在高并发或网络不稳定的情况下仍然需要考虑并发控制和事务处理等问题,以防止数据不一致的情况发生。
(二) Write-Behind 后写缓存模式
Write Behind(异步缓存写入),也称为 Write Back(回写)或 异步更新策略,是一种在处理缓存与持久化存储(如数据库)之间数据同步时的策略。在这种模式下,当数据在缓存中被更新时,并非立即同步更新到数据库,而是将更新操作暂存起来,随后以异步的方式批量地将缓存中的更改写入持久化存储。其流程如下:
- 应用程序首先在缓存中执行数据更新操作,而不是直接更新数据库。
- 缓存系统会将此次更新操作记录下来,暂存于一个队列(如日志文件或内存队列)中,而不是立刻同步到数据库。
- 在后台有一个独立的进程或线程定期(或者当队列积累到一定大小时)从暂存队列中取出更新操作,然后批量地将这些更改写入数据库。
- 使用 Write Behind 策略时,由于更新并非即时同步到数据库,所以在异步处理完成之前,如果缓存或系统出现故障,可能会丢失部分更新操作。
- 并且,对于高度敏感且要求强一致性的数据,Write Behind 策略并不适用,因为它无法提供严格的事务性和实时一致性保证。
- Write Behind 适用于那些可以容忍一定延迟的数据一致性场景,通过牺牲一定程度的一致性换取更高的系统性能和扩展性。
以上两种缓存设计模式都是以 缓存层 作为用户直接视图,用户只知道从缓存中读写,而不知道底层数据存储的存在,屏蔽了数据库的读写过程。
由于全量数据都存储在高速缓存层,这两种缓存设计模式都可以有效地提高系统的性能,然而使用内存作为直接存储介质会带来较高的成本,因此适用于存储全量活跃数据场景。
(三) Cache-Aside 旁路缓存模式
Cache Aside Pattern 是一种在分布式系统中广泛采用的缓存和数据库协同工作策略。
在这个模式中,数据以数据库为主存储,缓存作为提升读取效率的辅助手段。
旁路缓存模式的读写流程如下:
- 读取数据时。
- 首先尝试从缓存中获取数据;
- 如果缓存命中,则直接返回;
- 如果缓存未命中,从数据库中读取数据并将其放入缓存,最后返回给客户端。
- 写入数据时。
- 首先,应用程序会更新数据库中的数据。
- 然后,应用程序会删除缓存中的数据,或使得缓存中的数据失效。
- 这样一来,后续的读请求将无法从缓存获取数据,从而迫使系统从数据库加载最新的数据并重新填充缓存。
这个过程需要注意两点:
- 缓存与数据库操作的先后顺序问题。
- 必须应当使数据库操作在缓存操作之前完成,否则会导致数据不一致。
- 如果先删除缓存,再更新数据库,有可能会导致缓存中存在脏数据。
- 发生错误的情况:A 先删除缓存,然后 B 读取缓存发现未命中,此时 B 会将数据库中的数据更新至缓存,然后 A 再更新数据库。
- 更新数据时对缓存的处理。
- 更新数据时,我们不能更新缓存,而应将缓存中对应的数据删除或使其失效。
- 如果更新操作同时修改了数据库和缓存,则会导致数据不一致。
- 发生错误的情况:A 先更新数据库,然后 B 更新数据库并先更新缓存,然后 A 再更新缓存。
相比于上面两种模式,旁路缓存模式中缓存是按需加载的,所以不会浪费宝贵的缓存空间存储未被访问的数据,只会缓存相对热点的数据,从而提升系统的整体性能。
由于缓存通常并不支持事务回滚,因此如果写入过程时数据库更新成功但缓存删除失败,则会导致数据不一致,因此我们需要对缓存数据合理设置过期时间,从而使得错误缓存能够及时被清除。
(四) Delay-Double-Delete 延迟双删缓存模式
旁路缓存设计模式中,读取数据库操作和更新缓存操作之间存在时间窗口,这有可能导致在窗口内有写请求发生,导致缓存与数据库数据不一致。
请看下面的例子,假设 A 读取数据同时 B 更新数据:
- 线程 A 去缓存中读取数据,未命中;
- 线程 A 取数据库中读取数据;
- 线程 B 写入数据,删除缓存,此时数据库中数据为最新值;
- 线程 A 写入缓存,缓存中数据为旧值。
在这种情况下,缓存与数据库中数据发生了不一致。解决方案有以下四种:
- 对缓存中的数据设置合理的过期时间,即便缓存中存储了旧数据,也会在一段时间后自动过期。
- 使用异步策略,使用 Canal 等工具监听数据库变化,然后投放入消息队列中异步删除缓存。
- 使用删除重试策略,如果缓存删除失败,则再次尝试删除,直到成功为止。
- 写入数据时,先更新数据库,再删除缓存,由于后面有可能有读请求将缓存更新为旧值,因此延迟一段时间再删除缓存。
其中最后一种方案又称为延迟双删缓存模式,这种模式存在一定争议,即如何保存未进行第二次延迟删除的命令。这会引发一系列的复杂问题。
缓存 Cache Miss 问题
缓存 Miss 问题是指缓存中没有命中的情况,主要有以下几种情况:
- 缓存穿透:当缓存和数据库中都没有该数据时,请求会穿透到数据库,导致数据库压力过大。
- 缓存雪崩:当缓存服务器重启或者大量缓存集中在某一时间段失效时,所有请求都会落到数据库上,造成数据库压力过大。
- 缓存击穿:当某个热点数据在缓存过期的一瞬间,大量请求同时访问该数据,导致数据库压力过大。
(一) 缓存穿透 Cache Penetration
缓存穿透是指查询一个一定不存在的数据(通常是恶意攻击),由于缓存是不命中时查询数据库,所以如果数据库中没有这个数据,每次查询都会被缓存认为是缓存穿透。
事实上,这样的无效请求给缓存和数据库都带来了巨大的压力。因此这样的数据理论上就不应该经过业务处理,我们通常需要在网关层进行无效请求过滤。
解决方案:
布隆过滤器 Bloom Filter
- 布隆过滤器是一种牺牲空间换取时间和结果的技术
- 布隆过滤器是一种基于 bitmap 配合一组哈希函数的概率型数据结构,它可以用于检索一个元素是否在一个集合中
- 如果一个元素存在于集合中,那么布隆过滤器一定可以放行;如果不存在,布隆过滤器有可能不会放行,也有可能误判从而放行
- 在项目上线之前,可以先用布隆过滤器对所有存在的数据进行预处理
- 举例;某布隆过滤器由 10 个哈希函数和 10000 个 bit 组成
- 事先对所有的元素进行 10 次哈希运算,并将得到的 10 个哈希值与 10000 个 bit 进行位运算,得到的结果存入布隆过滤器中
- 当要判断某个元素是否存在于集合中时,需要对该元素进行 10 次哈希运算,并将得到的 10 个哈希值与 10000 个 bit 进行位运算
- 如果结果有 0 则该元素一定不存在于集合中;如果结果全部为 1 则该元素可能存在于集合中,也可能不存在,但误判的概率较低
缓存空值 Null Cache
- 前面提到,布隆过滤器可能会造成误判,导致部分恶意请求依然有可能穿透缓存。
- 当缓存和数据库都没有该数据时,可以将空值也缓存一段时间,称为缓存空值。
- 这样后续的恶意请求也会直接命中缓存,避免了穿透缓存。
(二) 缓存雪崩 Cache Avalanche
缓存雪崩是指缓存服务器重启或者大量缓存集中在某一时间段失效时,所有请求都会落到数据库上,造成数据库压力过大。
解决方案:
缓存失效时间设置随机或分散 Randomized Expiration
- 我们假设数据在同一时间进入缓存,将这些数据的缓存失效时间设置随机或分散,可以避免缓存集中失效。
多级缓存 Multi-Level Caching
- 使用多级缓存架构,避免缓存单点故障导致雪崩。
限流降级 Rate Limiting and Degradation
- 当系统检测到雪崩即将发生时,可以通过限制请求流量或者降级服务保护数据库。
(三) 缓存击穿 Cache Breakdown
缓存击穿是指某个热点数据在缓存失效或过期的一瞬间,大量未命中缓存的请求同时访问该数据,导致数据库压力过大。
缓存击穿与缓存雪崩的区别:
- 缓存击穿发生在某个热点数据的缓存失效或过期,这个热点数据本身就是高并发的,属于单点击穿。
- 缓存雪崩是大量缓存数据在同一时间集体失效或过期,数据本身并发量并不高,但放到一起就是缓存雪崩。
解决方案:
加锁 Locking
- 加锁可以保证大量未命中缓存的请求在同一时间只能有一个线程去查询数据库,从而避免缓存击穿。
- 一般我们需要准备另一台 redis 专门用于抢锁,谁抢到锁就先处理请求并更新缓存,其他线程等待,或重试,或返回兜底数据。
热点数据预热 Preloading
- 预热是指在系统启动时或热点数据失效之前,将热点数据加载到缓存中,避免缓存击穿。
- 如何知道哪些数据是热点数据?可以用统计分析、日志分析等手段,通过各种各样的度量系统进行判断。
使用永不过期策略
- 对于缓存数据,我们可以设置永不过期,彻底避免击穿问题。