【翻译】Redis 存储揭秘



转载请注明 AIQ - 最专业的机器学习大数据社区  http://www.6aiq.com

AIQ 机器学习大数据 知乎专栏 点击关注

原文地址: http://oldblog.antirez.com/post/redis-persistence-demystified.html

我在 Redis 的部分工作是阅读博客,论坛消息以及推特上关于 Redis 的搜索。对于一个开发者来说,社区用户以及非用户的对他开发的产品的看法非常重要。我的感触是 Redis 的持久化被人误解非常多。

在这篇博客中,我会努力的做到公正:不安利 Redis,不跳过可能让 Redis 有负面影响的细节。我想要提供一个清晰的,好理解的 Redis 存储的流程图,它有多可靠,以及和其他数据库的对比。

操作系统和磁盘

首先我们可以探讨一下数据库如何做到耐久性。为此,我们可以模拟一下一个简单的写入操作。

  • 1: 客户端发送一个写命令到数据库(数据存储客户端的内存中)。
  • 2:数据库接收了这条命令(存储到了数据库的内存)。
  • 3;数据库调用 system call(系统级调用),尝试写入到硬盘(这一步实际上写入到了操作系统内核的缓冲区)。
  • 4:操作系统将缓冲区的数据写入到磁盘控制器(数据转移到磁盘缓冲区)。
  • 5:磁盘控制器将数据真实写入到物理介质(硬盘,闪存……)

注意:上述步骤是非常简化的,真实环境的缓存更为复杂。

第二步在数据库中,往往被实现为一个复杂的缓存系统,有时候写入会在不同的线程或是进程执行(接收线程和 IO 线程分离)。以我们的视角,数据库迟早会将数据写入到磁盘。换言之,在内存中的数据会在某一时刻传输到操作系统内核(第三步)。

第三步中有一个较大的遗漏。因为真实的操作系统更为复杂,它实现了多个不同的缓存层。通常的,有文件系统缓存(在 linux 中被称为 page chche,页面缓冲),以及一个小一些的写缓冲会缓存将要写入到硬盘的数据。使用特定的 API 可以绕开这两层(例如 linux 中的 O_DIRECT 和 O_SYNC 参数)。以我们的视角,我们可以认为有一层不透明的缓存层(我们不明确实现细节)。这已经足够说明,如果数据库实现了自己的缓存系统,页面缓冲一般会被禁用。因为数据库和内核会在同时做同样的事情(引起不良后果)。写缓冲一般会开启,因为频繁的提交到磁盘对于大部分软件来说太慢了。

在真正的实现中,数据库不会总是调用系统调用来同步写缓冲到磁盘,仅在必须的时候调用。

在什么时候,我们的写是安全的

如果错误发生在数据库系统(管理员杀掉进程或者崩溃),如果第三步调用成功了(已经进入到页面缓冲),就可以认为数据已经安全写入了。这一步之后即使数据库崩溃,操作系统内核仍会将数据安全的转移到磁盘控制器。

如果我们考虑更严峻的事件,例如断电,那么只有第五步写入磁盘成功后,数据才是安全的。

步骤 3、4、5 中针对数据安全性的的重要阶段,我们可以总结一下。

  • 数据库间隔多久调用一次系统调用,将数据从用户空间写入到内核(即从数据库的内存到操作系统缓冲)。
  • 系统内核间隔多久将数据刷入到磁盘控制器。
  • 磁盘控制器多久将数据写入到物理介质。

注意 上文提到的磁盘控制器,主要是表达缓存的行为,不论是控制器还是磁盘本身。在耐久性要求很高的环境中,系统管理员一般会禁用这一层缓存。

默认的,磁盘控制器会穿透缓存直接写(即,仅有读缓存,没有写缓存)。如果有断电保护设备,那么启用写缓冲是安全的。

操作系统 API

以数据库开发人员的角度看,数据真正到达物理介质前的数据流转是很有意思的,但更有意思的是,API 提供的各种控制。

从第三步开始说明。我们可以使用 write 系统调用将数据传入系统内核缓存,从这点上看,我们借助系统 API 可以良好的控制行为。然而我们并不清楚这个系统调用需要执行多久才会返回成功。内核的写入缓冲有大小限制,如果磁盘不能应对写入带宽,内核写入缓冲会达到极限值,这时系统会阻塞我们的写入。当磁盘可以接收新的数据,write 系统调用才会返回。所有的操作执行完,数据写入到物理介质。

