分享

【腾云阁】memcached与redis实现的对比

 cljamy 2016-10-15




田京昆

腾讯后台研发工程师

memcached和redis,作为近些年最常用的缓存服务器,相信大家对它们再熟悉不过了。为了对他们有更深入的了解,我曾经读过它们的主要源码,并从个人角度简单对比一下它们的实现方式,有理解错误之处,欢迎指正。


文中使用的架构类的图片大多来自于网络,有部分图与最新实现有出入,文中已经指出。


1. 综述

读一个软件的源码,首先要弄懂软件是用作干什么的,那memcached和redis是干啥的?众所周知,数据一般会放在数据库中,但是查询数据会相对比较慢,特别是用户很多时,频繁的查询,需要耗费大量的时间。怎么办呢?数据放在哪里查询快?那肯定是内存中。memcached和redis就是将数据存储在内存中,按照key-value的方式查询,可以大幅度提高效率。所以一般它们都用做缓存服务器,缓存常用的数据,需要查询的时候,直接从它们那儿获取,减少查询数据库的次数,提高查询效率。


2. 事件模型

下面开始讲他们具体是怎么实现的了。首先来看一下它们的事件模型。

它们都使用epoll来做事件循环,不过redis是单线程的服务器(redis也是多线程的,只不过除了主线程以外,其他线程没有event loop,只是会进行一些后台存储工作),而memcached是多线程的。 redis的事件模型很简单,只有一个event loop,是简单的reactor实现。

而memcached是多线程的,使用master-worker的方式,主线程监听端口,建立连接,然后顺序分配给各个工作线程。每一个从线程都有一个event loop,它们服务不同的客户端。master线程和worker线程之间使用管道通信,每一个工作线程都会创建一个管道,然后保存写端和读端,并且将读端加入event loop,监听可读事件。同时,每个从线程都有一个就绪连接队列,主线程连接连接后,将连接的item放入这个队列,然后往该线程的管道的写端写入一个connect命令,这样event loop中加入的管道读端就会就绪,从线程读取命令,解析命令发现是有连接,然后就会去自己的就绪队列中获取连接,并进行处理。多线程的优势就是可以充分发挥多核的优势,不过编写程序麻烦一点,memcached里面就有各种锁和条件变量来进行线程同步。


3. 内存分配

memcached和redis的核心任务都是在内存中操作数据,内存管理自然是核心的内容。

首先看看他们的内存分配方式。memcached是有自己内存池的,即预先分配一大块内存,然后接下来分配内存就从内存池中分配,这样可以减少内存分配的次数,提高效率,这也是大部分网络服务器的实现方式,只不过各个内存池的管理方式根据具体情况而不同。而redis没有自己内存池,而是直接使用时分配,即什么时候需要什么时候分配,内存管理的事交给内核,自己只负责读取和释放(redis既是单线程,又没有自己的内存池,是不是感觉实现的太简单了?那是因为它的重点都放在数据库模块了)。不过redis支持使用tcmalloc来替换glibc的malloc,前者是google的产品,比glibc的malloc快。

由于redis没有自己的内存池,所以内存申请和释放的管理就简单很多,直接malloc和free即可,十分方便。而memcached是支持内存池的,所以内存申请是从内存池中获取,而free也是还给内存池,所以需要很多额外的管理操作,实现起来麻烦很多,具体的会在后面memcached的slab机制讲解中分析。


4. 数据库实现

接下来看看他们的最核心内容,各自数据库的实现。


4.1 memcached数据库实现


memcached只支持key-value,即只能一个key对于一个value。它的数据在内存中也是这样以key-value对的方式存储,它使用slab机制。

首先看memcached是如何存储数据的,即存储key-value对。如下图,每一个key-value对都存储在一个item结构中,包含了相关的属性和key和value的值。




item是从哪里分配的呢?从slab中。如下图,memcached有很多slabclass,它们管理slab,每一个slab其实是trunk的集合,真正的item是在trunk中分配的,一个trunk分配一个item。一个slab中的trunk的大小一样,不同的slab,trunk的大小按比例递增,需要新申请一个item的时候,根据它的大小来选择trunk,规则是比它大的最小的那个trunk。这样,不同大小的item就分配在不同的slab中,归不同的slabclass管理。 这样的缺点是会有部分内存浪费,因为一个trunk可能比item大,如图2,分配100B的item的时候,选择112的trunk,但是会有12B的浪费,这部分内存资源没有使用。




每次需要新分配item的时候,找到slabclass对于的链表,从尾部往前找,看item是否已经过期,过期的话,直接就用这个过期的item当做新的item。没有过期的,则需要从slab中分配trunk,如果slab用完了,则需要往slabclass中添加slab了。

