[译] 解密Redis持久化

本文是在重读《Redis设计与实现》时作者推荐的一篇博文翻译,它来自Redis的作者 antirez。网上也有其他的翻译,自己翻一遍,一来是英文版表达更清晰,二来加深自己的印象,三来测测自己的翻译水平:)。如有错误请大家指正。

解密Redis持久化

看博文、刷论坛以及在推特上搜索有关Redis的内容其实也是我开发Redis工作的一部分。对开发者来说,关注社区用户和非社区用户对你正在开发的产品看法是至关重要的。拿我自己来说,Redis的所有特性里面,持久化是被误解最深的了。

在这篇文章里,我尽量以不偏不倚、不吹不黑的视角分析Redis持久化。我的唯一目的是简单易懂地阐述Redis持久化的原理,可靠性并与其他数据库的差别。

OS和磁盘

首先,考虑一下数据库的持久化有什么作用。为了说明这点,先来看看一次简单的写操作(write)逻辑:

    1. 客户端发送写命令给服务器(数据还在客户机内存中)
    1. 数据库收到写命令(数据到达服务器内存)
    1. 数据库调用系统调用把数据写入磁盘(数据来到内核缓冲区)
    1. 操作系统,也就是内核,把写缓冲区中的数据传给磁盘驱动程序(数据来到磁盘缓存)
    1. 磁盘驱动程序真正将数据写进物理介质上(如磁盘、闪存)

/*
注意:上面这几步已经很简化了。实际的操作可能涉及很多级缓存和缓冲。

第2步通常在数据库中被实现成一个复杂的缓冲系统,写操作有时被不同的进程或线程处理。但是很快数据就会被写进磁盘,所以这里不做详述。一句话,内存的数据必须在某个时刻被传给内核(第3步)。

第3步也有比较重要的省略。现代内核实现了不同等级的缓存,它们通常是文件系统级别的缓存(Linux中称为页缓存)和存着欲被提交到磁盘的小型数据缓冲区,因此情况比描述的要复杂一些。使用特殊的API可以跳过这两种缓存机制,例如打开open系统函数的O_DIRECTO_SYNC标志。但是我们可以把这些缓存看成是不透明缓存的特殊层(即我们不关心细节)。我们敢断言,如果数据库已经实现了自己的缓存机制来避免数据库和内核并行执行同一项工作,那么大可关闭页缓存。缓冲区通常被保留下来,因为如果关闭了缓冲区,每次写文件操作会使得写磁盘的速度比较慢,(系统调用比较耗时)。

因此数据库的做法是,只有在必要的时候才调用系统调用把缓存区的数据同步到磁盘上,我们稍后详细说明。
*/

什么时候是写安全的?

如果数据库宕机或被停机,而内核没出问题,那只有当第3步成功执行后写操作才是安全的,即write(2)系统调用成功返回。这步之后,即使数据库进程崩溃,内核也会把数据平安护送到磁盘驱动程序。

考虑更为严重的情况,比如断电,那只有确保第5步执行成功了才是安全的,此时数据已经写到了物理设备上。

总结一下,数据安全的关键步骤是第3、4、5步。它们涉及到:

  • 数据库以什么样的频率调用write,把用户态的缓存区数据传到内核态缓存区?
  • 内核又会多久flush一次内核数据到磁盘驱动呢?
  • 磁盘驱动又会选择怎样的时机把数据持久到磁盘?

注:所谓的磁盘驱动,实际上指的是驱动程序或磁盘的缓存行为。在持久性比较敏感的系统中,系统管理员通常会关闭这种缓存行为。

大多数系统的驱动通常只执行写缓存。除非你的电力供应足够充足应对断电故障,否则不要开启写回模式(write back mode)。

POSIX API

从数据库开发者的眼光来看,数据被真正写入磁盘前的流动路径是很有趣的,但是更有趣的在于控制这一流动路径的APIs。

从第3步看起。write系统调用使得数据被传到内核缓冲区,因此我们可以使用POSIX API来控制这一过程。但是,我们不知道成功返回前系统需要执行多长时间。内核的写缓冲空间有限,如果内核无法满足应用的写请求,导致缓冲区满了,那么内核会阻塞这次写操作。当有空余空间了(部分数据被flush到驱动程序),write调用才返回。总之,我们的目标是把数据送到磁盘上。

第4步:内核将数据从内核缓冲区传到磁盘驱动器。默认情况下不会发生的过于频繁,因为传输更大的数据块速度更快(所以为什么不等等呢)。比如Linux默认30秒提交一次写操作。这意味着如果这期间发生故障,最近30秒内待传输的数据就会丢失。

