浅析Redis 4.0新特性之LazyFree

后端技术指南针 后端技术指南针 1.Redis作者

Redis的发起者 Salvatore Sanfilippo 意大利人1977年生,antirez是他的网名,活跃于Github,目前Redis总Star数39.2k(截止到2019.10.22)

2.Redis版本说明

Redis uses a standard practice for its versioning: major.minor.patchlevel. An even minor marks a stable release, like 1.2, 2.0, 2.2, 2.4, 2.6, 2.8. Odd minors are used for unstable releases, for example 2.9.x releases are the unstable versions of what will be Redis 3.0 once stable. redis.io官方说明

Redis版本号的命名规则:

  • 版本号第二位如果是奇数,则为非稳定版本 如2.7、2.9、3.1
  • 版本号第二位如果是偶数,则为稳定版本 如2.6、2.8、3.0、3.2
  • 当前奇数版本就是下一个稳定版本的开发版本,如2.9版本是3.0版本的开发版本

3.Redis版本迭代简介

备注:redis各个版本发布的时间点可能存在一些偏差

4.Redis4.0版本新功能

  • 4.0版本官方feature说明


Redis 4.0 was released as GA in July 2017, 
newcomers should use Redis 5, but Redis 4 is currently 
the most production-proven release and will be updated 
for the next year until Redis 6 will be out. 
It contains several big improvements: 
---------------------------------------------
1.a modules system, 
2.much better replication (PSYNC2), 
3.improvements to eviction policies, 
4.threaded DEL/FLUSH, 
5.mixed RDB+AOF format, 
6.Raspberry Pi support as primary platform, 
7.the new MEMORY command, 
8.Redis Cluster support for Nat/Docker, 
9.active memory defragmentation, 
10.memory usage and performance improvements, 
11.much faster Redis Cluster key creation, 
12.many other smaller features and a number of behavior fixed. 
13.others
-----------------------------------------------------------
  • 新功能详细说明

  • 模块系统(modules system)

4.0版本允许根据自己需求独立开发modules去扩展Redis的额外功能,模块系统的接口与Redis内核完全分离,有些类似Nginx的lua插件。

  • 部分重传PSYNCv2

解决了旧版本从机器重启必须与主机器重新进行全量复制的问题以及从机器在failover后成为新的主节点,在旧版中其他从节点在复制新主时就必须进行全量复制的问题,上述两种情况在新版中可使用部分复制。

  • 缓存淘汰改进

添加了LFU 缓存淘汰策略,对已有的缓存策略进行了优化。

  • 多线程异步删除

在旧版中使用del、flushdb、flushall删除包含体积较大的键,可能造成服务器阻塞,新版中增加ulink命令是del命令的异步版本,将删除放在后台线程执行,尽可能避免服务器阻塞; flushdb和flushall命令都新添加了async选项:FLUSHDB 和 FLUSHALL 带这个选项的数据库删除都将在后台线程进行;

  • 混合持久化功能

该功能融合了RDB和AOF各自的优点, AOF 重写产生的文件将同时包含 RDB 格式的内容和 AOF 格式的内容, 其中 RDB 格式的内容用于记录已有的数据, 而 AOF 格式的内存则用于记录最近发生了变化的数据。

  • 内存统计命令

新的memory命令可以用于查看内存使用情况、查看对应值的大小、手动触发分配器等内存管理操作,可以通过help来查看支持的选项以及基本功能。

5.异步删除LazyFree

  • 删除机制

旧版Redis有两种删除模式:定期删除和惰性删除。 惰性删除就是在读写key时才判断是否过期,如果过期就删除掉,属于将删除环节后置了,这样避免了轮询但是要增加了内存的占用,极端情况下如果某些体积非常大的key一直没有被访问,那么将占用内存很久,无疑在内存紧张的情况下对性能产生影响; 定期删除就是在主节点执行ServerCron任务定时扫描需要被删掉的key,节约了空间,但是使用了轮询消耗一定的CPU,因此在需要被删除键很多且CPU资源不富裕的情况下,对Redis服务的性能会产生影响; 在实际中Redis将惰性删除作为默认开启,定期删除可以通过配置来进行设定删除频率和内存阈值触发等,算是个折中的选择。

  • 单线程删除阻塞问题

