Redis 持久化

Redis 为了达到最快的读写速度将数据都读到内存中,并通过异步的方式将数据写入磁盘。所以 redis 具有快速和数据持久化的特征。如果不将数据放在内存中,磁盘 I/O 速度为严重影响 redis 的性能。在内存越来越便宜的今天,redis 将会越来越受欢迎。
如果设置了最大使用的内存,则数据已有记录数达到内存限值后不能继续插入新值。
不过 Redis 也提供了持久化的选项。

file snap shotting(RDB半持久化模式)

特点

以快照的方式将内存数据写入到一个二进制文件中(默认为 dump.rdb),异步保存到磁盘上。

开启

快照是 redis 的默认持久化方式。修改配置文件的 save 属性可以配置自动快照方式。

命令

可以使用 save 或 bgsave 命令直接通知 redis 做一个快照,save 操作是在主线程中保存快照的,由于 redis 是用一个主线程来处理所有 client 的请求,这种方式会阻塞所有 client 请求。但是bgsave就没有这个问题。
save命令入口见:rdb.c/saveCommand
bgsave命令入口见:rdb.c/bgsaveCommand

bgsave 过程

  1. 当 redis 需要做持久化时,redis 会 fork 一个子进程;
    代码见:rdb.c/rdbSaveBackground()
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    if ((childpid = fork()) == 0) {
    int retval;

    /* Child */

    ...

    // 执行保存操作
    retval = rdbSave(filename);

    ...

    // 向父进程发送信号
    exitFromChild((retval == REDIS_OK) ? 0 : 1);

    } else {

    /* Parent */

    ...

    // 记录数据库开始 BGSAVE 的时间
    server.rdb_save_time_start = time(NULL);

    // 记录负责执行 BGSAVE 的子进程 ID
    server.rdb_child_pid = childpid;

    // 关闭自动 rehash
    updateDictResizePolicy();

    return REDIS_OK;
    }
  2. 父进程继续处理客户端请求,子进程根据配置文件中的 save 策略将内存数据写到磁盘上一个临时 RDB 文件中;
    写入rdb文件代码:rdb.c/rdbSave()
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    // 遍历所有数据库
    for (j = 0; j < server.dbnum; j++) {

    // 指向数据库
    redisDb *db = server.db+j;

    // 指向数据库键空间
    dict *d = db->dict;

    // 跳过空数据库
    if (dictSize(d) == 0) continue;

    // 创建键空间迭代器
    di = dictGetSafeIterator(d);
    if (!di) {
    fclose(fp);
    return REDIS_ERR;
    }

    /* Write the SELECT DB opcode
    *
    * 写入 DB 选择器
    */
    if (rdbSaveType(&rdb,REDIS_RDB_OPCODE_SELECTDB) == -1) goto werr;
    if (rdbSaveLen(&rdb,j) == -1) goto werr;

    /* Iterate this DB writing every entry
    *
    * 遍历数据库,并写入每个键值对的数据
    */
    while((de = dictNext(di)) != NULL) {
    sds keystr = dictGetKey(de);
    robj key, *o = dictGetVal(de);
    long long expire;

    // 根据 keystr ,在栈中创建一个 key 对象
    initStaticStringObject(key,keystr);

    // 获取键的过期时间
    expire = getExpire(db,&key);

    // 保存键值对数据
    if (rdbSaveKeyValuePair(&rdb,&key,o,expire,now) == -1) goto werr;
    }
    dictReleaseIterator(di);
    }
  3. 当子进程完成写临时文件后,将原来的 RDB 文件替换掉,然后子进程退出。这样的好处就是可以 COW
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    /* Use RENAME to make sure the DB file is changed atomically only
    * if the generate DB file is ok.
    *
    * 使用 RENAME ,原子性地对临时文件进行改名,覆盖原来的 RDB 文件。
    */
    if (rename(tmpfile,filename) == -1) {
    redisLog(REDIS_WARNING,"Error moving temp DB file on the final destination: %s", strerror(errno));
    unlink(tmpfile);
    return REDIS_ERR;
    }
    Redis并不是在同步时就直接将内存都拷贝一份,而是写时复制(COW),也就是说当没有发生写入操作时,子进程和父进程共享内存空间,父进程对内存的某页有写入时,子进程会复制一份该页,之后都是基于该复制出的页面进行写盘。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    /* Save a key-value pair, with expire time, type, key, value.
    *
    * 将键值对的键、值、过期时间和类型写入到 RDB 中。
    *
    * On error -1 is returned.
    *
    * 出错返回 -1 。
    *
    * On success if the key was actually saved 1 is returned, otherwise 0
    * is returned (the key was already expired).
    *
    * 成功保存返回 1 ,当键已经过期时,返回 0 。
    */
    int rdbSaveKeyValuePair(rio *rdb, robj *key, robj *val,
    long long expiretime, long long now)
    {
    /* Save the expire time
    *
    * 保存键的过期时间
    */
    if (expiretime != -1) {
    /* If this key is already expired skip it
    *
    * 不写入已经过期的键
    */
    if (expiretime < now) return 0;

    if (rdbSaveType(rdb,REDIS_RDB_OPCODE_EXPIRETIME_MS) == -1) return -1;
    if (rdbSaveMillisecondTime(rdb,expiretime) == -1) return -1;
    }

    /* Save type, key, value
    *
    * 保存类型,键,值
    */
    if (rdbSaveObjectType(rdb,val) == -1) return -1;
    if (rdbSaveStringObject(rdb,key) == -1) return -1;
    if (rdbSaveObject(rdb,val) == -1) return -1;

    return 1;
    }
  4. 最后,子进程通知父进程RDB文件写入完毕

