Redis-bgsave致使的接口响应延迟波动(深刻分析Linux的fork()机制)

近期线上有个接口响应延迟P99波动较大,后对其进行了优化。响应延迟折线图以下:java

优化先后对比

在12月11号11点左右优化完成后,P99趋于平稳,平均在70ms左右。linux

下面来讲一下优化过程。git

1 思考接口的执行过程

这个接口一共会通过三个服务,最终返回给客户端。执行流程以下:github

服务结构

按照箭头所示流程,先访问服务1,服务1的结果返回给接口层,在请求服务2,服务2请求服务3,而后将结果返回给接口层。redis

2 分析

而后分别观察了服务一、服务二、服务3,主要观察的指标以下:数据库

  • 服务对外响应延迟
  • CPU负载
  • 网络抖动

观察后,服务2和服务3的这几个指标都没啥问题。服务器

服务2的对外响应延迟波动状况与接口的波动颇为类似,再针对服务2分析。服务2是个IO密集型的服务,平均QPS在3K左右。微信

主要的几个IO操做包括:网络

  • 单点Redis的读取
  • 集群Redis的读取
  • 数据库的读取
  • 两个http接口的拉取
  • 一次其余服务的调用

集群Redis的响应很快,平均在5ms左右(加上来回的网络消耗),数据库在10ms左右,http接口只有偶尔的慢请求,其余服务的调用也没问题。app

最后发现单点的Redis响应时间过长

P99响应

如图所示,服务2接受到的每次请求会访问三次这个单点redis,这三次加起来有接近100ms,而后针对这个单点redis进行分析。

发现这台redis的CPU有以下波动趋势

CPU波动

基本上每一分钟会波动一次。

立刻反应过来是开启了bgsave引发的(基本1分钟bgsave一次),由于以前有过相似的经验,就直接关掉bgsave再观察

关闭bgsave后的CPU波动

至此,业务平稳下来。

3 解决方案

线上的bgsave不能一直关闭,万一出现故障,会形成大量数据丢失。

具体方案以下:

  • 先开启这台机器的bgsave
  • 申请一台从服务器,并从这台机器上同步数据
  • 同步完成后,主节点关闭bgsave,从节点开启bgsave

这样一来,主节点的读写再也不受bgsave影响,同时也能用从节点保证数据不丢失。

4 bgsave引发CPU波动缘由探索

首先要说一下bgsave的执行机制。执行bgsave时(不管以哪一种方式执行),会先fork出一个子进程来,由子进程把数据库的快照写入硬盘,父进程会继续处理客户端的请求。

因此在平时没有bgsave的时候,进程状态以下:

无bgsave的进程状态

bgsave时,进程状态以下:

开启bgsave的进程状态

最上面CPU占用100%的就是fork出来的子进程,在执行bgsave,同时他彻底独占了一个CPU(上面的红框)。

因此得出结论,这个CPU的波动是正常的,每个波峰都是子进程bgsave所致。

5 bgsave引发的接口相应延迟探索

关于fork,在redis官网有这么一段描述:

RDB disadvantages

  • RDB is NOT good if you need to minimize the chance of data loss in case Redis stops working (for example after a power outage). You can configure different save points where an RDB is produced (for instance after at least five minutes and 100 writes against the data set, but you can have multiple save points). However you'll usually create an RDB snapshot every five minutes or more, so in case of Redis stopping working without a correct shutdown for any reason you should be prepared to lose the latest minutes of data.
  • RDB needs to fork() often in order to persist on disk using a child process. Fork() can be time consuming if the dataset is big, and may result in Redis to stop serving clients for some millisecond or even for one second if the dataset is very big and the CPU performance not great. AOF also needs to fork() but you can tune how often you want to rewrite your logs without any trade-off on durability.

这里说了RDB的劣势,第二点说明了fork会形成的问题。

大意是:RDB为了将数据持久化到硬盘,须要常常fork一个子进程出来。数据集若是过大的话,fork()的执行可能会很是耗时,若是数据集很是大的话,可能会致使Redis服务器产生几毫秒甚至几秒钟的拒绝服务,而且CPU的性能会急剧降低。

这个停顿的时间长短取决于redis所在的系统,对于真实硬件、VMWare虚拟机或者KVM虚拟机来讲,Redis进程每占用1个GB的内存,fork子进程的时间就增长10-20ms,对于Xen虚拟机来讲,Redis进程每占用1个GB的内存,fork子进程的时间须要增长200-300ms。

但对于一个访问量大的Redis来讲,10-20ms已是很长时间了(咱们的redis占用了10个G左右内存,估计停顿时间在100ms左右)。

至此,形成接口响应延迟的缘由就明确了:

因为redis是单进程运行的,在fork子进程时,若是耗时过多,形成服务器的停顿,致使redis没法继续处理请求,进一步就会致使向redis发请求的客户端全都hang住,接口响应变慢。