POSIX API 提供了一系列系统调用来强制内核将缓存区写到磁盘:最著名的要数fsync了。数据库可以通过这个函数将数据从内核持久到磁盘。不过你也能想到,这个操作开销很大,每次调用都发起一次写操作,故而一些数据会滞留在内核中。fsync为了完成这次写操作还会一直阻塞进程。在Linux上,如果需要的话,甚至阻塞所有写向同一个文件的所有线程。

控制之外的事情

到现在知道,我们可以控制第3、4步,那第5步呢? 严格来说,我们无法使用 POSIX API 干涉第5步。也许有些内核实现会尝试告诉驱动程序提交数据到物理介质上;又或者驱动程序为了速度,将写操作重新编排,并不将数据尽可能快地写入磁盘,而是等上几毫秒。这些实现使我们无法干预的。

余下文章将简单地探讨两种简化后的数据安全等级:

  • 使用write函数将数据写进内核缓冲区,这一过程可以保护数据安全,免受进程异常的影响。
  • 使用fsync函数将数据从内核缓冲区写入磁盘,这一过程保护数据安全,免受系统故障如断电的影响。我们明白这一点无法百分之百安全,因为磁盘驱动器可能还有一级缓存,但是我们不去考虑那种情况,因为那种缓存随数据库系统而变。并且,数据库管理员可以通过特定的工具来管理物理设备的行为。

注:并不是所有数据库都使用 POSIX API。有的申请过专利的数据库使用可以直接与硬件交互的内核模块。但是问题并没有因此解决。你可以使用用户态缓冲区、内核缓冲区,但早晚都得写到磁盘上(这是一个耗时操作)。 Oracle就直接使用了内核模块。

数据损坏

在前面我们分析了通过应用层和内核层将数据持久到磁盘的问题。但是这并不是持久化道路上的唯一问题。还有一个问题:经历灾难性事件之后,数据库仍然可读吗,或者说数据库的内部结构是否遭到一定程度的破坏,以至于无法支持正确的读取,再或者是否需要一些恢复措施来保证数据的正确表示?

打个比方,许多MySQL和NoSQL数据库实现在硬盘上使用树形数据结构来存储数据和索引。写操作就是修改这一树结构的过程。如果正在执行写操作时系统崩溃,树结构是否依然有效?

一般有三种安全级别来应对数据损坏:

  • 执行写数据的数据库不关心无效情况,而是请求用户使用备份来恢复数据或提供可以恢复合法数据结构的工具。
  • 数据库通过记录日志(journal)来恢复失效前的一致性状态。
  • 数据库不修改已经写进磁盘的数据,而是工作在只追加模式(append only mode),也就杜绝了数据损坏的可能性

现在我们已经认识了评价数据库持久层可靠性的所有要素。是时候来看看Redis在这方面的表现如何了。Redis提供了两种不同的持久化选项,我们就来逐一考察。

快照

Redis快照是最简单的持久模式。当条件满足时,它创建某一时间点下的数据库快照,比如距上一份快照过去2分钟且这段时间发生了至少100次写操作,那么就创建一份新快照。触发条件用户可以通过配置文件定义,还支持运行时动态修改。快照存储在 .rdb 文件中,它包含了整个数据库信息。

快照的持久化程度取决于用户定义的保存点(save points)。如果数据库每15分钟保存(save)一次,那么数据库一旦崩溃,15分钟内的写数据就会丢失。从Redis事务的角度看,快照确保了一个事务 MULTI/EXEC 要么全部写进快照中,要么一点也不写(我们说过,RDB文件保存某一时刻的数据库状态)。

RDB文件不会损坏,因为它是由子进程以 append-only 模式从Redis数据镜像生成的。一开始rdb快照是一个临时文件,一旦子进程成功生成快照(即成功同步到磁盘),接着执行一个原子操作--rename系统调用将这个临时文件更名。

如果对几分钟内的数据丢失比较介意的话,Redis所提供的持久化并不令人满意。因此它适用于短时间内数据丢失不敏感的场景。

但是,如果启用更高级的持久化模式,即“AOF”,仍然建议开启快照模式。因为保留一份完整数据库内容的rdb文件,对数据备份和恢复是非常有用的,把文件传给远程主机,帮助远程主机从灾难故障中恢复,或者应用的bug对数据库数据造成不可逆损坏时,进行一次历史回滚。