Redis作为一个单线程模型的服务,当执行一些耗时的命令时,比如使用DEL删除一个value特别大的key时,或使用FLUSHDB 和 FLUSHALL 进行清库操作,都会造成redis阻塞,从而降低性能甚至发生故障转移。 有网友做过相关实验在删除value为包含100w元素的hash结构时耗时达到了1000ms+,也就是1s多时间内主线程被阻塞了,这种情况在高并发场景是灾难性的。 一个实际的线上阻塞例子:

我们生产Redis Cluster大集群,业务缓慢地写入一个带有TTL的2000多万个字段的Hash键,当这个键过期时,redis开始被动清理它时,导致redis被阻塞20多秒,当前分片主节点因20多秒不能处理请求,并发生主库故障切换。 https://www.jianshu.com/p/e927e99e650d

因此对于阻塞删除的优化确实是很有必要的。

  • 异步删除命令

UNLINK是DEL的异步删除版本,UNLINK命令与DEL阻塞删除不同,UNLINK在删除集合类键时,如果集合键的元素个数大于64个,会把真正的内存释放操作,给单独的BackgroundIO线程来操作,有实验表明使用UNLINK命令删除一个大键mylist, 它包含200万个元素,但用时只有数毫秒。 通过对FLUSHALL/FLUSHDB添加ASYNC异步清理选项,redis在清理整个实例或DB时,操作也都是异步的,有实验数据表明异步清理200w数据耗时也只有数毫秒。 综上可知,采用UNLINK、FLUSHALL、FLUSHDB代替之前的阻塞删除命令可以使处理相同数据的耗时从传统秒级、甚至分钟级降低到目前的微妙,毫秒级,确实是个巨大的飞跃,或许这也是Redis直接从3.x飞跃到4.0的原因。 面对如此巨大的提升,不得不让人好奇antirez在实现这个功能时经历了哪些疯狂和绝望,最终才展现出优异的性能提升。

6.Redis的BIO线程

  • 创建BIO线程

Redis 4.0版本之前你可以洋洋洒洒地说,Redis是个基于epoll单线程的事件驱动的内存数据库(本质上3.x版本就有轻量级的辅助文件操作的线程了),但是在4.0版本之后,很多异步辅助线程被添加进来。 4.0版本Redis在启动的时,主函数main中会依次调用bioInit函数,来初始化三个后台线程,以及相应的锁、条件变量、任务队列等变量。三个后台线程分别用于:

  • BIO_CLOSE_FILE对应的关闭文件描述符线程
  • BIO_AOF_FSYNC对应的aof持久化冲刷磁盘线程
  • BIO_LAZY_FREE对应的异步惰性删除线程 这些线程底层都是,前面提到的高性能的删除也是由Background I/O线程来实现的,简称BIO线程,来简单看下实现的代码逻辑:

http://download.redis.io/redis-stable/src/bio.c http://download.redis.io/redis-stable/src/bio.h redis bio相关源码


//4.0版本增加了BIO_LAZY_FREE 之前并没有这个宏
/* Background job opcodes */
#define BIO_CLOSE_FILE    0 /* Deferred close(2) syscall. */
#define BIO_AOF_FSYNC     1 /* Deferred AOF fsync. */
#define BIO_LAZY_FREE     2 /* Deferred objects freeing. */
#define BIO_NUM_OPS       3

//主线程启动多个辅助后台线程
void bioInit(void) {
    pthread_attr_t attr;
    pthread_t thread;
    size_t stacksize;
    int j;

    // 初始化各任务类型的锁和条件变量, BIO_NUM_OPS 个
    for (j = 0; j < BIO_NUM_OPS; j++) {
        pthread_mutex_init(&bio_mutex[j],NULL);
        pthread_cond_init(&bio_condvar[j],NULL);
        bio_jobs[j] = listCreate();
        bio_pending[j] = 0;
    }

    //设置 stack 大小
    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) {
            serverLog(LL_WARNING,"Fatal: Can't initialize Background Jobs.");
            exit(1);
        }
        bio_threads[j] = thread;
    }
}
  • 主线程和BIO线程的交互

