关于Redis的一些思考和总结

作者: zqrferrari | 来源:发表于2017-11-21 16:06 被阅读728次

    日常的业务系统中经常使用到redis,平时也会研究下redis的设计文档和源码,对redis的使用场景、实现方案、运维要点这些常规知识点都有所了解,但是零零碎碎总感觉不够系统,这里结合源码对自己使用redis过程中的一些经验、疑惑、思考进行归纳和总结。

    主要有以下内容:

    • Redis的线程结构,单线程结构、异步化组件、最佳线程数、性能瓶颈、阻塞场景
    • 过期和淘汰策略,令人困惑的expire、单线程中的定时器、淘汰策略和时机
    • 数据结构的选择,内部数据结构实现、hash/B+/LSM的特点和场景对比
    • 高可用和集群部署的方式

    1、Redis的线程结构


    Redis在设计上最大的亮点是其单线程结构,并且还能提供极其强大的并发处理能力和丰富的数据结构,这点让我很激动也很是困惑的。

    激动的是redis强大的并发处理能力,以及其丰富的api接口,让日常的业务需要可以更爽的完成。更让我惊叹的是单线程的设计导致redis的代码非常的小巧,整个源码大约5w行,而且不需要处理多线程引入的并发问题,整个代码理解起来也很顺畅。

    困惑的是单线程的设计结构为什么能支持这么大的并发量,这一点和我们常规处理大并发的习惯性思维不同。一般在面对大并发的请求,首先想到的是用多个线程来处理,io线程和业务线程分开,业务线程使用线程池来避免频繁创建和销毁线程,即便是一次请求阻塞了也不会影响到其他请求。为什么redis会选择反其道而行之,这么做是否会局限redis的使用,在使用redis时有没有特别需要注意的点?

    1.1 IO/业务单线程

    准确的来说,Redis的单线程结构是指其主线程是单线程的,这里主线程包括IO事件的处理,以及IO对应的相关请求的业务处理,此外主线程还负责过期键的处理、复制协调、集群协调等等,这些除了IO事件之外的逻辑会被封装成周期性的任务由主线程周期性的处理。

    正因为采用单线程的设计,对于客户端的所有读写请求,都由一个主线程串行地处理,因此多个客户端同时对一个键进行写操作不会有并发问题,避免了频繁的上下文切换和锁竞争。

    并且在网络上使用epoll,利用epoll的非阻塞多路复用特性,不在IO上浪费时间。

    1.2 异步化组件

    但是除了客户端读写请求之外还有一些比较耗时的操作,如持久化RDB文件,持久化AOF文件等等,这些操作不能放在主线程里面处理,因此Redis会在适当的时候fork子进程来异步的处理这种任务。除了这些,Redis还有一组异步任务处理线程,用于处理不需要主线程同步处理的工作,总体上Redis的线程体系结构大致如下图:

    Redis线程结构

    上图中间蓝色的部分代表主线程,最左边虚线代表通过fork得到的子进程,用来处理RDB持久化以及AOF持久化等任务,最右边橙色部分代表一组异步任务处理线程,下面会详细介绍这组异步任务处理线程,即Redis异步化组件——BIO组件。

    在Redis中,异步任务处理线程组被封装在BIO组件中,源文件为bio.h和bio.c。bio异步线程启动时在main方法调用,会生成BIO_NUM_OPS(3)个线程,线程函数为bioProcessBackgroundJobs。

    void bioInit(void) {
        pthread_attr_t attr;
        pthread_t thread;
        size_t stacksize;
        int j;
    
        for (j = 0; j < BIO_NUM_OPS; j++) {
            pthread_mutex_init(&bio_mutex[j],NULL);
            pthread_cond_init(&bio_newjob_cond[j],NULL);
            pthread_cond_init(&bio_step_cond[j],NULL);
            bio_jobs[j] = listCreate();
            bio_pending[j] = 0;
        }
    
        pthread_attr_init(&attr);
        pthread_attr_getstacksize(&attr,&stacksize);
    
        if (!stacksize) stacksize = 1; /* The world is full of Solaris Fixes */
        while (stacksize < REDIS_THREAD_STACK_SIZE) stacksize *= 2;
        pthread_attr_setstacksize(&attr, stacksize);
    
        for (j = 0; j < BIO_NUM_OPS; j++) {
            void *arg = (void*)(unsigned long) j;
            if (pthread_create(&thread,&attr,bioProcessBackgroundJobs,arg) != 0) {
                exit(1);
            }
            
            bio_threads[j] = thread;
        }
    }
    

    bioProcessBackgroundJobs的处理过程如下:

    void *bioProcessBackgroundJobs(void *arg) {
    
        pthread_mutex_lock(&bio_mutex[type]);
    
        //...
    
        while(1) {
            listNode *ln;
    
            /* Pop the job from the queue. */
            ln = listFirst(bio_jobs[type]);
            job = ln->value;
    
            pthread_mutex_unlock(&bio_mutex[type]);
    
            /* Process the job accordingly to its type. */
            if (type == BIO_CLOSE_FILE) {
                close((long)job->arg1);
            } else if (type == BIO_AOF_FSYNC) {
                aof_fsync((long)job->arg1);
            } else if (type == BIO_LAZY_FREE) {
                if (job->arg1)
                    lazyfreeFreeObjectFromBioThread(job->arg1);
                else if (job->arg2 && job->arg3)
                    lazyfreeFreeDatabaseFromBioThread(job->arg2,job->arg3);
                else if (job->arg3)
                    lazyfreeFreeSlotsMapFromBioThread(job->arg3);
            } else {
                serverPanic("Wrong job type in bioProcessBackgroundJobs().");
            }
            
            zfree(job);
    
            //...
            pthread_mutex_lock(&bio_mutex[type]);
        }
    }
    

    BIO线程目前包括三个线程,处理三种类型的任务:

    • 文件句柄关闭任务

      文件句柄的释放(close)对于操作系统来说是一个比较重的操作,在Redis中,当需要重新创建新的文件句柄,废弃的文件句柄失效的时候,这个废弃的文件句柄将由异步任务处理线程来关闭。

    • AOF持久化任务,Redis对于AOF文件的持久化有三种策略:

      • 关闭AOF功能
      • aof_fsync_everysec策略,即每秒一次,实际上并不是一定一秒钟一次
      • aof_fsync_always策略,即每次IO事件处理完毕,都将AOF持久化

      这三种策略分别对应不同的业务场景和用户需求,默认的策略为aof_fsync_everysec,这个时候对于aof缓冲区内容持久化工作会交给异步任务处理线程来处理。

    • 内存的释放也是比较重的操作,这部分工作可以交给异步任务处理线程来处理,Redis中通过一部任务释放的空间主要包括三种:

      • 对象空间的释放,当当前内存够用的时候,主线程不会同步释放废弃对象的内存,交给异步任务处理线程来释放,当然需要配合lazyfree_lazy_server_del参数使用。
      • DB空间的异步释放,当需要删除DB的时候,Redis会申请一个新的哈希表作为新的DB,将DB内存的释放的工作交给异步任务处理线程来处理。
      • slots-leys空间释放,在Redis Cluster模式下,slots-keys是由Redis实现的跳跃表数据结构支撑的,当Redis Cluster需要刷新slots-keys的时候,首先创建一个新的跳跃表结构作为新的slots-keys,然后将老的slots-keys结构释放的工作交给异步任务处理线程来处理。

    1.3 最佳线程数 —— 单线程

    这里讨论下Redis的最佳线程数。

    关于服务器性能的思考中,可以看到一个应用系统的最佳线程数的计算公式:

    最佳线程数

    其中Tic是指一次请求过程中cpu计算耗时,Tiw是指请求处理过程中的等待耗时,这里的等待耗时可能由io、锁导致。但是redis是纯内存数据库,没有io操作,所以其Tiw为0,那么redis的最佳线程数就很明显了,就是一个线程。

    再来看看,线程数对系统性能的影响:


    线程数对系统性能的影响

    从压测的结果来看,系统的性能表现并不是线程越多越好,而是会在起最佳线程数附件产生性能拐点,过多的线程带来的上下文切换会对系统的性能表现带来负面影响。

    通过对系统最佳线程数的讨论,我们可以明确为什么Redis采用单线程依然可以达到超高的并发处理能力,因为所有的请求都在内存中完成,根本不需要去等待io,只要机器的cpu没有达到瓶颈,再多的请求也不怕。

    但是cpu会成为Redis的性能瓶颈吗,而且在多核cpu流行的今天,只有一个线程,只用一个核多少让人感觉很浪费,万一cpu的单核计算能力成为系统的性能瓶颈,又没法利用cpu其他的计算能力,那不是得不偿失?

    1.4 瓶颈 —— cpu or 网络?

    对cpu会成为Redis的性能瓶颈的担忧是可以理解的,但是在实际使用过程中cpu或许不是制约Redis的关键因素,网络io可能才是最大的瓶颈。

    看一组Redis的性能测试数据对比,Redis版本2.8.19,测试环境如下:

    • KVStore(阿里云Redis):E5-2682 2.5GHz 1G内存 1G网卡
    • Redis on ECS SSD: E5-2682 2.5GHz 1G内存 1G网
    • Redis on ECS 硬盘: E5-2682 2.5GHz 1G内存 1G网
    set接口测试
    get接口测试

    从对比的测试结果来看,Redis的性能还是很强悍的,单机qps最低也在5W以上,但是相同条件下KVStore比Redis on ECS要高出一倍。

    分析对比结果,Redis on ECS SSD和Redis on ECS 硬盘在性能上差不多,说明磁盘并不是Redis的性能瓶颈。而Redis on ECS之所以低于KVStore主要是受限于ECS的网络io性能,并没有跑满cpu,导致并发处理上不去。

    Redis性能分析官方文档中,也对影响Redis性能的因素进行了分析和对比测试,大部分情况下网络依然是制约其性能的首要因素。

    但是毕竟Redis的单线程模型对多核cpu没有完全利用,如果有这样的担心,那么在网络io没有成为瓶颈时,可以在一台机器上多部署几个Redis的实例,充分利用cpu和网卡的能力。

    1.5 噩梦 —— 阻塞

    基于单线程设计,Redis整体上设计精良并且运行良好。但是凡事有利有弊,正因为主线程只有一个,所有的读写请求都在主线程中完成,如果出现请求阻塞,哪怕是很短的时间,对应应用来说都是噩梦。

    Redis官方文档对绝大多数阻塞问题进行了分类说明,归纳起来导致阻塞问题的场景大致可以分为内在和外在两个原因。

    内在原因

    • 不合理使用API和数据结构

      通常Redis的执行速度都非常快,但是如果对一个包含上万个元素的hash结构执行hgetall操作,由于数据量比较大而且算法复杂度是O(n),这条命令执行速度必然很慢。

      对于高并发的场景我们应该尽量避免在大对象上执行算法复杂度超过O(n)的命令。在使用redis的命令时需要密切注意文档中算法复杂度为O(n)的api。

      Redis原生提供慢查询统计,执行slowlog get {n}命令可以获取最近n条慢查询命令。

    • CPU饱和的问题

      单线程的Redis处理命令时只能使用一个CPU,如果Redis把单核CPU使用率跑到接近100%,则会导致CPU饱和问题,这时Redis将无法处理更多的命令,严重影响吞吐量和系统的稳定性。

      对于CPU饱和的问题,首先需要确认当前Redis的并发处理量是否达到极限,如果只有几百或几千qps的Redis实例其cpu使用率就达到饱和是不正常,这个时候需要排查是否是使用了高算法复杂度的命令,或者是否是对内存过度优化。如果qps确实很高,则需要考虑集群化水平扩展来分摊qps压力。

    • 持久化相关的阻塞

      对于开启了持久化功能的Redis节点,需要排查是否是持久化导致的阻塞。持久化引起的主线程阻塞的操作有:fork阻塞、AOF刷盘阻塞、HugePage写操作阻塞。

      • fork阻塞:fork操作发生在RDB和AOF重写时,Redis主线程调用fork操作产生共享内存的子进程,由子进程完成持久化文件重写工作,如果fork操作本身耗时很长,必然会导致主线程阻塞。
      • AOF刷盘阻塞:在开启AOF持久化功能时,文件刷盘一般采用一秒一次,后台线程每秒对AOF文件做fsync操作,当硬盘压力过大时,fsync操作需要等待,直到写入完成。如果主线程发现距离上一次的fsync成功超过2秒,为了数据安全性它会阻塞直到后台线程执行fsync操作完成。
      • 子进程在执行重新期间利用linux写时复制技术降低内存开销,因此只有写操作时Redis才复制需要修改的内存页,对于开启Transparent HugePages的操作系统,每次写命令引起的复制内存页单位由4K变为2M,放大了512倍,会拖慢写操作的执行时间,导致大量写操作慢查询。

    外在原因

    • CPU竞争

      Redis是典型的cpu密集型应用,不建议和其他多核cpu密集型服务部署在一起,当其他进程过度消耗cpu时,将严重影响Redis的吞吐量。

    • 内存交换

      内存交换(swap)对于Redis来说是非常致命的,Redis保证高性能的一个重要前提是所有的数据都在内存中,如果操作系统把Redis使用的部分内存换出到硬盘,由于内存和硬盘读写速度相差几个数量级,会导致发生交换之后的Redis性能急剧下降。

      预防内存交换的方法有:保证机器有充足的可用内存,确保Redis实例设置了最大可用内存,防止极端情况下Redis内存不可控增长。

    • 网络问题

      网络问题也会引起Redis阻塞,常见的网络问题主要有:链接拒绝、网络延迟、网卡软中断。

    2、Redis的数据过期及淘汰策略


    Redis的数据过期策略和实现一直是让我非常困惑,Redis的文档也没有清晰的说明,下面结合源码一起分析下Redis的数据过期及淘汰策略。

    2.1 令人困惑的expire

    Redis文档中对于过期key的处理方式的描述有两种:被动和主动方式。

    当一些客户端尝试访问它时,key会被发现并主动的过期。
    但是这是不够的,因为有些过期的keys,永远不会访问他们。 无论如何,这些keys应该过期,所以定时随机测试设置keys的过期时间,并将过期的keys进行删除。
    具体就是Redis每秒10次做的事情:

    • 测试随机的20个keys进行相关过期检测。
    • 删除所有已经过期的keys。
    • 如果有多于25%的keys过期,重复步奏1。

    但是Redis的主线程是单线程,并没有一个专门的线程来负责定时对过期数据进行清理,Redis如何具体完成过期key的查找、定时任务如何设置、对过期keys删除的效果如何?Redis的文档并没有明确的说明,需要从源码中查找。

    2.2 定时器?

    Redis的文档中多处提到定时处理的逻辑,如过期key的定期清理,aof定时写文件,但是如何在单线程中实现一个定时器呢?

    在Redis中所有的IO事件都会被封装成redisServer.aeEventLoop,在Redis的启动函数中,会进行aeEventLoop事件的定时处理回调(serverCron)的注册:

    if (aeCreateTimeEvent(server.el, 1, serverCron, NULL, NULL) == AE_ERR) {
        serverPanic("Can't create event loop timers.");
        exit(1);
    }
    

    定时事件的注册过程如下:

    long long aeCreateTimeEvent(aeEventLoop *eventLoop, long long milliseconds,
        aeTimeProc *proc, void *clientData,
        aeEventFinalizerProc *finalizerProc)
    {
        long long id = eventLoop->timeEventNextId++;
        aeTimeEvent *te;
    
        te = zmalloc(sizeof(*te));
        if (te == NULL) return AE_ERR;
        
        te->id = id;
        aeAddMillisecondsToNow(milliseconds,&te->when_sec,&te->when_ms);
        te->timeProc = proc;
        te->finalizerProc = finalizerProc;
        te->clientData = clientData;
        te->next = eventLoop->timeEventHead;
        eventLoop->timeEventHead = te;
        return id;
    }
    

    int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) 是定时处理逻辑的回调,这里会处理过期key的清理、统计信息更新、对不合理的数据库进行大小调整、关闭和清理连接生效的客户端、尝试进行AOF或RDB持久化操作等。

    int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
    
        //...
    
        clientsCron();
    
        /* Handle background operations on Redis databases. */
        databasesCron();
    
        rewriteAppendOnlyFileBackground();
    
        backgroundSaveDoneHandler(exitcode,bysignal);
    
        freeClientsInAsyncFreeQueue();
    
        clientsArePaused();
    
        /* Replication cron function -- used to reconnect to master,
        * detect transfer failures, start background RDB transfers and so forth. */
        run_with_period(1000) replicationCron();
    
        /* Run the Redis Cluster cron. */
        run_with_period(100) {
            if (server.cluster_enabled) clusterCron();
        }
    
        /* Run the Sentinel timer if we are in sentinel mode. */
        run_with_period(100) {
            if (server.sentinel_mode) sentinelTimer();
        }
    
        /* Cleanup expired MIGRATE cached sockets. */
        run_with_period(1000) {migrateCloseTimedoutSockets();}
    
        //...
    
        return 1000/server.hz;
    }
    

    定时回调serverCron具体的处理业务后面再研究,先看看serverCron什么时候会被触发调用。
    在Redis事件循环中aeProcessEvents会调用processTimeEvents,从名字上看出是处理定时事件。

    static int processTimeEvents(aeEventLoop *eventLoop) {
        int processed = 0;
        aeTimeEvent *te, *prev;
        long long maxId;
        time_t now = time(NULL);
        
        /**
        * 如果系统时间被调整到将来某段时间然后又被设置回正确的时间,
        * 这种情况下链表中的timeEvent有可能会被随机的延迟执行,因
        * 此在这个情况下把所有的timeEvent的触发时间设置为0表示及执行
        */
        if (now < eventLoop->lastTime) {
            te = eventLoop->timeEventHead;
            while(te) {
                te->when_sec = 0;
                te = te->next;
            }
        }
        
        eventLoop->lastTime = now; // 设置上次运行时间为now
    
        prev = NULL;
        te = eventLoop->timeEventHead;
        maxId = eventLoop->timeEventNextId-1;
        while(te) {
            long now_sec, now_ms;
            long long id;
            
            /**
            * 删除已经被标志位 删除 的时间事件
            */
            if (te->id == AE_DELETED_EVENT_ID) {
            aeTimeEvent *next = te->next;
            if (prev == NULL)
                eventLoop->timeEventHead = te->next;
            else
                prev->next = te->next;
            if (te->finalizerProc)
                // 在时间事件节点被删除前调用finlizerProce()方法
                te->finalizerProc(eventLoop, te->clientData);
                zfree(te);
                te = next;
                continue;
            }
        
            if (te->id > maxId) {
                /**
                * te->id > maxId 表示当前te指向的timeEvent为当前循环中新添加的,
                * 对于新添加的节点在本次循环中不作处理。
                * PS:为什么会出现这种情况呢?有可能是在timeProc()里面会注册新的timeEvent节点?
                * 对于当前的Redis版本中不会出现te->id > maxId这种情况
                */
                te = te->next;
                continue;
            }
            
            aeGetTime(&now_sec, &now_ms);
            if (now_sec > te->when_sec ||
                (now_sec == te->when_sec && now_ms >= te->when_ms))
            {
                int retval;
                id = te->id;
                
                // 如果当前时间已经超过了对应的timeEvent节点设置的触发时间,
                // 则调用timeProc()方法执行对应的任务,即serverCron
                retval = te->timeProc(eventLoop, id, te->clientData);
                
                processed++;
                if (retval != AE_NOMORE) {
                    // 要执行多次,则计算下次执行时间
                    aeAddMillisecondsToNow(retval,&te->when_sec,&te->when_ms);
                } else {
                    // 如果只需要执行一次,则把id设置为-1,再下次循环中删除
                    te->id = AE_DELETED_EVENT_ID;
                }
            }
            prev = te;
            te = te->next;
        }
        return processed;
    }
    

    processTimeEvents会对定时事件进行时间判断,如果到了设置的触发时间则会调用注册的定时回调函数
    serverCron。

    这里需要注意te->timeProc,即serverCron的返回值,从之前serverCron分析来看,其返回值为1000/server.hz。server.hz是Redis server执行后台任务的频率,默认为10,此值越大表示redis对定时任务的执行次数越频繁,如定期清理过期key。aeAddMillisecondsToNow会根据serverCron的返回值来计算下次定时任务触发的unix时间。

    定时器的后果 —— 阻塞

    至此已经很清楚,Redis中的定时业务的处理是放在主线程之中,在主线程处理完一次请求之后,接着计算是否到了业务的定时周期,如果到了则处理定时业务。

    但是这会加大主线程处理请求的延时,如果在定时回调中塞入过多的处理逻辑或者某一次处理耗时严重,如由于磁盘压力导致aof写文件耗时增加,那么就会阻塞整个主线程的处理。

    Redis在主线程中塞入定时处理的业务逻辑,避免再引入一个单独的定时线程,简化了代码,但是也带来阻塞主线程业务处理的风险,因此在定时回调中处理相关定时业务逻辑时需要十分小心,密切注意处理耗时和对cpu的使用。

    2.3 数据过期策略

    了解了Redis对于定时任务的处理过程,再来看看Redis对于过期数据的处理策略。
    Redis在以下三种情况下会进行过期key的清理。

    访问key时,判断是否过期并淘汰

    在访问一个key时会顺便检测下这个key是否过期,如果过期则删除。

        robj *lookupKeyRead(redisDb *db, robj *key) {
            if (expireIfNeeded(db,key) == 1) {
                if (server.masterhost == NULL) return NULL;
            }
    
            val = lookupKey(db,key,flags);
            if (val == NULL)
                server.stat_keyspace_misses++;
            else
                server.stat_keyspace_hits++;
            return val;
        }
    

    定时逐出过期key

    了解了Redis中定时器的实现方式和调用时机,再看看定时逐出过期key是如何具体完成的。databasesCron中调用activeExpireCycle处理过期key清理,databasesCron由serverCron调用。

        void databasesCron(void) {
            /* Expire keys by random sampling. Not required for slaves
            * as master will synthesize DELs for us. */
            if (server.active_expire_enabled && server.masterhost == NULL) {
                activeExpireCycle(ACTIVE_EXPIRE_CYCLE_SLOW);
            } else if (server.masterhost != NULL) {
                expireSlaveKeys();
            }
    
            /* Defrag keys gradually. */
            if (server.active_defrag_enabled)
                activeDefragCycle();
    
            //...
        }
    

    activeExpireCycle比较复杂,在清除过期key的同时,还需要密切注意对cpu的使用,以免长时间占用cpu,阻塞业务处理。

    注意activeExpireCycle的入参type,主要用于设置清理过期key时的cpu占用时间,如果是ACTIVE_EXPIRE_CYCLE_FAST则最长占用时间为1000微秒(1毫秒),如果不是ACTIVE_EXPIRE_CYCLE_FAST则由配置确定,默认为25000微秒(25毫秒)。

        void activeExpireCycle(int type) {
            //...
    
            static int timelimit = 1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/server.hz/100;
            
            if (type == ACTIVE_EXPIRE_CYCLE_FAST)
                timelimit = ACTIVE_EXPIRE_CYCLE_FAST_DURATION; 
    
            //遍历所有db
            for (j = 0; j < dbs_per_call; j++) {
                //...
                do {        
                    while (num--) {
                        dictEntry *de;
                        long long ttl;
    
                        //随机选取20个key判断,如果过期则进行清除
                        if ((de = dictGetRandomKey(db->expires)) == NULL) break;
                        ttl = dictGetSignedIntegerVal(de)-now;
                        if (activeExpireCycleTryExpire(db,de,now)) expired++;
                        if (ttl > 0) {
                            /* We want the average TTL of keys yet not expired. */
                            ttl_sum += ttl;
                            ttl_samples++;
                        }
                    }
    
                    //避免长时间占用cpu,如果cpu使用时间超过timelimit则返回
                    iteration++;
                    if ((iteration & 0xf) == 0) { /* check once every 16 iterations. */
                        long long elapsed = ustime()-start;
    
                        latencyAddSampleIfNeeded("expire-cycle",elapsed/1000);
                        if (elapsed > timelimit) timelimit_exit = 1;
                    }
                    
                    if (timelimit_exit) return;
            
                //若达到了25%cpu时间,则返回
                } while (expired > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4);
            }
        }
    

    每次事件循环执行时逐出部分过期key

        void aeMain(aeEventLoop *eventLoop) {
            eventLoop->stop = 0;
            while (!eventLoop->stop) {
                if (eventLoop->beforesleep != NULL)
                    eventLoop->beforesleep(eventLoop);
                aeProcessEvents(eventLoop, AE_ALL_EVENTS|AE_CALL_AFTER_SLEEP);
            }
        }
    

    在每次事件循环之前会调用beforesleep,beforesleep会对过期key进行一次主动检查。

        void beforeSleep(struct aeEventLoop *eventLoop) {
            //...
    
            if (server.active_expire_enabled && server.masterhost == NULL)
                activeExpireCycle(ACTIVE_EXPIRE_CYCLE_FAST);
        }
    

    注意activeExpireCycle的入参为ACTIVE_EXPIRE_CYCLE_FAST,所以这次过期key的清理的cpu占用时长为1毫秒。

    Redis对过期key的清理策略总结

    • Redis配置项hz定义了serverCron任务的执行周期,默认为10,即cpu空闲时每秒执行10次。
    • 每次过期key清理的视觉不超过cpu时间的25%,即若hz=1,则一次清理时间最大为250ms,若hz=10,则一次清理时间最大为25ms。
    • 清理时依次遍历所有的db。
    • 从db中随机取20个key,判断是否过期,若过期则清理。
    • 若有5个以上key过期,则重复步骤4,否则遍历下一个db。
    • 在清理过程中,若达到了25%cpu时间,则退出清理过程。

    2.4 数据淘汰策略

    处理客户端请求的时候,如果设置了最大内存配置则会进行数据淘汰检查。

    int processCommand(client *c) {
        if (server.maxmemory) {
            int retval = freeMemoryIfNeeded();
        }
    }
    

    freeMemoryIfNeeded中根据配置的数据淘汰算法进行数据淘汰。

    int freeMemoryIfNeeded(void) {
        size_t mem_used, mem_tofree, mem_freed;
        mem_used = zmalloc_used_memory();
    
        /* Check if we are over the memory limit. */
        if (mem_used <= server.maxmemory) return C_OK;
    
        /* We need to free memory, but policy forbids. */
        if (server.maxmemory_policy == MAXMEMORY_NO_EVICTION)
            return C_ERR; 
    
        while (mem_freed < mem_tofree) {
            int j, k, keys_freed = 0;
    
            for (j = 0; j < server.dbnum; j++) {
                if (server.maxmemory_policy == MAXMEMORY_ALLKEYS_LRU ||
                    server.maxmemory_policy == MAXMEMORY_ALLKEYS_RANDOM)
                {
                    dict = server.db[j].dict;
                } else {
                    dict = server.db[j].expires;
                }
    
                if (dictSize(dict) == 0) continue;
    
                if (server.maxmemory_policy == MAXMEMORY_ALLKEYS_RANDOM ||
                    server.maxmemory_policy == MAXMEMORY_VOLATILE_RANDOM)
                {
                    //volatile-random and allkeys-random policy
                }
                else if (server.maxmemory_policy == MAXMEMORY_ALLKEYS_LRU ||
                    server.maxmemory_policy == MAXMEMORY_VOLATILE_LRU)
                {
                    //volatile-lru and allkeys-lru policy
                }
                else if (server.maxmemory_policy == MAXMEMORY_VOLATILE_TTL) {
                    //volatile-ttl
                }
        }
    
        return C_OK;
    }
    

    数据淘汰算法

    • volatile-lru:从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用 的数据淘汰
    • volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数 据淘汰
    • volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据 淘汰
    • allkeys-lru:从数据集(server.db[i].dict)中挑选最近最少使用的数据淘汰
    • allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰
    • no-enviction(驱逐):禁止驱逐数据

    3、数据结构的选择


    这里要说的数据结构不是redis对外支持的那几种,如果想了解可以去读redis的文档,这里关注下redis的内部实现数据结构。

    redis对外支持string、list、set、hash、zset5种数据结构(最新版支持geo等数据结构,但不在讨论范围),内部实现以下数据结构来支撑:

    • 整数:REDIS_ENCODING_INT
    • 字符串:REDIS_ENCODING_RAW
    • 双端链表:REDIS_ENCODING_LINKEDLIST
    • 跳跃表:REDIS_ENCODING_SKIPLIST
    • 压缩列表:REDIS_ENCODING_ZIPLIST
    • 字典:REDIS_ENCODING_HT
    • 整数集合:REDIS_ENCODING_INTSET

    内部数据结构和外部数据结构的对应关系如下:

    数据类型映射

    3.1 字典 —— hashTable

    字典在redis中主要有两个作用:

    • 实现数据库的键空间
    • 用作hash类型的一种底层实现

    这里重点说下hash作为redis数据库的键空间的实现。

    redis是一个键值对数据库,数据库中的键值对由字典来保存,这个字典被称为键空间。当用户添加一个键值对到redis(不论什么类型),redis都会把该键值对添加到键空间;当删除一个key时,则会把这个键值对从键空间中删除;当查询或者更新一个键时,都需要先在键空间中查询到这个键再做修改,所以键空间的实现以及其性能会直接影响到redis的整体性能

    redis 键空间

    redis中键空间的结构如图所示,之所以采用hashTable作为键空间的实现结构,是因为hashTable在添加、删除、查找建的算法复杂度都是O(1),而redis作为一个缓存数据库,其性能是第一位的。

    3.2 hash or LSM or B+树

    之所以特别关注redis中hashTable的实现,是想比较下场景存储实现方案中底层数据结构的实现。

    平时的业务开发中,常常会根据业务场景需求的不同进行数据存储的技术选型,常见的存储实现有redis(缓存)、mysql、Hbase,它们面对的场景各不相同,各自的能力也不一样,其底层实现的数据结构也不一样,这是一个很有意思的点。

    其实应该反过来想,正因为底层实现的数据结构的特点决定了其对外提供能力和适合应用场景的差别。redis、mysql(innodb)、hbase其采用的数据结构分别为hash、B+树、LSM树,它们各自的特性如下:

    • 哈希存储引擎是哈希表的持久化实现,支持增、删、改以及随机读取操作,但不支持顺序扫描,对应的存储系统为key-value存储系统。对于key-value的插入以及查询,哈希表的复杂度都是O(1),明显比树的操作O(n)快,如果不需要有序的遍历数据,哈希表就是最佳选举。
    • B树存储引擎是B树的持久化实现,不仅支持单条记录的增、删、读、改操作,还支持顺序扫描(B+树的叶子节点之间的指针),对应的存储系统就是关系数据库(Mysql等)。
    • LSM树(Log-Structured Merge Tree)存储引擎和B树存储引擎一样,同样支持增、删、读、改、顺序扫描操作。而且通过批量存储技术规避磁盘随机写入问题。当然凡事有利有弊,LSM树和B+树相比,LSM树牺牲了部分读性能,用来大幅提高写性能,hbase和levelDB的内部实现数据结构就是LSM树。

    4、集群部署


    在大型互联网应用中,单机部署redis往往是不够的,单机redis在处理能力、内存容量、系统稳定上都不足以满足业务的需求,因此redis的集群部署是必不可少的。

    Redis的集群部署方式平时业务开发过程中实际接触的比较少,在需要使用的时候会有专门的pe维护Redis集群。但是了解redis的高可用和集群部署方案还是很有必要的,这里对redis sentinel、redis cluster、kvstore、codis、tair的集群部署方式做一个分析对比。

    4.1 redis sentinel

    redis2.8之前只提供了sentinel作为高可用方案而无分布式方案,但sentinel却和很多常用的高可用及读写分离方案有相似之处。

    redis sentinel

    在sentinel系统中,服务器分为三种角色

    • master: 此套方案中master作为主节点,用于负责数据的读写sentinel架构中,并没有对数据进行分片处理,因此master只有一台。
    • slave: slave作为master从服务器,实时复制来自master的数据。必要时可设置读服务器,做到和master读写分离。
    • sentinel服务器: sentinel服务器用于监控数据服务器,并在master下线时选举slave作为新master。sentinel支持多台集群,并在每次选举master时先内部选出执行选举权的sentinel作为leader。

    高可用

    • 主观下线:redis采用PING-PONG命令来维持心跳。任意sentinel服务器会向master、slave及其它sentinel服务器定时发送PING请求获取最新状态,若master返回无效响应或响应超时,则PING命令发送者会在本地将此master标注为“主观下线”。
    • 客观下线: 当sentinel将一个服务器判断为主观下线后,会向同样监视这一主服务器的其它sentinel进行询问,判断它们是否也认为master已进入了下线状态。当从其它sentinel获取了足够的主观下线判断后,Sentinel就会将此master标注为客观下线。
    • 选取sentinel leader:在master被判断为客观下线时,sentinel内部会先通过一定算法选取leader来执行后续的故障转移操作,leader是通过Raft算法进行选举。
    • 选出新的master:在salve中,挑选一个数据相对完整的slave提升为master,并将其他slave的复制目标改为新的master,选择mater的策略见Redis 的 Sentinel 文档

    client

    • 以jedis为例,客户端在配置时只需要关心sentinel地址。sentinel服务器会定时与客户端通信以让客户端获取最新的master地址。如果slave配置了读写分离,则客户端也会从sentinel处获取slave地址用于读操作。

    横向扩展

    • 很遗憾,由于sentinel不具备数据分片能力,因此能做的扩展就是增加slave节点,从而分担读压力。增加slave节点时,新节点会与master做一次数据完全同步,比较简单。

    4.2 redis cluster

    redis3.0之后提供了支持数据分片的集群方式。

    redis cluster

    在redis-cluster中,服务器分为两种角色

    • master:用于处理读写请求,并承担了sentinel模型中sentinel服务器的作用。cluster中可存在多个master,每个master处理一部分分片数据并不重复
    • slave:用于实时复制来自master的数据。不提供读写服务,当master故障时被选举为新的master

    数据分片

    • 算法:CRC16(key)&16383 。redis cluster并没有采用常见的一致性哈希作为分片策略。而是通过计算key的CRC-16校验和的值与上16383将数据平均分配于0至16383个slot之一,每个master负责一部分slot的数据,然后启动master时由外部指定master需要处理的slot范围。
    • 主备复制:redis cluster采用异步冗余备份,主节点不需要等到从节点对写入操作的确认。采用异步设计性能上不会有太大的影响,但是导致的结果是会有一定数据不一致的风险。
    • 请求重定向:client向某master请求数据时,master会首先计算此数据是否属于自己负责的slot,若不是,则将客户端重定向至相应的master进行读取。此处需要注意的是, redis为了性能考虑,使用重定向而不是代理的方式减少网络及服务器性能开销。
    redis cluster

    高可用

    • 疑似下线:master定期向其他节点发送PING消息,若无效响应或超市未响应则在本地将此节点标记为疑似下线
    • 已下线: 与sentinel不同的是,判断为疑似下线后并不会立刻广播向其它节点求证。而是从master之间的心跳信息来获取节点下线状态。当半数以上的master认为此节点下线时,最先确定这一情况的master会广播一条FAIL消息,任意slave节点都会接到此消息。
    • 选取主节点:与sentinel选取leader类似,都采用了Raft算法,只不过这一职责合并到了Master节点中。同时将其它slave指向至新的master
    • 数据迁移: 新选举的master将会管理已故master的slot,而由于每个master和slave之间都做了数据实时复制,因此不存在数据再分片的麻烦(cassandra不存在slave和master之分,因此每挂掉一个节点都需要进行数据再分片)

    client

    • redis cluster是去中心化结构,在client进行访问时不需要先经过一个中心服务器,这样固然可以简化代码的开发,节省一次网络转发的开销,但是会导致client在进行服务请求的时候无法定位到具体key所在节点。redis cluster采用了重定向策略,客户端请求一个节点,如果所请求的key不在该节点上,客户端需要判断返回的move或ask等指令,重定向请求到对应的节点,为此需要client端做适当的适配和修改。

    横向扩展

    • 增加新的master时,需要重新制定每个master负责的slot的范围,已有master会将相应slot对应的数据复制到新的master节点。由于redis数据分片算法相对简单,因此横向扩展时,有可能会导致所有节点数据的重新分布,目前redis机器在重新分片时需要使用redis-trib.rb来进行。

    4.3 codis/kvStrore

    codis是豌豆荚开发的分布式redis集群解决方案,kvStrore是阿里云分布式redis产品,他们不同于redis cluster的无中心化方案,在服务端设置了一层proxy,在Proxy上实现数据分布策略,数据分布策略对客户端透明。

    codis官方说明中对其架构有详细的说明,这里对其架构做一个简要的描述。

    codis集群架构如下


    codis集群架构

    数据分片

    • Codis 采用 Pre-sharding 的技术来实现数据的分片, 默认分成 1024 个 slots (0-1023), 对于每个key来说, 通过以下公式确定所属的 Slot Id : SlotId = crc32(key) % 1024。

      每一个 slot 都会有一个且必须有一个特定的 server group id 来表示这个 slot 的数据由哪个 server group 来提供。数据的迁移也是以slot为单位的。

    高可用

    • proxy层高可用,codis-proxy是无状态的,每个proxy和所有redis实例相连,多个proxy在zk上注册,proxy的可用性由zk进行监控。客户端可以通过轮询选择一个可用的proxy,也可以实现负载均衡。

    • redis实例高可用,codis-proxy通过订阅redis-sentinel来感知redis实例的可用心。当一个group的master挂掉的时候,codis-proxy会检测到但不会自动将一个slave提升为master,而是报警出来,需要管理员通过codis-dashboard开放的RESTful API手动提升。

    横向扩展

    • Codis支持水平扩容/缩容,扩容可以直接界面的 "Auto Rebalance" 按钮,缩容只需要将要下线的实例拥有的slot迁移到其它实例,然后在界面上删除下线的group即可。

    4.4 tair

    tair是一个高性能的分布式缓存应用,其集群架构区别如redis cluster的无中心和codis的proxy结构,其架构如下:

    tair集群架构

    在tair中,集群的数据存储及容灾主要依赖于以下2个角色。

    • dataserver: 数据服务器,Dataserver负责数据的存储,并按照configserver的指示完成数据的复制和迁移工作。每份数据可能会在多个dataServer上进行存储
    • configserver: dataserver注册服务器,用于dataserver的注册管理以及健康检查。

    数据分片

    • 算法: tair采用一致性哈希算法进行数据分布。一致性哈希算法的好处在于每次增加节点或移除节点时,只有一小部分数据分布会受影响,其它的分布可以 保持不变。
    • 备份: tair会将每份数据在不同的节点放置多个备份,备份节点对此部分备份数据不具备读写功能。

    client

    • 客户端请求:configserver会维护一份对照表,对照表中存储了每个key在哈希取模之后与每台机器的对应关系,configserver与客户端同步对照表,而客户端会本地缓存对照关系。 可以看见,tair为了效率考虑,也是采用了类似于REDIS的P2P方式,不同的地方在于,增加了configServer对照表,以及对照表在client端的本地缓存,进一步提升连接效率。

    高可用

    • Configserver通过和dataserver的心跳(HeartBeat)维护集群中可用的节点。
    • 当Configserver发现节点下线时,找到此节点负责的主数据,以及备份数据,重新进行数据分布,并发送命令至dataserver完成指定数据的迁移
    • 在某种意义上,Configserver类似于一个增强版的sentinel服务器,除了不具备sentinel的集群及选主能力

    横向扩展

    • 横向扩展时,根据一致性哈希原理,configserver会将属于新节点的数据进行再分片并指挥dataserver进行数据迁移。此操作影响的节点相对较少,可较为方便的横向扩展。

    4.5 对比

    产品 结构 扩/缩容 高可用 client
    redis cluster 无中心 手动,使用redis-trib.rb进行节点迁移,迁移期间服务可用 集群master采用gossip协议检查master可用性,master采用redis sentinel作为group的高可用方案 直连redis实例,增加MOVED等重定向命令处理,client不支持事务、pipeline、mget等命令
    codis 中心proxy 手动,使用RESTful api进行节点迁移,迁移期间服务可用 proxy检查master redis实例的可用性,某台master redis不可用时手动提升slave为master client连proxy,由proxy根据key决定请求到具体那台实例上,client不支持事务,但支持pipeline、mget等命令
    tair configServer作为配置中心和监控dataServer,client直连dataServer 手动,添加新的dataServer到configServer, configServer根据一致性hash重做节点对照表发送到dataServer和client,dataServer根据节点对照表进行数据迁移 configServer通过心跳检测dataServer可用性,根据配置规则来决定是否采用备用节点 初始化和数据迁移时获取到节点对照表,client请求时根据key和节点对照表获取到key所在的dataServer机器,直连dataServer

    相关文章

      网友评论

      本文标题:关于Redis的一些思考和总结

      本文链接:https://www.haomeiwen.com/subject/iaswvxtx.html