值得注意的是,主从同步时Redis也使用RDB快照文件。

RDB的另一个优点是,数据库大小给定,则系统的IO次数也随之确定,不管数据库上发生什么动作。这一特性是许多传统数据库(以及Redis的AOF模式)不具备的。

Append only 文件

Append Only File,简称AOF,是 Redis 的主要持久模式。原理非常简单:每一次对内存数据做修改,其行为就被记录下来。日志的格式跟客户端向Redis传指令的格式一模一样。因此 AOF 可以通过netcat(一种网络工具)传送给其他Redis实例, 或者在需要的时候很容易被解析。重启时Redis重放所有操作来重新构建数据集。

为了显示Redis AOF工作模式,我做了一个小实验,使用带有AOF模式的Redis 2.6版本。

./redis-server --append-only yes

现在来写一些数据

redis 127.0.0.1:6379> set key1 hello
OK
redis 127.0.0.1:6379> append key1 " world!"
(integer 12)
redis 127.0.0.1:6379> del key1
(integer 1)
redis 127.0.0.1:6379> del non_existing_key
(integer 0)

前三个指令修改了数据库,第四个却没有,因为数据库不存在指定的键名。此时append-only 文件看起来像这样。

$ cat appendonly.aof 
*2
$6
SELECT
$1
0
*3
$3
set
$4
key1
$5
Hello
*3
$6
append
$4
key1
$7
 World!
*2
$3
del
$4
key1

请注意,最后一个(第4个)DEL指令没有出现在文件中,因为它没有改变数据库状态。

道理很简单,只有对实际数据产生了实际影响的操作才会被记录在AOF中。但并不是所有这种操作都会被记录。比如对于列表的阻塞操作,我们只记录其产生的最终效果,正如非阻塞指令一样。再比如,INCRBYFLOAT 实际使用 SET 来记录的, 使用增加之后的值来设置原来的值(伪指令:SET KEY $KEY+INCREMENT -- 译者理解)。如此,由于不同架构对浮点数不同的处理方式,就不会使得Redis在加载AOF时产生不一样的结果(屏蔽了架构差异,厉害)。

现在我们大概明白了Redis AOF的要义是APPEND ONLY,因此杜绝了数据损坏的可能。但是这一令人喜爱的功能也有其缺点:在上面那个例子里,DEL之后数据库是空的,而AOF文件却不是空的。 AOF文件总是不断增大,所以但它变得特别臃肿时,该如何处理呢?

AOF 重写

当AOF太大时,Redis会重写到一个临时文件。重写并不是去读旧的AOF文件,而是直接访问内存数据,这样Redis可以生成比较小的AOF,并省去了读取磁盘的开销。

一旦重写结束,使用fsync将临时文件同步到磁盘并覆盖旧的AOF文件。

你也许会问,当重写时,服务器如何处理到来的写请求呢?这些新数据仍会写进旧的(当前的)AOF文件里,同时进入一个内存缓冲区(也叫AOF重写缓冲区),这样当新的AOF文件生成完毕后,再把这个缓冲区的内容追加进去,最终替换旧AOF文件。

所有操作都是 append-only 的。当重写新的AOF时,仍然向旧的AOF追加写请求。因此,Redis重写并不是真的重写旧的AOF文件。现在问题就来到了,多久调用write(2), 多久调用fsync(2)

AOF 重写使用顺序IO,所以整个过程是非常高效的(不涉及随机IO)。RDB也是如此。完全不用随机IO对于数据库系统是很少见的,可能是由于Redis服务器从内存读数据,磁盘数据不需要随机化组织,但在重启时需要进行顺序加载。

AOF 持久性

这一部分是写这篇文章的目的。很高兴我坚持写到这里,你能读到这里我更开心。

AOF使用的用户态缓冲区充斥着新命令产生的新数据。每当我们从一次事件循环返回,就对AOF文件描述符调用 write(2),把数据flush到磁盘。但是,Redis有一个配置项提供了三种不同模式来修改write(2)fsync(2)的实际动作。

appendfsync配置指令决定着这一行为。可取三种值:no, everysec, always。该配置项也可以在运行时修改,使用 CONFIG SET 命令, 这样不用停机就可以动态修改。

appendfsync no

如果指定该值,Redis根本不会调用fsync(2)。但是,它会先确认客户端没有启用流水线(pipelining), 不使用流水线的意思是,客户端在发出下一条命令B之前,必须先收到这次命令A的返回结果。如此一来,只有服务器使用write(2)成功将A的修改提交到内核后,客户端才知道命令A被正确执行了。