以上就是memcached如何实现一个key-value的数据库的介绍。

4.2 redis数据库实现   


首先redis数据库的功能强大一些,因为不像memcached只支持保存字符串,redis支持string, list, set,sorted set,hash table 5种数据结构。例如存储一个人的信息就可以使用hash table,用人的名字做key,然后name super, age 24, 通过key 和 name,就可以取到名字super,或者通过key和age,就可以取到年龄24。这样,当只需要取得age的时候,不需要把人的整个信息取回来,然后从里面找age,直接获取age即可,高效方便。

为了实现这些数据结构,redis定义了抽象的对象redis object,如下图。每一个对象有类型,一共5种:字符串,链表,集合,有序集合,哈希表。 同时,为了提高效率,redis为每种类型准备了多种实现方式,根据特定的场景来选择合适的实现方式,encoding就是表示对象的实现方式的。然后还有记录了对象的lru,即上次被访问的时间,同时在redis 服务器中会记录一个当前的时间(近似值,因为这个时间只是每隔一定时间,服务器进行自动维护的时候才更新),它们两个只差就可以计算出对象多久没有被访问了。 然后redis object中还有引用计数,这是为了共享对象,然后确定对象的删除时间用的。最后使用一个void*指针来指向对象的真正内容。正式由于使用了抽象redis object,使得数据库操作数据时方便很多,全部统一使用redis object对象即可,需要区分对象类型的时候,再根据type来判断。而且正式由于采用了这种面向对象的方法,让redis的代码看起来很像c++代码,其实全是用c写的。

//#define REDIS_STRING 0  // 字符串类型

//#define REDIS_LIST 1     // 链表类型

//#define REDIS_SET 2      // 集合类型(无序的),可以求差集,并集等

//#define REDIS_ZSET 3     // 有序的集合类型

//#define REDIS_HASH 4     // 哈希类型

 

//#define REDIS_ENCODING_RAW 0     /* Raw representation */ //raw  未加工

//#define REDIS_ENCODING_INT 1     /* Encoded as integer */

//#define REDIS_ENCODING_HT 2      /* Encoded as hash table */

//#define REDIS_ENCODING_ZIPMAP 3  /* Encoded as zipmap */

//#define REDIS_ENCODING_LINKEDLIST 4 /* Encoded as regular linked list */

//#define REDIS_ENCODING_ZIPLIST 5 /* Encoded as ziplist */

//#define REDIS_ENCODING_INTSET 6  /* Encoded as intset */

//#define REDIS_ENCODING_SKIPLIST 7  /* Encoded as skiplist */

//#define REDIS_ENCODING_EMBSTR 8  /* Embedded sds

                                                                     string encoding */

 

