redis 的 value 一共有5种数据结构。其中 String 类型有3种内部编码实现。4种集合类型有5种底层的实现方式,redis对每种集合类型有默认的实现方式,也可以指定某个集合的实现方式。
对于 value 具体使用的编码,我们可以通过 object encoding 命令来查看
user_name> object encode key_name
"embstr"
1. String
- int:8个字节的长整型
- embstr:小于等于39个字节的字符串
- raw:大于39个字节的字符串
❯ redis-cli
127.0.0.1:6379> set str 123
OK
127.0.0.1:6379> object encoding str
"int"
127.0.0.1:6379> set str "sunxy"
OK
127.0.0.1:6379> object encoding str
"embstr"
127.0.0.1:6379> set str "sunxynnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn"
OK
127.0.0.1:6379> object encoding str
"embstr"
127.0.0.1:6379> set str "sunxynnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn"
OK
127.0.0.1:6379> object encoding str
"raw"
127.0.0.1:6379>
redis 内部使用的字符串结构体
struct sdshdr{
//记录buf数组中已使用字节的数量
//等于 SDS 保存字符串的长度 4byte
int len;
//记录 buf 数组中未使用字节的数量 4byte
int free;
//字节数组,用于保存字符串 字节\0结尾的字符串占用了1byte
char buf[];
}
c语言默认的字符串实现
对比:
C字符串 | SDS |
---|---|
获取字符串长度的复杂度为O(N) | 获取字符串长度的复杂度为O(1) |
API是不安全的,可能会造成缓冲区溢出 | API是安全的,不会造成缓冲区溢出 |
只能保存文本数据 | 可以保存文本或者二进制数据 |
修改字符串长度N次必然需要执行N次内存重分配 | 修改字符串长度N次最多需要执行N次内存重分配 |
2. redis底层数据结构
2.1 HashTable
在 redis 内部叫 dict
typedef struct dictEntry {
void *key;
void *val;
struct dictEntry *next;
} dictEntry;
typedef struct dictType {
unsigned int (*hashFunction)(const void *key);
void *(*keyDup)(void *privdata, const void *key);
void *(*valDup)(void *privdata, const void *obj);
int (*keyCompare)(void *privdata, const void *key1, const void *key2);
void (*keyDestructor)(void *privdata, void *key);
void (*valDestructor)(void *privdata, void *obj);
} dictType;
typedef struct dict {
dictEntry **table;
dictType *type;
unsigned long size;
unsigned long sizemask;
unsigned long used;
void *privdata;
} dict;
2.2 LinkedList
双向链表
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对象存放头尾节点, 可以双向遍历——从后向前,从前向后。
这样也可以用O(1)的时间复杂度,实现list的lpop、lpush、rpop、rpush的需求。
2.3 ZipList
压缩列表。ziplist 采用了一段连续的内存来存储数据,本质上就是一个字节数组,相比 linkedlist 和 hashtable减少了内存碎片,和指针的内存占用。而且当节点较少时,ziplist更容易被加载到CPU缓存中。
area |<---- ziplist header ---->|<----------- entries ------------->|<-end->|
size 4 bytes 4 bytes 2 bytes ? ? ? ? 1 byte
+---------+--------+-------+--------+--------+--------+--------+-------+
component | zlbytes | zltail | zllen | entry1 | entry2 | ... | entryN | zlend |
+---------+--------+-------+--------+--------+--------+--------+-------+
^ ^ ^
address | | |
ZIPLIST_ENTRY_HEAD | ZIPLIST_ENTRY_END
|
ZIPLIST_ENTRY_TAIL
2.3.1 各个字段的含义
字段名 | 含义 |
---|---|
zlbytes | 是一个无符号 4 字节整数,保存着 ziplist 使用的内存数量。通过 zlbytes ,程序可以直接对 ziplist 的内存大小进行调整,无须为了计算 ziplist 的内存大小而遍历整个列表 |
zltail | 压缩列表 最后一个 entry 距离起始地址的偏移量,占 4 个字节。这个偏移量使得对表尾的 pop 操作可以在无须遍历整个列表的情况下进行 |
zllen | 压缩列表的节点 entry 数目,占 2 个字节。当压缩列表的元素数目超过 2^16 - 2 的时候,zllen 会设置为2^16-1 ,当程序查询到值为2^16-1 ,就需要遍历整个压缩列表才能获取到元素数目。所以 zllen 并不能替代 zltail |
entryX | 压缩列表存储数据的节点,可以为字节数组或者整数 |
zlend | 压缩列表的结尾 |
2.3.2 entry节点的结构
area |<------------------- entry -------------------->|
+------------------+----------+--------+---------+
component | pre_entry_length | encoding | length | content |
+------------------+----------+--------+---------+
entry字段 | 含义 |
---|---|
pre_entry_length | 记录了前一个节点的长度,通过这个值,可以进行指针计算,从而跳转到上一个节点 |
encoding 和 length | encoding 和 length 两部分一起决定了 content 部分所保存的数据的类型(以及长度) |
content | content 部分保存着节点的内容,类型和长度由 encoding 和 length 决定。 |
2.3.3 压缩链表的主要操作
a. 将节点添加到 ziplist 的末端 O(n)
- 根据新节点要保存的值,计算出编码这个值所需的空间大小,以及编码它前一个节点的长度所需的空间大小,然后对 ziplist 进行内存重分配。
- 设置新节点的各项属性:
pre_entry_length
、encoding
、length
和content
。 - 更新 ziplist 的各项属性,比如记录空间占用的
zlbytes
,到达表尾节点的偏移量zltail
,以及记录节点数量的zllen
。
b. 将节点添加到某个/某些节点的前面。
这个比添加到末尾要复杂的多, 因为这种操作除了将新节点添加到 ziplist 以外, 还可能引起后续一系列节点的改变
- 将一个新节点
new
添加到节点prev
和next
之间。 next
现在的前驱节点是new
, 但是里面记录的 pre_entry_length 还是prev
节点的, 所以必须更新next
节点的 pre_entry_length ,这个过程中可能产生next
节点的扩容。- 如果
next
节点发生扩容,那就必须又更新next
的下一个节点。 - 这就是说, 在某个/某些节点的前面添加新节点之后, 程序必须沿着路径挨个检查后续的节点,是否满足新长度的编码要求, 直到遇到一个能满足要求的节点(如果有一个能满足,则这个节点之后的其他节点也满足), 或者到达 ziplist 的末端
zlend
为止, 这种检查操作的复杂度为 O(N^2) 。
2.4 IntSet
typedef struct intset {
// 保存元素所使用的类型的长度
uint32_t encoding;
// 元素个数
uint32_t length;
// 保存元素的数组
int8_t contents[];
} intset;
如果在一个 intset 里面, 最长的元素可以用 int16_t
类型来保存, 那么这个 intset 的所有元素都以 int16_t
类型来保存。
另一方面, 如果有一个新元素要加入到这个 intset , 并且这个元素不能用 int16_t
类型来保存 —— 比如说, 新元素的长度为 int32_t
, 那么这个 intset 就会自动进行“升级”: 先将集合中现有的所有元素从 int16_t
类型转换为 int32_t
类型, 接着再将新元素加入到集合中。
根据需要, intset 可以自动从 int16_t
升级到 int32_t
或 int64_t
, 或者从 int32_t
升级到 int64_t
。
2.5 SkipList
原始数据就是一个单向链表:
为了方便查找我们加一层索引:
索引层元素多了以后我们再加一层索引,一直重复。最后就得到了下面的跳表数据结构:
跳表的利用空间换时间的思想,实现了二分查找。主要操作,时间复杂度都是O(logN)。
操作 | 时间复杂度 |
---|---|
FIND | O(logN) |
ADD | O(logN) |
DELETE | O(logN) |
RANGE_FIND | O(logN) |
3. 四种集合类型的实现
3.1 Hash
3.1.1 ZipList 实现 Hash
当value元素比较少且数据类型是 Hash 的时候,redis 会优先使用 ziplist 来存放 Hash 元素。
127.0.0.1:6379> hset test_hash key1 one
(integer) 1
127.0.0.1:6379> hset test_hash key2 two
(integer) 1
127.0.0.1:6379> hset test_hash key3 three
(integer) 1
127.0.0.1:6379> object encoding test_hash
"ziplist"
这时查找元素,并不能保证O(1)的时间复杂度。因为 ziplist 要遍历查找元素。但是由于这时本身元素就比较少,所以速度并不是很慢。
默认情况下,一个哈希对象超过配置的阈值(键和值的长度有>64byte OR 键值对个数>512个)时,会转换成哈希表(hashtable)。
3.2 List
早期同Hash一样,元素少用 ziplist,元素多用 linkedlist。
在 redis 3.2版本之后,使用 快速列表
实现 List。
127.0.0.1:6379> lpush test_list 1
(integer) 1
127.0.0.1:6379> lpush test_list 2
(integer) 2
127.0.0.1:6379> lpush test_list 3
(integer) 3
127.0.0.1:6379> object encoding test_list
"quicklist"
快速链表定义
typedef struct quicklistNode {
struct quicklistNode *prev;
struct quicklistNode *next;
unsigned char *zl;
unsigned int sz; /* ziplist 占用的字节总数 */
unsigned int count : 16; /* ziplist 的元素个数 */
unsigned int encoding : 2; /* 是否被压缩,2表示被压缩,1表示原生 */
unsigned int container : 2;
unsigned int recompress : 1;
unsigned int attempted_compress : 1;
unsigned int extra : 10;
} quicklistNode;
typedef struct quicklist {
quicklistNode *head;
quicklistNode *tail;
unsigned long count; /* 所有 ziplists 的元素总和 */
unsigned long len; /* quicklistNodes 的个数 */
int fill : 16;
unsigned int compress : 16;
} quicklist;
quicklist 其实就是一个以 ziplist 为节点(quicklistNode 中存放指向 ziplist 的指针)的 adlist。quicklist 平衡了 ziplist 和 adlist 的优缺点:
-
双向链表便于在表的两端进行 push 和 pop 操作,但是它的内存开销比较大。首先,它在每个节点上除了要保存数据之外,还要额外保存两个指针;其次,双向链表的各个节点是单独的内存块,地址不连续,节点多了容易产生内存碎片,不利于内存管理。
-
ziplist 由于是一整块连续内存,所以存储效率很高。但是,它不利于修改操作,每次数据变动都会引发一次内存的 realloc。特别是当 ziplist 长度很长的时候,一次 realloc 可能会导致大批量的数据拷贝,进一步降低性能。
3.3 Set
Redis 的 Set 是 String 类型的无序集合。集合成员是唯一的,这就意味着集合中不能出现重复的数据。 默认情况下,如果一个集合:
- 只保存着整数元素;
- 元素的数量不多; 会优先使用 IntSet 保存数据。否则,就会转为HashTable。
127.0.0.1:6379> object encoding test_list
"quicklist"
127.0.0.1:6379> SADD test_set 1
(integer) 1
127.0.0.1:6379> SADD test_set 2
(integer) 1
127.0.0.1:6379> object encoding test_set
"intset"
127.0.0.1:6379> SADD test_set "redis"
(integer) 1
127.0.0.1:6379> object encoding test_set
"hashtable"
3.4 ZSet
Redis 有序集合和集合一样也是 string 类型元素的集合,且不允许重复的成员。 不同的是每个元素都会关联一个 double 类型的分数。redis 正是通过分数来为集合中的成员进行从小到大的排序。 有序集合的成员是唯一的,但分数(score)却可以重复。
默认未满足下列两个条件时,Redis 会使用 ziplist 实现 有序集合
- 当sorted set中的元素个数,即(数据, score)对的数目超过128的时候,也就是ziplist数据项超过256的时候。
- 当sorted set中插入的任意一个数据的长度超过了64的时候。
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;
当数据多的时候,sorted set是由一个叫zset的数据结构来实现的,这个zset包含一个dict + 一个skiplist。dict用来查询数据到分数(score)的对应关系,而skiplist用来根据分数查询数据(可能是范围查找)。