由于在这一模式下,fsync压根不会被调用,因此实际提交到磁盘的行为按内核的脾气来,比如Linux内核每30秒flush一次。

appendfsync everysec

如果指定为这个值,那么每隔1秒,会调用write(2)fsync(2)两个函数将数据持久化到磁盘。通常,write(2)函数会在返回事件循环时调用。但并不保证。

但是,如果磁盘跟不上写速度,后台fsync会延迟超过1秒,此时Redis会推迟写请求几秒钟(为了避免writefsync同时操作同一个文件描述符而阻塞主线程)。如果fsync(2)超过两秒钟没能执行,Redis最终调用write(2) (有可能阻塞)来不惜一切代价将数据持久化到磁盘。

因此在这一模式下,Redis能够保证,最坏情况下,2秒内保证将数据写到内核缓冲区进而写入磁盘。平均下来,每1秒提交一次数据。

appendfsync always

在这种模式下,如果客户端没有启用流水线,正在等待命令回复,在回复结果返回之前fsync(2)会被写入文件并持久化到磁盘。

这种模式持久性最强,但同样,也是最慢的。

默认的Redis配置项是appendfsync everysec,它是持久性和速度之间的很好平衡。

在Redis实现中,置 appendfsync 为 always的这种做法,也叫做组提交(group commit)。意思是,不为每次write(2)调用都执行一次fsync(2)函数,Redis在一次事件循环中,将多个提交打包,用一次write+fsync进行持久化,然后给多个发起写请求的客户端返回响应。

着眼于实际场景,它允许同时支持上百个客户端发起写请求: fsync操作会被分解,因此这一模式允许Redis支持每秒上千次事务处理,尽管物理设备只能支撑每秒 100-200次写操作。

这个特性在传统数据库中很难实现,而在Redis中却很简单。

为什么流水线独树一帜?

之所以对使用流水线方式的客户端区别对待,是因为客户端如果使用流水线化的写操作,则不允许在执行下一条指令之前获得给定指令的执行结果,以此获得速度优势。假如客户端十分在意速度,那么要求服务器必须发送一条用户不感兴趣的回复之后,才真正提交数据是没有什么意义的。但是即使客户端使用流水线模式,writefsync(取决于配置文件)总是在返回到事件循环时才发生。

AOF 和 Redis事务

AOF 支持正确的 MULTI/EXEC语法,拒绝加载末尾有不完整事务的文件。随着Redis一起安装的工具可以帮助删除文件末尾的破损事务语句。

注:由于AOF文件是在每次事件循环快结束时用write更新的,因此只有当AOF文件所在的磁盘没有空余空间了而Redis又要写数据时,不完整事务才会出现。

与 postgreSQL 的比较

使用默认持久引擎(AOF)的Redis,持久化程度是怎样的呢?

  • 最坏情况:保证writefsync两秒内肯定被执行
  • 正常情况:返回响应之前调用write, 每秒调用fsync

有趣的是,这种模式下的Redis运行依然飞快,这是有原因的。
一个是fsync由后台线程执行。再一个是Redis只进行 appen-only 的写操作,这是个巨大的优点。

但是如果你有很高的数据安全性要求,并且写能力不是很强,你也可以使用fsync always模式,获得和其他数据库系统一样的最佳持久化。

来和 PostgreSQL 做个比较,它被认为是性能很好,很稳定的一款数据库。

一起来看一下 PostgreSQL 的文档(这里选取我们关心的部分,全部文档在这里)

fsync(boolean)
如果参数为真,PostgreSQL 服务器会确保更新被同步到磁盘上,使用fsync系统调用或者其他同类函数。这意味着服务器集群经历系统崩溃或硬件故障后可以恢复到之前的一致性状态。

很多时候,为非关键事务关闭同步提交可以带来很大的性能提升。

所以 PostgreSQL 需要同步数据来避免数据损坏。幸运的是,Redis的AOF没有这个问题,压根就没有数据损坏的可能性。下一题,这个参数跟Redis的同步策略联系紧密,尽管它的名字不太一样。

synchronous_commit(enum)
指定在命令返回“成功”之前,事务提交是否等待WAL(wait ahead logging) 记录写进磁盘再进行。可选值:on, local和off。默认的、安全值是on。如果选择off,将在客户收到成功响应和数据持久到磁盘之间存在一定延迟。和fsync不同,将这一参数置为off并不会带来任何一致性风险:系统崩溃可能使得一些要执行的事务丢失,但是实际的数据库状态仍保持一致,好像这些丢失事务被明确终止一样。