写时复制(COW)

RDB存储保证高效的基础是COW,子进程会复制原始数据,而主进程仍可以修改原来的数据。

  1. fork子进程时,子进程会复制主进程的页表,有了这个页表,子进程就可以知道所有数据块在内存中的物理地址,子进程在生成RDB时,可以通过页表读取这些数据,再写入磁盘;
  2. 拷贝过程中,如果主进程接收到了新的写操作,主进程会使用写时复制机制,把数据写入到一个新的物理地址中,并修改自己的页表映射。
    Redis中bgsave机制的COW
    如上图所示,对父子进程来说访问的都是虚页7,但是父进程实际访问的是物理页53,而子进程访问的是物理页33。

问题

  • filesnapshotting 在 redis 异常死掉时,最近的数据会丢失(因为最近没有持久化的数据还在内存中)。
    解决办法是使用AOF增量保存产生的数据。
    当然,如果允许分钟级别的数据丢失,那么单纯使用RDB也可以接受。
  • 每次做快照都是将内存数据完整写入到磁盘,如果程序 io 频繁可能会严重影响性能,特别是磁盘带宽容易被打满。
  • bgsave子进程需要通过fork操作从主进程创建出来,子进程创建完后不会再阻塞主进程,但是fork这个创建进程操作本身是会阻塞主进程的,而且主进程内存越大,阻塞的时间就会越长,如果频繁fork出bgsave子进程,就会频繁阻塞主进程了。
    解决办法是采用增量快照,也就是AOF。

Append-only file(全持久化模式 / AOF)

特点

把每一次数据变化都写入到一个 append only file(默认是 appendonly.aof)里,所以可以做到全部数据都不丢失,但性能要劣于 filesnapshotting 模式。当 redis 重启时会通过重新执行文件中保存的写命令来在内存中重建整个数据库的内容。

开启

在配置文件中设置 appendonly 为 yes 可开启 AOF 模式。AOF 文件的刷新方式有三种,可以根据需要修改 appendfsync。

过程

同样用到了 COW,首先 redis 会 fork 一个子进程;
子进程将最新的 AOF 写入一个临时文件;
父进程增量的把内存中的最新执行的修改写入(这时仍写入旧的 AOF,rewrite 如果失败也是安全的);
当子进程完成 rewrite 临时文件后,父进程会收到一个信号,并把之前内存中增量的修改写入临时文件末尾;
这时 redis 将旧 AOF 文件重命名,临时文件重命名,开始向新的 AOF 中写入。

AOF日志的重写

AOF重写过程
有时候AOF日志文件过大了,可以通过重写来进行压缩,比如:

1
2
3
set x a
set x b
set x a

这三条命令最终达到的结果就是把x变成了a,因此最终可以被替换为一条set x a,也就是说,在重写的时候,会根据这个键值对当前的最新状态,为它生成对应的写入命令,这样一来,一个键值对在重写日志中只用一条命令就行了,恢复时也只用执行这一条命令。
AOF重写使用的是新的一个重写日志而不是原来的AOF日志:

  1. 每次执行重写时,主进程fork出一个后台的bgrewriteaof子进程,按COW机制拷贝内存给子进程使用,子进程逐一将拷贝的数据写成操作,记入重写日志;
  2. AOF照常写入到AOF日志内(当然需要先写入到操作系统的页缓冲中);
  3. AOF重写日志也是读取内存中的kv(而不是读的AOF日志),然后写入到重写日志的缓冲区内,这样,重写日志也不会丢失最新的操作;
  4. 最后写入完毕后,重写日志替换原来老的AOF日志。

命令

