前言
本书是Redis设计与实现的读书笔记,旨在对Redis底层的数据结构及实现有一定了解。本书所有的代码基于Redis 3.0。
简单动态字符串 SDS
Redis没有直接使用C语言中的字符串,而是自己构建了一种叫简单动态字符串(Simple Dynamic String,SDS)的类型。
使用SDS而不是C字符串的优势
- 获取字符串长度的复杂度降低:直接根据len属性,复杂度为O(1),C字符串需要遍历字符串,复杂度O(N)。
- 杜绝缓冲区溢出:SDS会在修改时,先检查缓冲区的大小,不足时,先扩容
- 预留未分配字节,减少内存重分配:C字符串实现总是N+1的数组(N为实际字符长度,1为空字符),SDS可以预留未分配数组,便于扩容,同时,在删除字符时,也不立即减小缓冲区,避免之后可能需要再次扩容
- 二进制安全:C语言字符串不能包含空字符,否则会被认作是字符串结尾。而SDS是二进制安全的,可以保存二进制数据。
具体数据结构如下:
struct sdshdr {
unsigned int len;
unsigned int free;
char buf[];
};
SDS相关API可以参考sds.h
文件
链表
结点数据结构:
typedef struct listNode {
struct listNode *prev;
struct listNode *next;
void *value;
} listNode;
由两个指针分别指向前后结点
链表数据结构:
typedef struct list {
listNode *head;
listNode *tail;
void *(*dup)(void *ptr);
void (*free)(void *ptr);
int (*match)(void *ptr, void *key);
unsigned long len;
} list;
list结构保存了头尾节点,并且记录了链表长度。相关数据结构定义在adlist.h
中。
字典
Redis的字典使用哈希表作为底层实现,一个哈希表里可以有多个哈希节点,每个哈希节点代表一个键值对。很多语言都内置了字典(例如Java中的Map),但是C语言没有内置这种结构,所以Redis提供了这种实现。
- 哈希表
typedef struct dictht {
//键值对数组
dictEntry **table;
//哈希表大小(数组大小)
unsigned long size;
//哈希表大小掩码(size-1),用来计算索引
unsigned long sizemask;
//哈希表已有节点(键值对)的数量
unsigned long used;
} dictht;
- 哈希表节点
typedef struct dictEntry {
//键
void *key;
//值
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
//指向下一个键值对的指针,形成链表
struct dictEntry *next;
} dictEntry;
- 字典
字典的底层是哈希表,它为rehash,键和值的处理设置了额外的一些数据。
typedef struct dict {
//类型特定函数,用来计算哈希值,复制键值等操作
dictType *type;
//私有数据
void *privdata;
//哈希表数组,长度为2,一般只使用ht[0],ht[1]只用在rehash时被使用
dictht ht[2];
//rehash索引
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
//迭代器
unsigned long iterators; /* number of iterators currently running */
} dict;
- 哈希冲突
哈希冲突是指多个键值对经过哈希后,散列在了同一个bucket上(数组的同一个索引上)。哈希表通过链表的方法解决哈希冲突,即同一个bucket上的多个Entry形成链表。 - rehash
当哈希表被添加或移除时,为了让哈希表的负载因子(load factor;计算方式为:used / size)在一个合理的范围内,哈希表需要通过rehash动态的扩容或收缩。假设哈希表的used数量为x。扩容或收缩的过程如下:
- 计算出新的容量m:
m = 2^n
且扩容操作时,m需要大于等于2 * x;当收缩时,m需要大于等x。
- 根据m值得到新的哈希表,size即为m。新的hash表通过ht[1]保存。
- 对ht[0]中的哈希节点dictEntry重新散列到ht[1]中。
- 将ht[0]指向ht[1],同时ht[1]指向NULL。
触发扩容条件:
- 当服务器没有在执行 BGSAVE 或是 BGREWRITEOF 命令时,负载因子 大于等于1。
- 当在执行 BGSAVE 或是 BGREWRITEOF 时,负载因子大于等于5。
收缩条件:
负载因子小于等于0.1时。
- 渐进式rehash
为了避免一次rehash所涉及的数据量太大,导致服务器卡顿,渐进式rehash会在每次操作该字典时,逐步将ht[0]上的哈希节点rehash到ht[1]上,每次rehashi时,rehashidx数值都会加1,直至ht[0]所有dictEntry都被移至ht[1]时,rehashidx再被重置为-1。
由于在渐进式rehash时,数据不确定在ht[0]还是在ht[1]中,程序都会先尝试从ht[0]搜索,再去ht[1]搜索。而rehash过程中,所有的增加操作都会增加到ht[1]中。这样ht[0]会只减不增,直至所有的键值对都rehash到ht[1]中,rehash过程也就结束了。
上述代码见dict.h
。
跳跃表(SkipList)
跳跃表是Redis中有序集合的底层实现。主要由跳表zskiplistNode
和zskiplist
两部分构成。zskiplist
结构如下:
typedef struct zskiplist {
//指向头尾节点的指针
struct zskiplistNode *header, *tail;
//跳表长度
unsigned long length;
//不含头节点的最大跳表节点层数
int level;
} zskiplist;
跳表结点数据结构:
typedef struct zskiplistNode {
//成员对象
robj *obj;
//分值
double score;
//后退指针
struct zskiplistNode *backward;
//层
struct zskiplistLevel {
//前进指针
struct zskiplistNode *forward;
//跨度
unsigned int span;
} level[];
} zskiplistNode;
- 跳表相比于链表,有什么优势?
从之前的数据结构中,我们可以了解到跳表其实也是一种链表,那么和传统的链表相比,调表有什么优势?
带着上述这个问题,能更好的帮我们理解跳表的查询过程。
我们知道跳表是Redis中zset的底层实现。zset是一个有序集合,常见的操作有查询某个值,或者查询某个区间的值等。如果用传统的链表来实现这些查询操作,平均算法复杂度为O(N)。
是否可以利用数据是有序的特性,来避免依次遍历这种耗时的操作。比如经典的二分法查找(算法复杂度为O(logN)),就是通过中间的某个值,来快速缩小查找的区间。我们能否用类似的思想来改善我们链表的特性?
有序数组可以使用二分法查找是因为我们能通过下标快速访问到中间的值。通过和中间值比较,从而缩小查找区间。这里的数组下标其实就是一种索引。如果我们能够对链表中间的一些值进行索引,那么同样可以达到快速缩小查找区间的目的。
我们以下图来了解下传统的链表查询和跳表查询过程的区别:
所谓的跳表既是通过维护的索引实现跳跃查询的链表,而且只要索引维护的合理,最优情况下的查找平均算法复杂度应该是O(logN)。
- zset
了解完跳跃表,再看zset
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;
zset
通过字典dict
保存了分值和对象的映射关系,再通过跳表维护了有序的集合。
上述代码见redis/zskiplistNode
,redis/zskiplist
,redis/zset
。
整数集合
当一个集合只包含整数值元素,且集合的元素不多时,Redis会使用整数集合(intset)作为底层实现。元素在INTSET中保由小到大的顺序。做个简单的验证:
- 数据结构
typedef struct intset {
//编码方式
uint32_t encoding;
//长度
uint32_t length;
//保存元素的数组
int8_t contents[];
} intset;
其中encoding决定了contents中的数据是16位(INTSET_ENC_INT16),32位(INTSET_ENC_INT32),64位(INT_ENC_INT64)。以上数据结构见intset.h
。
- 升级
intset会根据保存的值大小动态的确定编码方式。例如:intset之前存了1,2,3三个数据,那么此时的编码方式应该是INTSET_ENC_INT16(即上面每个数占16位大小)。此时,contents数组占了16 * 3 = 48位。如果添加了65535(需要32位来存放),那么intset会将编码调整为INTSET_ENC_INT32,那么contents数组会变成32 * 4 = 128位,第96至127位存放65535,第64至95位存放3,第32至63位存放2,第0至31位存放1。 - 为何使用intset?
intset使用的前提是集合内全是整数且数据量不大。- 由于数据量不大,那么增加删除操作的算法复杂度(O(logN))的影响较小。
- 由于intset可以动态确定元素占用的大小。因此即可以保存不同类型的整数,又达到了节省内存的目的。
- intset不支持降级,一旦对数组进行了升级操作,编码就会一直保持为升级后的状态。
压缩列表
压缩列表(ziplist)是列表和哈希的底层实现之一。以下两种情况,Redis会选择压缩列表作为列表和哈希表的底层实现:
- 列表只含有少量数据项,且每项为小整数值或是短字符串。
- 哈希表只含有少量键值对,切键值对的键与值要么是小整数值,要么是段字符串。
- ziplist的构成
压缩列表在Redis中没有专门的数据结构定义,只是用char *
来表示。但有其特定的结构:
<...>
- zlbytes: 四字节(uint32_t),表示ziplist占用的字节数。
- zltail:四字节(uint32_t),表示最后一个entry距离ziplist起始地址的偏移量。即假设ziplist的地址为p,zltail的偏移量为n,那么entryN的地址为p + N。
- zllen:2个字节(uint16_t),表示entry的数量。由于zlen最大只能表示65535,当节点数大于等于这个值时,只能由遍历确定具体节点数。
- entry:压缩列表节点。
- zlend: 特殊字符0xFF,表示ziplist末端。
- entry的构成
entry由header
和content
两部分构成。其中header
又由两部分信息组成:previous_entry_length
和encoding
。
previous_entry_length
:
变长(1字节或5字节),表示前一个节点的长度。当前一节点的长度小于254时,用一个字节表示。当前一节点长度大于等于254时,用五个字节表示。且第一个字节固定为0xFE,后四字节表示实际长度。previous_entry_length
可以用来从后往前遍历节点。假设有两个节点:entry1和entry2。已知entry2的起始地址为p,entry2.previous_entry_length为n,那么entry1的起始地址为p-n。encoding
:用来定义content
的类型和长度。变长:1个字节,2个字节或5个字节。其中第一个字节的前两位表示content
的类型。如果是Integer,则前两位设置为11,其他情况表示存储的是String。
当表示的为String时,后面的位构成的数表示String的类型。当表示为Integer时,后面的为表示具体的整型类型。
|00pppppp| - 1 byte
String value with length less than or equal to 63 bytes (6 bits).
表示content为小于等于63字节的字符串(2的6次-1)
|01pppppp|qqqqqqqq| - 2 bytes
String value with length less than or equal to 16383 bytes (14 bits).
表示content为小于等于16383字节的字符串。(2的14次-1)
|10______|qqqqqqqq|rrrrrrrr|ssssssss|tttttttt| - 5 bytes
String value with length greater than or equal to 16384 bytes.
表示小于等于4294967295字节的字符串(2的32次-1,受操作系统限制)
|11000000| - 1 byte
Integer encoded as int16_t (2 bytes).
表示content为int16_t
|11010000| - 1 byte
Integer encoded as int32_t (4 bytes).
表示content为int32_t
|11100000| - 1 byte
Integer encoded as int64_t (8 bytes).
表示content为int64_t
|11110000| - 1 byte
Integer encoded as 24 bit signed (3 bytes).
表示content为24位有符号整数
|11111110| - 1 byte
Integer encoded as 8 bit signed (1 byte).
表示content为8位有符号整数
|1111xxxx| - (with xxxx between 0000 and 1101) immediate 4 bit integer.
Unsigned integer from 0 to 12. The encoded value is actually from
1 to 13 because 0000 and 1111 can not be used, so 1 should be
subtracted from the encoded 4 bit value to obtain the right value.
表示一个4bit的整数,因此可以直接使用位表示,而无需再用content
|11111111| - End of ziplist. OxFF;特殊字符
- content
保存节点的值,可以表示字符串或整数,所有的整型都采用小端序表示。 - 连锁更新
由于entry中的previous_entry_length
是变长的,同时它又会影响entry自身的长度。考虑以下一种情况:假设entry1的长度为253个字节,且previous_entry_length
本来使用一个字节表示。此时,在entry1
之前插入一个新的entryNew
,他的长度超过254,那么entry1
原先一字节的previous_entry_length
不够表示新插入的entryNew
的长度,需要拓展成5字节,而一旦拓展,会导致entry1
的长度也超过254字节,因此entry1
之后的entry2
也需要拓展,就有可能出现连锁更新的情况。发生连锁更新时,最坏的算法复杂度为O(N2)。 - 为何使用压缩列表?
使用压缩列表的目的是Redis希望节省内存空间。假设我们需要存储5个int32_t的数据在32位的操作系统上时,使用list和使用ziplist需要的内存分别是:
- 当使用传统的list需要的内存分别是:list结构占(4 * 6) = 24字节,5个listNode占(5 * 4 * 3)= 60字节,5个int32_t占(5 * 4)= 20字节。一共104字节。
- 改用ziplist后zlbytes占4字节,zltail占4字节,zllen占2字节,zlend占1字节,5个entry占(5 * 6)= 30字节。一共站41字节。
因此ziplist相较于list,节省了很多内存空间。但由于连锁更新等情况存在,当数据量较大时,插入和删除操作可能比较耗时,因此只适用于数量小且值也为小数据的情况。
对象
Redis并没有直接使用上述的数据结构来实现数据库的键值对,而是基于这些数据结构创建了一个对象系统,这个系统包含字符串对象,列表对象,哈希对象,集合对象和有序集合对象五种类型的对象(我们常说的五种类型)。而对象可以根据应用场景的不同来决定类型底层的数据结构,从而优化效率。
此外,Redis对象系统还实现了基于引用计数的内存回收机制,帮助自动回收对象,释放内存。同时通过引用计数还可以在适当条件下实现多数据库键共享同一个对象。
最后,Redis对象还带有访问时间记录,用于计算数据库键的空转时间,在服务器启用了maxmemory功能时,,空转时间大的对象可能会被优先删除。
- 数据结构
typedef struct redisObject {
//对象的类型
unsigned type:4;
//对象编码
unsigned encoding:4;
//LRU时间戳,最后访问时间
unsigned lru:REDIS_LRU_BITS; /* lru time (relative to server.lruclock) */
//引用计数
int refcount;
//指向底层数据结构的指针
void *ptr;
} robj;
其中TYPE的定义如下:
#define REDIS_STRING 0 //字符串
#define REDIS_LIST 1 //链表
#define REDIS_SET 2 //集合
#define REDIS_ZSET 3 //有序集合
#define REDIS_HASH 4 //哈希表
数据库键的类型总是一个字符串对象。而数据库键的值对象的类型可以通过TYPE命令来确定。
编码encoding的定义如下:
#define REDIS_ENCODING_RAW 0 //普通的SDS
#define REDIS_ENCODING_INT 1 //long类型的整数
#define REDIS_ENCODING_HT 2 //字典
#define REDIS_ENCODING_ZIPMAP 3 //压缩MAP
#define REDIS_ENCODING_LINKEDLIST 4 //普通双向链表
#define REDIS_ENCODING_ZIPLIST 5 //压缩链表
#define REDIS_ENCODING_INTSET 6 //整数集合
#define REDIS_ENCODING_SKIPLIST 7 //跳表
#define REDIS_ENCODING_EMBSTR 8 //EmbStr的SDS
可以通过OBJECT ENCODING命令来查看一个数据库键的值对象编码。
总结下各数据类型底层可能使用的实现:
类型 | 数据结构 |
---|---|
REDIS_STRING | REDIS_ENCODING_INT |
REDIS_STRING | REDIS_ENCODING_RAW |
REDIS_STRING | REDIS_ENCODING_EMBSTR |
REDIS_LIST | REDIS_ENCODING_ZIPLIST |
REDIS_LIST | REDIS_ENCODING_LINKEDLIST |
REDIS_HASH | REDIS_ENCODING_ZIPLIST |
REDIS_HASH | REDIS_ENCODING_HT |
REDIS_SET | REDIS_ENCODING_INTSET |
REDIS_SET | REDIS_ENCODING_HT |
REDIS_ZSET | REDIS_ENCODING_ZIPLIST |
REDIS_ZSET | REDIS_ENCODING_SKIPLIST |
每种类型不直接使用特定的数据结构,而可以根据需要选择最优的数据结构,可以提高redis的效率。
- 字符串对象
- 如果字符串对象保存的是整数值(long),那么它的encoding是REDIS_ENCODING_INT(也就是用一个long类型表示)
- 如果字符串对象保存的是字符串值,且长度==大于39个字节==,那么它的encoding是REDIS_ENCODING_RAW(也就是SDS)
- 如果字符串对象保存的是字符串值,且长度==小于等于39个字节==,那么它的encoding是REDIS_ENCODING_EMBSTR(EMBSTR和RAW底层都是sdshdr,不同的是EMBSTR将redisObject和sdshrd做一次内存分配,且分配在连续的内存块中,而RAW则是分两次内存分配,redisObject和sdshdr在不连续的内存块中)
- 浮点数在Redis中的保存也是先转成字符串值,在需要的时候取出转换回去,进行计算(如加减)后,继续以字符串保存在Redis中。
- 过长的整型保存方式同浮点数
字符串对象编码的转换
整型和EMBSTR在APPEND字符串后,会变成RAW。
列表对象
列表对象的编码可能是ziplist和linkedlist。即列表对象的ptr可能指向一个list也可能是一个ziplist。如果只想list时,list中的每个节点可能会指向listNode。列表对象编码的转换
同时满足一下两个条件时,列表对象将使用ziplist编码:- 列表保存的字符串对象长度都==小于64字节==
- 列表的节点数==小于512==
可以通过修改list-max-ziplist-value选项和list-max-ziplist-entries选项改变转换触发的条件。
- 列表保存的字符串对象长度都==小于64字节==
- 哈希对象
哈希对象的底层编码可以是ziplist和hashtable。- 当使用ziplist时,新的键值对将分成键和值两个节点依次加入到压缩列表的表尾。即同一键值对的两个节点总是紧挨在一起的,键在前,值在后。
当使用hashtable时,键值对使用一个dicthtEntry来保存,此时键是一个字符串对象,值也是一个字符串对象。
哈希对象编码的转换
哈希对象使用ziplist的条件和链表对象使用ziplist的条件相似- 所有的键值对,键和值都小于64字节
- 键值对的数量小于512个
可以通过hash-max-ziplist-value和hash-max-ziplist-entries选项改变转换触发的条件。
- 集合对象
集合对象的编码可以是intset或者hashtable。- 当使用intset时,数据都保存在整数集合里
当使用hashtable时,数据以字符串对象保存在键值对的key中,而键值对的值为null。
集合对象编码的转换
当同时满足以下两个条件时,采用intset的编码形式- 数据全部为整数值
- 数据的长度不超过512个
长度的上限值可以通过set-max-intset-entries修改
- 有序集合对象
有序集合的编码可以是ziplist和skiplist。- 当编码为ziplist时,和链表类似,元素使用两个相连的节点表示,元素成员在前,元素分值在后。
- 当编码为skiplist时,底层用了zset的数据结构,zset同时使用了skiplist和dictht。在skiplist中,元素成员和分值同时保存在skiplistNode中,且维持有序状态。同时元素成员和分值也以键值对的形势保存在dictht中。skiplist的优势在于快速查询某个分值的对象,而dictht的优势在于快速根据对象获取分值。同时使用两种数据结构意味着拥有了这两种优势,且不会增加额外的保存元素需要的内存空间。
有序集合编码的转换
当同时满足以下两个条件时,对象使用ziplist编码:
- 元素数量小于128个
- 所有元素成员的长度小于64字节
可以通过修改zset-max-ziplist-entries选项和zset-max-ziplist-value选项控制
- 类型检查与命令多态
因为REDIS的键有五种类型,每种类型又有多种编码方式,因此在正确的执行一个命令,REDIS服务器需要分成两个步骤:类型检查和命令多态。- 类型检查是因为某些命令只支持某种键类型,当命令和要操作的键不匹配时(例如,针对字符串类型的键调用RPUSH),服务器会返回一个错误。类型检查是通过redisObject的type来确定的。
- 命令多态指的是针对某种类型键的不同编码,所调用的API不同。例如一个类型为列表的键,我们调用LLEN命令查询长度时,若底层编码为ziplist,那程序会使用ziplistLen函数来返回长度,若底层编码为linkedList时,程序则会调用listLength函数来返回长度。这就像面向对象中的多态,不同的实现有不同的逻辑。
- 类型检查是因为某些命令只支持某种键类型,当命令和要操作的键不匹配时(例如,针对字符串类型的键调用RPUSH),服务器会返回一个错误。类型检查是通过redisObject的type来确定的。
内存回收
由于C语言不具备自动内存回收的功能,因此Redis通过引用计数实现了内存回收的机制。引用计数在redisObject的refcount属性中。当对象被持有时,计数加一,当对象不被持有时,则计数减一。计数为零时,则被释放。可以通过OBJECT REFCOUNT来查看redisObject的引用计数。- 对象共享
Redis服务器为了节约内存,允许让一个相同的redisObject被多次持有,每次持有会让引用计数加一,不需要时,则计数减一。例如字符串类型键A包含了字符串100,而字符串类型键B同样保存了一个字符串100。那么A和B的指针可以同时指向该字符串对象。而该字符串对象的refcount此时为2。 对象的空转时长
redisObject还有一个属性lru,用来记录该对象的最后访问时间。对象的空转时长就是根据当前时间减去lru确定的,可以通过OBJECT IDLETIME命令来确定对象的空转时长。用该命令操作对象后,并不会更新redisObject的lru。空转时长可以在内存回收时被使用。当内存回收策略为volatile-lru或是allkeys-lru时,服务器的内存超过maxmemory的上限时,空转时长高的对象会被优先释放。