简单动态字符串SDS
传统的C语言字符串存在问题:
1. 获取字符串长度需要通过运算
2. 非二进制安全,如果中间有一个\0
则字符串直接结束了
3. 不可修改
set name sangs
: 这条命令会在底层创建两个SDS
,分别是name
和 sangs
的SDS
1 2 3 4 5 6
| struct __attribute__ ((__packed__)) sdshdr8{ uint8_t len; uint8_t alloc; unsigned char flags; char buf[]; };
|
len: 4 | alloc: 4 | flags: 1 | n | a | m | e | \0 |
- 由于通过
len
判断需要读取多少位,所以是二进制安全的,不会读取到一半终止
- 之所以叫动态字符串,是因为具备动态扩容的能力。如果要给
SDS
追加一段字符串,就会申请新的内存空间,即为内存预分配
- 如果新字符串长度小于
1M
,则新空间为扩展后字符串长度的两倍+1
- 如果新字符串长度大于
1M
,则新空间为扩展后字符串长度+1M+1
举例
len: 2 | alloc: 2 | flags: 1 | h | i | \0 |
len: 6 | alloc: 12 | flags: 1 | h | i | , | A | m | y | \0 | | | | | | |
优点
- 获取字符串长度的时间复杂度为
O(1)
- 支持动态扩容
- 减少内存分配次数
- 二进制安全
IntSet
- 是
set
集合的一种实现方式,基于整数数组实现。
- 长度可变
- 有序
1 2 3 4 5
| typeder struct intset{ uint32_t encoding; uint32_t length; int8_t contents[]; } intset;
|
IntSet
升级
假设有一个IntSet
,元素为5, 10 ,20
。则采用的编码为INTSET_ENC_INT16
,每个整数占用2B
,如果此时添加了一个数字50000
,超过了int16_t
的范围,则会自动升级编码方式到合适的大小
- 升级编码为
INTSET_ENC_INT32
,每个整数占用4B
,并按照新的编码方式以及元素个数扩容数组
- 倒序依次将数组中的元素拷贝到扩容后的正确位置(因为正序拷贝会导致后面的数字被覆盖)
- 将待添加的元素放入数组末尾
- 将
IntSet
的encoding
属性改为INTSET_ENC_INT32
,修改length:4
特点
Redis
确保IntSet
元素唯一有序
- 具备升级机制,可以节省内存空间
- 底层采用二分查找方式查询
- 因为需要连续的内存空间,所以适合少量的数据,且
IntSet
只能存储整数
Dict
- 三个部分组成:
- 哈希表(
DickHashTable
)
- 哈希节点(
DictEntry
)
- 字典(
Dict
)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| typedef struct dictht{ dictEntry **table; unsigned long size; 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; } dcitEntry;
typedef struct dict { dictType *type; void *privdata; dictht ht[2]; long rehashidx; int16_t pauserehash; }dict;
|
Dict
扩容
当我们向Dict
添加键值对时,Redis
首先根据key
计算出hash
值h
,然后利用h & sizemask
来计算元素应该存储得到数组中的哪个索引位置。
- 因为
sizemask
的大小恰好是size - 1
,所以h & sizemask
这个与运算的结果恰好等于对h
求余(其实就是除留余数法)
- 如果有同义词,则将新插入的元素接在前面,后面跟上原来的数据(这就是使用拉链法解决哈希冲突)
- 但是如果元素较多,冲突增多,链表过长,则查询效率会大大降低
- 因此每次新增键值对会检查负载因子(使用的位置个数和总空间个数的比值)
- 哈希扩容:
- 负载因子 >= 1,并且服务器没有执行
BGSAVE
或者BGREWRITEAOF
等后台进程
- 负载因子 > 5
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| static int _dictExpandIfNeeded (dict *d){ if (dictIsRehashing(d)) return DICT_OK; if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE); if (d->ht[0].used >= d->ht[0].size && (dict_can_resize || d->ht[0].used / d->ht[0].size > dict_force_resize_ratio)){ return dictExpand(d, d->ht[0].used + 1); } return DICT_OK; }
|
Dict
收缩
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| if (dictDelete((dict*)o->ptr, field) == c_OK){ deleted = 1 if(htNeedsResize(o->ptr)) dictResize(o->ptr); }
int htNeedsResize(dict *dict){ long long size, used; size = dictSlots(dict); used = dictSize(dict); return (size>DICT_HT_INITIAL_SIZE && (used*100 / size < HASHTABLE_MIN_FILL)); }
int dictResize(dict *d){ unsigned long minimal; if(!dict_can_resize || dictIsRehashing(d)) return DICT_ERR; minimal = d->ht[0].used; if(minimal < DICT_HT_INITIAL_SIZE) minimal = DICT_HT_INITIAL_SIZE; return dictExpand(d, minimal); }
|
rehash
无论是收缩还是扩容,都会创建新的哈希表,导致哈希表的size
和sizemask
变化,而key
的查询与sizemask
有关,因此必须对哈希表中的每一个key
重新计算索引,插入新的哈希表,这个过程称为rehash
- 计算新的哈希表的
realeSize
,取决于当前做的是扩容还是收缩
- 扩容,则新的
size
为第一个大于等于dict.ht[0].used + 1
的2n
- 收缩,则新的
size
为第一个大于等于dict.ht[0].used
的2n(不得小于4)
- 按照新的
realeSize
申请内存空间,创建dictht
,并赋值给dict.ht[1]
- 设置
dict.rehashidx=0
表示开始rehash
- 将
dict.ht[0]
中的每一个dictEntry
都rehash
到dict.ht[1]
- 将
dict.ht[1]
赋值给dict.ht[0]
,给dict.ht[1]
初始化为空哈希表,释放原来的dict.ht[0]
内存
渐进式rehash
但是这样做,一次完成rehash
,则很有可能导致主线程阻塞,所以实际上是分多次进行rehash
,称为渐进式rehash
- 计算新的哈希表的
realeSize
,取决于当前做的是扩容还是收缩
- 扩容,则新的
size
为第一个大于等于dict.ht[0].used + 1
的2n
- 收缩,则新的
size
为第一个大于等于dict.ht[0].used
的2n(不得小于4)
- 按照新的
realeSize
申请内存空间,创建dictht
,并赋值给dict.ht[1]
- 设置
dict.rehashidx=0
表示开始rehash
4. 将dict.ht[0]
中的每一个dictEntry
都rehash
到dict.ht[1]
- 每次执行增删改查操作时,检查一下
dict.rehashidx
是否大于-1,如果是则将dict.ht[0].table[rehashidx]
的entry
链表rehash
到dict.ht[1]
,并且将rehashidx ++
,直到dict.ht[0]
的所有数据都rehash
到dict.ht[1]
- 将
dict.ht[1]
赋值给dict.ht[0]
,给dict.ht[1]
初始化为空哈希表,释放原来的dict.ht[0]
内存.
- 将
rehashidx
赋值为-1,表示rehash
结束
- 在
rehash
过程中,新增操作直接写入ht[1]
,查询修改和删除则会在dict.ht[0], dict.ht[1]
依次查找并执行,可以确保ht[0]
中的数据只减不增,随着rehash
最终为空
ZipList
- 节省内存
- 一种特殊的"双端链表",实际上并不是链表,只是一系列特殊编码的连续内存组成的,可以在任意一端进行压入、弹出操作,并且时间复杂度为
O(1)
的数据结构