typedef struct redisObject {

    unsigned type:4;          // 对象的类型,包括 /* Object types */

    unsigned encoding:4;      // 底部为了节省空间,一种type的数据,

                                                // 可   以采用不同的存储方式

    unsigned lru:REDIS_LRU_BITS; /* lru time (relative to server.lruclock) */

    int refcount;         // 引用计数

    void *ptr;


5种type的对象,每一个都至少有两种底层实现方式。string有3种:REDIS_ENCODING_RAW,REDIS_ENCIDING_INT,REDIS_ENCODING_EMBSTR,list有:普通双向链表和压缩链表,压缩链表简单的说,就是讲数组改造成链表,连续的空间,然后通过存储字符串的大小信息来模拟链表,相对普通链表来说可以节省空间,不过有副作用,由于是连续的空间,所以改变内存大小的时候,需要重新分配,并且由于保存了字符串的字节大小,所有有可能引起连续更新(具体实现请详细看代码)。set有dict和intset(全是整数的时候使用它来存储), sorted set有:skiplist和ziplist, hashtable实现有压缩列表和dict和ziplist。skiplist就是跳表,它有接近于红黑树的效率,但是实现起来比红黑树简单很多,所以被采用(奇怪,这里又不造轮子了,难道因为这个轮子有点难?)。 hash table可以使用dict实现,则改dict中,每个dictentry中key保存了key(这是哈希表中的键值对的key),而value则保存了value,它们都是string。 而set中的dict,每个dictentry中key保存了set中具体的一个元素的值,value则为null。zset使用skiplist和ziplist实现,首先skiplist很好理解,就把它当做红黑树的替代品就行,和红黑树一样,它也可以排序。怎么用ziplist存储zset呢?首先在zset中,每个set中的元素都有一个分值score,用它来排序。所以在ziplist中,按照分值大小,先存元素,再存它的score,再存下一个元素,然后score。这样连续存储,所以插入或者删除的时候,都需要重新分配内存。所以当元素超过一定数量,或者某个元素的字符数超过一定数量,redis就会选择使用skiplist来实现zset(如果当前使用的是ziplist,会将这个ziplist中的数据取出,存入一个新的skiplist,然后删除改ziplist,这就是底层实现转换,其余类型的redis object也是可以转换的)。 另外,ziplist如何实现hashtable呢?其实也很简单,就是存储一个key,存储一个value,再存储一个key,再存储一个value。还是顺序存储,与zset实现类似,所以当元素超过一定数量,或者某个元素的字符数超过一定数量时,就会转换成hashtable来实现。各种底层实现方式是可以转换的,redis可以根据情况选择最合适的实现方式,这也是这样使用类似面向对象的实现方式的好处。

4.3 redis数据库持久化


redis和memcached的最大不同,就是redis支持数据持久化,这也是很多人选择使用redis而不是memcached的最大原因。 redis的持久化,分为两种策略,用户可以配置使用不同的策略。


4.3.1 RDB持久化

用户执行save或者bgsave的时候,就会触发RDB持久化操作。RDB持久化操作的核心思想就是把数据库原封不动的保存在文件里。


那如何存储呢?如下图, 首先存储一个REDIS字符串,起到验证的作用,表示是RDB文件,然后保存redis的版本信息,然后是具体的数据库,然后存储结束符EOF,最后用检验和。关键就是databases,看它的名字也知道,它存储了多个数据库,数据库按照编号顺序存储,0号数据库存储完了,才轮到1,然后是2, 一直到最后一个数据库。

每一个数据库存储方式如下,首先一个1字节的常量SELECTDB,表示切换db了,然后下一个接上数据库的编号,它的长度是可变的,然后接下来就是具体的key-value对的数据了。

保存RDB文件是一个很巨大的工程,所以redis还提供后台保存的机制。即执行bgsave的时候,redis fork出一个子进程,让子进程来执行保存的工作,而父进程继续提供redis正常的数据库服务。由于子进程复制了父进程的地址空间,即子进程拥有父进程fork时的数据库,子进程执行save的操作,把它从父进程那儿继承来的数据库写入一个temp文件即可。在子进程复制期间,redis会记录数据库的修改次数(dirty)。当子进程完成时,发送给父进程SIGUSR1信号,父进程捕捉到这个信号,就知道子进程完成了复制,然后父进程将子进程保存的temp文件改名为真正的rdb文件(即真正保存成功了才改成目标文件,这才是保险的做法)。然后记录下这一次save的结束时间。


4.3.2 AOF持久化

首先想一个问题,保存数据库一定需要像RDB那样把数据库里面的所有数据保存下来么?有没有别的方法?

RDB保存的只是最终的数据库,它是一个结果。结果是怎么来的?是通过用户的各个命令建立起来的,所以可以不保存结果,而只保存建立这个结果的命令。 redis的AOF就是这个思想,它不同RDB保存db的数据,它保存的是一条一条建立数据库的命令。

我们首先来看AOF文件的格式,它里面保存的是一条一条的命令,首先存储命令长度,然后存储命令,具体的分隔符什么的可以自己深入研究,这都不是重点,反正知道AOF文件存储的是redis客户端执行的命令即可。

redis server中有一个sds  aof_buf, 如果aof持久化打开的话,每个修改数据库的命令都会存入这个aof_buf(保存的是aof文件中命令格式的字符串),然后event loop没循环一次,在server cron中调用flushaofbuf,把aof_buf中的命令写入aof文件(其实是write,真正写入的是内核缓冲区),再清空aof_buf,进入下一次loop。这样所有的数据库的变化,都可以通过aof文件中的命令来还原,达到了保存数据库的效果。

4.4 redis的事务


redis另一个比memcached强大的地方,是它支持简单的事务。事务简单说就是把几个命令合并,一次性执行全部命令。对于关系型数据库来说,事务还有回滚机制,即事务命令要么全部执行成功,只要有一条失败就回滚,回到事务执行前的状态。redis不支持回滚,它的事务只保证命令依次被执行,即使中间一条命令出错也会继续往下执行,所以说它只支持简单的事务。


5. 总结

总的来看,redis比memcached的功能多很多,实现也更复杂。不过memcached更专注于保存key-value数据(这已经能满足大多数使用场景了),而redis提供更丰富的数据结构及其他的一些功能。不能说redis比memcached好,不过从源码阅读的角度来看,redis的价值或许更大一点。另外,redis3.0里面支持了集群功能,这部分的代码还没有研究,后续再跟进。



看完关于memcached与redis实现方式的对比,你还有什么疑问吗?欢迎在下面留言提问,小编会邀请作者田京昆亲自为你解疑答惑!

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多