主要流程:

  • 创建3个线程.这个三个线程的功能互不影响 相互独立
  • 每个线程都有一个工作队列,主线程生产任务放到任务队里,三个线程消费队列中的任务
  • 主线程和BIO线程作为生产者和消费者,从队列添加和消费任务时都得加锁防止竞争状态
  • BIO使用条件变量来等待任务,以及通知,典型的Pro-Con模型

交互运行时的相关数据结构:


//主线程和BIO线程之间的工作队列 使用双链表实现
static list *bio_jobs[REDIS_BIO_NUM_OPS];

//任务结构
struct bio_job {
    time_t time; /* Time at which the job was created. */
    /* Job specific arguments pointers. If we need to pass more than three
     * arguments we can just pass a pointer to a structure or alike. */
    void *arg1, *arg2, *arg3;
};

//主线程向队列中增加任务
void bioCreateBackgroundJob(int type, void *arg1, void *arg2, void *arg3) {
    struct bio_job *job = zmalloc(sizeof(*job));
    job->time = time(NULL);
    job->arg1 = arg1;
    job->arg2 = arg2;
    job->arg3 = arg3;
    pthread_mutex_lock(&bio_mutex[type]);
    listAddNodeTail(bio_jobs[type],job);
    bio_pending[type]++;
    pthread_cond_signal(&bio_condvar[type]);
    pthread_mutex_unlock(&bio_mutex[type]);
}

//BIO线程从工作队列中获取任务并处理
void *bioProcessBackgroundJobs(void *arg) {
    struct bio_job *job;
    unsigned long type = (unsigned long) arg;

    pthread_detach(pthread_self());
    pthread_mutex_lock(&bio_mutex[type]);
    while(1) {
        listNode *ln;

        /* The loop always starts with the lock hold. */
        if (listLength(bio_jobs[type]) == 0) {
            pthread_cond_wait(&bio_condvar[type],&bio_mutex[type]);
            continue;
        }
        /* Pop the job from the queue. */
        ln = listFirst(bio_jobs[type]);
        job = ln->value;
        /* It is now possible to unlock the background system as we know have
         * a stand alone job structure to process.*/
        pthread_mutex_unlock(&bio_mutex[type]);

        /* Process the job accordingly to its type. */
        if (type == REDIS_BIO_CLOSE_FILE) {
            close((long)job->arg1);
        } else if (type == REDIS_BIO_AOF_FSYNC) {
            aof_fsync((long)job->arg1);
        } else {
            redisPanic("Wrong job type in bioProcessBackgroundJobs().");
        }
        zfree(job);

        /* Lock again before reiterating the loop, if there are no longer
         * jobs to process we'll block again in pthread_cond_wait(). */
        pthread_mutex_lock(&bio_mutex[type]);
        listDelNode(bio_jobs[type],ln);
        bio_pending[type]--;
    }
}

上面的代码可以从bio.c中获得,都是纯C的代码,理解难度也不并不大,典型的生产者消费者模型,从中可以看到:

  • 使用双链表作为工作队列
  • bio_job任务结构
  • 主线程生产函数
  • BIO线程消费函数
  • 一些插曲

Antirez实现懒惰删除时最初使用类似于字典渐进式搬迁那样来实现渐进式删除回收,做起来却很难,渐进式回收需要控制回收频率,回收太快会导致CPU资源占用过多,回收太慢可能导致内存持续增长,因此频率控制也是很难的问题,且对现有性能会有较大影响。 后来使用了异步线程方案,主线程只需要将需要删除的对象放入队列,异步线程从队列里取出对象来释放逻辑,说起来容易Antirez在实际处理多种结构时也遇到了许多问题,比如共享对象等,这时就需要做权衡,Antirez选择了lazyfree而全部解决旧版本中的共享对象问题,也就是为了支持新功能解决痛点修改了之前的一些基础数据结构,可见4.0相比3.x确实是有很多改变。

7.参考资料

  • https://zhuanlan.zhihu.com/p/41754417
  • https://www.jianshu.com/p/d39a213362bd
  • https://www.cnblogs.com/liuhao/archive/2012/05/17/2506810.html