String底层:动态数组
Redis 中的字符串是可以修改的字符串,在内存中它是以字节数组的形式存在的。
我们知道 C 语言里面的字符串标准形式是以 NULL 作为结束符,但是在 Redis 里面字符串不是这么表示的。因为要获取 NULL 结尾的字符串的长度使用的是
strlen
标准库函数,这个函数的算法复杂度是 O(n),它需要对字节数组进行遍历扫描,作为单线程的 Redis 表示承受不起。Redis 的字符串叫着「SDS」,也就是
Simple Dynamic String
。它的结构是一个带长度信息的字节数组。struct SDS<T> { T capacity; // 数组容量 T len; // 数组长度 byte flags; // 特殊标识位,不理睬它 byte[] content; // 数组内容 }
如代码所示,
content
里面存储了真正的字符串内容,那 capacity
和 len
表示什么意思呢?它有点类似于 Java 语言的 ArrayList 结构,需要比实际的内容长度多分配一些冗余空间。capacity
表示所分配数组的长度,len
表示字符串的实际长度。前面我们提到字符串是可以修改的字符串,它要支持
append
操作。如果数组没有冗余空间,那么追加操作必然涉及到分配新数组,然后将旧内容复制过来,再 append
新内容。如果字符串的长度非常长,这样的内存分配和复制开销就会非常大。/* Append the specified binary-safe string pointed by 't' of 'len' bytes to the * end of the specified sds string 's'. * * After the call, the passed sds string is no longer valid and all the * references must be substituted with the new pointer returned by the call. */ sds sdscatlen(sds s, const void *t, size_t len) { size_t curlen = sdslen(s); // 原字符串长度 // 按需调整空间,如果 capacity 不够容纳追加的内容,就会重新分配字节数组并复制原字符串的内容到新数组中 s = sdsMakeRoomFor(s,len); if (s == NULL) return NULL; // 内存不足 memcpy(s+curlen, t, len); // 追加目标字符串的内容到字节数组中 sdssetlen(s, curlen+len); // 设置追加后的长度值 s[curlen+len] = '\0'; // 让字符串以\0 结尾,便于调试打印,还可以直接使用 glibc 的字符串函数进行操作 return s; }
上面的 SDS 结构使用了范型
T
,为什么不直接用 int
呢,这是因为当字符串比较短时,len
和 capacity
可以使用 byte
和 short
来表示,Redis 为了对内存做极致的优化,不同长度的字符串使用不同的结构体来表示。Redis 规定字符串的长度不得超过 512M 字节。创建字符串时
len
和 capacity
一样长,不会多分配冗余空间,这是因为绝大多数场景下我们不会使用 append
操作来修改字符串。embstr vs raw
Redis 的字符串有两种存储方式,在长度特别短时,使用
emb
形式存储 (embeded),当长度超过 44 时,使用 raw
形式存储。这两种类型有什么区别呢?为什么分界线是 44 呢?
> set codehole abcdefghijklmnopqrstuvwxyz012345678912345678 OK > debug object codehole Value at:0x7fec2de00370 refcount:1 encoding:embstr serializedlength:45 lru:5958906 lru_seconds_idle:1 > set codehole abcdefghijklmnopqrstuvwxyz0123456789123456789 OK > debug object codehole Value at:0x7fec2dd0b750 refcount:1 encoding:raw serializedlength:46 lru:5958911 lru_seconds_idle:1
注意上面
debug object
输出中有个 encoding
字段,一个字符的差别,存储形式就发生了变化。这是为什么呢?为了解释这种现象,我们首先来了解一下 Redis 对象头结构体,所有的 Redis 对象都有下面的这个结构头:
struct RedisObject { int4 type; // 4bits int4 encoding; // 4bits int24 lru; // 24bits int32 refcount; // 4bytes void *ptr; // 8bytes,64-bit system } robj;
不同的对象具有不同的类型
type(4bit)
,同一个类型的 type 会有不同的存储形式 encoding(4bit)
,为了记录对象的 LRU 信息,使用了 24 个 bit 来记录 LRU 信息。每个对象都有个引用计数,当引用计数为零时,对象就会被销毁,内存被回收。ptr
指针将指向对象内容 (body) 的具体存储位置。这样一个 RedisObject 对象头需要占据 16 字节的存储空间。接着我们再看 SDS 结构体的大小,在字符串比较小时,SDS 对象头的大小是
capacity+3
,至少是 3。意味着分配一个字符串的最小空间占用为 19 字节 (16+3)。struct SDS { int8 capacity; // 1byte int8 len; // 1byte int8 flags; // 1byte byte[] content; // 内联数组,长度为 capacity }
如图所示,
embstr
存储形式是这样一种存储形式,它将 RedisObject 对象头和 SDS 对象连续存在一起,使用 malloc
方法一次分配。而 raw
存储形式不一样,它需要两次 malloc
,两个对象头在内存地址上一般是不连续的。而内存分配器
jemalloc/tcmalloc
等分配内存大小的单位都是 2、4、8、16、32、64 等等,为了能容纳一个完整的 embstr
对象,jemalloc
最少会分配 32 字节的空间,如果字符串再稍微长一点,那就是 64 字节的空间。如果总体超出了 64 字节,Redis 认为它是一个大字符串,不再使用 emdstr
形式存储,而该用 raw
形式。当内存分配器分配了 64 空间时,那这个字符串的长度最大可以是多少呢?这个长度就是 44。那为什么是 44 呢?
前面我们提到 SDS 结构体中的
content
中的字符串是以字节\0
结尾的字符串,之所以多出这样一个字节,是为了便于直接使用 glibc
的字符串处理函数,以及为了便于字符串的调试打印输出。看上面这张图可以算出,留给
content
的长度最多只有 45(64-19) 字节了。字符串又是以\0
结尾,所以 embstr
最大能容纳的字符串长度就是 44。扩容策略
字符串在长度小于 1M 之前,扩容空间采用加倍策略,也就是保留 100% 的冗余空间。当长度超过 1M 之后,为了避免加倍后的冗余空间过大而导致浪费,每次扩容只会多分配 1M 大小的冗余空间。
什么场合下会用到字符串的
append
方法?字典
dict 是 Redis 服务器中出现最为频繁的复合型数据结构,除了 hash 结构的数据会用到字典外,整个 Redis 数据库的所有 key 和 value 也组成了一个全局字典,还有带过期时间的 key 集合也是一个字典。
zset 集合中存储 value 和 score 值的映射关系也是通过 dict 结构实现的。
struct RedisDb { dict* dict; // all keys key=>value dict* expires; // all expired keys key=>long(timestamp) ... } struct zset { dict *dict; // all values value=>score zskiplist *zsl; }
dict 内部结构
dict 结构内部包含两个 hashtable,通常情况下只有一个 hashtable 是有值的。但是在 dict 扩容缩容时,需要分配新的 hashtable,然后进行渐进式搬迁,这时候两个 hashtable 存储的分别是旧的 hashtable 和新的 hashtable。待搬迁结束后,旧的 hashtable 被删除,新的 hashtable 取而代之。
struct dict { ... dictht ht[2]; }
所以,字典数据结构的精华就落在了 hashtable 结构上了。
hashtable 的结构和 Java 的 HashMap 几乎是一样的,都是通过分桶的方式解决 hash 冲突。
第一维是数组,第二维是链表。数组中存储的是第二维链表的第一个元素的指针。
struct dictEntry { void* key; void* val; dictEntry* next; // 链接下一个 entry } struct dictht { dictEntry** table; // 二维 long size; // 第一维数组的长度 long used; // hash 表中的元素个数 ... }
渐进式rehash
大字典的扩容是比较耗时间的,需要重新申请新的数组,然后将旧字典所有链表中的元素重新挂接到新的数组下面,这是一个O(n)级别的操作,作为单线程的Redis表示很难承受这样耗时的过程。步子迈大了会扯着蛋,所以Redis使用渐进式rehash小步搬迁。虽然慢一点,但是肯定可以搬完。
dictEntry *dictAddRaw(dict *d, void *key, dictEntry **existing) { long index; dictEntry *entry; dictht *ht; // 这里进行小步搬迁 if (dictIsRehashing(d)) _dictRehashStep(d); /* Get the index of the new element, or -1 if * the element already exists. */ if ((index = _dictKeyIndex(d, key, dictHashKey(d,key), existing)) == -1) return NULL; /* Allocate the memory and store the new entry. * Insert the element in top, with the assumption that in a database * system it is more likely that recently added entries are accessed * more frequently. */ // 如果字典处于搬迁过程中,要将新的元素挂接到新的数组下面 ht = dictIsRehashing(d) ? &d->ht[1] : &d->ht[0]; entry = zmalloc(sizeof(*entry)); entry->next = ht->table[index]; ht->table[index] = entry; ht->used++; /* Set the hash entry fields. */ dictSetKey(d, entry, key); return entry; }
搬迁操作埋伏在当前字典的后续指令中(来自客户端的hset/hdel指令等),但是有可能客户端闲下来了,没有了后续指令来触发这个搬迁,那么Redis就置之不理了么?当然不会,优雅的Redis怎么可能设计的这样潦草。Redis还会在定时任务中对字典进行主动搬迁。
// 服务器定时任务 void databaseCron() { ... if (server.activerehashing) { for (j = 0; j < dbs_per_call; j++) { int work_done = incrementallyRehash(rehash_db); if (work_done) { /* If the function did some work, stop here, we'll do * more at the next cron loop. */ break; } else { /* If this db didn't need rehash, we'll try the next one. */ rehash_db++; rehash_db %= server.dbnum; } } } }
查找过程
插入和删除操作都依赖于查找,先必须把元素找到,才可以进行数据结构的修改操作。hashtable 的元素是在第二维的链表上,所以首先我们得想办法定位出元素在哪个链表上。
func get(key) { let index = hash_func(key) % size; let entry = table[index]; while(entry != NULL) { if entry.key == target { return entry.value; } entry = entry.next; } }
值得注意的是代码中的
hash_func
,它会将 key 映射为一个整数,不同的 key 会被映射成分布比较均匀散乱的整数。只有 hash 值均匀了,整个 hashtable 才是平衡的,所有的二维链表的长度就不会差距很远,查找算法的性能也就比较稳定。hash 函数
hashtable 的性能好不好完全取决于 hash 函数的质量。hash 函数如果可以将 key 打散的比较均匀,那么这个 hash 函数就是个好函数。Redis 的字典默认的 hash 函数是 siphash。siphash 算法即使在输入 key 很小的情况下,也可以产生随机性特别好的输出,而且它的性能也非常突出。对于 Redis 这样的单线程来说,字典数据结构如此普遍,字典操作也会非常频繁,hash 函数自然也是越快越好。
hash 攻击
如果 hash 函数存在偏向性,黑客就可能利用这种偏向性对服务器进行攻击。存在偏向性的 hash 函数在特定模式下的输入会导致 hash 第二维链表长度极为不均匀,甚至所有的元素都集中到个别链表中,直接导致查找效率急剧下降,从
O(1)
退化到O(n)
。有限的服务器计算能力将会被 hashtable 的查找效率彻底拖垮。这就是所谓 hash 攻击。扩容条件
/* Expand the hash table if needed */ static int _dictExpandIfNeeded(dict *d) { /* Incremental rehashing already in progress. Return. */ if (dictIsRehashing(d)) return DICT_OK; /* If the hash table is empty expand it to the initial size. */ if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE); /* If we reached the 1:1 ratio, and we are allowed to resize the hash * table (global setting) or we should avoid it but the ratio between * elements/buckets is over the "safe" threshold, we resize doubling * the number of buckets. */ 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*2); } return DICT_OK; }
正常情况下,当 hash 表中元素的个数等于第一维数组的长度时,就会开始扩容,扩容的新数组是原数组大小的 2 倍。不过如果 Redis 正在做 bgsave,为了减少内存页的过多分离 (Copy On Write),Redis 尽量不去扩容 (
dict_can_resize
),但是如果 hash 表已经非常满了,元素的个数已经达到了第一维数组长度的 5 倍 (dict_force_resize_ratio
),说明 hash 表已经过于拥挤了,这个时候就会强制扩容。缩容条件
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)); }
当 hash 表因为元素的逐渐删除变得越来越稀疏时,Redis 会对 hash 表进行缩容来减少 hash 表的第一维数组空间占用。缩容的条件是元素个数低于数组长度的 10%。缩容不会考虑 Redis 是否正在做 bgsave。
set 的结构
Redis 里面 set 的结构底层实现也是字典,只不过所有的 value 都是 NULL,其它的特性和字典一模一样。
为什么缩容不用考虑 bgsave?
zipList 压缩列表
Redis 为了节约内存空间使用,zset 和 hash 容器对象在元素个数较少的时候,采用压缩列表 (ziplist) 进行存储。压缩列表是一块连续的内存空间,元素之间紧挨着存储,没有任何冗余空隙。
> zadd programmings 1.0 go 2.0 python 3.0 java (integer) 3 > debug object programmings Value at:0x7fec2de00020 refcount:1 encoding:ziplist serializedlength:36 lru:6022374 lru_seconds_idle:6 > hmset books go fast python slow java fast OK > debug object books Value at:0x7fec2de000c0 refcount:1 encoding:ziplist serializedlength:48 lru:6022478 lru_seconds_idle:1
这里,注意观察 debug object 输出的 encoding 字段都是 ziplist,这就表示内部采用压缩列表结构进行存储。
struct ziplist<T> { int32 zlbytes; // 整个压缩列表占用字节数 int32 zltail_offset; // 最后一个元素距离压缩列表起始位置的偏移量,用于快速定位到最后一个节点 int16 zllength; // 元素个数 T[] entries; // 元素内容列表,挨个挨个紧凑存储 int8 zlend; // 标志压缩列表的结束,值恒为 0xFF }
在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,复杂度是 O(1)。而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 了。
压缩列表为了支持双向遍历,所以才会有
ztail_offset
这个字段,用来快速定位到最后一个元素,然后倒着遍历。entry 块随着容纳的元素类型不同,也会有不一样的结构。
struct entry { int<var> prevlen; // 前一个 entry 的字节长度 int<var> encoding; // 元素类型编码 optional byte[] content; // 元素内容 }
它的
prevlen
字段表示前一个 entry 的字节长度,当压缩列表倒着遍历时,需要通过这个字段来快速定位到下一个元素的位置。它是一个变长的整数,当字符串长度小于 254(0xFE) 时,使用一个字节表示;如果达到或超出 254(0xFE) 那就使用 5 个字节来表示。第一个字节是 0xFE(254),剩余四个字节表示字符串长度。你可能会觉得用 5 个字节来表示字符串长度,是不是太浪费了。我们可以算一下,当字符串长度比较长的时候,其实 5 个字节也只占用了不到(5/(254+5))<2%
的空间。encoding
字段存储了元素内容的编码类型信息,ziplist 通过这个字段来决定后面的 content 内容的形式。Redis 为了节约存储空间,对 encoding 字段进行了相当复杂的设计。Redis 通过这个字段的前缀位来识别具体存储的数据形式。下面我们来看看 Redis 是如何根据
encoding
的前缀位来区分内容的:00xxxxxx
最大长度位 63 的短字符串,后面的 6 个位存储字符串的位数,剩余的字节就是字符串的内容。
01xxxxxx xxxxxxxx
中等长度的字符串,后面 14 个位来表示字符串的长度,剩余的字节就是字符串的内容。
10000000 aaaaaaaa bbbbbbbb cccccccc dddddddd
特大字符串,需要使用额外 4 个字节来表示长度。第一个字节前缀是10
,剩余 6 位没有使用,统一置为零。后面跟着字符串内容。不过这样的大字符串是没有机会使用的,压缩列表通常只是用来存储小数据的。
11000000
表示 int16,后跟两个字节表示整数。
11010000
表示 int32,后跟四个字节表示整数。
11100000
表示 int64,后跟八个字节表示整数。
11110000
表示 int24,后跟三个字节表示整数。
11111110
表示 int8,后跟一个字节表示整数。
11111111
表示 ziplist 的结束,也就是 zlend 的值 0xFF。
1111xxxx
表示极小整数,xxxx 的范围只能是 (0001~1101
), 也就是1~13
,因为0000、1110、1111
都被占用了。读取到的 value 需要将 xxxx 减 1,也就是整数0~12
就是最终的 value。
注意到
content
字段在结构体中定义为 optional 类型,表示这个字段是可选的,对于很小的整数而言,它的内容已经内联到 encoding 字段的尾部了。增加元素
因为 ziplist 都是紧凑存储,没有冗余空间 (对比一下 Redis 的字符串结构)。意味着插入一个新的元素就需要调用 realloc 扩展内存。取决于内存分配器算法和当前的 ziplist 内存大小,realloc 可能会重新分配新的内存空间,并将之前的内容一次性拷贝到新的地址,也可能在原有的地址上进行扩展,这时就不需要进行旧内容的内存拷贝。
如果 ziplist 占据内存太大,重新分配内存和拷贝内存就会有很大的消耗。所以 ziplist 不适合存储大型字符串,存储的元素也不宜过多。
级联更新
/* When an entry is inserted, we need to set the prevlen field of the next * entry to equal the length of the inserted entry. It can occur that this * length cannot be encoded in 1 byte and the next entry needs to be grow * a bit larger to hold the 5-byte encoded prevlen. This can be done for free, * because this only happens when an entry is already being inserted (which * causes a realloc and memmove). However, encoding the prevlen may require * that this entry is grown as well. This effect may cascade throughout * the ziplist when there are consecutive entries with a size close to * ZIP_BIG_PREVLEN, so we need to check that the prevlen can be encoded in * every consecutive entry. * * Note that this effect can also happen in reverse, where the bytes required * to encode the prevlen field can shrink. This effect is deliberately ignored, * because it can cause a "flapping" effect where a chain prevlen fields is * first grown and then shrunk again after consecutive inserts. Rather, the * field is allowed to stay larger than necessary, because a large prevlen * field implies the ziplist is holding large entries anyway. * * The pointer "p" points to the first entry that does NOT need to be * updated, i.e. consecutive fields MAY need an update. */ unsigned char *__ziplistCascadeUpdate(unsigned char *zl, unsigned char *p) { size_t curlen = intrev32ifbe(ZIPLIST_BYTES(zl)), rawlen, rawlensize; size_t offset, noffset, extra; unsigned char *np; zlentry cur, next; while (p[0] != ZIP_END) { zipEntry(p, &cur); rawlen = cur.headersize + cur.len; rawlensize = zipStorePrevEntryLength(NULL,rawlen); /* Abort if there is no next entry. */ if (p[rawlen] == ZIP_END) break; zipEntry(p+rawlen, &next); /* Abort when "prevlen" has not changed. */ // prevlen 的长度没有变,中断级联更新 if (next.prevrawlen == rawlen) break; if (next.prevrawlensize < rawlensize) { /* The "prevlen" field of "next" needs more bytes to hold * the raw length of "cur". */ // 级联扩展 offset = p-zl; extra = rawlensize-next.prevrawlensize; // 扩大内存 zl = ziplistResize(zl,curlen+extra); p = zl+offset; /* Current pointer and offset for next element. */ np = p+rawlen; noffset = np-zl; /* Update tail offset when next element is not the tail element. */ // 更新 zltail_offset 指针 if ((zl+intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))) != np) { ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+extra); } /* Move the tail to the back. */ // 移动内存 memmove(np+rawlensize, np+next.prevrawlensize, curlen-noffset-next.prevrawlensize-1); zipStorePrevEntryLength(np,rawlen); /* Advance the cursor */ p += rawlen; curlen += extra; } else { if (next.prevrawlensize > rawlensize) { /* This would result in shrinking, which we want to avoid. * So, set "rawlen" in the available bytes. */ // 级联收缩,不过这里可以不用收缩了,因为 5 个字节也是可以存储 1 个字节的内容的 // 虽然有点浪费,但是级联更新实在是太可怕了,所以浪费就浪费吧 zipStorePrevEntryLengthLarge(p+rawlen,rawlen); } else { // 大小没变,改个长度值就完事了 zipStorePrevEntryLength(p+rawlen,rawlen); } /* Stop here, as the raw length of "next" has not changed. */ break; } } return zl; }
前面提到每个 entry 都会有一个
prevlen
字段存储前一个 entry 的长度。如果内容小于 254 字节,prevlen
用 1 字节存储,否则就是 5 字节。这意味着如果某个 entry 经过了修改操作从 253 字节变成了 254 字节,那么它的下一个 entry 的 prevlen
字段就要更新,从 1 个字节扩展到 5 个字节;如果这个 entry 的长度本来也是 253 字节,那么后面 entry 的 prevlen
字段还得继续更新。如果 ziplist 里面每个 entry 恰好都存储了 253 字节的内容,那么第一个 entry 内容的修改就会导致后续所有 entry 的级联更新,这就是一个比较耗费计算资源的操作。
删除中间的某个节点也可能会导致级联更新,读者可以思考一下为什么?
IntSet 小整数集合
当 set 集合容纳的元素都是整数并且元素个数较小时,Redis 会使用
intset
来存储结合元素。intset
是紧凑的数组结构,同时支持 16 位、32 位和 64 位整数。struct intset<T> { int32 encoding; // 决定整数位宽是 16 位、32 位还是 64 位 int32 length; // 元素个数 int<T> contents; // 整数数组,可以是 16 位、32 位和 64 位 }
为什么 intset 的 encoding 字段和 length 字段使用 32 位整数存储。毕竟它只是用来存储小整数的,长度不应该很长,而且 encoding 只有 16 位、32 位和 64 位三个类型,用一个字节存储就绰绰有余。
> sadd codehole 1 2 3 (integer) 3 > debug object codehole Value at:0x7fec2dc2bde0 refcount:1 encoding:intset serializedlength:15 lru:6065795 lru_seconds_idle:4 > sadd codehole go java python (integer) 3 > debug object codehole Value at:0x7fec2dc2bde0 refcount:1 encoding:hashtable serializedlength:22 lru:6065810 lru_seconds_idle:5
注意观察 debug object 的输出字段 encoding 的值,可以发现当 set 里面放进去了非整数值时,存储形式立即从 intset 转变成了 hash 结构。
为什么 set 集合在数量很小的时候不使用 ziplist 来存储?
skiplist 跳表
Redis 的 zset 是一个复合结构,一方面它需要一个 hash 结构来存储 value 和 score 的对应关系,另一方面需要提供按照 score 来排序的功能,还需要能够指定 score 的范围来获取 value 列表的功能,这就需要另外一个结构「跳跃列表」。
zset 的内部实现是一个 hash 字典加一个跳跃列表 (skiplist)。hash 结构很类似于 Java 语言中的 HashMap 结构。
基本结构
上图就是跳跃列表的示意图,图中只画了四层,Redis 的跳跃表共有 64 层,容纳 个元素应该不成问题。
每一个 kv 块对应的结构如下面的代码中的
zslnode
结构,kv header 也是这个结构,只不过 value 字段是 null 值——无效的,score 是 Double.MIN_VALUE,用来垫底的。kv 之间使用指针串起来形成了双向链表结构,它们是 有序 排列的,从小到大。
不同的 kv 层高可能不一样,层数越高的 kv 越少。同一层的 kv 会使用指针串起来。
每一个层元素的遍历都是从 kv header 出发。
struct zslnode { string value; double score; zslnode*[] forwards; // 多层连接指针 zslnode* backward; // 回溯指针 } struct zsl { zslnode* header; // 跳跃列表头指针 int maxLevel; // 跳跃列表当前的最高层 map<string, zslnode*> ht; // hash 结构的所有键值对 }
查找过程
设想如果跳跃列表只有一层会怎样?
插入删除操作需要定位到相应的位置节点 (定位到最后一个比「我」小的元素,也就是第一个比「我」大的元素的前一个),定位的效率肯定比较差,复杂度将会是 O(n),因为需要挨个遍历。
也许你会想到二分查找,但是二分查找的结构只能是有序数组。跳跃列表有了多层结构之后,这个定位的算法复杂度将会降到 O(lg(n))。
如图所示,我们要定位到那个紫色的 kv,需要从 header 的最高层开始遍历找到第一个节点 (最后一个比「我」小的元素),然后从这个节点开始降一层再遍历找到第二个节点 (最后一个比「我」小的元素),然后一直降到最底层进行遍历就找到了期望的节点 (最底层的最后一个比我「小」的元素)。
我们将中间经过的一系列节点称之为「搜索路径」,它是从最高层一直到最底层的每一层最后一个比「我」小的元素节点列表。
有了这个搜索路径,我们就可以插入这个新节点了。不过这个插入过程也不是特别简单。因为新插入的节点到底有多少层,得有个算法来分配一下,跳跃列表使用的是随机算法。
对于每一个新插入的节点,都需要调用一个随机算法给它分配一个合理的层数。直观上期望的目标是 50% 的 Level1,25% 的 Level2,12.5% 的 Level3,一直到最顶层,因为这里每一层的晋升概率是 50%。
/* Returns a random level for the new skiplist node we are going to create. * The return value of this function is between 1 and ZSKIPLIST_MAXLEVEL * (both inclusive), with a powerlaw-alike distribution where higher * levels are less likely to be returned. */ int zslRandomLevel(void) { int level = 1; while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF)) level += 1; return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL; }
不过 Redis 标准源码中的晋升概率只有 25%,也就是代码中的 ZSKIPLIST_P 的值。所以官方的跳跃列表更加的扁平化,层高相对较低,在单个层上需要遍历的节点数量会稍多一点。
也正是因为层数一般不高,所以遍历的时候从顶层开始往下遍历会非常浪费。跳跃列表会记录一下当前的最高层数
maxLevel
,遍历时从这个 maxLevel 开始遍历性能就会提高很多。插入过程
下面是插入过程的源码,它稍微有点长,不过整体的过程还是比较清晰的。
/* Insert a new node in the skiplist. Assumes the element does not already * exist (up to the caller to enforce that). The skiplist takes ownership * of the passed SDS string 'ele'. */ zskiplistNode *zslInsert(zskiplist *zsl, double score, sds ele) { // 存储搜索路径 zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x; // 存储经过的节点跨度 unsigned int rank[ZSKIPLIST_MAXLEVEL]; int i, level; serverAssert(!isnan(score)); x = zsl->header; // 逐步降级寻找目标节点,得到「搜索路径」 for (i = zsl->level-1; i >= 0; i--) { /* store rank that is crossed to reach the insert position */ rank[i] = i == (zsl->level-1) ? 0 : rank[i+1]; // 如果score相等,还需要比较value while (x->level[i].forward && (x->level[i].forward->score < score || (x->level[i].forward->score == score && sdscmp(x->level[i].forward->ele,ele) < 0))) { rank[i] += x->level[i].span; x = x->level[i].forward; } update[i] = x; } // 正式进入插入过程 /* we assume the element is not already inside, since we allow duplicated * scores, reinserting the same element should never happen since the * caller of zslInsert() should test in the hash table if the element is * already inside or not. */ // 随机一个层数 level = zslRandomLevel(); // 填充跨度 if (level > zsl->level) { for (i = zsl->level; i < level; i++) { rank[i] = 0; update[i] = zsl->header; update[i]->level[i].span = zsl->length; } // 更新跳跃列表的层高 zsl->level = level; } // 创建新节点 x = zslCreateNode(level,score,ele); // 重排一下前向指针 for (i = 0; i < level; i++) { x->level[i].forward = update[i]->level[i].forward; update[i]->level[i].forward = x; /* update span covered by update[i] as x is inserted here */ x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]); update[i]->level[i].span = (rank[0] - rank[i]) + 1; } /* increment span for untouched levels */ for (i = level; i < zsl->level; i++) { update[i]->level[i].span++; } // 重排一下后向指针 x->backward = (update[0] == zsl->header) ? NULL : update[0]; if (x->level[0].forward) x->level[0].forward->backward = x; else zsl->tail = x; zsl->length++; return x; }
首先我们在搜索合适插入点的过程中将「搜索路径」摸出来了,然后就可以开始创建新节点了,创建的时候需要给这个节点随机分配一个层数,再将搜索路径上的节点和这个新节点通过前向后向指针串起来。如果分配的新节点的高度高于当前跳跃列表的最大高度,就需要更新一下跳跃列表的最大高度。
删除过程
删除过程和插入过程类似,都需先把这个「搜索路径」找出来。然后对于每个层的相关节点都重排一下前向后向指针就可以了。同时还要注意更新一下最高层数
maxLevel
。更新过程
当我们调用 zadd 方法时,如果对应的 value 不存在,那就是插入过程。如果这个 value 已经存在了,只是调整一下 score 的值,那就需要走一个更新的流程。假设这个新的 score 值不会带来排序位置上的改变,那么就不需要调整位置,直接修改元素的 score 值就可以了。但是如果排序位置改变了,那就要调整位置。那该如何调整位置呢?
/* Remove and re-insert when score changes. */ if (score != curscore) { zskiplistNode *node; serverAssert(zslDelete(zs->zsl,curscore,ele,&node)); znode = zslInsert(zs->zsl,score,node->ele); /* We reused the node->ele SDS string, free the node now * since zslInsert created a new one. */ node->ele = NULL; zslFreeNode(node); /* Note that we did not removed the original element from * the hash table representing the sorted set, so we just * update the score. */ dictGetVal(de) = &znode->score; /* Update score ptr. */ *flags |= ZADD_UPDATED; } return 1;
一个简单的策略就是先删除这个元素,再插入这个元素,需要经过两次路径搜索。Redis 就是这么干的。
不过 Redis 遇到 score 值改变了就直接删除再插入,不会去判断位置是否需要调整,从这点看,Redis 的 zadd 的代码似乎还有优化空间。关于这一点,读者们可以继续讨论。
如果 score 值都一样呢?
在一个极端的情况下,zset 中所有的 score 值都是一样的,zset 的查找性能会退化为 O(n) 么?Redis 作者自然考虑到了这一点,所以 zset 的排序元素不只看 score 值,如果 score 值相同还需要再比较 value 值 (字符串比较)。
元素排名是怎么算出来的?
前面我们啰嗦了一堆,但是有一个重要的属性没有提到,那就是 zset 可以获取元素的排名 rank。那这个 rank 是如何算出来的?如果仅仅使用上面的结构,rank 是不能算出来的。Redis 在 skiplist 的 forward 指针上进行了优化,给每一个 forward 指针都增加了 span 属性,span 是「跨度」的意思,表示从前一个节点沿着当前层的 forward 指针跳到当前这个节点中间会跳过多少个节点。Redis 在插入删除操作时会小心翼翼地更新 span 值的大小。
struct zslforward { zslnode* item; long span; // 跨度 } struct zsl { String value; double score; zslforward*[] forwards; // 多层连接指针 zslnode* backward; // 回溯指针 }
这样当我们要计算一个元素的排名时,只需要将「搜索路径」上的经过的所有节点的跨度 span 值进行叠加就可以算出元素的最终 rank 值。
文中我们提到当 score 值的变化微小,不会带来位置上的调整时,是不是可以直接修改 score 后就返回?
请读者们对这个问题进行讨论。如果确实如此,可以考虑向 Redis 作者 Antirez 提 issue 了。
于 2018 年 7 月 28 日向 Redis 的 Github Repo 提交了这个小优化的建议 《maybe an optimizable point for zadd operation》,5 天后,Redis 作者 Antirez 接受了这个建议,对 skiplist 的代码做了小修改并 merge 到了 master。
listpack 紧凑列表
Redis 5.0 又引入了一个新的数据结构 listpack,它是对 ziplist 结构的改进,在存储空间上会更加节省,而且结构上也比 ziplist 要精简。它的整体形式和 ziplist 还是比较接近的,如果你认真阅读了 ziplist 的内部结构分析,那么 listpack 也是比较容易理解的。
struct listpack<T> { int32 total_bytes; // 占用的总字节数 int16 size; // 元素个数 T[] entries; // 紧凑排列的元素列表 int8 end; // 同 zlend 一样,恒为 0xFF }
首先这个 listpack 跟 ziplist 的结构几乎一摸一样,只是少了一个
zltail_offset
字段。ziplist 通过这个字段来定位出最后一个元素的位置,用于逆序遍历。不过 listpack 可以通过其它方式来定位出最后一个元素的位置,所以zltail_offset
字段就省掉了。struct lpentry { int<var> encoding; optional byte[] content; int<var> length; }
元素的结构和 ziplist 的元素结构也很类似,都是包含三个字段。不同的是长度字段放在了元素的尾部,而且存储的不是上一个元素的长度,是当前元素的长度。正是因为长度放在了尾部,所以可以省去了
zltail_offset
字段来标记最后一个元素的位置,这个位置可以通过total_bytes
字段和最后一个元素的长度字段计算出来。长度字段使用 varint 进行编码,不同于 skiplist 元素长度的编码为 1 个字节或者 5 个字节,listpack 元素长度的编码可以是 1、2、3、4、5 个字节。同 UTF8 编码一样,它通过字节的最高为是否为 1 来决定编码的长度。
同样,Redis 为了让 listpack 元素支持很多类型,它对 encoding 字段也进行了较为复杂的设计。
0xxxxxxx
表示非负小整数,可以表示0~127
。
10xxxxxx
表示小字符串,长度范围是0~63
,content
字段为字符串的内容。
110xxxxx yyyyyyyy
表示有符号整数,范围是2048~2047
。
1110xxxx yyyyyyyy
表示中等长度的字符串,长度范围是0~4095
,content
字段为字符串的内容。
11110000 aaaaaaaa bbbbbbbb cccccccc dddddddd
表示大字符串,四个字节表示长度,content
字段为字符串内容。
11110001 aaaaaaaa bbbbbbbb
表示 2 字节有符号整数。
11110010 aaaaaaaa bbbbbbbb cccccccc
表示 3 字节有符号整数。
11110011 aaaaaaaa bbbbbbbb cccccccc dddddddd
表示 4 字节有符号整数。
11110011 aaaaaaaa ... hhhhhhhh
表示 8 字节有符号整数。
11111111
表示 listpack 的结束符号,也就是0xFF
。
级联更新
listpack 的设计彻底消灭了 ziplist 存在的级联更新行为,元素与元素之间完全独立,不会因为一个元素的长度变长就导致后续的元素内容会受到影响。
取代 ziplist
listpack 的设计的目的是用来取代 ziplist,不过当下还没有做好替换 ziplist 的准备,因为有很多兼容性的问题需要考虑,ziplist 在 Redis 数据结构中使用太广泛了,替换起来复杂度会非常之高。它目前只使用在了新增加的
Stream
数据结构中。Radix Tree 基数树
Rax 是 Redis 内部比较特殊的一个数据结构,它是一个有序字典树 (基数树 Radix Tree),按照 key 的字典序排列,支持快速地定位、插入和删除操作。Redis 五大基础数据结构里面,能作为字典使用的有 hash 和 zset。hash 不具备排序功能,zset 则是按照 score 进行排序的。rax 跟 zset 的不同在于它是按照 key 进行排序的。Redis 作者认为 rax 的结构非常易于理解,但是实现却有相当的复杂度,需要考虑很多的边界条件,需要处理节点的分裂、合并,一不小心就会出错。
应用
你可以将一本英语字典看成一棵 radix tree,它所有的单词都是按照字典序进行排列,每个词汇都会附带一个解释,这个解释就是 key 对应的 value。有了这棵树,你就可以快速地检索单词,还可以查询以某个前缀开头的单词有哪些。
你也可以将公安局的人员档案信息看成一棵 radix tree,它的 key 是每个人的身份证号,value 是这个人的履历。因为身份证号的编码的前缀是按照地区进行一级一级划分的,这点和单词非常类似。有了这棵树,你就可以快速地定位出人员档案,还可以快速查询出某个小片区都有哪些人。
Radix tree 还可以应用于时间序列应用,key 为时间戳,value 为发生在具体时间的事件内容。因为时间戳的编码也是按照【年月日时分秒毫秒微秒纳秒】进行一级一级划分的,所以它也可以使用字典序来排序。有了这棵数,我们就可以快速定位出某个具体时间发生了什么事,也可以查询出一段时间内都有哪些事发生。
我们经常使用的 Web 服务器的 Router 它也是一棵 radix tree。这棵树上挂满了 URL 规则,每个 URL 规则上都会附上一个请求处理器。当一个请求到来时,我们拿这个请求的 URL 沿着树进行遍历,找到相应的请求处理器来处理。因为 URL 中可能存在正则 pattern,而且同一层的节点对顺序没有要求,所以它不算是一棵严格的 radix tree。
# golang 的 HttpRouter 库 The router relies on a tree structure which makes heavy use of *common prefixes* it is basically a *compact* [*prefix tree*](https://en.wikipedia.org/wiki/Trie) (or just [*Radix tree*](https://en.wikipedia.org/wiki/Radix_tree)). Nodes with a common prefix also share a common parent. Here is a short example what the routing tree for the `GET` request method could look like: Priority Path Handle 9 \ *<1> 3 ├s nil 2 |├earch\ *<2> 1 |└upport\ *<3> 2 ├blog\ *<4> 1 | └:post nil 1 | └\ *<5> 2 ├about-us\ *<6> 1 | └team\ *<7> 1 └contact\ *<8>
Rax 被用在 Redis
Stream
结构里面用于存储消息队列,在 Stream
里面消息 ID 的前缀是时间戳 + 序号,这样的消息可以理解为时间序列消息。使用 Rax 结构进行存储就可以快速地根据消息 ID 定位到具体的消息,然后继续遍历指定消息之后的所有消息。Rax 被用在 Redis Cluster 中用来记录槽位和key的对应关系,这个对应关系的变量名成叫着
slots_to_keys
。这个raxNode的key是由槽位编号hashslot和key组合而成的。我们知道cluster的槽位数量是16384,它需要2个字节来表示,所以rax节点里存的key就是2个字节的hashslot和对象key拼接起来的字符串。因为rax的key是按照key前缀顺序挂接的,意味着同样的hashslot的对象key将会挂在同一个raxNode下面。这样我们就可以快速遍历具体某个槽位下面的所有对象key。
结构
rax 中有非常多的节点,根节点、叶节点和中间节点,有些中间节点带有 value,有些中间节点纯粹是结构性需要没有对应的 value。
struct raxNode { int<1> isKey; // 是否没有 key,没有 key 的是根节点 int<1> isNull; // 是否没有对应的 value,无意义的中间节点 int<1> isCompressed; // 是否压缩存储,这个压缩的概念比较特别 int<29> size; // 子节点的数量或者是压缩字符串的长度 (isCompressed) byte[] data; // 路由键、子节点指针、value 都在这里 }
rax 是一棵比较特殊的 radix tree,它在结构上不是标准的 radix tree。如果一个中间节点有多个子节点,那么路由键就只是一个字符。如果只有一个子节点,那么路由键就是一个字符串。后者就是所谓的「压缩」形式,多个字符压在一起的字符串。比如前面的那棵字典树在 Rax 算法中将呈现出如下结构:
图中的深蓝色节点就是「压缩」节点。
接下来我们再细看
raxNode.data
里面存储的到底是什么东西,它是一个比较复杂的结构,按照压缩与否分为两种结构压缩结构 子节点如果只有一个,那就是压缩结构,data 字段如下伪代码所示:
struct data { optional struct { // 取决于 header 的 size 字段是否为零 byte[] childKey; // 路由键 raxNode* childNode; // 子节点指针 } child; optional string value; // 取决于 header 的 isNull 字段 }
如果是叶子节点,child 字段就不存在。如果是无意义的中间节点 (isNull),那么 value 字段就不存在。
非压缩节点 如果子节点有多个,那就不是压缩结构,存在多个路由键,一个键是一个字符。
struct data { byte[] childKeys; // 路由键字符列表 raxNode*[] childNodes; // 多个子节点指针 optional string value; // 取决于 header 的 isNull 字段 }
也许你会想到如果子节点只有一个,并且路由键字符串的长度为 1 呢,那到底算压缩还是非压缩?仔细思考一下,在这种情况下,压缩和非压缩在数据结构表现形式上是一样的,不管 isCompressed 是 0 还好是 1,结构都是一样的。