Redis持久化漫谈

在Redis的使用当中,持久化一直是一个比较重要的话题,很多同学在使用Redis的过程中对持久化策略如何选择、如何配置持久化存在疑问。本文试图对Redis的持久化做比较系统地分析比较,以期达到能够正确理解Redis的持久化,并且能够结合应用实际选择合理的持久化机制的目的。

1. 背景知识

持久化是一个数据存储世界中的普遍话题。持久化可以描述为将关心的数据存储在非易失性存储(non-volatile memory)的过程。当数据得到持久化后,即使发生一定程度的故障,只要持久化设备不损坏,我们都可以利用持久化的数据进行恢复,在一定程度上降低故障带来的损失。

我们使用的数据存储,不论是传统的关系型数据库还是类似Redis的所谓NoSQL,当他们运行起来之后,都是一个运行于操作系统之上的程序,由应用程序发送的数据都需要由这些数据存储程序先进行一定的处理,然后才能按照一定的格式进行持久化存储。这个过程中数据必然先经过应用程序的内存,由CPU进行一定的运算,然后才能存储到持久化设备上。所以要理解持久化,就先得具备几个方面的背景知识。

1.1 用户空间、内核空间以及内核缓冲区

在Linux中,为了保证操作系统稳定、高效运行,内存空间被划分为用户空间和内核空间。简单来说,各个应用程序一般情况下位于用户空间,而操作系统内核运行于内核空间。之所以要做如此划分,是因为对操作系统很多函数的调用,都会触发敏感资源的操作,例如清理内存、设置时钟等等。为了防止各个程序操作敏感资源造成系统崩溃,将内存空间进行了上述划分。在用户空间内,应用程序并不具备对敏感资源操作的权限(相应的指令被限制执行),这样就避免了可能发生的系统崩溃等等异常现象。

我们知道,绝大部分应用程序都避免不了和底层资源打交道,例如从磁盘读取数据、从网络读取或者接收报文等。但是,划分了用户空间和系统空间后,应用程序没有权限访问系统资源。为了解决这个问题,操作系统暴露了一系列系统调用接口(System Call Interface)供应用程序和底层资源交互,这样应用程序可以通过调用这些接口实现资源的访问。我们常见的系统调用有write(),read()等等。

那么当应用程序触发了系统调用接口之后会发生什么呢。我们以写入某个文件为例,当应用程序调用了写入操作,系统并不会直接访问硬盘进行文件写入,而是首先将文件写入内核缓冲区,此后再定时批量将内核缓冲区中的数据写入磁盘(这个过程也可以由应用程序通过触发调用fsync()来完成)。非常明显,之所以会划分出内核缓冲区,主要是为了解决底层IO读写速度和内存读写速度的不匹配,如果频繁地同步写入硬盘,将会严重拖慢程序的运行速度。

总的来说,对于理解持久化的过程,我们需要知道:在Linux操作系统中,内存被划分为用户空间和内核空间,当应用程序中的数据需要写入文件时,会依次经过应用程序内存、内核缓冲区最终写入硬盘中,这个过程中涉及到的系统调用有写入操作write()和强制硬盘同步操作fsync()。

1.2 写时复制Copy-on-Write

我们知道,在linux系统中,可以通过fork()调用创建出一个与父进程完全相同的子进程拷贝。但是,在fork的过程中,如果父进程所占用的内存空间过大,单单完成内存数据的拷贝,耗时可能就会很久,这样会阻塞父进程响应其他命令,这对一个面向客户的服务端程序来说,是不能接受的。

为了解决这个问题,大佬们提出了写时复制技术。简单来说,写时复制技术是指,在fork的过程中,对应用程序的内存不进行整个拷贝,而是在子进程中创建一个指向父进程对应内存地址的引用,只有当子进程读取内存并且发现数据在拷贝之后发生了新的写入时,才进行实际的拷贝动作。这样,由于在fork的过程中不需要完成整个数据的拷贝,大大降低了fork()调用的耗时。

但实际上,写时复制技术并没有完全解决问题。比如,某些大型应用程序,占用的内存空间非常大(例如一个占用几十G内存的Redis实例),仅仅完成内存地址的指向就会耗时很久。

