Redis数据持久化解决方案

Published on 2017 - 06 - 03

持久化选项

Redis提供了两种不同的持久化方法来将数据存储到硬盘里面。一种方法叫快照(snapshotting),它可以将存在于某一时刻的所有数据都写入硬盘里面。另一种方法叫只追加文件(append-only file,AOF),它会在执行写命令时,将被执行的写命令复制到硬盘里面。这两种持久化方法既可以同时使用,又可以单独使用,在某些情况下甚至可以两种方法都不使用,具体选择哪种持久化方法需要根据用户的数据以及应用来决定。

将内存中的数据存储到硬盘的一个主要原因是为了在之后重用数据,或者是为了防止系统故障而将数据备份到一个远程位置。另外,存储在Redis里面的数据有可能是经过长时间计算得出的,或者有程序正在使用Redis存储的数据进行计算,所以用户会希望自己可以将这些数据存储起来以便之后使用,这样就不必再重新计算了。对于一些Redis应用来说,“计算”可能只是简单地将另一个数据库的数据复制到Redis里面,但对于另外一些Redis应用来说,Redis存储的数据可能是根据数十亿行日志进行聚合分析得出的结果。

两组不同的配置选项控制着Redis将数据写入硬盘里面的方式,代码清单1展示了这些配置选项以及它们的示例配置值。

save 60 1000                        #A
stop-writes-on-bgsave-error no      #A
rdbcompression yes                  #A
dbfilename dump.rdb                 #A

appendonly no                       #B
appendfsync everysec                #B
no-appendfsync-on-rewrite no        #B
auto-aof-rewrite-percentage 100     #B
auto-aof-rewrite-min-size 64mb      #B

dir ./                              #C
#A Snapshotting persistence options
#B Append-only file persistence options
#C Shared option, where to store the snapshot or append-only file

代码清单1最开头的几个选项和快照持久化有关,比如:如何命名硬盘上的快照文件、多久执行一次自动快照操作、是否对快照文件进行压缩,以及在创建快照失败后是否仍然继续执行写命令。代码清单的第二组选项用于配置AOF子系统(subsystem):这些选项告诉Redis是否使用AOF持久化、多久才将写入的内容同步到硬盘、在对AOF进行压缩(compaction)的时候能否执行同步操作,以及多久执行一次AOF压缩。接下来的一节将介绍如何使用快照来保持数据安全。

快照持久化

Redis可以通过创建快照来获得存储在内存里面的数据在某个时间点上的副本。在创建快照之后,用户可以对快照进行备份,可以将快照复制到其他服务器从而创建具有相同数据的服务器副本,还可以将快照留在原地以便重启服务器时使用。

根据配置,快照将被写入dbfilename选项指定的文件里面,并储存在dir选项指定的路径上面。如果在新的快照文件创建完毕之前,Redis、系统或者硬件这三者之中的任意一个崩溃了,那么Redis将丢失最近一次创建快照之后写入的所有数据。

举个例子,假设Redis目前在内存里面存储了10GB的数据,上一个快照是在下午2:35开始创建的,并且已经创建成功。下午3:06时,Redis又开始创建新的快照,并且在下午3:08快照文件创建完毕之前,有35个键进行了更新。如果在下午3:06至下午3:08期间,系统发生崩溃,导致Redis无法完成新快照的创建工作,那么Redis将丢失下午2:35之后写入的所有数据。另一方面,如果系统恰好在新的快照文件创建完毕之后崩溃,那么Redis将只丢失35个键的更新数据。

创建快照的办法有以下几种。

  1. 客户端可以通过向Redis发送BGSAVE命令来创建一个快照。对于支持BGSAVE命令的平台来说(基本上所有平台都支持,除了Windows平台),Redis会调用fork1来创建一个子进程,然后子进程负责将快照写入硬盘,而父进程则继续处理命令请求。
  2. 客户端还可以通过向Redis发送SAVE命令来创建一个快照,接到SAVE命令的Redis服务器在快照创建完毕之前将不再响应任何其他命令。SAVE命令并不常用,我们通常只会在没有足够内存去执行BGSAVE命令的情况下,又或者即使等待持久化操作执行完毕也无所谓的情况下,才会使用这个命令。
  3. 如果用户设置了save配置选项,比如save 60 10000,那么从Redis最近一次创建快照之后开始算起,当“60秒之内有10 000次写入”这个条件被满足时,Redis就会自动触发BGSAVE命令。如果用户设置了多个save配置选项,那么当任意一个save配置选项所设置的条件被满足时,Redis就会触发一次BGSAVE命令。
  4. 当Redis通过SHUTDOWN命令接收到关闭服务器的请求时,或者接收到标准TERM信号时,会执行一个SAVE命令,阻塞所有客户端,不再执行客户端发送的任何命令,并在SAVE命令执行完毕之后关闭服务器。
  5. 当一个Redis服务器连接另一个Redis服务器,并向对方发送SYNC命令来开始一次复制操作的时候,如果主服务器目前没有在执行BGSAVE操作,或者主服务器并非刚刚执行完BGSAVE操作,那么主服务器就会执行BGSAVE命令。

在只使用快照持久化来保存数据时,一定要记住:如果系统真的发生崩溃,用户将丢失最近一次生成快照之后更改的所有数据。因此,快照持久化只适用于那些即使丢失一部分数据也不会造成问题的应用程序,而不能接受这种数据损失的应用程序则可以考虑使用AOF持久化。接下来将展示几个使用快照持久化的场景,读者可以从中学习到如何通过修改配置来获得自己想要的快照持久化行为。

个人开发

在个人开发服务器上面,我主要考虑的是尽可能地降低快照持久化带来的资源消耗。基于这个原因以及对自己硬件的信任,我只设置了save 900 1这一条规则。其中save选项告知Redis,它应该根据这个选项提供的两个值来执行BGSAVE操作。在这个规则设置下,如果服务器距离上次成功生成快照已经超过了900秒(也就是15分钟),并且在此期间执行了至少一次写入操作,那么Redis就会自动开始一次新的BGSAVE操作。

如果你打算在生产服务器中使用快照持久化并存储大量数据,那么你的开发服务器最好能够运行在与生产服务器相同或者相似的硬件上面,并在这两个服务器上使用相同的save选项、存储相似的数据集并处理相近的负载量。把开发环境设置得尽量贴近生产环境,有助于判断快照是否生成得过于频繁或者过于稀少(过于频繁会浪费资源,而过于稀少则带有丢失大量数据的隐患)。

对日志进行聚合计算

在对日志文件进行聚合计算或者对页面浏览量进行分析的时候,我们唯一需要考虑的就是:如果Redis因为崩溃而未能成功创建新的快照,那么我们能够承受丢失多长时间以内产生的新数据。如果丢失一个小时之内产生的数据是可以被接受的,那么可以使用配置值save 3600 1(3600为一小时的秒数)。在决定好了持久化配置值之后,另一个需要解决的问题就是如何恢复因为故障而被中断的日志处理操作。

在进行数据恢复时,首先要做的就是弄清楚我们丢失了哪些数据。为了弄明白这一点,我们需要在处理日志的同时记录被处理日志的相关信息。代码清单2展示了一个用于处理新日志的函数,该函数有3个参数,它们分别是:一个Redis连接;一个存储日志文件的路径;待处理日志文件中各个行(line)的回调函数(callback)。这个函数可以在处理日志文件的同时,记录被处理日志文件的名字以及偏移量。

def process_logs(conn, path, callback):                     #K
    current_file, offset = conn.mget(                       #A
        'progress:file', 'progress:position')               #A

    pipe = conn.pipeline()

    def update_progress():                                  #H
        pipe.mset({                                         #I
            'progress:file': fname,                         #I
            'progress:position': offset                     #I
        })
        pipe.execute()                                      #J

    for fname in sorted(os.listdir(path)):                  #B
        if fname < current_file:                            #C
            continue

        inp = open(os.path.join(path, fname), 'rb')
        if fname == current_file:                           #D
            inp.seek(int(offset, 10))                       #D
        else:
            offset = 0

        current_file = None

        for lno, line in enumerate(inp):                    #L
            callback(pipe, line)                            #E
            offset += int(offset) + len(line)               #F

            if not (lno+1) % 1000:                          #G
                update_progress()                           #G
        update_progress()                                   #G

        inp.close()
#A Get the current progress
#B Iterate over the logfiles in sorted order
#C Skip over files that are before the current file
#D If we are continuing a file, skip over the parts that we've already processed
#E Handle the log line
#F Update our information about the offset into the file
#G Write our progress back to Redis every 1000 lines, or when we are done with a file
#H This closure is meant primarily to reduce the number of duplicated lines later
#I We want to update our file and line number offsets into the logfile
#J This will execute any outstanding log updates, as well as to actually write our file and line number updates to Redis
#K Our function will be provided with a callback that will take a connection and a log line, calling methods on the pipeline as necessary
#L The enumerate function iterates over a sequence (in this case lines from a file), and produces pairs consisting of a numeric sequence starting from 0, and the original data

通过将日志的处理进度记录到Redis里面,程序可以在系统崩溃之后,根据进度记录继续执行之前未完成的处理工作。

大数据

当Redis存储的数据量只有几个GB的时候,使用快照来保存数据是没有问题的。Redis会创建子进程并将数据保存到硬盘里面,生成快照所需的时间比你读这句话所需的时间还要短。但随着Redis占用的内存越来越多,BGSAVE在创建子进程时耗费的时间也会越来越多。如果Redis的内存占用量达到数十个GB,并且剩余的空闲内存并不多,或者Redis运行在虚拟机(virtual machine)上面,那么执行BGSAVE可能会导致系统长时间地停顿,也可能引发系统大量地使用虚拟内存(virtual memory),从而导致Redis的性能降低至无法使用的程度。

执行BGSAVE而导致的停顿时间有多长取决于Redis所在的系统:对于真实的硬件、VMWare虚拟机或者KVM虚拟机来说,Redis进程每占用一个GB的内存,创建该进程的子进程所需的时间就要增加10~20毫秒;而对于Xen虚拟机来说,根据配置的不同,Redis进程每占用一个GB的内存,创建该进程的子进程所需的时间就要增加200~300毫秒。因此,如果我们的Redis进程占用了20 GB的内存,那么在标准硬件上运行BGSAVE所创建的子进程将导致Redis停顿200~400毫秒;如果我们使用的是Xen虚拟机(亚马逊EC2和其他几个云计算供应商都使用这种虚拟机),那么相同的创建子进程操作将导致Redis停顿4~6秒。用户必须考虑自己的应用程序能否接受这种停顿。

为了防止Redis因为创建子进程而出现停顿,我们可以考虑关闭自动保存,转而通过手动发送BGSAVE或者SAVE来进行持久化。手动发送BGSAVE一样会引起停顿,唯一不同的是用户可以通过手动发送BGSAVE命令来控制停顿出现的时间。另一方面,虽然SAVE会一直阻塞Redis直到快照生成完毕,但是因为它不需要创建子进程,所以就不会像BGSAVE一样因为创建子进程而导致Redis停顿;并且因为没有子进程在争抢资源,所以SAVE创建快照的速度会比BGSAVE创建快照的速度要来得更快一些。

根据我的个人经验,在一台拥有68 GB内存的Xen虚拟机上面,对一个占用50 GB内存的Redis服务器执行BGSAVE命令的话,光是创建子进程就需要花费15秒以上,而生成快照则需要花费15~20分钟;但使用SAVE只需要3~5分钟就可以完成快照的生成工作。因为我的应用程序只需要每天生成一次快照,所以我写了一个脚本,让它在每天凌晨3点停止所有客户端对Redis的访问,调用SAVE命令并等待该命令执行完毕,之后备份刚刚生成的快照文件,并通知客户端继续执行操作。

如果用户能够妥善地处理快照持久化可能会带来的大量数据丢失,那么快照持久化对用户来说将是一个不错的选择,但对于很多应用程序来说,丢失15分钟、1小时甚至更长时间的数据都是不可接受的,在这种情况下,我们可以使用AOF持久化来将存储在内存里面的数据尽快地保存到硬盘里面。

AOF持久化

简单来说,AOF持久化会将被执行的写命令写到AOF文件的末尾,以此来记录数据发生的变化。因此,Redis只要从头到尾重新执行一次AOF文件包含的所有写命令,就可以恢复AOF文件所记录的数据集。AOF持久化可以通过设置代码清单4-1所示的appendonly yes配置选项来打开。表1展示了appendfsync配置选项对AOF文件的同步频率的影响。

文件同步在向硬盘写入文件时,至少会发生3件事。当调用file.write()方法(或者其他编程语言里面的类似操作)对文件进行写入时,写入的内容首先会被存储到缓冲区,然后操作系统会在将来的某个时候将缓冲区存储的内容写入硬盘,而数据只有在被写入硬盘之后,才算是真正地保存到了硬盘里面。用户可以通过调用file.flush()方法来请求操作系统尽快地将缓冲区存储的数据写入硬盘里,但具体何时执行写入操作仍然由操作系统决定。除此之外,用户还可以命令操作系统将文件同步(sync)到硬盘,同步操作会一直阻塞直到指定的文件被写入硬盘为止。当同步操作执行完毕之后,即使系统出现故障也不会对被同步的文件造成任何影响。

选项 同步频率
always 每个Redis写命令都要同步写入硬盘。这样做会严重降低Redis的速度
everysec 每秒执行一次同步,显式地将多个写命令同步到硬盘
no 让操作系统来决定应该何时进行同步

如果用户使用appendfsync always选项的话,那么每个Redis写命令都会被写入硬盘,从而将发生系统崩溃时出现的数据丢失减到最少。不过遗憾的是,因为这种同步策略需要对硬盘进行大量写入,所以Redis处理命令的速度会受到硬盘性能的限制:转盘式硬盘(spinning disk)在这种同步频率下每秒只能处理大约200个写命令,而固态硬盘(solid-state drive,SSD)每秒大概也只能处理几万个写命令。

警告:固态硬盘和appendfsync always使用固态硬盘的用户请谨慎使用appendfsync always选项,因为这个选项让Redis每次只写入一个命令,而不是像其他appendfsync选项那样一次写入多个命令,这种不断地写入少量数据的做法有可能会引发严重的写入放大(write amplification)问题,在某些情况下甚至会将固态硬盘的寿命从原来的几年降低为几个月。

为了兼顾数据安全和写入性能,用户可以考虑使用appendfsync everysec选项,让Redis以每秒一次的频率对AOF文件进行同步。Redis每秒同步一次AOF文件时的性能和不使用任何持久化特性时的性能相差无几,而通过每秒同步一次AOF文件,Redis可以保证,即使出现系统崩溃,用户也最多只会丢失一秒之内产生的数据。当硬盘忙于执行写入操作的时候,Redis还会优雅地放慢自己的速度以便适应硬盘的最大写入速度。

最后,如果用户使用appendfsync no选项,那么Redis将不对AOF文件执行任何显式的同步操作,而是由操作系统来决定应该在何时对AOF文件进行同步。这个选项在一般情况下不会对Redis的性能带来影响,但系统崩溃将导致使用这种选项的Redis服务器丢失不定数量的数据。另外,如果用户的硬盘处理写入操作的速度不够快的话,那么当缓冲区被等待写入硬盘的数据填满时,Redis的写入操作将被阻塞,并导致Redis处理命令请求的速度变慢。因为这个原因,一般来说并不推荐使用appendfsync no选项,在这里介绍它只是为了完整列举appendfsync选项可用的3个值。

虽然AOF持久化非常灵活地提供了多种不同的选项来满足不同应用程序对数据安全的不同要求,但AOF持久化也有缺陷——那就是AOF文件的体积大小。

重写/压缩AOF文件

在阅读了上一节对AOF持久化的介绍之后,读者可能会感到疑惑:AOF持久化既可以将丢失数据的时间窗口降低至1秒(甚至不丢失任何数据),又可以在极短的时间内完成定期的持久化操作,那么我们有什么理由不使用AOF持久化呢?但是这个问题实际上并没有那么简单,因为Redis会不断地将被执行的写命令记录到AOF文件里面,所以随着Redis不断运行,AOF文件的体积也会不断增长,在极端情况下,体积不断增大的AOF文件甚至可能会用完硬盘的所有可用空间。还有另一个问题就是,因为Redis在重启之后需要通过重新执行AOF文件记录的所有写命令来还原数据集,所以如果AOF文件的体积非常大,那么还原操作执行的时间就可能会非常长。

为了解决AOF文件体积不断增大的问题,用户可以向Redis发送BGREWRITEAOF命令,这个命令会通过移除AOF文件中的冗余命令来重写(rewrite)AOF文件,使AOF文件的体积变得尽可能地小。BGREWRITEAOF的工作原理和BGSAVE创建快照的工作原理非常相似:Redis会创建一个子进程,然后由子进程负责对AOF文件进行重写。因为AOF文件重写也需要用到子进程,所以快照持久化因为创建子进程而导致的性能问题和内存占用问题,在AOF持久化中也同样存在。更糟糕的是,如果不加以控制的话,AOF文件的体积可能会比快照文件的体积大好几倍,在进行AOF重写并删除旧AOF文件的时候,删除一个体积达到数十GB大的旧AOF文件可能会导致操作系统挂起(hang)数秒。

跟快照持久化可以通过设置save选项来自动执行BGSAVE一样,AOF持久化也可以通过设置auto-aof-rewrite-percentage选项和auto-aof-rewrite-min-size选项来自动执行BGREWRITEAOF。举个例子,假设用户对Redis设置了配置选项auto-aof-rewrite-percentage 100和auto-aof-rewrite-min-size 64mb,并且启用了AOF持久化,那么当AOF文件的体积大于64 MB,并且AOF文件的体积比上一次重写之后的体积大了至少一倍(100%)的时候,Redis将执行BGREWRITEAOF命令。如果AOF重写执行得过于频繁的话,用户可以考虑将auto-aof-rewrite-percentage选项的值设置为100以上,这种做法可以让Redis在AOF文件的体积变得更大之后才执行重写操作,不过也会让Redis在启动时还原数据集所需的时间变得更长。

无论是使用AOF持久化还是快照持久化,将数据持久化到硬盘上都是非常有必要的,但除了进行持久化之外,用户还必须对持久化所得的文件进行备份(最好是备份到多个不同的地方),这样才能尽量避免数据丢失事故发生。如果条件允许的话,最好能将快照文件和最新重写的AOF文件备份到不同的服务器上面。

参考文档


  1. 当一个进程创建子进程的时候,底层的操作系统会创建该进程的一个副本。在Unix和类Unix系统上面,创建子进程的操作会进行如下优化:在刚开始的时候,父子进程共享相同的内存,直到父进程或者子进程对内存进行了写入之后,对被写入内存的共享才会结束。