第四步,这一步,系统内核将数据传入磁盘控制器。默认的,系统会限制写入频率,因为传输更大的块写入更快。例如,在 Linux 中,30 秒之后写入才会被真正提交。这意味着,如果有故障,最近 30 秒的数据有可能丢失。

系统 API 提供了一系列系统调用来强制将内核缓冲写入到硬盘,最有名的是 fsync 系统调用(更多信息可以搜索 msync,fdatasync)。数据库使用 fysnc 将内核中的数据提交到硬盘,但是可以猜测到,这是一个非常昂贵的操作:如果 fsync 被调用并且内核缓冲有数据的时候,会立即执行执行一个写操作。fsync 会一直阻塞写入的进程,在 Linux 上,fsync 还会阻塞写入同一个文件的其他线程。

哪些是我们无法控制的

目前为止,我们学习到可以控制第 3、4 步,第 5 步可以么?正式的说,我们通过系统 API 无法控制。也许一些内核的实现可以通知驱动提交数据到物理介质,或者为了速度考虑,磁盘控制器自己记录数据但不立即提交,而是过几个毫秒或更久再提交。这已经脱离了我们的控制。

在接下来的文章中,我们会简化到两种数据安全级别:

  • 通过 write 系统调用,将数据写入到内核缓冲区。即使用户系统(例如数据库)故障,仍保证数据安全性。
  • 通过 fsync 系统调用,将数据提交到磁盘。提供了完整的数据安全性,操作系统故障,断电,仍保证数据安全性。我们明确的知道这个没有保障性,因为磁盘控制器还有缓存。我们不对此做考虑,是因为对于大部分数据库系统这是不变的。另外,系统管理员可以使用一些特殊的工具来控制磁盘设备的写入行为。

注意 不是所有的数据库都使用系统 API。一些专有的数据库使用一些内核模块可以直接和硬件设备交互。然而,面临的问题是类似的。你可以使用用户空间的内存,内核缓存,或早或晚将数据写入到磁盘来保障安全性。Oracle 就是使用内核模块的一个例子。

数据损坏

前面的文章中,我们从几个方面分析了确保数据安全写入到磁盘的的一些问题:应用程序(用户空间),系统内核。然而这并不是困扰耐久性的唯一问题。另一个问题如下:数据库在灾难后是否可以恢复?或者内部的结构因为某种行为被破坏,导致它不能被正确读取,需要恢复步骤来重建结构。

很多 SQL 和 NoSQL 数据库实现了基于磁盘的树形结构来保存数据和索引。这种数据结构在写入的时候会被调整。如果在写入过程中,系统停止工作了,那么树形结构还能保持正确么?

一般的,这里有 3 种数据故障安全级别:

  • 数据库不关心写入磁盘故障,要求用户使用一个副本做数据恢复。或者提供工具来尝试重建内部结构。
  • 数据库记录操作日志,在故障后通过日志重放保证一致性。
  • 数据库从不修改已经写入的数据,仅在后面调价,所以没有数据损坏。

现在我们拥有了所有的知识,用来评估数据库系统持久层的可靠性。现在去检验 Redis 在这一点上的表现。Redis 提供了两种不同的持久化模式,我们会一一的检验。

Snapshotting 快照

Redis 快照是最简单的 Redis 持久化模式。它将再以下场景产生某一时刻的快照,前一个快照时间点已经超过 2 分钟,同时有 100 个写入。这个触发条件可以用户自定义,可以修改配置文件重启,也可以在运行期配置。快照会生成一个.rdb 文件,文件中包含了整个数据集。

快照的耐久性被用户定义的触发条件所限制。如果数据每隔 15 分钟存储一次,如果 Redis 进程崩溃或者更严重的时间,那么至多有 15 分钟的数据会丢失。从这点来看 Redis 的 MULTI/EXEC 事务或许完整存储到快照,或许完全没有存储。

RDB 文件不会发生数据损坏。首先它启动子进程将 Redis 内存中的数据写入到镜像,然后在文件后面追加。一个新的 rdb 快照被创建成临时文件,子进程写入成功后,通过原子性的重命名到目标文件(仅在调用 fsync 系统调用持久化到磁盘后发生)。

Redis 快照提供不了足够的耐久性,如果无法忍受几分钟的数据丢失。这种存储模式仅适合数据丢失影响不大的场景。