6 深刻分析fork机制

知道缘由后,来看一下redis执行bgsave的源码(fork部分):

注释中分析了若是fork卡住,会形成的影响。

// 执行bgsave
int rdbSaveBackground(char * filename, rdbSaveInfo * rsi) {
    pid_t childpid;
    long long start;
    if (server.aof_child_pid != -1 || server.rdb_child_pid != -1) return C_ERR;
    server.dirty_before_bgsave = server.dirty;
    server.lastbgsave_try = time(NULL);
    openChildInfoPipe();

    // 记录执行fork的起始时间,用于计算fork的耗时
    start = ustime();
    // 在这里执行fork !!
    // 因而可知,若是fork卡住,下面执行父进程的else条件就会卡住,子进程的执行也须要fork完成后才会开始
    if ((childpid = fork()) == 0) {
        // fork()返回了等于0的值,说明执行成功,
        int retval;
        // 下面是子进程的执行过程
        /* Child */
        closeListeningSockets(0);
        redisSetProcTitle("redis-rdb-bgsave");
        // 子进程执行硬盘的写操做
        retval = rdbSave(filename, rsi);
        if (retval == C_OK) {
            size_t private_dirty = zmalloc_get_private_dirty( - 1);
            if (private_dirty) {
                serverLog(LL_NOTICE, "RDB: %zu MB of memory used by copy-on-write", private_dirty / (1024 * 1024));
            }
            server.child_info_data.cow_size = private_dirty;
            sendChildInfo(CHILD_INFO_TYPE_RDB);
        }
        // 子进程执行完毕退出,返回执行结果给父进程,0 - 成功,1 - 失败
        exitFromChild((retval == C_OK) ? 0 : 1);
    } else {
        // 下面是父进程的执行过程
        /* Parent */
        // 计算fork的执行时间
        server.stat_fork_time = ustime() - start;
        server.stat_fork_rate = (double) zmalloc_used_memory() * 1000000 / server.stat_fork_time / (1024 * 1024 * 1024);
        /* GB per second. */
        latencyAddSampleIfNeeded("fork", server.stat_fork_time / 1000);
        if (childpid == -1) { // fork出错,打印错误日志
            closeChildInfoPipe();
            server.lastbgsave_status = C_ERR;
            serverLog(LL_WARNING, "Can't save in background: fork: %s", strerror(errno));
            return C_ERR;
        }
        serverLog(LL_NOTICE, "Background saving started by pid %d", childpid);
        server.rdb_save_time_start = time(NULL);
        server.rdb_child_pid = childpid;
        server.rdb_child_type = RDB_CHILD_TYPE_DISK;
        updateDictResizePolicy();
        return C_OK;
    }
    return C_OK;
    /* unreached */
}
复制代码

fork()方法返回值的描述:

Return Value

On success, the PID of the child process is returned in the parent, and 0 is returned in the child. On failure, -1 is returned in the parent, no child process is created, and errno is set appropriately.

意思是,若是fork成功,此进程的PID会返回给父进程,而且会给fork出的子进程返回一个0。若是fork失败,给父进程返回-1,没有子进程建立,并设置一个系统错误码。

因而可知,fork的执行流程以下:

fork的执行流程

再来看看Linux中关于fork()的注意事项。

Notes

Under Linux, fork() is implemented using copy-on-write pages, so the only penalty that it incurs is the time and memory required to duplicate the parent's page tables, and to create a unique task structure for the child. Since version 2.3.3, rather than invoking the kernel's fork() system call, the glibc fork() wrapper that is provided as part of the NPTL threading implementation invokes clone(2) with flags that provide the same effect as the traditional system call. (A call to fork() is equivalent to a call to clone(2) specifying flags as just SIGCHLD.) The glibc wrapper invokes any fork handlers that have been established using pthread_atfork(3).

第一段描述了fork()的一些问题。大意以下:

在Linux系统下,fork()经过copy-on-write策略实现,所以,他会带来的问题是:复制父进程和为子进程建立惟一的进程结构所须要的时间和内存。

7 补充-进程的内存模型

系统内核会为每个进程开辟一块虚拟内存空间,其分布以下

虚拟内存空间图示

fork的子进程至关于父进程的一个clone,可见,若是父进程中数据量比较多的话,clone的耗时会比较长。

参考文档

  • Redis in Action. Josiah L. Carison
  • Redis官网:Redis Persistence, 连接:https://redis.io/topics/persistence
  • Redis源码:rdb.c, 连接:https://github.com/antirez/redis/blob/unstable/src/rdb.c
  • Linux man page: fork(), 连接:https://linux.die.net/man/2/fork

欢迎关注个人微信公众号

公众号
相关文章
相关标签/搜索