属性 |
类型 |
长度 |
用途 |
zlbytes |
uint32_t |
4B |
记录整个压缩列表占用的内存字节数 |
zltail |
uint32_t |
4B |
记录压缩列表表尾节点距离压缩列表的起始地址有多少字节,通过这个偏移量,可以确定尾节点的位置 |
zllen |
uint16_t |
2B |
记录压缩列表包含的节点数量,最大值为UINT16_MAX(65534),超过这个值会记录为65535,但是节点的真实数量需要遍历整个压缩列表才能计算得出 |
entry |
列表节点 |
不定 |
压缩列表包含的各个节点,节点长度由节点保存的内容决定 |
zlend |
uint8_t |
1B |
特殊值0xff,用于标记压缩列表的末端 |
ZipListEntry
- 每一个
Entry
内部又包含了三个部分,分别为
previous_entry_length | encoding | content |
previous_entry_length
: 前一个节点的长度,占1 or 5
字节
- 如果前一个节点的长度小于254,则采用
1B
来保存这个长度
- 如果前一个字节的长度大于254,则采用
5B
来保存这个长度,其中第一个字节为0xfe
,后面四个字节才是真实长度数据(所以字节的长度不能太大)
encoding
: 编码属性,记录content
的数据类型(字符串还是整数)以及长度,占用1,2,或者5个字节
contents
: 负责保存节点的数据,可以是字符串或者整数
- 这样可以避免使用两个指针记录前后节点,这样需要消耗16个字节,浪费内存。
- 小端存储
Encoding
编码
字符串编码: 以00, 01, 10
开头则content
为字符串
编码 |
编码长度 |
字符串大小 |
|00pppppp| |
1B |
<= 63B |
|01pppppp|qqqqqqqq| |
2B |
<= 16383 B |
|10000000|qqqqqqqq|rrrrrrrr|ssssssss|tttttttt| |
5B |
<= 4294967295B |
- 例如保存’ab’, ‘bc’:

整数编码: 以11
开头,则content
为整数,并且encoding
固定占用一个字节
编码 |
编码长度 |
字符串大小 |
11000000 |
1 |
int16_t(2B) |
11010000 |
1 |
int32_t(4B) |
11100000 |
1 |
int64_t(8B) |
11110000 |
1 |
24位有符号整数(3B) |
11111110 |
1 |
8位有符号整数(1B) |
1111xxxx |
1 |
直接在xxxx位置保存数值,范围从0001~1101,减一后结果为实际值,实际上保存了(0~12) |
- 例如保存2, 5:

连锁更新问题
- 每个
entry
都包含previous_entry_length
来记录上一个节点大小,长度是1 or 5
字节
- 假设有
N
个连续的,长度为250~253
字节之间的entry
,只需要使用1B
即可
- 如果在第一个节点插入一个大小为
254B
的entry
,就需要占用5B
,此时导致后面的每一个entry
大小都恰好达到了254B
,导致了每个元素都需要改变
最终产生了连续多次空间扩展操作,就是连锁更新。新增,删除都有可能导致连锁更新
- 因为概率很低,所以暂时还没有完全解决。目前有新的
listpack
可以在一定程度上解决ZipList
的连锁更新问题,但是还没有完全引入