然而,即使使用更加先进的“AOF”模式,我们仍建议开启快照模式。因为快照非常适合用来做备份,发送数据到远程数据中心做灾难恢复,或者将数据回滚到一个旧版本。

特别要提一点,RDB 快照被用来做主从同步。

  • RDB 有一个附加的好处, 对于给定的数据库规模,不管在数据库上执行什么动作,系统上的 IO 数据是固定的。这个特性是大部分传统的数据库没有的(包括 Redis 其他的持久化,AOF)。

追加文件

AOF 是 Redis 主要的持久化选项。它的执行非常简单:每一次对内存数据造成修改的写入操作被执行,记录该操作。日志格式和客户端提交给 Redis 的格式是一致的,所以 AOF 可以通过 netcat 传输到另一个 Redis 实例。或者如果需要,可以简单的将内容解析出来。Redis 重启的时候,会重放 aof 文件的内容来重建数据。

为了描述 AOF 是如何工作的,我做了一个简单的实验。安装 Redis 实例,通过以下方式启动:

_./redis-server --appendonly yes_

开始的 3 个操作会修改数据,第四个不会,因为没有对应的 key。下面是 aof 文件的格式范例:

$ 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

执行一些写入操作:

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

如你所见,最后的删除没有体现,因为它没有造成数据的修改。

就这么简单,被接收到的命令会被写入到 AOF,只要它修改了数据。然而,不是所有的命令会被记录。举个例子,在 lists 上面的阻塞操作会被记录成非阻塞的命令,因为这个对于数据的影响是一致的。同样的,INCRBYFLOAT 被记录成 SET,使用计算后的最终结果做记录,所以重载 AOF 文件的时候,不会因为架构不同产生不同的结果。

现在我们知道 Redis AOF 仅追加文件,所以没有数据损坏。然而这个令人满意的特性也会成为一个问题:在上述例子中,DEL 操作的数据没有记录,但是仍然浪费了一些空间。AOF 文件是持续增长的,如何避免它过大?

AOF 重写

当 AOF 过大的时候,Redis 会尝试在临时文件重写。重写操作不是读取旧的文件,而是直接读取内存中的数据,所以 Redis 可以创建尽可能小的 AOF 文件。并且在写一个新的文件的时候不需要从磁盘读取。

当重写结束后,临时文件会通过 fsync 命令同步到到磁盘,替换到原先老的 AOF 文件。

你可能会疑惑,当重写进程在执行的时候,正好有数据写入到 Redis,这时会发生什么。新的数据会写入一份到老的 AOF 文件,同时写入一份到内存缓冲区,当新的 AOF 文件已经重写完成后,将新的数据写入。最后将老的 AOF 替换成新的。

如你所见,所有的数据仅追加在末尾,当我们重写 AOF 文件的时候,我们仍将这些数据写入到老的文件,直到新 AOF 文件创建成功。这意味着我们的分析过程可以不考虑 AOF 的重写。真实的问题是,我们多久调用一次 write,以及多久调用一次 fsync。

AOF 重写使用顺序的 IO 操作,所以整个 dump 进程非常高效(没有随机的 IO 读写)。这个在生成 RDB 文件中也是同样的。几乎没有随机 IO 是一个数据库中非常稀有的特性,大部分原因是 Redis 仅从内存中读取数据,不需要在磁盘上组织数据结构提供随机访问。磁盘文件仅用来重启时候通过顺序加载来恢复。

AOF 耐久性

这一整篇文章是为了这个段落。

Redis AOF 使用用户态缓冲数据。每一次当我们返回到时间循环的时候,通常会将数据刷新到磁盘,使用 write 系统调用写入到 AOF 文件。实际上有 3 中不同的配置来控制 write 和 fsync 的行为。

这个配置由 appendfsync 参数控制,有 3 中不同的值:no、everysec、always。这个配置可以在线读取,或者通过 CONFIG SET 在线修改。所以可以随时修改而不需要关闭 Redis 实例。

appendfsync no

这个配置 Redis 完全不执行 fsync。但是,它会确认客户端没有使用管道:(一次请求 / 响应服务器能实现处理新的请求即使旧的请求还未被响应 这样就可以将多个命令发送到服务器, 而不用等待回复, 最后在一个步骤中读取该答复)。指令结果会在命令成功执行后返回:已经通过 write 系统调用将数据写入到系统缓存。

因为 fsync 没有被调用,数据何时被持久化到磁盘需要看内核的实现,比如在 Linux 上每 30 秒刷新一次。

appendfsync everysec

这个配置下,每隔一秒钟,数据会通过 write 写入到内核缓冲,然后通过 fsync 刷新到磁盘。 一般的,write 操作在返回到事件线程的时候,几乎都会执行,但是这不能保证。

但是,如果磁盘不能应对写入速度,后台的 fsync 调用所花的时间超过 1 秒钟,Redis 可能会延迟 1 秒钟再写入到系统缓冲(为了住址写入操作阻塞主进程。因为后台 fsync 线程写入的是同一个文件)。如果 2 秒时间过去,fsync 未能终止,Redis 最终执行一个阻塞式的写入,不管花费多少代价。

所以在这个模式下,Redis 能保证在最坏的情况下,2 秒钟之内写入的所有数据都会被写入到系统缓冲,然后持久化到磁盘。在一般的场景下,每一秒钟数据都会被提交。

appendfsync always

在这种模式下,并且不使用管道,在返回给客户端响应之前,数据会写入到 AOF 文件并且通过 fsync 刷新到磁盘。

这个是你能获取到的关于耐久性的最高保障,但是它比其他模式都要更慢。

默认的 Redis 配置是 appendfsync everysec,它提供了速度(几乎和 appendfsync no 一样快)和耐久性的平衡。


当 Redis 的 appendfsync 配置成 always 的时候,它采取群组提交的形式。这意味着,写入的时候,Redis 不是每次都提交,它能够将这些操作组合成一个 write+fsync 的操作。

在实践中,这意味着你可以用几百个客户端同时操作 redis:fsync 操作会被分解 - 所以即使在这种模式下,Redis 可以支持几千个 TPS,即使磁盘每秒只能支持 100~200 次写入。

对于传统数据库来说,这个特性通常很难实现,但是 Redis 让它变得更加简单。

为什么采用 pipeline 会不一样

用不同的方式处理客户端使用管道的原因是,使用 pipeline 的客户端为了速度,牺牲了响应性。开启了 pipeline 后,指令会批量提交,那么 Redis 就不能在下一个指令被执行前知道前一个指令的结果。对于 pipeline 客户端来说,在响应前提交写入是没有必要的,客户端需求的是速度。然而即使客户端使用管道,write 和 fsync 经常在返回到事件循环的时候发生。

AOF 和 Redis 事务

AOF 保证 MULTI/EXEC 事务语义上的正确性,它发现文件末尾有损坏的事务的时候,会拒绝加载。Redis 有工具可以整理 AOF 文件,去除掉末尾损坏的事务

注意:因为 AOF 只是通过单独的 write 写入(没有同步到磁盘),不完整的事务只会出现在磁盘已经被写满的时候。

和 PostrgreSQL 对比

AOF 默认配置的耐久性如何?

  • 最坏情况:它保证 write 和 fsync 在两秒内执行。
  • 一般情况:给客户端响应之前已经 write,每秒钟 fsync 一次。

有趣的是在这种模式下,Redis 依然非常快。这里有几个原因,一方面是 fsync 通过后台线程执行,另一方面是 redis 是文件追加的形式写,这个是一个很大的提升。

然而,如果你需要最大程度的数据安全性保障,而且负载不高,那么为了达到最好的耐久性,应该配置 fsync always。

这点和 PostgreSQL 相比如何?PostgreSQL 被认为是一个很好很可靠的数据库。

让我们一起阅读 PostgreSQL 的文档(注意:我只应用有趣的片段,你可以在这里查看完整的 PostgreSQL 文档)


fsync(boolean)

如果开启这个参数,PostgreSQL 会尝试去保证修改被写入到物理介质,通过 fsync 或者类似的方式(比如 wal_sync_method)。这个可以保证数据库集群可以在操作系统或者软件宕机后恢复到一致的状态。

在很多场景中,不重要的事务可以关闭同步提交,可以提供更好的性能,不存在数据损坏的风险。


所以 PostgreSQL 需要 fsync 防止数据损坏。幸好 Redis 有 AOF,我们不存在数据损坏的问题。让我们看下一个参数,这个和 Redis 的 fsync 策略更为接近,尽管名字不同。


synchronous_commit (enum)

这个参数指定在返回给客户端“success”之前,事务提交是否需要等待 WAL 记录写入到磁盘。正确的参数值是 on、local、off。默认的安全的配置是 on。当参数为 off 的时候,返回给客户端的是成功,但是事务此时还没有安全的写入到磁盘,如果服务宕机,不能保证安全性(最大的延迟是 3 个 wal_writer_delay)。不像 fsync,这个参数设置成 off 不会导致数据库的磁盘存在不一致的风险:操作系统或者数据库宕机会引起最近提交的事务丢失,但是数据库的状态和抛弃这些事务的状态是一致的。


这里有很多相似点,我们可以参照来调优 Redis。基本的,PostgreSQL 的兄弟会告诉你,设么是速度?禁用同步的提交可能是一个好主意。就像 Redis 中一样:想要速度?不要配置 appendfsync always。

如果在 PostgreSQL 中禁用同步提交,那么和使用 Redis 的 appendfsync everysec 很想,因为 PostgreSQL 默认的 wal_writer_delay 延迟 200 毫秒,文档中写明了需要将这个延迟乘以 3 才是真实的延迟,也就是 600 毫秒,非常接近 Redis 默认的 1 秒。


Mysql 的 InnoDb 有类似的参数可供用户调优。从文档中可以看到:

如果 innodb_flush_log_at_trx_commit 的配置项为 0,日志缓冲每隔一秒钟将数据写入到文件并刷新到磁盘。这个配置下事务提交不会立即提交到磁盘。当配置值为 1 的时候,日志缓冲在每次事务提交的时候都会刷新到磁盘。当配置值为 2 的时候,每次事务提交都会写入到文件,但是不确定刷新到硬盘的时间。然而,当配置值为 2 的时候,还是会每秒刷新到磁盘。需要注意的是,因为进程调度的问题,每一秒刷新不是百分百确定的。

你可以点击这里查看更多。

长话短说:虽然 Redis 是一个内存数据库,但是它提供了和基于磁盘的数据库差不多良好的耐久性。

从更实际的角度去看,Redis 提供了 AOF 和 RDB,它们可以同时开启(这个也是推荐配置)。同时开启可以提供高效的操作和耐久性。

这里我们讨论的 Redis 耐久性,不单单适用于将 Redis 作为数据存储,也适用于做中间件,因为它提供了良好的耐久性。


由于个人能力有限,在翻译过程中难免有一些疏漏,可以在博客下面留言,或者联系我的邮箱 moyiguke@hotmail.com 修改。
原文有一些地方直译较为难懂,在尽量保持原意的基础上做了一些修改。


一些总结

持久化的核心点:write,fsync。write 在语义上是写入到文件,但是操作系统做了优化,不会直接往磁盘去写,而是保存在页面缓冲(page cache)中。fsync 这条指令会强制将页面缓冲的数据往磁盘写入,但是磁盘并不会立即固化,亦它也有一层缓冲。不过磁盘的缓冲我们一般不去考虑,简单的认为 fsync 保证了数据写入到物理介质了。

再谈为什么会有多层缓冲。考虑这样的场景,如果持续性的写入到磁盘,IO 会一直处于高负荷,而且负载到达一定程度,磁盘无法提供写入。这时为了不丢失数据,仍需要有内存进行数据的缓冲。这是第一点,缓冲无法避免。另一点,缓冲可以带来写入性能的提升。在内存中,将小段的数据组合成了大段的数据,然后一次性同步到磁盘,减少了很多的磁盘访问。

这篇文章主要讲了数据安全性以及耐久性。数据安全即数据落地到磁盘,耐久性即能做到什么程度的数据同步。这两点实际上有部分重叠,数据安全和耐久性的评价标准都是持久化到磁盘,通过 fsync 或者类似的实现。举一个例子,如果某个内存数据库宕机后无法保留数据,启动后是全新的,那么它是不安全的,不耐久的。Redis 在 AOF 默认配置下,重启后可以恢复数据,至多丢失两秒数据,那么它是安全的,耐久性是平均一秒数据的丢失。

如果在阅读的时候,仍有一些难以理解的地方,欢迎留言给我,我将尽力解答。


更多高质资源 尽在AIQ 机器学习大数据 知乎专栏 点击关注

转载请注明 AIQ - 最专业的机器学习大数据社区  http://www.6aiq.com