2021年3月

背景介绍

Redis在工作中特别常见,在很多业务架构的分享,Redis常常是作为单纯的缓存使用,目的是缓解持久层(比如MySQL)的大流量的访问,最终起到的作用是防止持久层因为海量访问而挂掉。Redis通过将数据保存在内存中,Redis得以拥有极高的读写性能。一旦服务进程退出,Redis的数据就会全部丢失。所以,很多情况下,业务上并不会使用Redis作为数据存储层。
但是,为了解决这个问题,稍微了解些Redis的同学,至少应该听说过Redis的两个持久化方案,分别是RDB、AOF两种持久化方案,这两个方案目标是一样的,就是将内存中的数据保存到磁盘中,避免数据丢失。很多人和我应该类似,听过但从没在实践中使用Redis持久化数据,在我看来,没用过光听过,肯定不是真正的了解,咱至少也得深入了解(暂时用不上的情况下),不能光停留在“听过”。
我会带着这几个问题,去深入了解Redis的持久化机制?
问题一、Redis的持久化机制RDB如何实现?
问题二、Redis的持久化机制AOF如何实现?
问题三、Redis的持久化机制怎么保证数据高可用?
问题四、Redis的持久化机制在哪些业务场景适用?

Redis的持久化机制-RDB

Redis的持久化机制怎么实现,大白话说法,Redis持久化也是把内存数据保存到磁盘上。RDB(Redis Database)是保存内存数据库的快照(SanpShot),而AOF(Append Only File)是保存执行的写操作列表。但这里面的门道是很多的,我们先来了解下RDB的持久化。

Redis数据库状态.png

举个例子,上图展示了一个包含三个非空数据库的Redis服务器,这三个数据库以及数据库中的键值对就是该服务器的数据库状态。RDB持久化就是生成一个RDB文件,当然是经过压缩的二进制文件,通过该文件可以还原生成RDB文件时对应的数据库状态。

RDB文件持久化数据库状态.png

RDB文件是通过SAVE或者BGSAVE命令创建的,代码是rdb.c/rdbSave,有兴趣可以读一下,我这就不展开分析了,SAVE和BGSAVE最终都会调用rdbSave的函数,区别在于调用的方法不同,看名字也能猜出个大概,SAVE是阻塞运行的,而BGSAVE是非阻塞运行的,
SAVE命令调用的方式:

void saveCommand(redisClient *c) {

    // BGSAVE 已经在执行中,不能再执行 SAVE
    // 否则将产生竞争条件
    if (server.rdb_child_pid != -1) {
        addReplyError(c,"Background save already in progress");
        return;
    }

    // 执行 
    if (rdbSave(server.rdb_filename) == REDIS_OK) {
        addReply(c,shared.ok);
    } else {
        addReply(c,shared.err);
    }
}

BGSAVE

int rdbSaveBackground(char *filename) {
    pid_t childpid;
    long long start;

    // 如果 BGSAVE 已经在执行,那么出错
    if (server.rdb_child_pid != -1) return REDIS_ERR;

    ……

    if ((childpid = fork()) == 0) {
        ……
        // 执行保存操作
        retval = rdbSave(filename);
        ……

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

    } else {

        /* Parent */

        // 计算 fork() 执行的时间
        server.stat_fork_time = ustime()-start;

        // 如果 fork() 出错,那么报告错误
        if (childpid == -1) {
            server.lastbgsave_status = REDIS_ERR;
            redisLog(REDIS_WARNING,"Can't save in background: fork: %s",
                strerror(errno));
            return REDIS_ERR;
        }

        ……

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

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

        ……

        return REDIS_OK;
    }

    return REDIS_OK; /* unreached */
}

代码很明显能看到,不管是SAVE还是BGSAVE,在执行命令期间(生成RDB文件时),如果再发送SAVE或者BGSAVE都是拒绝的,原因是生成RDB文件时,保存的是全量内存数据,所以极可能产生不小的磁盘I/O和CPU算力。
再来讲讲生成RDB文件的触发方式,有两种,一种是发送SAVE和BGSAVE命令。另外一种是自动间隔性保存,举个例子:
save 1000 1
save 300 20
save 60 20000
那么只要满足以下三个条件中的任意一个,BGSAVE命令就会被执行:
服务器在1000秒之内,对数据库进行了至少1次修改。
服务器在300秒之内,对数据库进行了至少20次修改。
服务器在60秒之内,对数据库进行了至少20000次修改。
那么代码实现上,关键的数据结构是struct saveparam,如下:

struct redisServer {
    ……
    // 记录保存条件的数组
    struct saveparam * saveparams;
    ……
}

那么上面的自动保存间隔参数,在内存中就是这样保存的:
serverparams.png

OK,Redis服务器会有一个定时任务的入口redis.c/serverCron,当中就会遍历以上条件是否满足,满足的话,触犯BGSAVE操作。

// 遍历所有保存条件,看是否需要执行 BGSAVE 命令
 for (j = 0; j < server.saveparamslen; j++) {
    struct saveparam *sp = server.saveparams+j;

    /* Save if we reached the given amount of changes,
     * the given amount of seconds, and if the latest bgsave was
     * successful or if, in case of an error, at least
     * REDIS_BGSAVE_RETRY_DELAY seconds already elapsed. */
    // 检查是否有某个保存条件已经满足了
    if (server.dirty >= sp->changes &&
        server.unixtime-server.lastsave > sp->seconds &&
        (server.unixtime-server.lastbgsave_try >
         REDIS_BGSAVE_RETRY_DELAY ||
         server.lastbgsave_status == REDIS_OK))
    {
        redisLog(REDIS_NOTICE,"%d changes in %d seconds. Saving...",
            sp->changes, (int)sp->seconds);
        // 执行 BGSAVE
        rdbSaveBackground(server.rdb_filename);
        break;
    }
 }

这里面还是不少实现细节的,比如生成RDB文件的子进程如何通知主进程,已经完成了生成文件的操作(通过信号),还有异常情况的处理,主进程收到了Kill信号,这时候也需要调用RDB生成文件,优雅退出程序等等。
关于RDB二进制文件的格式,这里就先略过了,不详细记录在学习笔记上了。

Redis的持久化机制-AOF

再来是AOF持久化机制,AOF( append only file )持久化以独立日志文件的方式记录每条写命令,并在 Redis 启动时回放 AOF 文件中的命令以达到恢复数据的目的。由于AOF会以追加的方式记录每一条redis的写命令,因此随着Redis处理的写命令增多,AOF文件也会变得越来越大,命令回放的时间也会增多,为了解决这个问题,为了解决AOF文件体积膨胀的问题,Redis提供了AOF文件重写(rewrite)功能。通过该功能,Redis服务器可以创建一个新的AOF文件来替代现有的AOF文件,新旧两个AOF文件所保存的数据库状态相同,但新AOF文件不会包含任何浪费空间的冗余命令,所以新AOF文件的体积通常会比旧AOF文件的体积要小得多。在同步到AOF文件前,Redis服务程序会先把命令写入到AOF缓冲区。

AOF机制.png

在介绍“AOF文件重写”之前,先简单说下,AOF这里的触发机制,这个和RDB不同,首先没有命令手动触发的方式,完全是通过服务器的设置,appendonly和appendfsync,appendonly打开情况下,appendfsync有3个可选值,always、everysecond和no。always服务器在每个事件循环都要将aof_buf缓冲区中的所有内容写入到AOF文件,并且同步AOF文件,服务器在每个事件循环都要将aof_buf缓冲区中的所有内容写入到AOF文件,并且同步AOF文件,服务器在每个事件循环都要将aof_buf缓冲区中的所有内容写入到AOF文件,至于何时对AOF文件进行同步,则由操作系统控制。
比较妙的是,虽然Redis将生成新AOF文件替换旧AOF文件的功能命名为“AOF文件重写”,但实际上,AOF文件重写并不需要对现有的AOF文件进行任何读取、分析或者写入操作,这个功能是通过读取服务器当前的数据库状态来实现的。直接上代码不啰嗦:

// 遍历所有数据库
for (j = 0; j < server.dbnum; j++) {

     ……
    /* Iterate this DB writing every entry 
     *
     * 遍历数据库所有键,并通过命令将它们的当前状态(值)记录到新 AOF 文件中
     */
    while((de = dictNext(di)) != NULL) {
        sds keystr;
        robj key, *o;
        long long expiretime;

        // 取出键
        keystr = dictGetKey(de);

        // 取出值
        o = dictGetVal(de);
        initStaticStringObject(key,keystr);

        // 取出过期时间
        expiretime = getExpire(db,&key);

        /* If this key is already expired skip it 
         *
         * 如果键已经过期,那么跳过它,不保存
         */
        if (expiretime != -1 && expiretime < now) continue;

        /* Save the key and associated value 
         *
         * 根据值的类型,选择适当的命令来保存值
         */
        if (o->type == REDIS_STRING) {
            /* Emit a SET command */
            char cmd[]="*3\r\n$3\r\nSET\r\n";
            if (rioWrite(&aof,cmd,sizeof(cmd)-1) == 0) goto werr;
            /* Key and value */
            if (rioWriteBulkObject(&aof,&key) == 0) goto werr;
            if (rioWriteBulkObject(&aof,o) == 0) goto werr;
        } else if (o->type == REDIS_LIST) {
            if (rewriteListObject(&aof,&key,o) == 0) goto werr;
        } else if (o->type == REDIS_SET) {
            if (rewriteSetObject(&aof,&key,o) == 0) goto werr;
        } else if (o->type == REDIS_ZSET) {
            if (rewriteSortedSetObject(&aof,&key,o) == 0) goto werr;
        } else if (o->type == REDIS_HASH) {
            if (rewriteHashObject(&aof,&key,o) == 0) goto werr;
        } else {
            redisPanic("Unknown object type");
        }

        /* Save the expire time 
         *
         * 保存键的过期时间
         */
        if (expiretime != -1) {
            char cmd[]="*3\r\n$9\r\nPEXPIREAT\r\n";

            // 写入 PEXPIREAT expiretime 命令
            if (rioWrite(&aof,cmd,sizeof(cmd)-1) == 0) goto werr;
            if (rioWriteBulkObject(&aof,&key) == 0) goto werr;
            if (rioWriteBulkLongLong(&aof,expiretime) == 0) goto werr;
        }
    }

    // 释放迭代器
    dictReleaseIterator(di);
}

是不是很简单明了,但是AOF这里有一个重写一致性的问题,举例子:

AOF重写数据不一致.png

上图所示,当子进程开始进行文件重写时,数据库中只有k1一个键,但是当子进程完成AOF文件重写之后,服务器进程的数据库中已经新设置了k2、k3、k4三个键,因此,重写后的AOF文件和服务器当前的数据库状态并不一致,新的AOF文件只保存了k1一个键的数据,而服务器数据库现在却有k1、k2、k3、k4四个键。为了解决这种数据不一致问题,Redis服务器设置了一个AOF重写缓冲区,这个缓冲区在服务器创建子进程之后开始使用,当Redis服务器执行完一个写命令之后,它会同时将这个写命令发送给AOF缓冲区和AOF重写缓冲区。如下图所示:

AOF重写解决数据一致性问题.png

最右侧的虚线表示重写完之后,替换AOF文件。更详细的流程,可以参考下图:

详细AOF重写解决数据一致性问题.png

注:AOFRW表示AOF文件重写

Redis的持久化机制怎么保证数据高可用

关于这个问题,在了解上述持久化机制的实现方案后,我的理解是,不管是RDB还是AOF机制,都存在丢失数据的可能性。RDB机制下,数据丢失的概率比AOF机制更大些,而且不管是RDB还是AOF都仅仅是单机上的数据持久化,如果单机的存储磁盘挂了,是没有多备份的,关于这点,Redis也是有解决方案的,Redis的哨兵模式或者Redis集群模式就能提供多机的高可用,所以在实际业务场景中,我认为完全有可能,在清楚地评估业务丢失数据容忍度的情况下,去使用Redis的数据持久化方案。

Redis的持久化机制在哪些业务场景适用

对持久化有要求,又特别适合使用Redis的场景主要有这么两个,特点数据量不大,写入和查询比例差不多。

  • 排行榜相关问题

关系型数据库在排行榜方面写入和查询速度较慢,可能不太适合使用关系型数据库,太重。
比如在线上PK类型的活动中,需要实时展示参与作品的点赞排行榜, 点赞数随着活动进行会不断变化,可能还涉及关联点赞用户的基础信息,那么可以使用到redis中的数据结构,SortedSet和hashmap,来组合使用,满足业务场景。

  • 好友关系、黑白名单等的存储

在微博应用中,每个用户关注的人存在一个集合中,就很容易实现求两个人的共同好友功能,或者黑白名单的存储,都可以使用Redis作为持久存储,写入和查询速度较快,也不会比关系型数据库重,而且可以快速响应需求。

RDB和AOF的优劣对比

RDB和AOF的对比.png