最后,对于Redis持久化来说,我们只需要知道,在持久化的过程中会涉及fork调用(RDB方式和AOF重写时发生),虽然Linux采用了写时复制技术,在实例内存占用较大时,fork调用仍旧可能带来长时间的阻塞。

前面我们简要讨论了Linux的内存空间划分以及写时复制(Linux对fork调用的消耗做出的一种优化,即使进行了这种优化,高内存占用的实例在fork时仍可能长时间阻塞)这两个背景知识点。下面我们对持久化过程中数据的写入过程进行分析。

2. 持久化数据写入的过程

当我们采用某种数据存储保存应用数据的时候,数据由应用程序通过网络发送至数据存储程序,再由数据存储程序进行加工计算,然后以一定的格式存储在硬盘等持久化设备中。这个过程涉及到网络调用、程序加工、操作系统写入文件等多个步骤,为了更清晰地进行接下来的讨论,我们把这个步骤进行一定的分解。简要来说,应用数据从产生到被存储到持久化设备,经过了如下步骤:

  1. 客户端向数据库服务端发送写入或者更新数据的请求,此时数据位于客户端内存中

  2. 服务端接收到写命令,此时数据位于服务端数据库应用内存中(站在服务端服务器视角,数据位于应用(数据库)内存中,即用户态内存)

  3. 数据库调用系统函数向硬盘写数据,此时数据位于内核缓冲区中(kernel’s buffer)

  4. 操作系统将数据从写缓冲区转移到硬盘控制器,此时数据位于硬盘缓冲区(disk cache)

  5. 硬盘控制器将数据实际写入物理介质中

通常步骤②的实现因数据库的实现不同而不同,但相同的是,最终都会触发调用系统写函数进行数据写入。步骤③也因不同系统的实现而不同,但在讨论当前问题时,可将其视为单独的一步而不用关心其细节。

通过上述分解的步骤我们看到,数据先后经过了客户端(应用程序)内存、服务端(数据存储程序)内存、内核缓冲区、硬盘缓冲区,最终被写入物理介质中。

3. 衡量持久化的标准

前面我们对持久化涉及的数据流转过程进行了分步讨论。下面我们主要从更加普遍的意义上讨论持久化,主要包括持久化的目的以及如何衡量某应用的持久化能力是否优秀。

3.1 灵魂发问:为什么要做持久化

要理解一个事物,我们必须得理解这个事物出现的原因。

有一定开发经验的同学都知道,在计算机世界中,意外无处不在,除了由于各种原因产生的软件Bug,硬件设备,例如内存、网络、磁盘等等都可能发生故障。更有甚者,对于一些重要性比较高的应用来说,可能还不得不考虑外部世界的干扰,例如:由于机房空调故障导致的服务器停止运行,市政施工导致的网络线缆被挖断等等。我们在构建应用系统时,不得不考虑这些各种各样的异常情况,来提高我们系统的可靠性(非功能)。对于数据存储系统来说,数据是其核心资产,如果因为一些轻微的故障导致了数据的丢失,那么这个数据存储系统不仅不是可靠的,甚至可以说是不可用的。

进行持久化,可以理解为提高数据存储系统可靠性的一种技术手段,只有当将数据存储到非易失性存储设备上后,才能保证在一定程度的故障面前(自然灾害面前,人类创造的事物往往不堪一击),数据不会丢失。

那么,对于不同的数据存储系统,他们都实现了持久化,我们怎么衡量他们实现的持久化到底好不好呢?前面已经说过,持久化主要解决由于各种故障引起数据丢失的问题,那么,我们在考虑某种持久化机制的时候,可以从两个维度对其进行考量:

故障发生后,我的数据会不会丢,会丢失多少。

故障发生后,我是不是可以利用持久化的数据进行数据恢复,这种恢复的成本和效率如何。

这可以通过如下两个指标来衡量。

3.2 Durability(持久性)

故障发生后,我们的数据是否得到了正确的保存,从而能够在系统恢复时得到恢复,这是我们关心的首要问题。首先要考虑的问题是,可能发生哪些故障。不考虑机房被卡车撞了或者电缆被鲨鱼咬断这种意外事件,我们可以对可能发生的异常进行如下划分:

1. 应用级的异常。这种异常是由于诸如数据库服务被异常关闭,如kill -9等。这时服务器仍旧正常运转。那么这时当上述讨论的第③步完成时,可以认为数据是安全的。因为,即使此后数据库应用被异常关闭,数据仍旧会被写入物理介质中,此后的操作已可以由操作系统独立完成。

2. 服务器级异常,例如掉电。这种情况下,只有当第⑤步完成时,才可以认为数据是真正安全的。

可见,对于持久化来说,关键的是上述③、④、⑤三个步骤的执行情况,从另一个角度考虑三个步骤的动作,他们分别表示的含义分别是:

  • 数据从用户态内存向系统态内存的转移频率(write()操作的调用频率)

  • 系统多久将数据从系统态内存转移到硬盘控制器中

  • 硬盘控制器多久将数据写入物理介质中

在第三步中,数据存储程序可以控制通过调用系统函数write频率,但是调用该函数消耗的时间却无法得到控制。因为write函数的成功返回,依赖于写入数据量的大小及硬盘的实际写入能力,当硬盘无法实际处理写入请求时,数据会被缓存到写入缓存中,如果进一步缓存被写满,此时write调用将会阻塞,直至可以完成全部数据的写入时,write调用才会成功返回。

在第四步中,数据的转移由硬盘控制器控制,通常该写入频率不会太高,因为大量碎片数据的写入相比一次写入大数据量更慢。在Linux的默认实现中,写入间隔是30s。这意味着,当这一步失败时,最多可能有30s内的数据无法持久化到硬盘中。而在实际中,可以调用系统函数fsync()强制执行该步骤。同样地,该系统调用在无法成功完成时,也会阻塞用户进程,同时也会阻塞对当前文件执行写入操作的其他进程。

第五步的实现应用层面已经无法控制,这里我们不加讨论。

综上讨论,我们提取要点,关于Durability(持久性)能力的讨论归结为如下两个问题:

  • 应用级,由通过write()系统调用保证。

  • 系统级,由通过fsync()系统调用保证。

3.3 可用性

以上,对Durability进行了讨论。除此之外,我们还关注持久化数据的可用性,即当发生异常时,持久化数据是否可以用来恢复现场。这里有三种可能:

  • 数据结构被损坏,不能恢复;

  • 损坏的数据可以通过一定的工具得到修复;

  • 数据可用,直接加载即可。

现有的数据存储实现,提供如下几类数据可用性保证:

  • 当某节点发生异常时,数据可以通过副本(Replica)恢复,因而持久化数据是否可用无关紧要。

  • 数据的持久化通过类似日志的方式实现(比如mysql的binlog)

  • 数据的持久化通过追加模式的文件实现,这种情况下,如果对文件的写入是保证命令级原子性的,则也可以不用考虑数据损坏的情况。除非是在第5步写入时发生了系统异常。

小结一下,上面我们对持久化的目的-通过将数据保存到非易失性存储设备来提高系统的可靠性、持久化的衡量指标-Durability(数据会不会丢、可能会丢多少)和可用性(用持久化数据进行数据恢复是否麻烦)进行了讨论。

4. Redis两种持久化模式及其比较

众所周知,Redis提供了两种持久化方式:内存快照方式(RDB)以及追加写文件方式(AOF: append only file)。

4.1 RDB

RDB持久化的相关配置相对比较简单,主要通过save N(seconds) M(operations)的格式来实现,该配置表示当在N秒内,若至少发生了M次写入操作,则进行持久化。在实际当中可以配置多个触发条件,当任意一个触发条件满足时,都会触发持久化操作。

RDB采用内存快照的方式完成持久化,当达到触发条件后,Redis将当前的内存全部数据以一定的格式保存为快照文件。这种快照文件是经过压缩的,其格式非常紧凑,适合用来进行数据备份(例如结合crontab等进行定期的数据备份)。由于是一种格式紧凑的内存快照文件,当采用RDB进行数据恢复时,其效率相比于采用AOF文件更高。

