日期:2014-05-16  浏览次数:20430 次

redis 持久化理解

快照方式(Snapshotting)
作者将这种持久化方式称为point-in-time, 即它并不能保证每个时刻内存中的数据集与磁盘上的二进制文件是完全一样的,但可以保证磁盘上的二进制文件与系统内存中某个时刻(最近一次fork时刻)的数据是完全一样的。若以时间为横轴,且每个fork时刻用Delta函数来描述,你会看到很多脉冲式的图像。当然,若某次持久化失败,那么相应的Delta函数也应该删除掉。在每次fork的时刻,系统都会把脏数据的数目清零(若持久化成功的话,是这样的;若失败的话,在失败后需要将当前脏数据的数目加上持久化前的数值作为新的脏数据数目),也同时将该时刻作为新的零时刻来计算,上述说明会直接影响到参数设置的理解。

若 fork的时刻是t1, 相应的持久化成功结束的时刻是t2,? 那么t2-t1是该次持久化需要的时间。每次持久化过程需要的时间是与系统数据集的大小成正比的,那么典型的数值是什么呢?同时,在加载的时候,需要的时间是多少呢?现在我还没有太多数据做测试,以后慢慢积累这些有价值的经验数据了。
?
参数设置
Save 60 10000
Save 300 10
Save 900 1
根据上述解释,所有的参数设置只有在两个Delta函数之间才是有意义的,即两类计数都不会跨越脉冲/Delta函数。参数的意义是,若每六十秒内,有一万或以上个键的值改变过(包含新创建或删除的键值对),则启动持久化过程;若连续五次都不满足此要求,就会同时进行后面的判断,即若每五分钟内有十或以上个键的值改变过,则启动持久化过程;依次类推。采取类似的设置,我估计原因可能是:在实际中,单位时间键值的改变数量,随时间的分布是随机的,有高有低,需要使用不同频率来匹配各种情形(好似用网捉鱼,用最大网眼的网快速扫,同时用中等网眼的网以一般速度扫,再同时用非常密的网来慢慢扫,比喻也不太恰当,呵呵)。只要数据有任何改变,此设置保证15分钟内系统会至少持久化一次,至多1分钟会持久化一次,因此在系统出问题时,会最多丢失“一分钟的数据”。

需要注意的是,尽管系统是利用单位时间内脏键值的数量来启动持久化事件,但在持久化过程中,并不是采取增量的方式进行的,而是每次都将此刻整个数据集的快照重新编码写到硬盘的二进制文件中。在持久化结束后,硬盘上的数据是数据库中某时刻(持久化过程启动的时刻)的数据快照。若由于系统断电或操作系统出问题,而使得持久化过程失败,那么系统的损失是自上次持久化之后新增的脏数据信息。

系统是利用上述参数来判断何时启动快照方式的持久化过程的,而此功能的实现是基于REDIS自带的事件驱动库完成的。之后,我们再专门讨论这个简洁的事件驱动库。
?
持久化过程(copy-on-write)
当持久化事件被启动时,系统的主进程会采用copy-on-write的模式 fork一个子进程。当然,具体的模式与操作系统中fork的实现有关。所谓copy-on-write, 在此例中可以做如下理解:

系统会根据父进程的信息创建其子进程,但创建之初,他们是共享地址空间的。父进程继续提供各种读写操作的服务,而子进程则进行持久化操作,将内存中的数据重新编码后全部写到硬盘上。对于客户端来讲,它的请求会像往常一样得到响应,与相应的数据信息是否被持久化无关,所以快照持久化是异步进行的。其实,也不是完全无关,因系统的CPU与内存等资源是有限的,两个进程有时会存在资源竞争关系,从而造成相互影响,比如当数据量已占据内存的80%时,父进程同时要对高负载的读写操作作出响应,尤其是写操作。此种情况后面会仔细讨论。

当父进程进行写操作时,它只把被操作的键值从共享地址空间里做本地化的复制,之后在副本上进行写操作,而不是去修改“与子进程共享的地址空间里”的键值信息,因为那样的行为会违背 point-in-time的语义,即在持久化结束前fork时刻内存数据的快照会被改变。若此时父进程需要进行大量写操作,系统会对每个被操作的键值做复制,从而需要更多的内存资源。极限情况是,在子进程持久化的过程中,父进程应客户端的请求,对所有的键值都进行写操作,那么就需要“容量为两倍于数据集大小”的内存才可以应付。当子进程结束持久化时,系统会将其关掉,其被复制的键值所占用的地址空间将被释放,未被复制的键值直接归父进程私有。