在这方面与Redis有许多可以调优的相似之处。也许玩过PostgreSQL的朋友会告诉你,想更快吗?把synchronous_commit给关了。Redis也一样:想更快吗?把** appendfsync always**给关了。

关闭同步提交的PostgreSQL 跟 开启了 appendfsync everysec类似,因为默认的wal_writer_delay是200毫秒,文档建议将这个数字乘以3(即600毫秒)作为写操作的实际延迟。600毫秒跟Redis的1秒比较接近了。

MySQL InnoDB 也提供了这样的可调参数。来自文档:

如果innodb_flush_log_at_trx_commit的值是0,日志缓冲区每秒向日志文件发生一次写操作,进而向磁盘发起持久化请求,但提交一个事务时什么也不会发生。如果设置为1(默认),每提交一次事务日志缓冲写一次日志文件,以及日志文件写一次磁盘。如果设置为2,提交一次事务会促使日志缓存写一次日志文件,但不会发生到磁盘的持久化操作。但是此时每1秒还是会产生一次从日志文件到磁盘的写操作。注意这里的每秒flush并不严格保证,因为有进程调度的影响。

进一步了解

长话短说:即使Redis是内存型数据库,它仍提供了与其他数据库相差无几的持久性。

从实用角度看,Redis提供的AOF和RDB快照可以同时启用(如果拿不准,推荐这么做),方便使用,持久化好。

可信度

Didier Spezia为这篇文章提供了很多有用的想法。这个话题太大了,我肯定漏掉了很多东西。不过很感谢Didier Spezia,他让这篇文章比初稿好很多。

附注:重启时间

我收到一些私信,要求再给出一些关于重启时间的信息。因为当Redis重启时它需要从磁盘读数据进内存。我觉得值得添加,因为Redis 2.6和Redis 2.3的AOF和RDB还是有所不同的。同时,比较Redis和MySQL、PostgreSQL在这方面的差异也很有意思。

首先,得说说为什么Redis在服务器接收用户请求之前为什么要载入整个数据集进内存:严格来说,并不是因为它是一个内存型数据库。可以设想,一个运行在内存、但内存和磁盘上数据表达形式完全一致的数据库当然能尽快地(ASAP)提供服务。

事实上,真正的原因是我们为不同的服务类型优化了不同的数据表现形式:在磁盘上采用紧凑的append-only结构,不适合随机访问;在内存数据可以尽情获取和修改。但是这种差异要求我们在加载时需要进行必要的转换工作。Redis挨个读取磁盘上的键,然后以内存数据格式将键值关联。

RDB文件的处理速度很快,因为:1.RDB文件更加紧凑、是二进制的。有时甚至使用内存格式来编码数据(小型数据格式被编码成压缩列表或整数集合)。

CPU和磁盘速度影响很大,但一般Redis RDB文件的加载速度一般是每载入1G数据需要10~20秒,所以加载10G的数据需要花费几分钟。

在Redis 2.6中,加载服务器刚刚被服务器重写的AOF文件,每1G数据需要花上两倍的时间。但是如果距上次生成AOF产生很多的写操作,那么加载将花费更长时间(Redis默认配置会在AOF文件大小达到初始大小的2倍时,自动触发重写工作)。

通常不需要重启Redis实例,但是在设置单主机时,最好使用复制来转移控制权到新的Redis实例,而不必中断服务。比如要升级Redis版本,管理员可以让新版本的Redis实例成为要替换实例的slave(SLAVE OF XXX),然后使客户端指向这个新实例,把新实例指定成master(SLAVE OF NONE),如此就完成了Redis的平滑升级。

如果是传统型数据库呢?它们不需要把数据加载进内存...也许是吧?通常在这点上,传统型数据库比Redis更好,因为MySQL一开机就可以立即提供服务。不过如果数据库数据和索引文件不在系统缓存中,会进行冷重启。数据库现在就可以开始服务,但是速度会很慢,可能无法满足应用的速度要求。我好几次遇到过这种情况。

冷重启实际上就是从磁盘加载数据进内存,跟Redis特别像,只不过它是增量式的。

长话短说:如果数据集很大,Redis重启需要一点时间。基于磁盘的数据库表现更好,但是冷重启表现就不太行了。如果负载较重,应用可能被整体阻塞。而Redis一旦启动,全速运行。


翻译的比价粗糙,如有错误请大家指正~:)

你可能感兴趣的:([译] 解密Redis持久化)