数据结构
简单动态字符串(SDS)
我们都知道Redis是用C写的,但Redis中并没有直接使用C语言传统的字符串(以空字符结尾的字符数组),而是自己构建了一种名为简单动态字符串(simple dynamic string,简称SDS)的结构,并用作Redis的默认字符串。在Redis中,C字符串只会用作字符串字面量,一些不会对字符串发生修改的地方,比如说打印日志等等。
Redis中SDS的定义:
typedef char *sds;
struct __attribute__ ((__packed__)) sdshdr5 { // 对应的字符串长度小于 1<<5
unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 { // 对应的字符串长度小于 1<<8
uint8_t len; /* used */ //目前字符串的长度
uint8_t alloc; //已经分配的总长度
unsigned char flags; //flag用3bit来标明类型
char buf[]; //柔性数组,以'\0'结尾
};
struct __attribute__ ((__packed__)) sdshdr16 { // 对应的字符串长度小于 1<<16
uint16_t len; /* used */
uint16_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 { // 对应的字符串长度小于 1<<32
uint32_t len; /* used */
uint32_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 { // 对应的字符串长度小于 1<<64
uint64_t len; /* used */
uint64_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
SDS和C字符串的区别
- C字符串中使用n+1个字符数组表示n个长度的字符串,并且数组的最后一个总是‘\0’,表示该字符串结尾了。
- C字符串获取长度时,需要遍历一遍字符串,时间复杂度为O(N),而SDS获取字符串长度时,只需要从SDS结构中取出len字段便可以,时间复杂度为O(1)
- C字符串为其增加一段字符串时,如果没有为其分配足够的空间,则会造成缓冲区溢出;而使用我们的SDS时,则SDS会自动检查是否容量足够,不够的话就扩容,所以使用我们的SDS时不需要手动扩容,也就不会发生缓冲区溢出
- 减少字符串带来的内存重分配次数,在C中,每次添加字符串都需要对数组扩容,删除字符串也需要内存重新分配。而SDS通过提前分配和惰性释放可以很好的改善内存重分配次数
- 提前分配:当SDS剩余空间不足时,Redis不但会给它分配足够的空间,还会给它分配多余的空间,如果下次增加字符串时,则可以使用这部分空余的空间,减少内存重分配
- 惰性释放:在删除一些字符时,Redis并不会立即释放空间,这样的话可以为将来的增加操作减少内存重分配的次数;于此同时,在Redis真正需要空间时,Redis也会释放掉这部分空间,不会内存泄露
- 二进制安全:C字符串中不能包含一些空字符,否则可能会被认为是字符串结尾导致字符串截断,所以不能保存一些图片视频的二进制数据。而SDS并不是通过空字符来判断结束的,不会对内容进行任何处理,可以保存二进制数据
- 兼容部分C字符串函数:Redis也会在结尾加上多余的一个‘\0’,使得某些情况可以使用C字符串的函数,减少自己实现重复功能
链表
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;
字典(hashtable)
Redis的字典底层使用哈希表来实现
,一个哈希表中有多个哈希表结点,一个哈希表结点保存了字典中的一个键值对
/* This is our hash table structure. Every dictionary has two of this as we
* implement incremental rehashing, for the old to the new table. */
typedef struct dictht {
//哈希表数组,里面是一个dictEntry结构
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;
typedef struct dict {
//类型特定函数
dictType *type;
//私有数据
void *privdata;
//两个哈希表,用于rehash
dictht ht[2];
//当rehash不进行时,值为-1
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
int16_t pauserehash; /* If >0 rehashing is paused (<0 indicates coding error) */
} dict;
type和privdata是针对不同类型的键值对,为多态而创建的
ht[2]两个哈希表是为了rehash而设计的,一般只使用ht[0]这个哈希表,ht[1]只会在对ht[0]进行rehash的时候使用
rehashidx它记录了目前rehash的进度,为-1时则说明不进行rehash
哈希算法
当插入一个键值对时,需要用哈希算法算出对应的索引值,并把它插入其中(具体就不再多说什么,不了解的可以去查看数据结构这门课程)
Redis计算哈希值和索引的方法:
#使用字典设置的哈希函数,计算键key 的哈希值
hash = dict->type->hashFunction(key);
#使用哈希表的sizemask 属性和哈希值,计算出索引值
#根据情况不同,
ht[x] 可以是ht[0] 或者ht[1] index = hash & dict->ht[x].sizemask;
rehash
Redis使用MurmurHash2算法来计算键的哈希值,这种算法最大的优点就是当输入有规律的数时,也能平均散列到数组中
随着操作的进行,哈希表的键值对会逐渐增多或者减少,为了让哈希表的负载因子保持在一个合理的范围区间,就必须对哈希表进行扩展或者收缩。而扩展或者收缩,我们需要执行rehash(重新散列)来完成一次操作
rehash执行步骤如下:
为字典的ht[1]分配空间,大小取决于ht[0]和所执行的扩展或者收缩操作。
将ht[0]的所有键值对rehash到ht[1]上(rehash指的是重新计算哈希值和索引,重新散列到ht[1]这个哈希表中)
移动完成后,释放ht[0]的空间,将ht[1]改为ht[0],并为ht[1]重新创建一个空的哈希表,为下一次rehash准备
渐进式rehash
在渐进式rehash期间,字典进行的删除,更新,查找会在两张哈希表上进行。比如查找,redis会先在ht[0]查找,找不到才会到ht[1]上面查找
而字典进行的插入操作,则只会在ht[1]表里执行。这样的话,ht[0]表里的数量只减 不增,也减少了重复插入的操作
如果数据量比较多时,一次性移动我们的hash表,那么时间会比较久,就有可能造成redis服务停止。所以执行rehash时,并不是一次完成的,而是渐进式完成的
渐进式rehash步骤如下:
- 为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表
- 在字典中维持一个rehashidx,也就是上面的字典结构的属性,将其值设置为0,表示rehash工作开始
- 在rehash期间,程序除了执行指定的操作外,还会将索引为rehashidx的数据移动到ht[1]相当于将ht[0]里的数据删除,在ht[1]里面增加,当rehashidx这个索引的数据全部移动完成时,则将rehashidx值加1,直到全部完成
- 如果有增删改查操作时,如果
index
大于rehashindex
,访问ht[0]
,否则访问ht[1]。
- 完成后,将rehashidx的值表示为-1,并将ht[1]设置为ht[0].
扩容的条件:
负载因子是大于1的时候。
fork为了避免写时复制,负载因子大于时候5的时候才进行扩容。
每次redis在进行正删改查的时候(hset、hdel 等操作指令时)进行rehash一次。
在没有fork操作的时候,定时的进行的rehash 1ms 步长是100次。
缩容的条件
负载因子小于0.1的时候进行缩容。当字典使用容量不足总空间的 10% 时就会触发缩容,Redis 在进行缩容时也会把 rehashindex 设置为 0,表示之后需要进行 rehash 操作。
解决键冲突
Redis使用链地址法解决键冲突,相同的哈希值会对应一个链表结构,每次有哈希冲突时,就把新的元素插入到链表的尾部
skiplist跳表
/* ZSETs use a specialized version of Skiplists */
typedef struct zskiplistNode {
//成员对象
sds ele;
//分值
double score;
//回退指针
struct zskiplistNode *backward;
//跳表层
struct zskiplistLevel {
//前进指针
struct zskiplistNode *forward;
//跨度
unsigned long span;
} level[];
} zskiplistNode;
typedef struct zskiplist {
//表示表头结点和表尾节点
struct zskiplistNode *header, *tail;
//节点数量
unsigned long length;
//最大层数
int level;
} zskiplist;
backward回退指针:
每个节点只有一个回退指针,所以每次只能回退到前一个节点,而不能跳来跳去;回退指针用于从后向前遍历
score分值:是一个double类型的浮点数,跳表中所有元素都是按分值来排序的。
ele成员对象:是一个sds的字符串对象。成员对象必须是唯一的,而分值可以是相同的。分值相同时,按成员变量的字典序排序
表头节点虽然都有对应的属性,但是我们是不会用到的,只是作为一个索引使后面插入,删除时更加方便
随即造层(指定高度为3)
整数集合intset
typedef struct intset {
//编码方式
uint32_t encoding;
//集合数量
uint32_t length;
//保存元素的数组
int8_t contents[];
} intset;
encoding编码方式
表示数组用什么类型来保存length集合数量
保存了集合中的数量contents[]集合数组
虽然是类型是int8_t,但是实际取决于encoding编码方式。数组内是大小有序的,从小到大,不含重复值
数组升级
当对数组中添加新元素时,如果新添加的元素类型大于原来的数组的类型,则需要对数组进行升级。
升级步骤如下:
- 按新的类型扩展原有数组大小,并为新元素分配空间
- 将原来的数组元素转换为新的元素类型,并且保存有序
- 将新添加的元素加入数组
不能降级
压缩列表(ziplist)
下图是压缩列表的各个组成部分:
各个字段含义如下:
entry:
previous_entry_length:表示前一个节点的长度,用于从尾向前遍历;如果前一个节点长度小于254个字节,那么就用1字节来保存,如果大于等于254字节,就用5字节来保存。
encoding:记录了content保存的数据类型和长度
content:保存节点的值,类型和长度由encoding类型决定
MySQL:“为什么说 ziplist 省内存?”
- 与 linkedlist 相比,少了 prev、next 指针。
- 通过 encoding 字段针对不同编码来细化存储,尽可能做到按需分配,当 entry 存储的是 int 类型时,encoding 和 entry-data 会合并到 encoding ,省掉了 entry-data 字段。
- 每个 entry-data 占据内存大小不一样,为了解决遍历问题,增加了 prevlen 记录上一个 entry 长度。遍历数据时间复杂度是 O(1),但是数据量很小的情况下影响不大。
连锁更新
那我们考虑一种情况,如果每一个节点的长度都在靠近254字节。那么新插入了一个大于等于254字节的节点,那么下一个节点的头部就必须是5个字节了(本来previous_entry_length只用一个字节),所以程序将对压缩列表执行空间重分配操作,将该节点扩容至5字节。但是该节点现在扩容后又大于254字节了,所以后面的节点又要接着扩容。当然,删除一个节点也可能造成这种情况:比如说第一个节点长度大于等于254,第二个节点长度小于254,那么后面一个节点previous_entry_length保存的就是1字节,那我们把第二个节点删除,现在就又变成刚才的情况!Redis把这种特殊情况造成的连续更新叫做连锁更新。因此:从 Redis7.0 后, hash 默认使用 listpack 结构。
quicklist(linkedlist)
插入元素:
【1】判断head节点ziplist是否已满,_quicklistNodeAllowInsert函数中根据quicklist.fill属性判断节点是否已满。
【2】head节点未满,直接调用ziplistPush函数,插入元素到ziplist中。
【3】更新quicklistNode.sz属性。
【4】head节点已满,创建一个新节点,将元素插入新节点的ziplist中,再将该节点头插入quicklist中。
typedef struct quicklist { // src/quicklist.h
quicklistNode *head;
quicklistNode *tail;
unsigned long count; /* ziplist 的个数 */
unsigned long len; /* quicklist 的节点数 */
unsigned int compress : 16; /* LZF 压缩算法深度 */
//...
} quicklist;
typedef struct quicklistNode {
struct quicklistNode *prev;
struct quicklistNode *next;
unsigned char *zl; /* 对应的 ziplist */
unsigned int sz; /* ziplist 字节数 */
unsigned int count : 16; /* ziplist 个数 */
unsigned int encoding : 2; /* RAW==1 or LZF==2 */
unsigned int container : 2; /* NONE==1 or ZIPLIST==2 */
unsigned int recompress : 1; /* 该节点先前是否被压缩 */
unsigned int attempted_compress : 1; /* 节点太小无法压缩 */
//...
} quicklistNode;
typedef struct quicklistLZF {
unsigned int sz;
char compressed[];
} quicklistLZF;
quicklist 添加操作对应函数是 quicklistPush,源码如下:
void quicklistPush(quicklist *quicklist, void *value, const size_t sz,
int where) {
if (where == QUICKLIST_HEAD) {
// 在列表头部添加元素
quicklistPushHead(quicklist, value, sz);
} else if (where == QUICKLIST_TAIL) {
// 在列表尾部添加元素
quicklistPushTail(quicklist, value, sz);
}
}
int quicklistPushHead(quicklist *quicklist, void *value, size_t sz) {
quicklistNode *orig_head = quicklist->head;
if (likely(
_quicklistNodeAllowInsert(quicklist->head, quicklist->fill, sz))) {
// 在头部节点插入元素
quicklist->head->zl =
ziplistPush(quicklist->head->zl, value, sz, ZIPLIST_HEAD);
quicklistNodeUpdateSz(quicklist->head);
} else {
// 头部节点不能继续插入,需要新建 quicklistNode、ziplist 进行插入
quicklistNode *node = quicklistCreateNode();
node->zl = ziplistPush(ziplistNew(), value, sz, ZIPLIST_HEAD);
quicklistNodeUpdateSz(node);
// 将新建的 quicklistNode 插入到 quicklist 结构中
_quicklistInsertNodeBefore(quicklist, quicklist->head, node);
}
quicklist->count++;
quicklist->head->count++;
return (orig_head != quicklist->head);
}
参数说明:
entry:quicklistEntry结构,quicklistEntry.node指定元素插入的quicklistNode节点,quicklistEntry.offset指定插入ziplist的索引位置。
after:是否在quicklistEntry.offset之后插入。
【1】根据参数设置以下标志。
full:待插入节点ziplist是否已满。
at_tail:是否ziplist尾插。
at_head:是否ziplist头插。
full_next:后驱节点是否已满。
full_prev:前驱节点是否已满。
REDIS_STATIC void _quicklistInsert(quicklist *quicklist, quicklistEntry *entry,
void *value, const size_t sz, int after) {
int full = 0, at_tail = 0, at_head = 0, full_next = 0, full_prev = 0;
int fill = quicklist->fill;
quicklistNode *node = entry->node;
quicklistNode *new_node = NULL;
...
// [1]
if (!_quicklistNodeAllowInsert(node, fill, sz)) {
full = 1;
}
if (after && (entry->offset == node->count)) {
at_tail = 1;
if (!_quicklistNodeAllowInsert(node->next, fill, sz)) {
full_next = 1;
}
}
if (!after && (entry->offset == 0)) {
at_head = 1;
if (!_quicklistNodeAllowInsert(node->prev, fill, sz)) {
full_prev = 1;
}
}
// [2]
...
}
quicklist 元素删除分为两种情况:单一元素删除和区间元素删除,它们都位于 src/quicklist.c 文件中。
1、单一元素删除
单一元素的删除函数是 quicklistDelEntry,源码如下:
void quicklistDelEntry(quicklistIter *iter, quicklistEntry *entry) {
quicklistNode *prev = entry->node->prev;
quicklistNode *next = entry->node->next;
// 删除指定位置的元素
int deleted_node = quicklistDelIndex((quicklist *)entry->quicklist,
entry->node, &entry->zi);
//...
}
可以看出 quicklistDelEntry 函数的底层,依赖 quicklistDelIndex 函数进行元素删除。
2、区间元素删除
区间元素删除的函数是 quicklistDelRange,源码如下:
// start 表示开始删除的下标,count 表示要删除的个数
int quicklistDelRange(quicklist *quicklist, const long start,
const long count) {
if (count <= 0)
return 0;
unsigned long extent = count;
if (start >= 0 && extent > (quicklist->count - start)) {
// 删除的元素个数大于已有元素
extent = quicklist->count - start;
} else if (start < 0 && extent > (unsigned long)(-start)) {
// 删除指定的元素个数
extent = -start; /* c.f. LREM -29 29; just delete until end. */
}
//...
// extent 为剩余需要删除的元素个数,
while (extent) {
// 保存下个 quicklistNode,因为本节点可能会被删除
quicklistNode *next = node->next;
unsigned long del;
int delete_entire_node = 0;
if (entry.offset == 0 && extent >= node->count) {
// 删除整个 quicklistNode
delete_entire_node = 1;
del = node->count;
} else if (entry.offset >= 0 && extent >= node->count) {
// 删除本节点的所有元素
del = node->count - entry.offset;
} else if (entry.offset < 0) {
// entry.offset<0 表示从后向前,相反则表示从前向后剩余的元素个数
del = -entry.offset;
if (del > extent)
del = extent;
} else {
// 删除本节点部分元素
del = extent;
}
D("[%ld]: asking to del: %ld because offset: %d; (ENTIRE NODE: %d), "
"node count: %u",
extent, del, entry.offset, delete_entire_node, node->count);
if (delete_entire_node) {
__quicklistDelNode(quicklist, node);
} else {
quicklistDecompressNodeForUse(node);
node->zl = ziplistDeleteRange(node->zl, entry.offset, del);
quicklistNodeUpdateSz(node);
node->count -= del;
quicklist->count -= del;
quicklistDeleteIfEmpty(quicklist, node);
if (node)
quicklistRecompressOnly(quicklist, node);
}
// 剩余待删除元素的个数
extent -= del;
// 下个 quicklistNode
node = next;
// 从下个 quicklistNode 起始位置开始删除
entry.offset = 0;
}
return 1;
}
从上面代码可以看出,quicklist 在区间删除时,会先找到 start 所在的 quicklistNode,计算删除的元素是否小于要删除的 count,如果不满足删除的个数,则会移动至下一个 quicklistNode 继续删除,依次循环直到删除完成为止。
quicklistDelRange 函数的返回值为 int 类型,当返回 1 时表示成功的删除了指定区间的元素,返回 0 时表示没有删除任何元素。
quicklist插入流程表格化:
Redis核心原理与实践--列表实现原理之quicklist结构 - binecy - 博客园 (cnblogs.com)
条件 | 条件说明 | 处理方式 |
---|---|---|
!full && after | 待插入节点未满,ziplist尾插 | 再次检查ziplist插入位置是否存在后驱元素,如果不存在则调用ziplistPush函数插入元素(更快),否则调用ziplistInsert插入元素 |
!full && !after | 待插入节点未满,非ziplist尾插 | 调用ziplistInsert函数插入元素 |
full && at_tail && node -> next && !full_next && after | 待插入节点已满,尾插,后驱节点未满 | 将元素插入后驱节点ziplist中 |
full && at_head && node -> prev && !full_prev && !after | 待插入节点已满,ziplist头插,前驱节点未满 | 将元素插入前驱节点ziplist中 |
full && ((at_tail && node -> next && full_next && after) ||(at_head && node->prev && full_prev && !after)) | 满足以下条件:(1)待插入节点已满 (2)尾插且后驱节点已满,或者头插且前驱节点已满 | 构建一个新节点,将元素插入新节点,并根据after参数将新节点插入quicklist中 |
full | 待插入节点已满,并且在节点ziplist中间插入 | 将插入节点的数据拆分到两个节点中,再插入拆分后的新节点中 |
MySQL:“ziplist听起来很完美,为啥还搞什么 quicklist ”
既要又要还要的需求是很难实现的,ziplist 节省了内存,但是也有不足。
- 不能保存过多的元素,否则查询性能会大大降低,O(N) 时间复杂度。
- ziplist 存储空间是连续的,当插入新的 entry 时,内存空间不足就需要重新分配一块连续的内存空间,引发连锁更新的问题。
quicklist 是综合考虑了时间效率与空间效率引入的新型数据结构。结合了原先 linkedlist 与 ziplist 各自的优势,本质还是一个链表,只不过链表的每个节点是一个 ziplist。
数据结构定义在 quicklist.h 文件中,链表由 quicklist 结构体定义,每个节点由 quicklistNode 结构体定义(源码版本为 6.2,7.0 版本使用 listpack 取代了 ziplist)。
quicklist 是一个双向链表,所以每个 quicklistNode 都有前序指针(*prev)、后序指针(*next)。每个节点是 ziplist,所以还有一个指向 ziplist 的指针 *zl。
typedef struct quicklistNode { // 前序节点指针 struct quicklistNode *prev; // 后序节点指针 struct quicklistNode *next; // 指向 ziplist 的指针 unsigned char *zl; // ziplist 字节大小 unsigned int sz; // ziplst 元素个数 unsigned int count : 16; // 编码格式,1 = RAW 代表未压缩原生ziplist,2=LZF 压缩存储 unsigned int encoding : 2; // 节点持有的数据类型,默认值 = 2 表示是 ziplist unsigned int container : 2; // 节点持有的 ziplist 是否经过解压, 1 表示已经解压过,下一次操作需要重新压缩。 unsigned int recompress : 1; // ziplist 数据是否可压缩,太小数据不需要压缩 unsigned int attempted_compress : 1; // 预留字段 unsigned int extra : 10;} quicklistNode;
quicklist 作为链表,定义了 头、尾指针,用于快速定位表表头和链表尾。
typedef struct quicklist { // 链表头指针 quicklistNode *head; // 链表尾指针 quicklistNode *tail; // 所有 ziplist 的总 entry 个数 unsigned long count; // quicklistNode 个数 unsigned long len; int fill : QL_FILL_BITS; unsigned int compress : QL_COMP_BITS; unsigned int bookmark_count: QL_BM_BITS; // 柔性数组,给节点添加标签,通过名称定位节点,实现随机访问的效果 quicklistBookmark bookmarks[];} quicklist;
结构
结合 quicklist 和 quicklistNode定义,quicklist 链表结构如下图所示。
从结构上看,quicklist 就是 ziplist 的升级版,优化的关键点在于控制好每个 ziplist 的大小或者元素个数。
- quicklistNode 的 ziplist 越小,可能会造成更多的内存碎片,极端情况下是每个 ziplist 只有一个 entry,退化成了 linkedlist。
- quicklistNode 的 ziplist 过大,极端情况下一个 quicklist 只有一个 ziplist,退化成了 ziplist。连锁更新的性能问题就会暴露无遗。
合理配置很重要,Redis 提供了 list-max-ziplist-size(default: -2),
当 list-max-ziplist-size 为负数时表示限制每个 quicklistNode 的 ziplist 的内存大小,超过这个大小就会使用 linkedlist 存储数据,每个值有以下含义:
- -5:每个 quicklist 节点上的 ziplist 大小最大 64 kb <--- 正常环境不推荐
- -4:每个 quicklist 节点上的 ziplist 大小最大 32 kb <--- 不推荐
- -3:每个 quicklist 节点上的 ziplist 大小最大 16 kb <--- 可能不推荐
- -2:每个 quicklist 节点上的 ziplist 大小最大 8 kb <--- 不错
- -1:每个 quicklist 节点上的 ziplist 大小最大 4kb <--- 不错
默认值为 -2,也是官方最推荐的值,当然你可以根据自己的实际情况进行修改。
listpack结构
ziplist由于存在对前一个entry大小的记录导致存在连锁更新问题,因此7.0引入listpack完全替代所有的ziplist结构(eg:quicklist=listpack + linkedlist):
listpack 也是一种紧凑型数据结构,用一块连续的内存空间来保存数据,并且使用多种编码方式来表示不同长度的数据来节省内存空间。
源码文件 listpack.h对 listpack 的解释:A lists of strings serialization format,意思是一种字符串列表的序列化格式,可以把字符串列表进行序列化存储,可以存储字符串或者整形数字。
- tot-bytes,也就是 total bytes,占用 4 字节,记录 listpack 占用的总字节数。
- num-elements,占用 2 字节,记录 listpack elements 元素个数。
- elements,listpack 元素,保存数据的部分。
- listpack-end-byte,结束标志,占用 1 字节,值固定为 255。
相比ziplist最大变化:element 不再像 ziplist 的 entry 保存前一项的长度。
- encoding-type,元素的编码类型,会不同长度的整数和字符串编码。
- element-data,实际存放的数据。
- element-tot-len,encoding-type + element-data 的总长度,不包含自己的长度。
每个 element 只记录自己的长度,不像 ziplist 的 entry,记录上一项的长度。当修改或者新增元素的时候,不会影响后续 element 的长度变化,解决了连锁更新的问题。
数据类型
Redis——底层数据结构原理_51CTO博客_redis底层数据结构
字符串(strings), 散列(hashes), 列表(lists), 集合(sets), 有序集合(sorted sets) 与范围查询, bitmaps, hyperloglogs 和 地理空间(geospatial)
Memcached没有Redis那么多数据类型,数据都是以字符串(可以用JSON转换)的形式表示。
Redis存在那么多数据类型的意义是什么?
如果客户端想要获得JSON中的某一小部分元素,可以只取其一小部分,直接返回给客户端。
而Memcached需要全量地返回整个JSON而不能去解析它的一部分,需要客户端自己去解析。Memcached的性能损耗会在IO以及客户端数据的解析上。
因此,重要的不是类型,重要的是Redis Server对每种数据类型都实现了自己的方法(函数)。
本质上是一种解耦,计算向数据移动。
redisObject
redis中每个对象用redisObject表示:
typedef struct redisObject {
//类型
unsigned type:4;
//编码方式
unsigned encoding:4;
unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
* LFU data (least significant 8 bits frequency
* and most significant 16 bits access time). */
//用来实现引用计数的,内存管理计数
int refcount;
//指向底层实现数据结构的指针
void *ptr;
} robj;
type
可以使用type命令显示键对应值的数据类型
ptr指针
指向底层实现的数据结构
encoding
编码方式:可通过object encoding xxx来看
/* Objects encoding. Some kind of objects like Strings and Hashes can be
* internally represented in multiple ways. The 'encoding' field of the object
* is set to one of this fields for this object. */
#define OBJ_ENCODING_INT 1 /* Encoded as integer */
#define OBJ_ENCODING_EMBSTR 8 /* Embedded sds string encoding */
#define OBJ_ENCODING_RAW 0 /* Raw representation */
#define OBJ_ENCODING_HT 2 /* Encoded as hash table */
#define OBJ_ENCODING_ZIPMAP 3 /* Encoded as zipmap */
#define OBJ_ENCODING_LINKEDLIST 4 /* No longer used: old list encoding. */
#define OBJ_ENCODING_ZIPLIST 5 /* Encoded as ziplist */
#define OBJ_ENCODING_INTSET 6 /* Encoded as intset */
#define OBJ_ENCODING_SKIPLIST 7 /* Encoded as skiplist */
#define OBJ_ENCODING_QUICKLIST 9 /* Encoded as linked list of ziplists */
#define OBJ_ENCODING_STREAM 10 /* Encoded as a radix tree of listpacks */
字符串strings
编码:
#define OBJ_ENCODING_INT 1 /* Encoded as integer */
#define OBJ_ENCODING_EMBSTR 8 /* Embedded sds string encoding */
#define OBJ_ENCODING_RAW 0 /* Raw representation */
int
存储8个字节的长整型(long,2的63次方-1),那么字符串对象会将整数值保存在字符串里的ptr属性里面,将void*转换为long,并将字符串编码设置为int
embstr
在 Redis 中,如果 SDS 的存储值大于 64 字节时,Redis 的内存分配器会认为此对象为大字符串,并使用 raw 类型来存储,当数据小于 64 字节时(字符串类型),会使用 embstr 类型存储。既然内存分配器的判断标准是 64 字节,那为什么 embstr 类型和 raw 类型的存储判断值是 44 字节?
embstr格式的SDS = redisObject结构 + sdshdr结构
为什么时44字节
从 SDS 的源码可以看出,SDS 的存储类型一共有 5 种:SDSTYPE5、SDSTYPE8、SDSTYPE16、SDSTYPE32、SDSTYPE64,在这些类型中最小的存储类型为 SDSTYPE5,但 SDSTYPE5 类型会默认转成 SDSTYPE8,以下源码可以证明,如下图所示:
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; // 1 byte
uint8_t alloc; // 1 byte
unsigned char flags; // 1 byte
char buf[];
};
可以看出除了内容数组(buf)之外,其他三个属性分别占用了 1 个字节,最终分隔字符等于 64 字节,减去 redisObject 的 16 个字节,再减去 SDS 自身的 3 个字节,再减去结束符 \0 结束符占用 1 个字节,最终的结果是 44 字节(64-16-3-1=44),内存占用如下图所示:
raw
存储大于44字节的字符串,需要调用两次内存分配函数
Redis浮点数
也是用字符串值来表示的,需要用时先把它转为浮点数,计算完后又转为字符串值保存
命令
Redis 字符串(String) | 菜鸟教程 (runoob.com)
set k1 value1 nx nx表示无值的时候才执行成功
set k1 value1 xx xx表示有值的时候才执行成功
mset k3 value3 k4 value4 含义为 more set,用来设置多个值
mget k1 k2 获取两个值
msetnx k2 c k3 d 这个命令可以保证多笔操作是原子操作,类似于一个事务中
appenk k1 "world" 在k1后面追加world
getrange k1 2 5 获取k1某两个索引之间的字符串子串(支持正负索引)
setrange k1 offset value
从某个索引开始覆盖
strlen k1
获取字符串长度
getset k1 bashibing
取出旧值,并将旧值设置为新值(这样可以减少一次IO通信)
数值类型的加减操作
decr k1
k1-1
decrby k1 22
k1+22
incrbyfloat k1 0.5
k1-0.5
查看key类型
type key
查看编码类型
object encoding xxx
应用
二进制安全
redis 是二进制安全的:并不会去破坏你的编码,也不去关心你是什么编码。底层存储的时候,是按照Byte字节存储的。我们前面看到的encoding,只是为了让加减之类的运算方法变得更快一些。
(HBASE也是二进制安全的)
在redis进程与外界交互的时候,redis存储的是字节流,而不会转换成字符流,也不会擅自按照某种数据类型存储,这样保证了数据不会被破坏,不会发生数据被截断/溢出等错误。
编码并不会影响数据的存储
因此,在多人使用redis的时候,我们一定要在用户端约定好数据的编码和解码。
————————————————
版权声明:本文为CSDN博主「寒泉Hq」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/sinat_42483341/article/details/106931121
bitmap
bitop
按位操作
应用:
公司有用户系统,让你统计用户登录天数,且时间窗口随机。例如,A用户在某一年中登陆了几次。怎么优化?
可以使用redis实现,假设一年400天,让每一天对应一个二进制位,需要50个字节即可。
setbit Tom 1 1 表示 Tom 在第2天登录了一次(下标从0开始)
setbit Tom 364 1 表示 Tom 在第365天登录了一次
bitcount Tom -2 -1 统计 Tom 在最后16天的总登录次数
你是京东,618做活动,登录就送礼物,假设京东有2亿用户。大库应该备货多少礼物?
用户应该分为:僵尸用户、冷热用户、忠诚用户
你需要统计活跃用户,也是随机时间窗口
比如说,统计本月1号~3号范围内的活跃过的用户,需要去重
list
quicklist实现,有序(插入顺序)
命令
Redis 列表(List) | 菜鸟教程 (runoob.com)
应用场景
list 类型的应用场景主要是实现队列和栈,比如:
-
消息队列,利用 lpush 和 rpop 命令实现生产者消费者模式。
lpush k1 a b c d e
左边push
rpop k1 a b c d e
右边pop(先进先出)
-
最新消息,利用 lpush 和 ltrim 命令实现固定长度的时间线。
-
历史记录,利用 lpush 和 lrange 命令实现浏览记录或者搜索记录。
-
栈
lpush k1 a b c d e
左边pushlpop k1 a b c d e
左边pop(后进先出)
散列hashes
- hash-max-ziplist-entries :表示当 hash 中的元素数量小于或等于该值时,使用 ziplist 编码,否则使用 hashtable 编码。 ziplist 是一种压缩列表,它可以节省内存空间,但是访问速度较慢。 hashtable 是一种哈希表,它可以提高访问速度,但是占用内存空间较多。默认值为 512 。
- hash-max-ziplist-value :表示当 hash 中的每个元素的 key 和 value 的长度都小于或等于该值时,使用 ziplist 编码,否则使用 hashtable 编码。默认值为 64 。
每次添加都会添加都末尾,添加时,需要遍历一遍整个压缩列表,如果有key和插入的key相同,则将它的value替换,否则就添加到末尾。
命令
Redis 哈希(Hash) | 菜鸟教程 (runoob.com)
使用场景
哈希字典的典型使用场景如下:
- 商品购物车,购物车非常适合用哈希字典表示,使用人员唯一编号作为字典的 key,value 值可以存储商品的 id 和数量等信息;
- 存储用户的属性信息,使用人员唯一编号作为字典的 key,value 值为属性字段和对应的值;
- 收藏,详情页,点赞
集合(sets)集合
集合对象底层可以是intset
或者hashtable
,如果集合对象全为整数,且数量小于等于512个
,则使用intset;否则使用hashtable,无序,去重
使用intset实现:
使用hashtable实现:
集合元素为字典的key,字典的value则为null。(类似Java的Set集合,底层也是使用map实现的,不过它的value值是一个final常量,不是null)
命令
Redis 集合(Set) | 菜鸟教程 (runoob.com)
应用
有序集合对象sorted set
有序集合对象使用ziplist
或者skiplist
来实现,当集合数量小于128且字符长度小于等于64字节时
使用ziplist;否则使用zset(hashtable + skiplist)
除了元素本身以外,需要有分值这个维度,用来排序。
如果分值相同,则按照名称字典序排列。
每个元素都有自己的正负向索引
底层使用ziplist实现:压缩列表,示意图如下:
集合内的元素按分值大小排序
底层使用skiplist实现:这种编码的有序集合对象使用zset结构作为底层实现,一个zset结构包含一个字典和一个跳跃表
跳跃表中按分值从大到小进行保存所有元素,跳跃表节点保存一个集合元素,跳跃表的ele成员对象保存了元素的成员,跳跃表的score保存了元素的分值
字典中也保存了集合的全部元素,字典的键是元素成员,字典的值是元素分值
注意:有序集合的每个元素成员是一个字符串,分值是double类型的浮点数。
并且虽然看起来是保存了两份,但是他们相同元素通过指针指向的都是同一个地方,所以并不会有冗余;
为什么要同时使用跳跃表和字典呢??
虽然用单独的一种都可以实现有序集合的功能,但是两个都各有利弊,使用两个的话就相当于同时保留了他们的优点,摒弃了缺点。
比如我们只使用字典来实现,那么它查找一个元素的分值复杂度为O(1),但是如果范围查找的话,就需要先排序,时间复杂度为O(nlogn),空间复杂度为O(n)
比如我们只使用跳跃表来实现,那么它可以很快的支持范围查找,但它查找一个分值的时间复杂度为O(logn)。
而范围查找和只查询分值在有序集合是很常见的,所以综合起来,同时使用两个最优。
为什么要使用跳跃表,而不是用平衡树???
跳跃表更加简单,使用范围查找比其他的平衡树效率要高;并且是容易实现容易调试的;并且跳表插入和删除只要维护节点指针即可,不需要调整树。
那为什么Java的HashMap底层不使用跳跃表呢???不是更加简单吗??
跳表是以空间换时间的数据结构,而红黑树并不需要额外空间
并且HashMap的Entry之间并没有排序关系,无法满足跳跃表的条件。
额外:TreeMap 的并发实现 ConcurrentSkipListMap 就是使用的跳表;concurrentHashMap8 之前采用链表数据结构,8 之后是红黑树。concurrentSkipListMap 跳表,是因为要实现线程安全的 TreeMapCAS 操作会很复杂,才产生的。高并发有序。
————————————————
版权声明:本文为CSDN博主「small_engineer」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/small_engineer/article/details/124623298
命令
Redis 有序集合(sorted set) | 菜鸟教程 (runoob.com)
ZUNIONSTORE
示例: