综述
Memcached和Redis都是高性能的内存数据存储系统,通常用作缓存服务器。它们以key-value的形式存储数据,使得数据的访问速度非常快。当应用程序需要频繁地读取或写入数据时,如果每次都从数据库中进行操作,不仅会造成数据库的压力增大,而且查询效率也会降低。因此,我们可以将一部分常用的、热点数据存储在Memcached或Redis这样的内存数据存储系统中。
当用户发起查询请求时,应用程序首先会检查Memcached或Redis中是否存在所需的数据。如果存在,则直接从内存中获取数据并返回给用户,避免了对数据库的查询,从而大大提高了查询效率。如果Memcached或Redis中没有所需的数据,则应用程序再从数据库中查询,并将查询到的数据存入Memcached或Redis中,以备下次使用。
服务方式
Memcached和Redis作为独立的服务进程运行,它们通过监听特定的网络端口或Unix域套接字来提供服务。这些服务进程可以配置为在系统启动时自动作为守护进程(daemon)运行,确保服务的持续可用性。
当其他用户进程需要使用Memcached或Redis的服务时,它们会通过网络或Unix域套接字与这些服务进程进行通信。由于用户进程和Memcached/Redis服务可能部署在不同的机器上,因此网络通信是必需的。而TCP是最常用且最可靠的通信协议之一,因此Memcached和Redis都支持TCP连接。
除了TCP,Memcached还支持UDP协议进行通信,尽管UDP不提供像TCP那样的可靠连接和错误恢复机制,但它在某些场景下可能提供更快的性能。Redis也支持UDP,但在实际应用中较少使用,因为Redis更侧重于数据的一致性和可靠性。
另外,当Memcached或Redis服务与用户进程位于同一台机器上时,为了提高性能并减少网络开销,它们还支持使用Unix域套接字(Unix Domain Socket)进行通信。Unix域套接字是一种在同一台机器上的进程间通信机制,它使用文件系统路径名作为地址,而不是像TCP和UDP那样使用网络地址和端口号。
事件模型
自从epoll(一种Linux特有的I/O事件通知机制)问世以来,由于其高效的性能和可扩展性,它几乎成了网络服务器领域的首选I/O多路复用技术。因此,大多数现代的网络服务器,包括Redis,已经放弃了传统的select和poll机制,转而采用epoll。Redis虽然仍然保留了对select和poll的支持,但在实际部署中,用户通常都会选择使用epoll以获取更好的性能。
对于基于BSD的系统,服务器软件则倾向于使用kqueue,它是BSD系统提供的一种与epoll类似的I/O事件通知机制。
至于memcached,虽然它基于libevent库进行网络I/O处理,但libevent库在内部实现时,对于Linux平台也是优先使用epoll作为其后端机制。因此,可以认为在使用Linux作为操作系统的环境中,无论是直接使用epoll还是通过libevent这样的库间接使用,最终的效果都是利用了epoll的高效性能。
图片
Memcached和Redis都利用epoll机制来处理事件循环,但它们在线程使用上有所不同。Redis主要是单线程模型,其核心的事件处理是由单一线程完成的。这意味着Redis的主要操作,如接收请求、处理数据、返回结果,都在同一个线程中串行执行。虽然Redis也有多线程的应用场景,但这些线程通常用于执行像数据持久化这样的后台任务,并不参与事件循环。
而Memcached则采用了多线程模型,它能够利用多个线程并行处理来自不同客户端的请求,从而提高了整体的并发处理能力。
在Redis的事件模型中,虽然epoll机制能够监控文件描述符(fd)的就绪状态,但仅仅知道哪个fd就绪是不够的。为了找到与这个fd关联的客户端信息,Redis使用了一种高效的数据结构——红黑树。它将每个fd作为键,将对应的客户端信息作为值存储在树中。当epoll通知Redis有fd就绪时,Redis便可以在红黑树中快速查找到与该fd关联的客户端信息,进而执行相应的操作。这种设计使得Redis能够高效地处理大量的并发连接。
与一些其他系统不同,Redis允许你设置客户端连接的数量上限,这意味着它可以预知在任何给定时间打开的文件描述符(fd)的最大数量。这一特性使得Redis能够以一种非常高效的方式来管理客户端连接。
由于文件描述符在同一时间内是唯一的,Redis利用了这一特性,采用了一个简单而直接的方法来处理连接信息。它使用一个数组,其中文件描述符(fd)直接作为数组的索引。这样,当Redis需要查找与特定文件描述符相关联的客户端信息时,它只需简单地使用该文件描述符作为数组的索引,从而直接访问到相应的客户端信息。这种方法的查找效率达到了O(1),因为它避免了复杂的搜索算法或数据结构的开销。
然而,这种方法的适用性是有局限性的。它主要适用于那些连接数量有限且相对固定的网络服务器。对于像Nginx这样的HTTP服务器,由于其需要处理大量且不断变化的连接,因此使用更复杂的数据结构(如红黑树)来管理连接信息可能更为合适。这些数据结构能够更有效地处理大量的数据插入、删除和查找操作,确保在高并发环境下依然保持高效的性能。
而Memcached是一个多线程的内存数据存储系统,它采用了master-worker的架构。在这种架构中,主线程(master thread)负责监听端口和建立连接,一旦有新的连接请求,主线程会将这些连接顺序分配给各个工作线程(worker threads)。
每个工作线程都有自己独立的事件循环(event loop),它们各自服务不同的客户端请求。为了与主线程进行通信,每个工作线程都会创建一个管道(pipe),并保存其写端和读端。然后,工作线程将管道的读端加入其事件循环中,以便监听可读事件。
当主线程建立一个新的连接时,它会将连接项放入对应工作线程的就绪连接队列中。接着,主线程会向该工作线程的管道写端写入一个连接命令。这样,工作线程的事件循环中监控的管道读端就会触发就绪事件。工作线程会读取这个命令,解析后发现是一个连接请求,于是它会从自己的就绪连接队列中取出这个连接,并进行相应的处理。
多线程的好处在于能够充分利用多核处理器的性能优势,提高系统的整体吞吐量。然而,多线程编程也会带来一些复杂性,比如需要处理线程间的同步和通信问题。在Memcached中,为了避免数据竞争和不一致,使用了各种锁和条件变量来进行线程同步。
内存分配
memcached和redis的核心任务都是在内存中操作数据,所以内存管理才是最核心的内容。
Memcached和Redis在内存分配方式上有所不同。Memcached采用的是内存池策略,这意味着它预先分配一大块内存,并在后续的内存分配请求中从这块内存池中满足。这种做法减少了内存分配的次数,从而提高了效率。许多网络服务器也采用类似的策略,但每个内存池的具体管理方式可能因应用场景而异。
相比之下,Redis并没有采用内存池方式。它采取的是即时分配策略,即当需要内存时直接进行分配,并将内存管理的任务交给操作系统内核处理。Redis只负责内存的申请和释放操作。虽然Redis是单线程的,并且没有自己的内存池,但其设计使得内存管理变得相对简单。Redis还提供了使用tcmalloc替换glibc的malloc的选项,这是Google开发的一款内存分配器,它在某些情况下比glibc的malloc更为高效。
由于Redis没有自己的内存池,其内存申请和释放操作变得十分直接和方便,只需使用标准的malloc和free函数即可。而Memcached的内存管理则相对复杂,因为它需要维护自己的内存池,并在分配和释放内存时进行相应的管理操作。具体的实现细节和复杂性将在后续关于Memcached的slab机制的讲解中进一步分析。
数据库实现
Memcached数据库实现
Memcached是一个高性能的分布式内存对象缓存系统,它主要支持简单的key-value数据存储模型。在Memcached中,每个数据项都以key-value对的形式存在,其中key是唯一的标识符,而value是与该key相关联的数据。这种存储方式使得数据的存取变得非常高效。
为了管理这些key-value对,Memcached采用了称为“slab”的内存管理机制。Slab分配器负责内存的分配和回收,它将内存划分为不同大小的块(或称为slab class),每个块的大小是固定的。当需要存储一个key-value对时,Memcached会根据value的大小选择一个合适的slab块来存储它。这种方式可以减少内存碎片,提高内存利用率
图片
Memcached利用哈希表来高效地存储和查找大量的key-value对(即items)。为了快速定位特定的item,Memcached维护了一个精心设计的哈希表。当item数量增多时,这个哈希表能够支持快速的查找操作。
哈希表采用了开链法(也称为链地址法)来处理键的冲突。这意味着每个哈希表的桶(bucket)内部都维护了一个链表,链表中的节点是指向item的指针。这种结构允许多个具有相同哈希值的键(即冲突的键)能够链接在一起。
随着item数量的增长,哈希表可能需要扩容以维持高效的查找性能。当item数量达到桶数量的1.5倍以上时,Memcached会触发扩容操作。扩容过程中,会先将旧的哈希表(old_hashtable)设置为当前使用的哈希表(primary_hashtable),然后为primary_hashtable分配一个新的、桶数量翻倍的哈希表。接着,系统会逐个将old_hashtable中的数据迁移到新的primary_hashtable中。这个过程通过一个名为expand_bucket的变量来跟踪已经迁移了多少个桶。一旦数据迁移完成,旧的old_hashtable`就会被释放,从而完成扩容。
值得一提的是,Memcached的扩容操作是由一个专门的后台线程来完成的。当需要扩容时,系统会通过条件变量通知这个后台线程。扩容完成后,后台线程会再次阻塞,等待下一个扩容条件变量的触发。这种设计允许扩容操作在不影响正常查找操作的情况下进行,提高了系统的整体性能。
在扩容过程中,查找一个item可能需要同时检查primary_hashtable和old_hashtable。这取决于item的桶位置与expand_bucket的大小关系。这种双表查找策略确保了即使在扩容过程中,系统也能准确地定位到每个item。
在Memcached中,item的内存分配是通过slab机制来实现的。这个机制包含多个slab class,每个slab class负责管理一组称为trunks的内存块。每个trunk的大小是固定的,而不同的slab class之间的trunk大小则按照一定的比例递增。这种设计使得Memcached可以根据item的大小来灵活分配内存。
当一个新的item需要被创建时,Memcached会根据item的大小选择一个合适的slab class。选择的规则是找到比item大小稍大的最小trunk。例如,如果一个item的大小是100字节,Memcached会选择一个大小为112字节的trunk来分配内存。虽然这样做会导致一些内存浪费(在这个例子中,有12字节的内存没有被使用),但它也带来了性能上的优势。
通过预先分配和管理内存块(即trunks),Memcached能够减少频繁的内存分配和释放操作,从而提高内存使用的效率和性能。这种内存管理方式虽然会有一定的内存浪费,但在许多情况下,这种浪费是可以接受的,因为它换取了更好的性能和可伸缩性。
图片
图片
图片
如上图,整个构造就是这样;Memcached实现了一个高效的key-value数据库,其数据存储和管理机制独特而精巧。在Memcached中,数据以key-value对的形式存在,每个这样的对都被封装在一个item结构中,包含了key、value以及相关属性。
这些item被组织成多个slab,而每个slab则由相应的slabclass进行管理。一个slabclass可以管理多个slab,它们的大小相同,由slabclass的trunk大小决定。slabclass内部有一个slot机制,用于快速分配和回收item。当item不再使用时,它会被放回slot的头部,供后续分配使用,而不需要真正的释放内存。
每个slabclass还与一个链表相关联,这个链表存储了由该slabclass分配的所有item。新分配的item被放置在链表头部,而链表中的item按照最近使用顺序排列,这使得链表末尾的item是最久未使用的。这种机制为实现LRU(Least Recently Used)缓存策略提供了基础。
为了快速查找和定位item,Memcached还使用了一个hash表。当需要查找或分配item时,hash表用于快速定位到相应的item,而链表则用于维护item的最近使用顺序。
在item分配过程中,如果当前slabclass的链表中有过期的item,那么这些item会被优先使用。如果没有过期的item可用,系统会尝试从slab中分配新的trunk。如果slab也用完,系统会向slabclass中添加新的slab。
值得注意的是,Memcached支持设置item的过期时间,但并不会定期检查过期数据。只有在客户端请求某个item时,Memcached才会检查其过期时间,并在需要时返回错误。这种方法的优点是减少了不必要的CPU开销,但缺点是可能导致过期数据长时间占用内存。
为了处理并发访问和数据一致性问题,Memcached采用了CAS(Compare-And-Swap)协议。每个item都有一个版本号,每次数据更新时,版本号会增加。当客户端尝试更新某个item时,它必须提供当前的版本号。只有当服务器端的版本号与客户端提供的版本号一致时,更新操作才会被执行。否则,服务器会返回错误,提示客户端数据已经被其他进程修改。这种机制确保了数据的一致性和并发安全性。
Redis数据库实现
Redis数据库的功能确实比Memcached更为丰富,因为它不仅限于存储字符串,还支持string(字符串)、list(列表)、set(集合)、sorted set(有序集合)和hash table(哈希表)这五种数据结构。这种灵活性使得Redis在处理复杂数据时更为高效。例如,如果要存储一个人的信息,使用Redis的hash table结构,可以将人的名字作为key,然后将name和age等属性作为field和value存储。这样,当只需要获取某个特定属性时,如年龄,Redis可以直接定位并返回该属性的值,而不需要加载整个对象。
为了实现这些多样化的数据结构,Redis引入了抽象对象的概念,称为Redis Object。每个Redis Object都有一个类型标签,可以是字符串、链表、集合、有序集合或哈希表之一。这种设计使得Redis能够根据不同的使用场景选择最适合的数据结构来实现。
为了提高效率,Redis还为每种数据类型准备了多种内部编码方式(encoding),这些编码方式是根据具体的使用情况和性能要求来选择的。此外,Redis Object还包含了LRU(最近最少使用)信息,用于实现缓存淘汰策略。LRU信息记录了对象上次被访问的时间,与当前服务器维护的近似时间相比较,可以计算出对象的空闲时间。
Redis Object还引入了引用计数机制,用于共享对象和确定对象的删除时间。通过引用计数,Redis可以追踪对象的使用情况,并在适当的时候释放内存。
最后,Redis Object使用了一个void*指针来指向对象的实际内容。这种设计使得Redis能够以一种统一的方式来处理不同类型的对象,简化了代码逻辑。通过使用抽象对象,Redis实现了一种类似面向对象编程的风格,尽管其底层实现完全是基于C语言的。这种设计使得Redis的代码结构清晰、易于理解和维护。
//#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;
} robj;
说到底redis还是一个key-value的数据库,无论它支持多么复杂的数据结构,最终都是以key-value对的形式进行存储。这些value可以是字符串、链表、集合、有序集合或哈希表,而key始终是字符串类型。这些高级数据结构在内部实现时,也会利用字符串作为基本元素。
在C语言中,没有内置的字符串类型,因此Redis为了实现其强大的字符串处理能力,创建了一个名为SDS(Simple Dynamic String)的自定义字符串类型。SDS是一个简单的结构体,其中包含三个字段:
- len:表示当前字符串的实际长度。
- free:表示当前字符串未使用空间的长度,即可以在不重新分配内存的情况下追加的字节数。
- buf:一个字符数组,用于存储字符串的实际内容。
通过len和free字段,SDS能够高效地管理字符串的内存分配,避免了C语言原生字符串在处理长字符串或频繁修改时可能遇到的性能瓶颈和内存浪费问题。同时,SDS还提供了一系列操作函数,如字符串拼接、长度计算等,使得字符串操作更加安全和便捷。
尽管Redis内部使用了复杂的数据结构,但所有这些结构都是以key-value对的形式存储,并通过SDS来实现字符串的高效处理。这种设计使得Redis在保持简洁和一致性的同时,能够提供强大的数据存储和操作能力。
struct sdshdr {
int len;
int free;
char buf[];
};
在Redis中,key和value的关联是通过哈希表(dictionary)来实现的。由于C语言本身没有提供内置的字典数据结构,Redis自行实现了一个名为dict的字典结构。这个dict结构内部包含了一些关键的组件,如dictht(哈希表数组)和dictType(操作函数集合)。
dictht是dict结构中的哈希表数组,它实际上是一个包含多个哈希表的数组,通常包含两个哈希表dictht[0]和dictht[1]。这样做的主要目的是为了支持哈希表的动态扩容和缩容。当哈希表中的数据量增长到一定程度时,Redis会启动扩容操作,将dictht[0]中的数据逐步迁移到dictht[1]中,同时会调整哈希表的大小以适应更多的数据。
dictType则是一个操作函数集合,它包含了处理哈希表中元素所需的各种函数指针,如哈希函数、比较函数、复制函数等。通过这些函数指针,Redis可以动态配置哈希表中元素的操作方法,从而实现灵活的键值对管理。
在dictht中,每个哈希表都由多个桶(bucket)组成,桶的数量由哈希表的大小决定。每个桶中存储的是一个链表(dictEntry),链表中包含了具体的键值对。当插入或查找一个键时,Redis会根据键的哈希值和一个掩码(sizemask)计算出对应的桶索引,然后在该桶的链表中进行查找或插入操作。
关于扩容和缩容操作,Redis采用了渐进式的方式。当需要扩容或缩容时,Redis并不会一次性完成所有的数据迁移工作,而是将这个过程分成多个阶段。在每个阶段中,Redis会在处理用户请求的同时,顺便迁移一部分数据。这样做的好处是,可以避免一次性迁移数据导致的长时间延迟,从而保证了Redis的高性能。当然,在扩容或缩容期间,Redis的性能会受到一定的影响,因为每个操作都需要额外处理数据迁移。
typedef struct dict {
dictType *type; // 哈希表的相关属性
void *privdata; // 额外信息
dictht ht[2]; // 两张哈希表,分主和副,用于扩容
int rehashidx; /* rehashing not in progress if rehashidx == -1 */ // 记录当前数据迁移的位置,在扩容的时候用的
int iterators; /* number of iterators currently running */ // 目前存在的迭代器的数量
} dict;
typedef struct dictht {
dictEntry **table; // dictEntry是item,多个item组成hash桶里面的链表,table则是多个链表头指针组成的数组的指针
unsigned long size; // 这个就是桶的数量
// sizemask取size - 1, 然后一个数据来的时候,通过计算出的hashkey, 让hashkey & sizemask来确定它要放的桶的位置
// 当size取2^n的时候,sizemask就是1...111,这样就和hashkey % size有一样的效果,但是使用&会快很多。这就是原因
unsigned long sizemask;
unsigned long used; // 已经数值的dictEntry数量
} dictht;
typedef struct dictType {
unsigned int (*hashFunction)(const void *key); // hash的方法
void *(*keyDup)(void *privdata, const void *key); // key的复制方法
void *(*valDup)(void *privdata, const void *obj); // value的复制方法
int (*keyCompare)(void *privdata, const void *key1, const void *key2); // key之间的比较
void (*keyDestructor)(void *privdata, void *key); // key的析构
void (*valDestructor)(void *privdata, void *obj); // value的析构
} dictType;
typedef struct dictEntry {
void *key;
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
struct dictEntry *next;
} dictEntry;
有了dict,数据库就好实现了。所有数据读存储在dict中,它作为键值对(key-value pair)的存储容器。在 Redis 中,所有的数据都是以 key-value 对的形式存储的,而每个 value 实际上是一个指向 redisObject 的指针,这个 redisObject 可以是 Redis 支持的五种数据类型(字符串、列表、集合、有序集合和哈希表)中的任何一种
5种type的对象,每一个都至少有两种底层实现方式,以适应不同的使用场景和性能需求
- REDIS_ENCODING_RAW: 最常见的实现方式,用于存储任意长度的字符串。
- REDIS_ENCODING_INT: 当字符串表示的是一个整数值,并且这个整数值在Redis能够表示的范围内时,Redis会使用这种编码方式来存储该字符串,这样可以节省内存。
- REDIS_ENCODING_EMBSTR: 当字符串长度较小时,Redis会使用embstr编码,将字符串和对象头部信息一起分配在一块连续的内存中,以提高内存使用效率。
2. List(列表):
- 普通双向链表: 当列表元素较多时,Redis使用双向链表来存储列表元素,这样可以在O(1)的时间复杂度内完成列表的头部和尾部插入、删除操作。
- 压缩链表(ziplist): 当列表元素较少时,Redis使用压缩链表来存储列表元素。压缩链表是一种特殊的链表结构,它将节点之间的指针和节点的值存储在一起,以节省内存空间。
3. Set(集合):
- dict: 当集合中的元素不是整数时,Redis使用哈希表(dict)来实现集合,这样可以快速判断一个元素是否存在于集合中。
- intset: 当集合中的元素全是整数时,Redis使用intset来实现集合,这样可以更加高效地存储和查找整数元素。
4. Sorted Set(有序集合):
- skiplist(跳表): Redis使用跳表来实现有序集合,跳表是一种可以进行二分查找的有序链表,它可以在O(logN)的时间复杂度内完成元素的插入、删除和查找操作。
- ziplist: 当有序集合中的元素较少时,Redis也会使用压缩链表来实现有序集合,以节省内存空间。
5. Hash(哈希表):
- dict: Redis使用哈希表(dict)来实现哈希类型的数据结构,哈希表可以快速地根据键来查找对应的值。
- ziplist: 当哈希表中的字段和值都比较小时,Redis也会使用压缩链表来实现哈希表,以节省内存空间。
在Redis中,zset(有序集合)是通过结合skiplist(跳表)和ziplist(压缩列表)来实现的。skiplist是一种可以进行对数级别查找的数据结构,它通过构建多级索引来实现快速查找、插入和删除操作。而ziplist则是一种紧凑的、连续的内存结构,适用于存储元素较少且元素值较小的情况。
当zset中的元素数量较少或者元素值较小时,Redis会使用ziplist来存储元素和它们的分数(score)。在这种情况下,ziplist会按照分数的大小顺序存储元素,每个元素后面紧跟着它的分数。这种顺序存储的方式使得插入和删除操作在内存分配方面相对高效。
然而,当zset中的元素数量超过一定阈值或者元素值过大时,ziplist可能会变得不再高效。在这种情况下,Redis会将ziplist转换为skiplist,并使用一个额外的哈希表来存储元素和它们的分数。哈希表允许Redis在O(1)时间复杂度内查找元素,而skiplist则提供了对数级别的查找性能。
Redis的数据结构
- Hashtable(哈希表): Redis使用字典(dict)来实现哈希表。在Redis的字典中,每个dictEntry包含一个键(key)和一个值(value),它们都是字符串。字典的哈希表使用链地址法来解决哈希冲突,即当多个键具有相同的哈希值时,它们会被放置在同一个桶(bucket)中,形成一个链表。
- Set(集合): Redis的集合也是通过字典来实现的。在集合的字典中,每个dictEntry的键(key)是集合中的一个元素,而值(value)通常是一个空值或占位符。这种设计使得Redis可以在O(1)时间复杂度内检查一个元素是否存在于集合中。
- Zset(有序集合): 如前所述,有序集合是通过结合skiplist和ziplist来实现的。skiplist提供了排序和快速查找的功能,而ziplist则用于在元素较少或元素值较小时进行高效存储。当元素数量或元素值超过一定阈值时,Redis会将ziplist转换为skiplist以保持性能。
此外,Redis还使用了一种称为“expire dict”的字典来记录每个键的过期时间。这个字典的键是数据库中的键,而值是一个整数,表示该键的过期时间戳。Redis通过定期扫描这个字典来检查键是否过期,并在必要时删除它们。这种惰性删除和定期扫描的机制有助于平衡内存使用和性能。
zset(有序集合)可以使用ziplist或skiplist作为底层数据结构来实现。ziplist(压缩列表)是一种为节省内存而设计的连续内存块结构,通常用于存储小的字符串和整数,以及小的列表、哈希和有序集合。
当使用ziplist来存储zset时,每个元素及其对应的score会连续地存储在这个列表里。元素和score之间会有一定的编码来区分它们。由于ziplist是紧凑存储的,所以插入和删除操作可能需要重新分配内存块,并将相邻的元素向前或向后移动以保持连续性。
在ziplist中,zset元素的存储顺序是根据它们的score来决定的。具有较小score的元素会被放置在列表的前面,而较大的score则会被放置在列表的后面。这样,遍历ziplist就可以按照score的顺序访问zset中的元素。
然而,当zset中的元素数量增加或者元素的字符串长度变得较长时,ziplist可能会变得不再高效。在这种情况下,Redis会选择将zset 的底层数据结构从ziplist转换为skiplist。skiplist是一种支持对数级别查找、插入和删除操作的数据结构,它通过在原始链表的基础上增加多级索引来实现快速查找
转换过程大致如下:
这种转换过程确保了zset在元素数量增加或元素值变大时仍然能够保持高效的性能。通过使用ziplist和skiplist这两种不同的数据结构,Redis能够根据不同的使用场景来平衡内存使用和性能。
另外,ziplist如何实现hashtable呢?其实也很简单,就是存储一个key,存储一个value,再存储一个key,再存储一个value。还是顺序存储,与zset实现类似,所以当元素超过一定数量,或者某个元素的字符数超过一定数量时,就会转换成hashtable来实现。各种底层实现方式是可以转换的,redis可以根据情况选择最合适的实现方式,这也是这样使用类似面向对象的实现方式的好处
需要指出的是,使用skiplist来实现zset的时候,其实还用了一个dict,这个dict存储一样的键值对。为什么呢?因为skiplist的查找只是lgn的(可能变成n),而dict可以到O(1), 所以使用一个dict来加速查找,由于skiplist和dict可以指向同一个redis object,所以不会浪费太多内存。另外使用ziplist实现zset的时候,为什么不用dict来加速查找呢?因为ziplist支持的元素个数很少(个数多时就转换成skiplist了),顺序遍历也很快,所以不用dict了。
简单的来说。redis与Memcached在数据库结构上的显著不同就是:Memcached是一个简单的内存缓存系统,它没有多个数据库的概念,所有的数据都存储在一个单一的内存空间中。这意味着在Memcached中,如果你尝试存储一个与现有键相同的键,新的值会覆盖旧的值。
相反,Redis的设计更为灵活和强大。它默认提供了16个数据库,编号从0到15,这些数据库在逻辑上是完全隔离的。这意味着,你可以在不同的数据库中存储具有相同键名的数据,而这些数据在各自的数据库中是完全独立的。例如,在0号数据库中有一个键叫"user:123",你可以在1号数据库中再存储一个同名键"user:123",而这两个键的值和内容是完全不同的。
这种多数据库的设计给了用户更多的灵活性和选择,特别是在需要隔离不同数据集或者想要使用键空间的不同部分进行不同的操作时。然而,需要注意的是,尽管这些数据库在逻辑上是隔离的,但它们实际上都存储在同一个Redis实例的内存中,因此仍然受到该实例的总内存限制。
Redis支持为数据设置过期时间。尽管我们在Redis对象的结构中并没有直接看到保存expire时间的字段,但Redis实际上为每个数据库额外维护了一个称为"expire dict"的字典来专门记录key的过期时间。
在"expire dict"中,每个键值对(dict entry)的键(key)对应着原始数据库中的key,而值(value)则是一个64位的整数,表示该key的过期时间戳。当需要检查一个key是否过期时,Redis会到这个"expire dict"中查找对应的过期时间戳,并与当前时间进行比较。
为什么Redis选择这种方式来记录过期时间呢?原因在于,并不是所有的key都需要设置过期时间。如果为每个key都保存一个过期时间字段,那么对于不设置过期时间的key来说,这将是一种空间的浪费。通过将过期时间单独保存在"expire dict"中,Redis可以更加灵活地管理内存,只有在key实际设置了过期时间时才会为其在"expire dict"中创建相应的条目。
关于Redis的过期机制,它与Memcached有些类似,都采用了惰性删除的策略。这意味着,当需要访问一个key时,Redis会先检查该key是否已过期。如果过期,则删除该key并返回相应的错误。然而,仅仅依赖惰性删除可能会导致内存浪费,因为过期的key可能长时间不被访问,从而一直占据内存。
为了解决这个问题,Redis引入了定时任务"servercron"来辅助处理过期数据的删除。这个函数会在一定的时间间隔内随机选取"expire dict"中的一部分key进行检查,如果它们已过期,则进行删除。这个过程并不是一次性删除所有过期的key,而是在每次执行时随机选取一部分进行处理,以确保不会对整个系统造成过大的负担。此外,Redis还会根据系统的负载情况调整删除操作的执行时间,通常会在较短的时间内进行多次删除操作,并在一定的时间间隔后进行一次较长时间的删除操作,以平衡内存使用和系统性能
以上就是redis的数据的实现,与memcached不同,redis还支持数据持久化
Redis数据库持久化
redis和memcached的最大不同,就是redis支持数据持久化,这也是很多人选择使用redis而不是memcached的最大原因。redis的持久化,分为两种策略,用户可以配置使用不同的策略。
RDB持久化
当用户执行save或bgsave命令时,Redis会触发RDB(Redis DataBase)持久化操作。RDB持久化的核心理念是将Redis数据库在某个时间点的完整快照保存到磁盘上的文件中。
在存储过程中,RDB会遵循一定的格式:
- 文件头:首先写入一个特定的字符串,作为RDB文件的标识符,这用于验证文件是否为有效的RDB文件。
- 版本信息:紧接着是Redis的版本信息,这有助于确定文件是否与当前的Redis版本兼容。
- 数据库列表:随后是数据库的实际内容。Redis会按照数据库的编号顺序(从0开始,一直到最大的编号)逐个保存每个数据库的内容。这意味着,如果一个Redis实例配置了多个数据库,并且它们都包含数据,那么这些数据库的内容将按照编号顺序连续存储在RDB文件中。
- 结束标志:当所有数据库的内容都保存完毕后,会写入一个特定的结束标志(如“EOF”),表示数据库内容的结束。
- 校验和:最后,为了确保文件的完整性,RDB会计算文件的校验和并存储在文件末尾。这可以在后续加载文件时验证数据的完整性。
图片
每个数据库的数据存储方式具有特定的结构。首先,Redis使用1字节的常量SELECTDB作为标识,表明接下来要切换至不同的数据库。紧接着,SELECTDB之后是一个可变长度的字段,用于表示数据库的编号。这个编号的长度不是固定的,它根据实际的数据库编号大小而定,可能是1字节、2字节、3字节等,以确保能够容纳所有可能的数据库编号。
完成数据库编号的读取后,接下来就是具体的key-value对数据了。这些数据按照key-value对的顺序依次存储,每个key-value对之间没有明显的分隔符,而是通过Redis的内部数据结构和编码规则来区分。
图片
int rdbSaveKeyValuePair(rio *rdb, robj *key, robj *val,
long long expiretime, long long now)
{
/* Save the expire time */
if (expiretime != -1) {
/* If this key is already expired skip it */
if (expiretime < now) return 0;
if (rdbSaveType(rdb,REDIS_RDB_OPCODE_EXPIRETIME_MS) == -1) return -1;
if (rdbSaveMillisecondTime(rdb,expiretime) == -1) return -1;
}
/* Save type, key, value */
if (rdbSaveObjectType(rdb,val) == -1) return -1;
if (rdbSaveStringObject(rdb,key) == -1) return -1;
if (rdbSaveObject(rdb,val) == -1) return -1;
return 1;
}
由上面的代码也可以看出,存储的时候,先检查expire time,如果已经过期,不存就行了,否则,则将expire time存下来,注意,及时是存储expire time,也是先存储它的类型为REDIS_RDB_OPCODE_EXPIRETIME_MS,然后再存储具体过期时间。接下来存储真正的key-value对,首先存储value的类型,然后存储key(它按照字符串存储),然后存储value,如下图。
图片
RDB持久化过程中,rdbSaveObject函数会根据值的不同类型(val)采用不同的存储策略。尽管最终的目标是将数据以字符串的形式存储到文件中,但这个过程涉及到了多种数据类型和相应的编码技巧。
对于简单的数据类型,如字符串(strings)和整数(integers),Redis会直接将其转换为字符串格式并存储。对于更复杂的数据结构,如链表(linked lists)和哈希表(hash tables),Redis会采取一种更为结构化的方法。
对于链表,Redis首先会记录整个链表的字节数,然后遍历链表中的每个元素,将其逐个转换为字符串并写入文件。这样做可以确保链表的结构在持久化过程中得以保留。
对于哈希表,Redis会先计算整个哈希表的字节数,然后遍历其中的每个dictEntry。每个dictEntry的键(key)和值(value)都会被转换为字符串并存储到文件中。这样,哈希表中的所有键值对都会在持久化过程中得到保留。
在存储每个键值对时,如果设置了过期时间(expire time),Redis会首先将其存储到文件中。接下来是值的类型信息,这对于后续的数据恢复至关重要。之后是键的字符串表示,最后是值的字符串表示。根据值的类型和底层实现,Redis会使用不同的编码技巧将其转换为字符串格式。
为了实现数据压缩和便于从文件中恢复数据,Redis采用了一系列编码技巧。这些技巧包括使用特定的格式来存储不同类型的数据,以及使用压缩算法来减少文件的大小。通过这些方法,Redis能够在保持数据完整性的同时,提高RDB文件的存储效率和加载速度。
保存了RDB文件,当redis再启动的时候,就根据RDB文件来恢复数据库。由于以及在RDB文件中保存了数据库的号码,以及它包含的key-value对,以及每个key-value对中value的具体类型,实现方式,和数据,redis只要顺序读取文件,然后恢复object即可。由于保存了expire time,发现当前的时间已经比expire time大了,即数据已经超时了,则不恢复这个key-value对即可。
保存RDB文件是一个相对繁重的任务,因此Redis提供了后台保存的机制来优化这一过程。当执行bgsave命令时,Redis会利用fork系统调用创建一个子进程。这个子进程是父进程的一个副本,它继承了父进程当前的内存状态,包括Redis的数据库。
由于子进程拥有父进程fork时的数据库快照,它可以在不影响父进程的情况下独立执行保存操作。子进程会将这个数据库快照写入一个临时文件(temp file)。在此过程中,Redis会追踪对数据库的修改次数(dirty count),这是为了确保在子进程保存期间的数据一致性。
一旦子进程完成了临时文件的写入,它会向父进程发送一个SIGUSR1信号。父进程在接收到这个信号后,会知道子进程已经完成了数据库的保存工作。此时,父进程会安全地将这个临时文件重命名为正式的RDB文件。这种重命名操作只有在子进程成功保存数据后才进行,从而确保了数据的完整性和安全性。
完成这一步骤后,父进程会记录下这次保存的结束时间,并继续提供正常的Redis数据库服务。整个后台保存过程对用户来说是透明的,他们可以继续进行数据库操作,而不需要等待保存操作完成。这种后台保存机制显著提高了Redis的性能和响应能力。
这里有一个问题,在子进程保存期间,父进程的数据库已经被修改了,而父进程只是记录了修改的次数(dirty),被没有进行修正操作。似乎使得RDB保存的不是实时的数据库,不过AOF持久化,很好的解决了这个问题。
了用户手动执行SAVE或BGSAVE命令来触发RDB持久化外,Redis还允许在配置文件中设置自动保存的条件。这些条件通常基于两个因素:一是时间间隔(save配置项中的t),二是数据库在这段时间内发生的修改次数(dirty)。
在Redis的配置文件中,可以通过save指令来设置自动保存规则。例如,save 900 1表示如果数据库在900秒内至少有1次修改,则触发一次后台保存。类似地,save 300 10和save 60 10000分别表示在300秒内至少有10次修改,或者在60秒内至少有10000次修改时,也会触发后台保存。
Redis的服务器周期函数(serverCron)会定期检查这些条件是否满足。每当serverCron运行时,它会计算自上次RDB保存以来数据库发生的修改次数(dirty count)以及距离上次保存的时间。如果这两个条件都满足任意一个save指令设置的要求,那么Redis就会执行BGSAVE命令,启动一个子进程来进行后台保存操作。
值得注意的是,Redis确保在任何时刻都只有一个子进程在进行后台保存操作。这是因为RDB保存是一个相对耗时的操作,特别是当涉及到大量的IO操作时。多个子进程同时进行大量的IO操作不仅可能导致性能下降,还可能使得IO管理变得复杂和不可预测。因此,Redis通过限制同时只有一个后台保存进程来确保数据的一致性和系统的稳定性。
AOF持久化
在考虑数据库持久化时,不必总是遵循RDB的方式,即完整地保存当前数据库的所有数据。另一种方法是记录数据库的变化过程,而不是结果状态。这就是AOF(Append Only File)持久化的核心思想。与RDB不同,AOF不是保存数据库的最终状态,而是保存了构建这个状态所需的一系列命令。
AOF文件的内容格式相对简单,它首先记录每个命令的长度,然后紧接着是命令本身。这些命令是按照它们在数据库中执行的顺序逐条保存的。这些命令不是随机或无关的,而是由Redis客户端发送给服务器的,用于修改数据库状态。
在Redis服务器中,有一个内部缓冲区aof_buf,当AOF持久化功能被启用时,所有修改数据库的命令都会被追加到这个缓冲区中。随着事件的循环,这些命令会被定期地写入AOF文件。这个过程是通过flushAppendOnlyFile函数实现的,它会将aof_buf中的内容写入到文件系统的内核缓冲区中,然后清空aof_buf以准备下一次的命令追加。这样,只要AOF文件存在,并且其中的命令按序执行,就可以完全重建数据库的状态。
值得注意的是,虽然命令被写入了内核缓冲区,但它们何时真正被物理写入到磁盘上是由操作系统决定的。为了确保数据的可靠性,Redis提供了几种同步策略供用户选择。例如,可以选择每次写入后都进行同步操作,这虽然会增加一些开销,但可以提供更强的数据一致性保证。另一种策略是每秒同步一次,这通过后台线程来实现,避免了主线程的性能开销。
与RDB不同,AOF持久化在写入数据时不需要考虑同步的问题。因为RDB是一次性生成完整的数据库快照,所以即使在生成过程中进行同步操作,对性能的影响也是有限的。而AOF则是持续不断地追加命令,因此同步策略的选择就显得尤为重要。
除了实时追加命令到AOF文件外,Redis还提供了AOF重写功能。这是一种优化手段,通过读取当前数据库的状态,并生成相应的命令序列来重写AOF文件。这样做可以减小AOF文件的大小,提高重建数据库的效率。AOF重写也是在后台进程中完成的,它读取数据库的当前状态,并生成相应的命令序列,然后写入到一个临时文件中。当重写完成后,这个临时文件会被替换成最终的AOF文件。
在AOF重写期间,如果数据库有新的更新操作,Redis会将这些操作保存在一个特殊的缓冲区中。当AOF重写完成后,这些在重写期间产生的更新命令会被追加到最终的AOF文件中,确保所有的数据库变化都被完整地记录下来。
最后,当Redis服务器启动时,它会读取AOF文件并执行其中的命令,从而重建数据库的状态。为了执行这些命令,Redis创建了一个虚拟的客户端,这个客户端没有实际的网络连接,但它拥有和真实客户端相同的读写缓冲区。通过模拟客户端的行为,Redis可以逐条执行AOF文件中的命令,从而恢复数据库到持久化时的状态。
// 创建伪客户端
fakeClient = createFakeClient();
while(命令不为空) {
// 获取一条命令的参数信息 argc, argv
...
// 执行
fakeClient->argc = argc;
fakeClient->argv = argv;
cmd->proc(fakeClient);
}
Redis的事务
Redis相较于memcached的另一个显著优势在于其支持简单的事务处理。事务,简而言之,就是将多个命令组合在一起,一次性执行。对于关系型数据库而言,事务通常配备有回滚机制,意味着如果事务中的任何一条命令执行失败,整个事务都会回退到执行前的状态。然而,Redis的事务并不支持回滚功能,它仅保证命令按顺序执行,即使中途有命令失败,也会继续执行后续命令。因此,Redis的事务被称为“简单事务”。
在Redis中执行事务的过程相对直观。首先,客户端发送MULTI命令来标识事务的开始。随后,客户端输入要执行的一系列命令。最后,通过发送EXEC命令来执行整个事务。当Redis服务器接收到MULTI命令时,它会将客户端的状态设置为REDIS_MULTI,表明该客户端正在执行事务。同时,服务器会在客户端的multiState结构体中保存事务的具体信息,包括命令的数量和每个命令的具体内容。如果命令无法识别,服务器将不会保存这些命令。当收到EXEC命令时,Redis会按照multiState中保存的命令顺序依次执行它们,并记录每个命令的返回值。如果在执行过程中出现错误,Redis不会中断事务,而是记录错误信息并继续执行后续命令。当所有命令执行完毕后,Redis会将所有命令的返回值一起返回给客户端。
Redis之所以不支持回滚功能,部分原因是基于其设计哲学和性能考虑。传统的关系型数据库事务通常需要处理复杂的回滚逻辑,这可能会消耗大量的系统资源。而Redis作为一个内存数据库,追求的是高性能和简洁性。因此,它选择不实现回滚机制,以换取更高的运行效率。同时,有观点认为,如果事务中出现错误,通常是由于客户端程序的问题,而不是服务器的问题。在这种情况下,要求服务器进行回滚可能并不合理。
我们知道redis是单event loop的,在真正执行一个事物的时候(即redis收到exec命令后),事物的执行过程是不会被打断的,所有命令都会在一个event loop中执行完。但是在用户逐个输入事务的命令的时候,这期间,可能已经有别的客户修改了事务里面用到的数据,这就可能产生问题。
所以redis还提供了watch命令,用户可以在输入multi之前,执行watch命令,指定需要观察的数据,这样如果在exec之前,有其他的客户端修改了这些被watch的数据,则exec的时候,执行到处理被修改的数据的命令的时候,会执行失败,提示数据已经dirty。这是如何是实现的呢?原来在每一个redisDb中还有一个dict watched_keys,watched_kesy中dictentry的key是被watch的数据库的key,而value则是一个list,里面存储的是watch它的client。
同时,每个client也有一个watched_keys,里面保存的是这个client当前watch的key。在执行watch的时候,redis在对应的数据库的watched_keys中找到这个key(如果没有,则新建一个dictentry),然后在它的客户列表中加入这个client,同时,往这个client的watched_keys中加入这个key。当有客户执行一个命令修改数据的时候,redis首先在watched_keys中找这个key,如果发现有它,证明有client在watch它,则遍历所有watch它的client,将这些client设置为REDIS_DIRTY_CAS,表面有watch的key被dirty了。
当客户执行的事务的时候,首先会检查是否被设置了REDIS_DIRTY_CAS,如果是,则表明数据dirty了,事务无法执行,会立即返回错误,只有client没有被设置REDIS_DIRTY_CAS的时候才能够执行事务。需要指出的是,执行exec后,该client的所有watch的key都会被清除,同时db中该key的client列表也会清除该client,即执行exec后,该client不再watch任何key(即使exec没有执行成功也是一样)。所以说redis的事务是简单的事务,算不上真正的事务。
Redis的发布订阅频道
Redis 支持频道功能,允许用户创建频道并加入其中,形成一个类似于消息群组的机制。在频道中,任何用户发送的消息都会被频道内的所有订阅者接收到。
Redis 服务器内部维护了一个名为 pubsub_channels 的字典结构,用于存储频道与订阅者之间的关系。字典的键是频道的名称,而值则是一个链表,其中包含了所有订阅了该频道的客户端。每个客户端也维护着自己的 pubsub_channels 列表,记录了自己所关注的频道。
当用户在某个频道中发布消息时,Redis 服务器会首先根据频道名称在 pubsub_channels 字典中查找对应的链表。一旦找到,服务器会遍历链表中的所有客户端,并将消息发送给它们。同样地,当客户端订阅或取消订阅某个频道时,服务器仅需对 pubsub_channels 进行相应的操作即可。
除了普通频道,Redis 还支持模式频道,这是一种更灵活的消息订阅方式。模式频道使用正则表达式来匹配频道名称,当向某个普通频道发送消息时,如果其名称与某个模式频道匹配,那么该消息不仅会被发送到普通频道的订阅者,还会被发送到与该模式频道匹配的所有订阅者。
在 Redis 服务器内部,模式频道的实现依赖于一个名为 pubsub_patterns 的列表。这个列表存储了多个 pubsubPattern 结构体,每个结构体都包含一个正则表达式和与之关联的客户端。与 pubsub_channels 不同,pubsub_patterns 的数量通常较少,因此使用简单的列表结构就足够了。
不过虽然 pubsub_patterns 列表存储了与模式匹配的客户端信息,但每个客户端并不直接维护自己的 pubsub_patterns 列表。相反,客户端在其内部维护了一个自己的模式频道列表,这个列表仅包含客户端所关注的模式频道的名称(以字符串形式存储),而不是完整的 pubsubPattern 结构体。这样的设计既简化了客户端的实现,又有效地利用了内存资源。
typedef struct pubsubPattern {
redisClient *client; // 监听的client
robj *pattern; // 模式
} pubsubPattern;
当用户向一个频道发送消息时,Redis 服务器会首先查找 pubsub_channels 字典,确定哪些客户端订阅了该频道,并将消息发送给这些客户端。同时,服务器还会检查 pubsub_patterns 列表,查找是否有与该频道名称匹配的模式频道。如果找到匹配的模式频道,服务器会进一步确定哪些客户端订阅了这些模式频道,并将消息发送给这些客户端。
值得注意的是,在这个过程中,如果有客户端同时订阅了某个普通频道和与该频道名称匹配的模式频道,那么该客户端可能会收到重复的消息。这是因为 Redis 在发送消息时,并没有进行去重处理。即,即使一个客户端已经通过普通频道接收到了消息,它仍然可能通过模式频道再次接收到相同的消息。
这种设计选择是基于这样的考虑:Redis 认为处理消息去重的责任应该由客户端程序来承担,而不是由服务器来处理。这意味着,如果客户端程序不希望接收到重复的消息,它需要在自己的逻辑中实现去重机制。这样的设计使得 Redis 服务器可以更加高效地处理消息发布和订阅操作,而不需要过多地考虑去重等复杂性问题。
因此,在使用 Redis 的发布/订阅功能时,开发者需要注意这一点,并根据自己的需求在客户端程序中实现适当的去重机制,以确保消息的正确性和一致性。
/* Publish a message */
int pubsubPublishMessage(robj *channel, robj *message) {
int receivers = 0;
dictEntry *de;
listNode *ln;
listIter li;
/* Send to clients listening for that channel */
de = dictFind(server.pubsub_channels,channel);
if (de) {
list *list = dictGetVal(de);
listNode *ln;
listIter li;
listRewind(list,&li);
while ((ln = listNext(&li)) != NULL) {
redisClient *c = ln->value;
addReply(c,shared.mbulkhdr[3]);
addReply(c,shared.messagebulk);
addReplyBulk(c,channel);
addReplyBulk(c,message);
receivers++;
}
}
/* Send to clients listening to matching channels */
if (listLength(server.pubsub_patterns)) {
listRewind(server.pubsub_patterns,&li);
channel = getDecodedObject(channel);
while ((ln = listNext(&li)) != NULL) {
pubsubPattern *pat = ln->value;
if (stringmatchlen((char*)pat->pattern->ptr,
sdslen(pat->pattern->ptr),
(char*)channel->ptr,
sdslen(channel->ptr),0)) {
addReply(pat->client,shared.mbulkhdr[4]);
addReply(pat->client,shared.pmessagebulk);
addReplyBulk(pat->client,pat->pattern);
addReplyBulk(pat->client,channel);
addReplyBulk(pat->client,message);
receivers++;
}
}
decrRefCount(channel);
}
return receivers;
}