redis内部数据结构详解
redis基本特性
-
非关系型的键值对数据库, 可以根据键以O(1)的时间复杂度取出或者插入关联值.
-
redis数据存在于内存中.
-
键值对中的类型可以是字符串, 整型, 浮点型, 且键是唯一的.
-
键值对中的值类型可以是string, hash, list, set, sorted set等.
-
redis内置了复制, 磁盘持久化, lua脚本, 事务, SSL, ACLs, 客户端缓存, 客户端代理等功能.
-
通过redis哨兵和redis cluster模式提高可用性.
redis数据库: redisDb
redis在非集群状态下, 具有16个db, 下面是db的结构体定义.
redisDb
typedef struct redisDb {
dict *dict; /* The keyspace for this DB */
dict *expires; /* Timeout of keys with a timeout set 过期时间字典 */
dict *blocking_keys; /* Keys with clients waiting for data (BLPOP)*/
dict *ready_keys; /* Blocked keys that received a PUSH */
dict *watched_keys; /* WATCHED keys for MULTI/EXEC CAS */
int id; /* Database ID */
long long avg_ttl; /* Average TTL, just for stats */
unsigned long expires_cursor; /* Cursor of the active expire cycle. */
list *defrag_later; /* List of key names to attempt to defrag one by one, gradually. */
} redisDb;
其中id就是这个数据库的索引, 也就是0号数据库~15号数据库这16个数据库的唯一标识.
dict字典
其中dict* dict就是字典的指针, 它指向了这个db自己的所有key空间. 而dict*这里定义了很多种, expires就代表是被设置了expire的key, blocking就代表来自于客户端对阻塞队列处理的key, ready就代表是正在等待解阻塞的push操作的key, 下面是dict的结构体.
typedef struct dict {
dictType *type;
void *privdata;
dictht ht[2];// ht[0] , ht[1] =null
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
unsigned long iterators; /* number of iterators currently running */
} dict;
dictType字典类型
第一个成员是dictType, 因为不同的dict, 可以为它指定不同的hash函数以及其它函数, 可以看看dictType的结构体定义:
typedef struct dictType {
uint64_t (*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;
其中hashFunction就是该字典的hash函数指针, 还有一个比较重要的函数指针是keyCompare, 因为产生hash冲突并不只能靠取模来判断, 取模完成之后, 还得比较两个key是否相同, 因此keyCompare就是一个指向当前字典使用的比较key是否相同的函数的函数指针.
dict就好比是redis中的hashtable, 由数组+链表组成, 其中dictht实际上就是dict hashtable, 它是一个数组, 有2个元素, 这是为了实现渐进式rehash才这么做的.
dictht(dict hash table)
下面是dictht的结构体定义:
typedef struct dictht {
dictEntry **table;
unsigned long size; // hashtable 容量
unsigned long sizemask; // size -1
unsigned long used; // hashtable 元素个数 used / size =1
} dictht;
其中table是一个二级指针, 二级指针其实就可以看做是一个一级指针的数组, 这个数组里面放的就是类型为dictEntry的指针, 这样就串起来了, table这个属性, 实际上就是每个链表头节点的地址数组, 而链表中的每个元素的类型, 就是dictEntry.
size就是table数组的总长度.
sizemask是size-1, 因为size一定是2的整数次方, 任何数%2的整数次方, 都可以写为任何数&2的整数次方, 因此这里专门存储一个mask.
used就是table中的元素个数.
dictEntry键值对结构体
下面我们看看dictEntry的结构体定义.
typedef struct dictEntry {
void *key;
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next;
} dictEntry;
entry就是一个链表节点, 它具有下一个entry的指针, 以及自己的key, 因此*key就是这个键值对的key, 它是string类型, 因此是一个sds类型的结构体, 重点看一下v.
v是一个联合体结构, 联合体其实跟结构体差不多, 只不过更节省空间, 并且对内部成员数据类型有要求而已.
v中有一个void*类型的指针, 成员名是val, 大概就能猜出来, 这个指针指向的就是这个键值对的value. 实际上这个指针指向的是一个redisObject类型的结构体:
#define LRU_BITS 24
typedef struct redisObject {
unsigned type:4; // 4 bit, sting , hash
unsigned encoding:4; // 4 bit
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).
* 24 bit
* */
int refcount; // 4 byte
void *ptr; // 8 byte 总空间: 4 bit + 4 bit + 24 bit + 4 byte + 8 byte = 16 byte
} robj;
结构体使用上面的变量名:数字, 表示这个变量的位数. 因此type是半个字节, encoding是半个字节, lru是3个字节
refcount是引用数量, redis中的值以引用计数法来进行内存销毁.
*ptr就是指向真正的数据的指针了, 也就是我们平时所说的string, set, list等, 只不过还会进行进一步的封装而已.
string内部实现: sds
string的实现是一个结构体: sds(simple dynamic string), 简单动态字符串.
sds对于字符串有什么优点
首先在C语言中是没有字符串这个类型的, 但是C语言为了使用方便, 还是提供了一些字符串常量的用法, 在C语言中如果想使用字符串, 只能如下来定义:
char str1[] = "hello darkness";
char str2[] = {'h', 'e', 'l', 'l', 'l', 0};
char str2[] = {'h', 'e', 'l', 'l', 'l', '\0'};
如果直接使用字符串常量的写法: "hello"这种格式, 会默认在后面补充一个0作为字符串结尾. C语言提供了一个%s用于字符串的格式化输出, 比如printf("%s\n", str1), str1作为一个char类型的指针传递进去, 执行流程就是从str1指向的地址开始, 往后一个字节一个字节地去找字符, 直到遇到0停止.
这个逻辑本身没什么问题, 但是用到redis中就有问题了, 因为redis本身是一个第三方工具, 它无法保证使用它的这个客户端不可能在使用字符串的时候, 序列化后的二进制字节序中不存在0. 如果使用c语言原生的字符串, 将会把遇到的第一个0后面的内容全部抛弃.
因此redis自定义了一个字符串类型叫做sds, 它就是一个二进制安全的, 不会有原生字符串的那种缺陷. 下面是它的结构体代码
/* Note: sdshdr5 is never used, we just access the flags byte directly.
* However is here to document the layout of type 5 SDS strings. */
struct __attribute__ ((__packed__)) sdshdr5 {
unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
char buf[];// buf[0]: z: 0101001
};
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* used */
uint8_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
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 {
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 {
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分了这5种sds, 其中hdr5是不用的
- attribute ((packed))
使用这个属性, 就可以让结构体中的元素变为连续的地址空间, 否则默认结构体定义的时候, 会有内存对齐的优化.
- flags
其中1字节的flags是一个状态标志位, 标识这个sds的元信息. 之所以不叫flag而叫flags, 是因为这1个字节表示了两种状态. 在hdr5中, 高三位表示类型(hdr5), 低五位表示sds中buf[]的长度. 而在hdr8, 16, 32, 64中, 高三位依然表示各自的类型, 低五位闲置未使用.
type的宏定义如下:
#define SDS_TYPE_5 0
#define SDS_TYPE_8 1
#define SDS_TYPE_16 2
#define SDS_TYPE_32 3
#define SDS_TYPE_64 4
根据长度确认sds类型代码如下:
static inline char sdsReqType(size_t string_size) {
if (string_size < 32)
return SDS_TYPE_5;
if (string_size < 0xff) //2^8 -1 = 255
return SDS_TYPE_8;
if (string_size < 0xffff) // 2^16 -1 = 65535
return SDS_TYPE_16;
if (string_size < 0xffffffff) // 2^32 -1 = 42.9亿
return SDS_TYPE_32;
return SDS_TYPE_64;
}
- alloc
buf[]的空间.
- len
buf[]中已经使用的空间.
- buf[]
真正存储字符串的数组. 这里定义成为了一个柔性数组, 本来结构体中定义数组的时候一定指定长度, 但是如果数组是结构体的最后一个成员的话, 可以不指定长度, 这样就可以在使用的时候再去分配长度了.
string类型详解
通过对字典的分析, 我们知道, string对象是封装在一个叫做redisObject中的, 这个redisObject中有一个void*的指针变量ptr指向真正的sds, 在redisObject中, 还有一个属性type, 这个属性就是为了限制客户端命令的.
/* A redis object, that is a type able to hold a string / list / set */
/* The actual Redis Object */
#define OBJ_STRING 0 /* String object. */
#define OBJ_LIST 1 /* List object. */
#define OBJ_SET 2 /* Set object. */
#define OBJ_ZSET 3 /* Sorted set object. */
#define OBJ_HASH 4 /* Hash object. */
一共5种数据类型, 0就代表了string, 之所以说是限制, 比如set name darkness, 设置了一个字符串类型的name, 那么这个name是无法使用lpop这种命令的, 因为它的类型被指定位了0, 字符串.
另一个字段encoding, 是编码, redis虽然分了5大数据类型, 但是每种数据类型下面还有不同的编码, 编码的目的就是为了节省内存空间以及提升效率. 比如string就有三种编码: int, embstr, raw.
#define OBJ_ENCODING_RAW 0 /* Raw representation */
#define OBJ_ENCODING_INT 1 /* Encoded as integer */
#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_EMBSTR 8 /* Embedded sds string encoding */
#define OBJ_ENCODING_QUICKLIST 9 /* Encoded as linked list of ziplists */
#define OBJ_ENCODING_STREAM 10 /* Encoded as a radix tree of listpacks */
int编码
在客户端中使用set name 123, 之后使用type name和object encoding name, 输出如下:
可以看到, 虽然值为123, 但是类型却是string, 但是编码就是int了. 那么int编码有什么特点呢?
int编码特点
回到redisObject中, 它有一个属性void* ptr指向真正的值, 但是这样真的好吗? 在64位中, 一个指针的大小是8个字节, 8个字节是64位, 可以表示的有符号整数已经是个天文数字了, 如果一个键值对的值是一个数字, 何必要用一个8字节的指针, 再去指向呢, 这不是浪费了8个字节的空间吗? 浪费空间是其一, 如果用一个指针, 再去找另一个地址的对象, 这就是两次内存io, 不论时间空间都是下下策, 因此int编码的时候, 会把指针直接赋值为键值对的值, 这样不仅省了8字节的空间, 还少使用一次内存io, 既省空间又省时间.
embstr编码
embstr, 嵌入式string编码. 在客户端随便设置一个 set money 8.8, 再用type和encoding看看输出:
可以看到, 编码已经变为了嵌入式字符串, 嵌入式字符串有什么特点呢?
embstr编码特点
介绍embstr之前, 首先需要提一下缓存行的概念, cpu从内存中读取数据的时候, 并不是需要多少数据就读多少数据, 也不可能实现这样. cpu读取的时候, 一次会读取64字节的数据, 这是由于局部性原理.
embstr利用了cpu的这个特点, 如果一个字符串的长度小于等于44, 也就是大小在44个字节以内, 就会正好够一个缓存行的大小. 为什么呢?
在redisObject中, type4位, encoding4位, lru24位, refcount4字节, ptr8字节, 加起来就是4 + 4 + 8 = 16字节. 在255以下字节的字符串, 由sdshdr8存储, 在sdshdr8中, len8位, alloc8位, flags1字节, buf结尾0是一个字节, 加起来4个字节, 一共加起来就是20个字节, 那么64个字节就剩下44个字节了, 这也就是44的由来.
不妨测试一下, 分别使用44个字节的字符串和45个字节的字符串作为值, 会不会有不同的object encoding:
正如同分析的那样, 44长度的embstr, 被encoding为embstr, 而45长度的rawstr, 被encoding为raw, 也就是普通字符串
raw编码
长度为45(包含45)以上的字符串, 将不会做优化, 直接使用ptr指向一个随机内存空间的指针, 该指针类型为sdshdr(8, 16, 32, 64).
以上三种编码, 最终ptr指向的都是sds结构体, 只不过编码格式造成内存分配的方式不一样.
sds扩容
在sds中, 定义了len和alloc两个属性, 其中alloc就是用于记录buf[]的总长度的. redis使用了空间换时间的方式, 每次扩容都会是(原始长度 + 扩容长度) * 2.
扩容的情况仅限于append和setbit命令, 而且扩容只会越加越大, 因此sds只存在扩容, 不存在伸缩.
位图
redis中存在位图这个概念, 但是并没有位图这个数据结构, 这是因为位图是基于string实现的.
执行命令: setbit testbit 1000 1, 得到一个0的返回, 是因为setbit返回的是这个位图的某个位置的原始值.
使用strlen testbit, 得到126, 是因为操作系统中只能按字节去分配内存, 因此分配1000位的位图, 得到的是一个126字节的内存大小(1001/8 + 1 = 126, 位图下标由0开始).
使用get testbit, 可以得到数据, 但是是一个看不懂的玩意儿, 可以证明, 位图的确是由string实现的. 最后一个字节是80(1000 0000), 是小端存储的原因.
位图操作
- setbit
上面已经介绍过了 setbit key offset value, 其中key是位图的键, 也就是string的键, offset是下标, 从0开始, value是位值, 只能取0或1. 命令执行后的返回值为当前offset之前的值.
- getbit
getbit key offset, 得到键为key的位图的offset位置的值. 返回为0或1.
- bitcount
bitcount key (start end), 统计键为key的位图中从start个字节到end个字节中1的个数. start和end要么都写, 要么都不写. 都不写统计全部. 如下图所示:
注意: start和end是以字节来算的, 比如输入0 1, 就是统计0字节开始到1字节的1的个数, 也就是0-16位中有多少个1. 如下图所示, 由于统计8到9字节的个数, 也就是从64到80位之间, 1的个数, 由于设置了77为1, 因此返回1.
- bitop
op就是operate, bit操作的意思. bit操作有4种, 也就是常说的 与(and), 或(or), 非(not), 异或(xor). 其中只有非(not)操作只接受一个key, 其它的三个操作都可以接收多个key.
- bitop and destkey key1 [key2 key3...] 其中destkey是操作过后存入的那个位图的key
执行bitop and anddest testbitop1 testbitop2
- bitiop or destkey key1 [key2 key3]
执行bitop or opdest testbitop1 testbitop2
- bitop xor destkey key1 [key2 key3] 执行bitop xor xordest testbitop1 testbitop2
- bitop not destkey key 执行bitop not notdest testbitop2, 只能带一个key, 多个key会报错.
list内部实现
list数据结构在redis中使用quicklist和ziplist两种数据结构实现.
redis中的list是一个插入有序的数据结构, list的操作有lpush, rpush, lpop, rpop, lindex, llen, linsert before/after等操作, 说明它可以从后往前遍历, 也可以从前往后遍历.
实现这种数据结构, 我们一般第一个想到的就是双向链表, 但是redis并没有直接这么做. 我们可以试想一下, 如果一个list, 我们都存的是1, 2, 3这样的数值类型, 却又要使用两个8字节的指针去指向前后, 就导致本身一个要不了多大内存的数据, 却由于指针的缘故, 放大了三四倍, 这种情况称为"胖指针".
ziplist
redis使用ziplist来解决"胖指针"的问题.
robj *createZiplistObject(void) {
unsigned char *zl = ziplistNew();
robj *o = createObject(OBJ_LIST,zl);
o->encoding = OBJ_ENCODING_ZIPLIST;
return o;
}
/* Create a new empty ziplist. */
unsigned char *ziplistNew(void) {
unsigned int bytes = ZIPLIST_HEADER_SIZE+ZIPLIST_END_SIZE;
unsigned char *zl = zmalloc(bytes);
ZIPLIST_BYTES(zl) = intrev32ifbe(bytes);
ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(ZIPLIST_HEADER_SIZE);
ZIPLIST_LENGTH(zl) = 0;
zl[bytes-1] = ZIP_END;
return zl;
}
上述方法将会创建一个空的ziplist
ziplist的组成格式如下所示
-
zlbytes: 32位, 表示ziplist占用的字节总数, 本身占用4个字节.
-
zltail: 32位, 表示ziplist中最后一项(entry)的偏移字节数, 通过zltail可以直接定位到尾部节点, 可以在list中很快速地进行rpush和rpop.
-
zllen: 16位, 表示ziplist中数据项的个数(有多少个entry).
-
entry: 表示真正存放数据的数据项, 长度不定.
-
zlend: 表示ziplist的结尾, 是一个结束标记, 全为1, 无符号整数, 十进制值255.
其中每个entry的格式如下:
- prerawlen: 前一个数据项的长度, 根据前一个数据的数据项长度是否小于254, prerawlen占据的位数不一样.
-
前一个数据的数据项长度小于254, 就使用1个字节, 因为1个字节无符号整数最大可以表示255, 之所以这里不能全为1是因为255已经作为ziplist的end标志了.
-
前一个数据的数据项长度大于等于254字节, 第一个字节则为固定的254, prerawlen将会使用5个字节来表示(实际上只有4个字节, 因为第一个字节被254占用), 那么就是4个字节来表示前一个数据项的长度.
-
len: 当前数据项的长度, 太过复杂, 分为9种情况, 对应zlentry的9种编码, 因此这里就不细说, 大概就是根据第一个字节的位不同, 去区分data的不同大小, 整体来说就是能用1个字节的绝对不使用2个字节, 能使用2个字节的绝不使用3个字节.
-
encoding: 图中未标注, 使用encoding来区分是ZIP_STR_*还是ZIP_INT_*.
-
data: 真实的数据存储. 实际上是由一个char* p的指针来指向.
介绍完ziplist, 可以看到, ziplist的高效省空间主要是因为连续的存储空间, 不会存在内存碎片, 也很容易利用缓存行, 能用1字节的data, 绝不用2字节表示, 因此非常省空间. 并且没有前后指针也能实现向前和向后遍历. 但是缺点也很明显, 因为要求太高, 所以一旦遇到插入或者删除中间节点就非常麻烦.
quicklist
redis并不是完全靠ziplist去实现list的, 还会存在于一个quicklist. 下面是quicklist的结构体定义:
typedef struct quicklist {
quicklistNode *head;
quicklistNode *tail;
unsigned long count; /* total count of all entries in all ziplists */
unsigned long len; /* number of quicklistNodes */
int fill : QL_FILL_BITS; /* fill factor for individual nodes */
unsigned int compress : QL_COMP_BITS; /* depth of end nodes not to compress;0=off */
unsigned int bookmark_count: QL_BM_BITS;
quicklistBookmark bookmarks[];
} quicklist;
quicklist的代码注释也非常清晰, 包含了核心的4个数据:
- quicklistNode* head
指向一个quicklistNode结构体的指针, 也就是每个quicklist的头节点.
- struct quicklistNode* next
指向一个quicklistNode结构体的指针, 也就是每个quicklist的尾节点.
- unsigned long count
所有quicknode中的所有ziplist的所有entry的个数.
- unsigned long len
所有quicklistNode的个数.
quicklist内部是quicklistNode, 结构体定义如下:
typedef struct quicklistNode {
struct quicklistNode *prev;
struct quicklistNode *next;
unsigned char *zl;
unsigned int sz; /* ziplist size in bytes */
unsigned int count : 16; /* count of items in ziplist */
unsigned int encoding : 2; /* RAW==1 or LZF==2 */
unsigned int container : 2; /* NONE==1 or ZIPLIST==2 */
unsigned int recompress : 1; /* was this node previous compressed? */
unsigned int attempted_compress : 1; /* node can't compress; too small */
unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;
- struct quicklistNode* prev
指向上一个quicklistNode的指针.
- struct quicklistNode* next
指向下一个quicklistNode的指针.
- unsigned char* zl
指向当前quicklistNode中包含的ziplist的指针.
- unsigned int sz
当前quicklistNode中ziplist的zlentry个数.
hash的内实现: dict和ziplist
ziplist编码格式
先看一个现象: 使用hset user name darkness age 28 gender 1 height 175创建一个hash的数据, 再使用hgetall看看结果:
发现所有的kv都是按照我们设置的顺序出现的, 这并非巧合. 然后使用object encoding看看当前user的编码格式:
格式为ziplist, 这是因为当前user这个hash的编码格式是ziplist, ziplist前面已经介绍过了, 下面主要看看这个ziplist中的zlentry具体的数据存储结构:
可见, 每个kv都按顺序排列在一个顺序的存储空间中, 使用zlentry去保存.
hashtable编码格式
接下来执行下面的操作: hset user image aaaaaaaaaa...aaa(超过64个a)
再使用hgetall, 会发现字段的顺序都变了, 这时候使用object encoding user会发现, 此时的编码格式已经变为了hashtable:
hashtable编码格式其实就和redisDb差不多.
ziplist转hashtable
redis设定:
- 在ziplist的zlentry个数大于512时, 将从ziplist转为hashtable, 可配置, 对应配置:
# ziplist 元素个数超过 512 ,将改为hashtable编码
hash-max-ziplist-entrie 512
- ziplist中单个entry中的元素值大小超过64字节时, 将转为hashtable存储, 可配置, 对应配置:
# 单个元素大小超过 64 byte时,将改为hashtable编码
hash-max-ziplist-value 64
set的内部实现
set是无序的, 可以自动去重的数据类型, 底层实现为一个value为null的字典(dict).
intset编码
如果set集合中的数据可以使用整数类型表示, 那么编码格式为intset.
执行sadd testset 1 2 3 4 5 6 7 7 8 9 9 11 11 11 13 13, 使用smembers testset查看所有元素.
使用object encoding testset, 发现编码格式为intset:
set为intset编码时的有序性
intset结构体定义如下:
typedef struct intset {
uint32_t encoding;
uint32_t length;
int8_t contents[];
} intset;
/* Note that these encodings are ordered, so:
* INTSET_ENC_INT16 < INTSET_ENC_INT32 < INTSET_ENC_INT64. */
#define INTSET_ENC_INT16 (sizeof(int16_t))
#define INTSET_ENC_INT32 (sizeof(int32_t))
#define INTSET_ENC_INT64 (sizeof(int64_t))
根据注释可以看到, intset编码时, 确实是有序的, 并且我们在smembers命令的时候, 确实发现所有的整数都被排好序展示了出来.
编码为intset时, 所有的元素处于一块连续的内存空间中.
hashtable编码
当如下两个条件满足其一时, 将从intset编码转为hashtable编码:
- 任意一个元素无法使用整型表示:
- 元素个数大于512(默认), 可配置, 配置参数如下:
# intset 能存储的最大元素个数,超过则用hashtable编码
set-max-intset-entries 512
将此参数改为5, 重启redis, 证实一下上述结论:
操作结果发现, 当set元素个数为5的时候, 还是intset编码, 但是再向里面add一个不同的元素, 使得元素个数为6, 大于了5, 编码格式变为hashtable.
zset的内部实现
zset由dict和skiplist组合实现.
zset编码类型有ziplist和skiplist.
当元素个数较少时, 采用ziplist, 和hash结构一样, 只不过key是score, 值是元素值.
当单个元素大小超过ziplist的阈值, 或者元素个数超过ziplist的阈值, 将会转为skiplist.
转载自:https://juejin.cn/post/7077889236711505956