RDB的实现过程可以简要概括如下:首先,主进程通过fork创建一个子进程;然后,子进程将数据写入一个临时的RDB文件;最后,当临时文件完成写入后,通过原子操作用临时文件替换老的RDB文件。这里需要注意的是,很多人认为整个持久化过程会阻塞Redis对客户端命令的处理,事实上在RDB持久化的过程中仅有在主进程fork子进程的过程中可能造成对客户端读写的阻塞(尤其是当内存占用较高时)。

最后,在性能影响方面,RDB的配置通常是数十秒甚至分钟级的,因此采用RDB时对Redis性能的影响相比AOF更小。

4.2 AOF

AOF采用追加写文件的方式进行持久化,文件内容是每个写操作包含的命令和数据内容,具有较高的可读性。要打开AOF持久化,通过如下配置实现:

# 打开AOF持久化
appendonly yes

AOF的完成依赖fsync调用(第2节,第四步骤),而由于fsync调用会阻塞write调用,因此fsync的调用频率高低直接影响Redis的性能表现,这里只能在持久性和性能间进行取舍。可以通过如下配置设置fsync调用的频率:

# fsync调用的频率
appendfsync everysec

Redis提供了no/everysec/always三个配置项,分别表示任何写入操作都触发、每秒触发、不显示触发fsync(由操作系统完成,在Linux下,这个时间间隔通常为30s),默认的配置为everysec。这三种配置依次提供了更高的数据持久化保证,同时也带来了更加明显的性能影响。

由于采用追加写的方式,随着写入操作的累积,AOF会逐渐增长,可能会占用巨大的空间。为此,Redis实现了AOF重写机制。AOF重写的出发点在于,一段时间内多某个key进行若干次写操作,都会被记录到AOF文件中,而当前的数据仅包含一种状态,那么可以将这段时间内的写入操作进行合并,这样可以降低AOF文件的空间占用。AOF重写相关的配置如下:

# 在进行AOF重写的时候是否进行fsync调用,yes-不调用fsync,no-调用fsync
no-appendfsync-on-rewrite no
# AOF重写的触发条件:百分比-当相比上次重写达到100%数据增长时,大小的绝对值-当AOF文件增加达到64MB时
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb

与RDB类似,AOF重写也采用fork子进程的方式,其步骤较RDB稍微复杂一些:

  1. fork 出子进程

  2. 子进程在临时文件中进行AOF重写

  3. 父进程同时保持两个动作:

  • 继续对所有的写请求,向原AOF文件写入,保证数据的安全性

  • 将所有新来的写请求记录到一块单独的内存缓冲区中。

  1. 当子进程完成重写后,父进程收到通知,将内存缓冲区中的新的变化追加到临时文件中

  2. 将两个文件进行交换重命名,并向启用新的AOF文件。

4.3 从Durability和可用性角度讨论

4.3.1 RDB

持久性方面:用户可以定义当满足某种条件时即进行快照持久化,通常,考虑到性能问题,这个时间间隔会设置为数十秒甚至分钟级。因此RDB方式并不能提供很好的持久性,若在两次持久化间发生故障,可能造成较大规模数据的丢失。

可用性方面:在不考虑首次RDB生成的情况下,首次以后的RDB文件的生成Redis的实现采用了双文件的机制,即在进行RDB时,先生成新的临时文件,当新临时文件生成完成时,通过原子性的系统函数rename进行重命名。因此,在大部分的情况下(除了首次RDB),Redis都保证RDB文件是可用的。

4.3.2 AOF

AOF方式以追加的方式进行持久化,因此提供了较好的持久化数据可用性。需要注意的是,采用AOF文件进行数据恢复时,效率不如RDB。

持久性方面,与fsync触发频率配置相关。对于常用的everysec选项,Redis至少保证2s的数据持久时间间隔,因此,最差情况下,最多有2s的数据是不可用的。对于always,则是命令级别;而对于no,依赖于操作系统的不同实现,Linux的默认实现中磁盘数据持久化的时间间隔为30s。

综上,RDB在持久性方面不够但持久化数据的可用性较好。AOF在持久性和可用性方面均表现良好。

4.3 那么我该怎么选