通过上述解释,在持久化过程中,内存的使用会出现暂时地上升。当然,若系统是处于一次接一次地进行持久化的状态,那么内存的使用会一直是数据集的1.x倍。那么,具体数值与哪些因素有关呢? 我想基本有以下三个:写操作的负载(平均每秒有多少写操作,取决于业务逻辑),子进程持久化的速度(取决于REDIS内部实现),还有数据集的大小(基本取决于可以占用的内存空间)。这是快照方式需要注意的核心问题,作者也给出过一些典型的数值,请参见[4]

到此为止,算是基本上将 copy-on-write的语义与在持久化背景中的后果解释清楚,那么我们再来仔细看看,子进程所进行的持久化过程是具体怎样的呢。基本上,是通过遍历REDIS实例的每个数据库中的主哈希表,将键与值分别编码而存储起来。至于具体的编码形式,我会在相应的代码阅读中,详细地整理出来。需要提醒一点是,在REDIS 2.4中作者改进了持久化速度,主要是针对小的 hash /list/set/zset。因这四种数据结构的mini版本实现,完全是利用单纯的数组实现的(没有复杂的指针),且其在内存中的结构 [3] 与持久化后的存储方式基本一致。那么,在持久化此类数据时,REDIS没有必要进行编码,只需直接放进缓冲区中。在REDIS将全部数据集写到临时文件后,系统会将之替换掉原来的 RDB文件,从而使得快照持久化过程也是原子操作。

也许你还可能有另外个疑问,在持久化过程中,当内存的需求量超过实际内存的大小时,系统会怎样处理呢?这触及到内存数据库的另一个死穴,而这两个问题,即持久化与容量限制,在传统的硬盘数据库领域,它们是比较容易被同时解决地。但对于REDIS来讲,将来可能会在其自带虚拟内存模块与更先进的持久化技术的结合下,得到较好的解答吧。作者对此的见解是这样 [4],但这是在解决集群与开发出更好的持久化技术之后,才可能被回答的。所以,对于REDIS社区来讲,至少是一年以后的事情。我对容量限制的解决也有些浅显的认识,以后再专门谈。


追加命令方式(Append Only File)
由上述分析可知,快照方式的持久化效果并非尽如人意,在机器或操作系统出现某种问题时,系统会丢失部分数据,同时内存被过多消耗也是遭人诟病的一点(但作者觉得这是个tradeoff)。为此,REDIS提供另一种更好的持久化方式AOF,即主进程每次将收到的写操作命令 以追加的方式写到同一个文件AOF中。而当加载的时候,通过重新播放即可得到数据集原有的状态。

参数设置及追加过程
在AOF中,有三点值得注意:其一,AOF以增长方式进行存储的,所以每次写的信息比快照少很多,只与单位时间内的写操作数目成正比。其二,系统的主进程是在将写操作命令放到通往AOF文件的缓冲区之后,才对相应客户端的请求作出回应的。这表明AOF具有更好的持久化效果,但同时也意味着主进程时刻占用着Disk I/O这个宝贵的资源。表面乍看,好似AOF方式是同步的,其实不然,因它并不能保证在回复客户端前已将命令写落到硬盘上,而只是写到缓存区中。其三,从缓冲区到硬盘的写操作是由系统函数fsync控制的,REDIS提供三种方式。其中,每秒钟 fsync一次是默认的。

appendfsync always
appendfsync everysec
appendfsync no
第一种设置具有最好的持久化效果,每个命令都要即时写到硬盘上,但DISK IO与系统的CPU等主要资源会被占用很多;第二种是每秒做一次fsync,持久化效果也比快照的每分钟做一次好很多;第三个策略是完全由操作系统掌控,比如,在等缓冲区满后,操作系统调用fsync写数据到硬盘上。

问题及引入日志重新技术
乍看这个方案很完美,不像快照那样利用多余的内存,且由于采取追加的方式,系统的CPU与DISK IO等资源的负载压力都被分散掉,同时也得到较好的持久化效果。但任何问题的难度永远是个“守恒量”,除非快照的想法太差,不然AOF方案是不会这样完美的。它的问题是硬盘上的AOF文件会一直单调递增下去,除了硬盘的容量有限制,其他诸如REDIS启动时加载AOF文件会非常慢,备份会非常慢,网络传输也会非常慢等等。

为了解决这个问题,作者提出日志重写的方法(Log rewrite)来不断控制AOF文件的过速增长