调用 bgrewriteaof 可以使用与快照类似的方式将内存中的数据以命令的形式保存到临时文件,最后替换原来的文件,具体的

  1. Redis 调用 fork ,现在有父子两个进程;
  2. 子进程根据内存中的数据库快照,往临时文件中写入重建数据库状态的命令(是命令而不是内存数据);
  3. 父进程继续处理 client 请求,除了把写命令写入到原来的 aof 文件中。同时把收到的写命令缓存起来。这样就能保证如果子进程重写失败的话并不会出问题;
  4. 当子进程把快照内容写入已命令方式写到临时文件中后,子进程发信号通知父进程。然后父进程把缓存的写命令也写入到临时文件;
  5. 现在父进程可以使用临时文件替换老的 aof 文件,并重命名,后面收到的写命令也开始往新的 aof 文件中追加。

问题

  1. AOF 文件损坏,redis 无法加载的情况,照下面步骤解决,(1) 备份当前 AOF 文件;(2) 修复执行 redis-check-aof -fix;(3) 重启 redis 服务。
  2. Master AOF 持久化,如果不重写 AOF 文件,这个持久化方式对性能的影响是最小的,但是 AOF 文件会不断增大,AOF 文件过大会影响 Master 重启的恢复速度。Master 最好不要做任何持久化工作,包括内存快照和 AOF 日志文件,特别是不要启用内存快照做持久化,如果数据比较关键,某个 Slave 开启 AOF 备份数据,策略为每秒同步一次。
  3. Master 调用 BGREWRITEAOF 重写 AOF 文件,AOF 在重写的时候会占大量的 CPU 和内存资源,导致服务 load 过高,出现短暂服务暂停现象。

宕机恢复

如果突然机器掉电会怎样?取决于 aof 日志 sync 属性的配置,如果不要求性能,在每条写指令时都 sync 一下磁盘,就不会丢失数据。但是在高性能的要求下每次都 sync 是不现实的,一般都使用定时 sync,比如 1s1 次,这个时候最多就会丢失 1s 的数据。
最后,为以防万一(机器坏掉或磁盘坏掉),记得定期把使用 filesnapshotting 或 Append-only 生成的*rdb *.aof 文件备份到远程机器上。比如用 crontab 每半小时 scp 一次。
或者一种常见替代方案是主从,虽然会浪费一些机器资源。

文件压缩

如果 aof 文件过大可能会导致恢复时间过长,Redis 提供了两个特性来减小这个影响:

  1. Redis 会定期做 aof 重写,压缩 aof 文件日志大小。
  2. Redis4.0 之后有了混合持久化的功能,将 bgsave 的全量和 aof 的增量做了融合处理,AOF 只记录最后一次 bgsave 之后的增量 AOF 日志,这样既保证了恢复的效率又兼顾了数据的安全性。

生产中如何设计持久化方案

bgsave 做镜像全量持久化,aof 做增量持久化。
因为 bgsave 会耗费较长时间,不够实时,在停机的时候会导致大量丢失数据,所以需要 aof 来配合使用。在 redis 实例重启时,优先使用 aof 来恢复内存的状态,如果没有 aof 日志,就会使用 rdb 文件来恢复。

RDB配置

RDB生成策略用默认的也差不多,也可以根据实际需求设置保存的时机。
比如希望RDB最多丢一分钟的数据,则尽量每分钟生成一个快照,而且生成RDB文件比较耗费资源,可以设置有10000条数据变化则生成RDB:

1
save 60 10000

AOF

1
2
3
4
5
6
fsync
everysec
# 当前AOF大小膨胀到超过上次100%,则重写
auto-aof-rewrite-percentage 100
# 如果业务量大可以稍微调大些
auto-aof-rewrite-min-size 64mb

数据备份

RDB适合作为冷备。

  1. 写crontab定时调度脚本做数据备份;
  2. 每小时copy一份rdb的备份到一个目录中去,仅仅保留最近48小时的备份;
  3. 每天都保留一份当日的rdb备份,到一个目录中去,仅仅保留最近1个月的备份;
  4. 每次copy备份的时候,都把太旧的备份删除;
  5. 每天晚上将当前服务器上所有的数据备份,发送一份到远程的云服务器上。

QA

如果一个系统大部分Redis操作都是写(比如写读比例是8:2),使用RDB快照备份会有什么隐患?

因为大部分操作都是写入,那么由于COW机制,大部分内存页都会拷贝一份,导致内存浪费;如果机器内存不够,Redis引发OOM又很有可能会被Linux系统kill掉;如果引入Swap来扩容内存,由于内存页面的交换又会极大影响Redis性能。
如果写RDB文件的线程变多了,也会与主线程竞争CPU资源。

AOF重写过程中有没有潜在的阻塞风险?

fork
拷贝内存页(包括管理内存页的PCB),写的越多需要拷贝的也会越多。

AOF重写为什么不共享AOF本身的日志?

如果共享相同的文件,则必然会造成两个线程竞争同一个文件的文件锁,对性能造成影响。