鲁迅曾经说过,离开具体应用场景谈一项实现的优劣都是耍流氓,持久化机制的选择也是一样。总的来说,有四种选项供我们选择:都不选(裸奔,飞一般的感觉),选择RDB,选择AOF,我全都要(鳌拜脸)。针对这四种进行简要讨论:

都不选。遇到的这种情况的应用场景通常都是:我就缓存个数据,丢了就丢了,数据库里面反正还有,我要的是性能你明白我意思吧。不可否认,这是一种非常有效的办法,但是,这里存在一个问题:假如某天应用发生了异常,开发同学怀疑是Redis数据有问题导致的,但祸不单行(祸往往真的就不单行),还没查到原因呢,Redis重启了,这种情况下可能就会变成无头案。这个时候如果我们打开了RDB,并且通过定时任务或者其他手段对RDB文件进行了备份,持久化就会变得非常香了。

这种情况可能很多同学还是会说,丢了就丢了,出现这种情况我认了,一开RDB,fork拖慢我的响应,得不偿失啊。针对这种想法,首先,墨菲定律在这里举起了他的小手。其次,从前面的讨论可以得知,fork影响Redis响应的情况主要是对于单个实例存储的数据量过大的情况。这里我们推荐可以通过采用更多的小内存占用的机器构成多实例的集群,而不是较少的大内存机器构成集群。因为:1. Redis是单线程处理命令的,理论上多台机器能多用个核(毕竟缸多马力大);2. 一台低配的X86他也不贵啊。

选择RDB或者选择AOF。二者择其一,反而比较简单。从前面的讨论我们知道,这两者者的取舍,无非是性能和持久性之间做权衡(没办法,只有付出才能得到),只要我们应用的场景能够容忍相应时间的数据丢失,选择对应的持久化机制即可。

我全都要。实际当中,我们当然希望构建一个足够健壮的系统,我们可以综合利用RDB适合用来备份以及恢复效率高和AOF提供的更加好的持久性保证。但是,我们必须关注两种同时存在的情况下是否会带来额外的不利因素。从前面的讨论我们知道,不论是RDB还是AOF,都是将数据持久化到硬盘上,但是硬盘的写入能力是有限的,在两种持久化方式都打开的情况下,尤其是假如RDB和AOF重写同时发生,这个时候可能更容易造成达到硬盘的写入能力瓶颈的情况,如果无法在短时间内完成文件写入,那么后续的fsync和write都可能阻塞。这显然是我们不愿意看到的(生产环境中,我就遇到过系统同时打开两种持久化的情况下,由于达到硬盘写入瓶颈而导致的阻塞)。当然,这并不是说我们不建议同时打开两种持久化,假设我们拥有较高性能的硬盘,同时Redis面临的写入压力并不是很高,完全可以采用两种持久化结合的方式来获得一个更加稳健的系统。

5. 总结

以上,我们对Redis的持久化进行了讨论。主要涉及以下几个方面的内容:

首先,为了更好的理解持久化,介绍了用户空间、内核空间以及内核缓冲区几个Linux内存划分涉及的基础概念,以及Linux在进程fork过程中涉及的写时复制技术。这两点内容主要涉及Redis在持久化过程中数据的转移过程,以及RDB文件写入以及AOF重写的过程。

其次,我们分步骤讨论了持久化过程中数据在机器上的流转过程,主要是从用户态内存空间转移到内核缓冲区再转移到最终的持久化设备。在转移的过程中涉及到应用程序触发不同的系统调用。

再次,我们讨论了持久化的目的以及如何衡量持久化能力,可以从Durability以及可用性两个方面进行。

最后,我们对Redis两种持久化模式参数配置、机制进行了对比介绍,然后采用Durability和可用性进行了对比分析,最终讨论了如何结合实际应用场景选择持久化机制。在持久化机制的实际选择时,需要结合应用场景进行具体分析,从性能要求、持久化能力要求以及系统健壮性几个方面综合考虑,做出适合实际应用场景的选择。

本文大量参考了Redis作者对Redis持久化的讨论,参考文档如下:

【REF】http://oldblog.antirez.com/post/redis-persistence-demystified.html

你可能感兴趣的:(Redis持久化漫谈)