深入浅出Redis持久化

前言

redis作为内存数据库,数据都在内存里,如果突然宕机,则数据都会丢失(这里假设不使用非易失性内存),redis提供了持久化机制来防止这种情况发生

如果将redis仅作为缓存使用,且不需要宕机后快速生成缓存数据,可以不使用持久化机制,还能提升性能

redis提供了以下两种持久化方式:

  • RDB(redis database):定时将某一时刻内存中的数据保存到磁盘上
  • AOF(append only file):通过持续增量记录redis的写命令来持久化数据

下面介绍其原理和实现

RDB

快照原理

redis通过定时将内存中某一时刻的快照持久化到磁盘上,来实现数据保存

redis怎么“捕获”某一时刻的快照?

一个简单的想法是,在持久化的过程中,不处理任何请求,也就是执行save命令

但这样会阻塞线上业务,更好的做法是:边持久化边响应客户请求,也就是bgsave命令

以快照的方式持久化,需要保证获取的是“一致性”的快照

什么意思呢?在持久化的过程中,如果先持久化了内存的前半部分,再持久化后半部分,可能在持久化后半部分时,又有请求同时写了前,后半部分的数据。而此时前半部分已经持久化完毕,不会修改了。这样前后两部分的数据就是“不一致”

举个例子: image.png

  1. 内存中一开始A=1,B=2
  2. 将A=1序列化到磁盘
  3. 某请求在内存中将A,b修改为2,此时磁盘上A=1
  4. 继续序列化,将B=2写入磁盘
  5. 出现问题,此时磁盘中是不合法的数据,因为内存中从来没有某一时刻是A=1,B=2,也就是说A的老版本B的新版本混在一起了

那怎么获取一致性的快照呢?

  • Mysql Innodb的可重复读隔离级别采用MVCC的方式,使得每次的读操作获取的都是一致性的数据,同时不阻塞其他读写请求
  • redis采用多进程写时复制(copy on write)技术来实现

当redis服务端收到bgsave命令时,调用fork函数产生一个子进程,由该子进程创建RDB文件,主进程继续响应客户请求

刚创建子进程时,其和父进程共享数据段,这是操作系统节约内存的机制

只有在某进程需要对数据进行修改时,才会将其复制一份,单独给自己使用,才这个复制出来的页面进行修改。也就是说,子进程在生成快照的过程中需要遍历的页面,是不会被修改的,永远是刚fork出来的样子。

刚fork出来的页面肯定是那一时刻的一个一致性快照,因此将其序列化到磁盘没有任何问题

保存时机

redis在什么时机会触发一次fork子进程生成快照文件的操作呢?

若在配置文件中进行如下save配置:

save 900 1

save 300 10

save 60 3600
复制代码

以上配置表示redis会在以下情况满足时,自动执行bgsave命令:

  • 900秒内至少1个key发送变化(新增,修改,删除)
  • 300秒内至少10个key发送变化
  • 60秒内至少3600个key发送变化

为什么需要配置多个规则?

试想如果只有中间的规则(300秒内至少10个key发送变化)

那如果redis中只有9个key发生变化,则不管经过多久,这9个key都不会被持久化,因为永远不满足300秒内至少10个key发送变化的条件

因此在save配置项中最好有一条 save XXX 1 的配置兜底,确保redis中所有的变动在一定时间内都能被持久化

如果短时间内变化的次数较多,但根据唯一的那条配置,需间隔300秒后才会进行一次持久化。如果宕机,则会丢失从上一次持久化到宕机这段时间内的修改。也就是说最多会丢失300秒的数据

因此在save配置项中最好有一条 save XXX(小于300秒) XXX(大于10个)的配置,这样在短时间内变更较多时,能提早,更频繁地持久化。如果宕机,最多只会丢失更短时间间隔的数据

怎么实现的?其实非常简单

以下用go代码示例,不过没有特殊的语法,不影响理解

redis维护了两个变量:

  • dirty:在上次bgsave后,执行了多少次修改操作
  • lastsave:上次bgsave的时间

以及配置的规则saveConfig:

type saveConfig struct {

    Change int       // 多少时间内        save 60 3600 中的 3600 

    time   int       // 多少个key发生变化  save 60 3600 中的 60

}
复制代码

在每次事件循环中尝试每个配置,如果符合某个配置的条件,执行bgsave



func serverCron(){

   /**

 执行其他操作

 */



 interval := time.Now().Sub(lastsave)

   // 尝试每个配置

   for saveConfig := range saveConfigs {

      // 如果符合某个配置的条件,执行bgsave

      if dirty >= saveConfig.Change && interval >= saveConfig.Time {

         bgsave()

         break

      }

   }

}
复制代码

AOF

与RDB通过快照的方式保存redis中的数据不同,AOF通过保存redis执行的写命令来记录数据库的状态。如果AOF文件记录了有史以来的所有命令,则将这些命令在一个空的redis重播一遍,就可以恢复数据

同步策略

一条写命令从产生到写入AOF文件需经过以下三步:

命令追加文件写入文件同步

一条写命令在被执行完毕后,会被追加到内存缓冲区

接下来就到了事件循环的末尾,一次事件循环的伪代码如下所示:

func eventLoop() {

    for {

        // 处理文件事件,即客户的请求读写,也就是在这里面完成命令追加

        processFileEvents()

        

        // 处理时间事件,例如定期删除过期键

        processTimeEvents()

        

        // 根据配置,执行文件写入或文件同步

        flushAppendOnlyFile()

    }

}
复制代码

在事件循环的开头,会执行文件事件,即客户的请求读写,也就是在这里面完成命令追加

在一次事件循环结束前,redis会根据配置appendfsync的值来来决定怎么处理之前加入到内存缓冲区的aof命令:

  • always:将aof_buf缓冲区中的内容全部写入并同步aof文件

    • 由于每次事件循环都会同步数据到磁盘,总所周知,磁盘的速度比内存慢很多,因此该配置效率最低,但安全性也最高,因为若宕机最多只会丢失一个事件循环的数据
  • everysec:将aof_buf缓冲区中的内容全部写入到aof文件,若当前距离上次同步超过1秒,则执行同步

    • 同步的频率从每次时间循环变为每隔一秒,效率提升不少,同时若宕机最多丢失1秒的数据
  • no:将aof_buf缓冲区中的内容全部写入到aof文件,但不执行同步,何时同步由操作系统决定

    • 从效率上来说是最快的,因此每次都不用等待数据同步到磁盘,但是何时同步数据不可控,有丢失较长时间范围的数据的风险

文件写入和同步: 现代操作系统为了提升效率,用户将一些数据写入磁盘时(文件写入),操作系统通常会将数据暂存于内存缓冲区,等数据填满或超过一定时限后再真正刷入磁盘。同时操作系统也提供了文件同步的函数

值得一提的是,当配置为always时,并不是每写一条命令就同步一次磁盘,而是一次事件循环后同步一次。因为一次事件循环中可能会执行多条命令

生产环境中通常将appendfsync配置为everysec,在保存高性能的同时尽量减少数据丢失

AOF重写

随着程序的运行,aof文件会越来越大,若不加以处理,数据库重启或宕机时使用aof文件来还原的耗时就会越来越长,甚至超出磁盘容量限制。因此redis会定时为aof文件瘦身,使其只保存必要的数据

那什么是不必要的数据呢?

举个例子,假设历史上对list执行了以下6条命令

rpush list "A" // ["A"]

rpush list "B" // ["A","B"]

rpush list "C" // ["A","B","C"]

lpop list // ["B","C"]

lpop list // ["C"]

rpush list "D" "E" // ["C","D","E"]
复制代码

但这6条命令其实可以用1条命令来替代:

rpush list "C" "D" "E"
复制代码

这样一来,占用空间和恢复时间都答复减少

进行重写有以下两种方式:

  1. 分析现有aof文件中有关于list的内容,进行重写

  2. 读取内存中list的值,用rpush XXX 这一条命令替换掉aof文件中和list有关的命令

    1. 就像上面的rpush list "C" "D" "E"

很明显,第二种方式实现简单,效率也高,不像第一种方式需要设计复杂的算法来比对,处理aof文件

redis作为单线程应用,如果将aof重写放到主线程中执行,会导致重写期间redis无法处理客户响应

为了避免这种情况,redis将aof文件重写的工作放到子进程中。这么做有以下优点:

  • 主线程能继续对外提供服务,不受aof文件重写的影响
  • 子进程基于fork那一时刻的数据,不受主线程后续操作的影响,这也是fork子进程方式的通用优点

子进程根据内存快照,生成一份新的aof文件

主线程在子进程重写期间,还在源源不断地接收并执行新的写命令,可能在子进程完成重写后,数据库实际的状态,和重写后的aof文件不一致。因此需要将这期间新的写命令追加到重写后的aof文件中,再将重写后的aof文件替换到原来的aof文件,这样aof重写才算完成

为了解决数据不一致问题,redis设置了aof重写缓冲区,在子进程重写期间,新的写命令除了会被写入原aof文件中,还会被写入aof重写缓冲区,这样在子进程重写完毕后,能知道哪些是新命令,将这些新命令追加到重写好的aof文件即可大功告成

image.png

为啥新命令还要写到原aof文件中?保证原来的aof持久化逻辑正常运行,当重写失败时不会对原来产生影响

将新命令追加到重写好的aof文件中不需要在子进程中执行,在主线程中执行即可,原因为

  • 这些新的写命令不会很多,不会对主线程造成太大影响
  • 若再用子进程,还需要考虑怎么合并追加期间的新命令,最终还是需要一个同步操作去合并

混合持久化

单独使用RDB,可能会丢失很多数据,但若单独使用AOF,在恢复数据时相比RDB会慢很多。于是redis 4.0推出了混合持久化,将RDB文件和增量的AOF日志文件放在一起,这里的AOF日志是RDB持久化结束到当前时刻的增量更新日志,通常比较小

image.png 这样的混合持久化方式既有了RDB恢复快的优点,也有AOF不会丢失大量数据的优点

总结

  • RDB通过写时复制技术抓取快照进行持久化,配置保存实际时需考虑到各种情况
  • 根据业务需要配置AOF同步策略,为了避免文件过大,需要进行AOF文件重写
  • 混合持久化方式集成了两者的优点

Guess you like

Origin juejin.im/post/7034674991404679181