36-源码 3:挨肩迭背 —— 探索「压缩列表」内部
课程
1
开篇:授人以鱼不若授人以渔 —— Redis 可以用来做什么?
学习时长: 5分21秒
2
基础:万丈高楼平地起 —— Redis 基础数据结构
上次学到
学习时长: 16分14秒
3
应用 1:千帆竞发 —— 分布式锁
学习时长: 7分47秒
4
应用 2:缓兵之计 —— 延时队列
学习时长: 8分9秒
5
应用 3:节衣缩食 —— 位图
学习时长: 8分52秒
6
应用 4:四两拨千斤 —— HyperLogLog
学习时长: 14分17秒
7
应用 5:层峦叠嶂 —— 布隆过滤器
学习时长: 17分54秒
8
应用 6:断尾求生 —— 简单限流
学习时长: 4分37秒
9
应用 7:一毛不拔 —— 漏斗限流
学习时长: 7分22秒
10
应用 8:近水楼台 —— GeoHash
学习时长: 7分52秒
11
应用 9:大海捞针 —— Scan
学习时长: 8分42秒
12
原理 1:鞭辟入里 —— 线程 IO 模型
学习时长: 4分1秒
13
原理 2:交头接耳 —— 通信协议
学习时长: 3分34秒
14
原理 3:未雨绸缪 —— 持久化
学习时长: 5分27秒
15
原理 4:雷厉风行 —— 管道
学习时长: 3分51秒
16
原理 5:同舟共济 —— 事务
学习时长: 6分36秒
17
原理 6:小道消息 —— PubSub
学习时长: 7分7秒
18
原理 7:开源节流 —— 小对象压缩
学习时长: 7分14秒
19
原理 8:有备无患 —— 主从同步
学习时长: 4分9秒
20
集群 1:李代桃僵 —— Sentinel
学习时长: 3分52秒
21
集群 2:分而治之 —— Codis
学习时长: 7分28秒
22
集群 3:众志成城 —— Cluster
学习时长: 8分38秒
23
拓展 1:耳听八方 —— Stream
学习时长: 13分40秒
24
拓展 2:无所不知 —— Info 指令
学习时长: 4分4秒
25
拓展 3:拾遗补漏 —— 再谈分布式锁
学习时长: 2分18秒
26
拓展 4:朝生暮死 —— 过期策略
学习时长: 2分21秒
27
拓展 5:优胜劣汰 —— LRU
学习时长: 4分34秒
28
拓展 6:平波缓进 —— 懒惰删除
学习时长: 2分13秒
29
拓展 7:妙手仁心 —— 优雅地使用 Jedis
学习时长: 6分35秒
30
拓展 8:居安思危 —— 保护 Redis
学习时长: 2分19秒
31
拓展 9:隔墙有耳 —— Redis 安全通信
学习时长: 6分34秒
32
拓展 10:法力无边 —— Redis Lua 脚本执行原理
学习时长: 9分24秒
33
拓展 11:短小精悍 —— 命令行工具的妙用
学习时长: 9分21秒
34
源码 1:丝分缕析 —— 探索「字符串」内部
学习时长: 5分20秒
35
源码 2:循序渐进 —— 探索「字典」内部
学习时长: 7分24秒
36
源码 3:挨肩迭背 —— 探索「压缩列表」内部
学习时长: 10分42秒
37
源码 4:风驰电掣 —— 探索「快速列表」内部
学习时长: 3分49秒
38
源码 5:凌波微步 —— 探索「跳跃列表」内部
学习时长: 9分57秒
39
源码 6:破旧立新 —— 探索「紧凑列表」内部
学习时长: 2分42秒
40
源码 7:金枝玉叶 —— 探索「基数树」内部
学习时长: 5分36秒
41
源码 8:精益求精 —— LFU vs LRU
学习时长: 8分4秒
42
源码 9:如履薄冰 —— 懒惰删除的巨大牺牲
学习时长: 9分53秒
43
源码 10:跋山涉水 —— 深入字典遍历
学习时长: 9分24秒
44
源码 11:见缝插针 —— 探索 HyperLogLog 内部
学习时长: 13分3秒
45
尾声:百尺竿头 —— 继续深造指南
学习时长: 2分32秒
juejin_logo copyCreated with Sketch.

源码 3:挨肩迭背 —— 探索「压缩列表」内部

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
}

压缩列表为了支持双向遍历,所以才会有 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的前缀位来区分内容的:

  1. 00xxxxxx 最大长度位 63 的短字符串,后面的 6 个位存储字符串的位数,剩余的字节就是字符串的内容。
  2. 01xxxxxx xxxxxxxx 中等长度的字符串,后面 14 个位来表示字符串的长度,剩余的字节就是字符串的内容。
  3. 10000000 aaaaaaaa bbbbbbbb cccccccc dddddddd 特大字符串,需要使用额外 4 个字节来表示长度。第一个字节前缀是10,剩余 6 位没有使用,统一置为零。后面跟着字符串内容。不过这样的大字符串是没有机会使用的,压缩列表通常只是用来存储小数据的。
  4. 11000000 表示 int16,后跟两个字节表示整数。
  5. 11010000 表示 int32,后跟四个字节表示整数。
  6. 11100000 表示 int64,后跟八个字节表示整数。
  7. 11110000 表示 int24,后跟三个字节表示整数。
  8. 11111110 表示 int8,后跟一个字节表示整数。
  9. 11111111 表示 ziplist 的结束,也就是 zlend 的值 0xFF。
  10. 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 结构。

思考

  1. 为什么 set 集合在数量很小的时候不使用 ziplist 来存储?
  2. 执行rpush codehole 1 2 3命令后,请写出列表内容的 16 进制形式。
留言
Ctrl + Enter
全部评论(61)
BinK_1783的头像
删除
天台一场雨后,烟花布满夜空
点赞
回复
山治不是香吉的头像
删除
“为什么 intset 的 encoding 字段和 length 字段使用 32 位整数存储?”
猜测,会不会是为了混合不同位数存储呢?比如说32位代表的是16个整数的encoding,对应后面16个content,每2位代表一个content的位数。这样可以更加节省空间。比如存储64位表示的数字11时,其实前48个0完全没有必要存储,我直接用16位0x0000000000000111存储就可以了。甚至可以00表示8位,01表示16位,10表示32位,11表示64位,这样的话,可以用0x00000111存储,消耗的内存更少。
点赞
回复
younglionwell的头像
删除
C++
intset 有序,ziplist 无序。intset 可以折半查找,ziplist则不行。
点赞
2
删除
sorted set 不用 intset 是因为它的元素是 key+score,而 intset 只能存最基本的各种 int
点赞
回复
删除
like this...
sorted set 不用 intset 是因为它的元素是 key+score,而 intset 只能存最基本的各种 int
点赞
回复
WiseJason的头像
删除
4 5 6没看懂,怎么冒出来的
点赞
回复
maybe_的头像
删除
后台 @ BIG Company
第一句话错了叭?我不信hash元素少的时候是用ziplist存的
2
5
删除
我错了
1
回复
删除
打脸现场
1
回复
查看更多回复
程序猿七哥的头像
删除
java工程师
为什么 intset 的 encoding 字段和 length 字段使用 32 位整数存储?
1. 当我们创建一个 intset 集合的时候,redis 会直接创建一个 intset_enc_int16(16bit) 的intset。
2. 我们添加的数据可能占 2字节,也可能占 4字节,也可能占 8字节,这就涉及到 intset 的升级了。
3. intset 只有升级没有降级。
2
1
删除
但是1个字节也足够去表示64位的encode
点赞
回复
春天百花开的头像
删除
为什么删除中间元素会造成级联更新?
删除中间元素导致之后的元素都需要向前移动,这个就是级联更新?
1
1
删除
级联更新时由于当前元素存储了前一个节点的长度,存储前一个节点的长度时为了通过自己当前的entry的大小-前一个节点的来求前一个节点的指针位置
点赞
回复
more_money的头像
删除
make money @ money make
ziplist怎样对zset进行排序的?难道是因为元素个数少,遍历在内存排序么?
点赞
2
删除
按score排序。
点赞
回复
删除
存进去的时候就是按score有序存的
点赞
回复
zorroyue的头像
删除
因为不需要计算长度
点赞
回复
杜阿的头像
删除
java程序媛
是因为set的结构是value为null的hashmap吗?值都是null,如果变成ziplist则浪费空间?
点赞
回复
姚先生爱学习的头像
删除
服务端工程师 @ 小米MIUI
list和hash在元素较少时,采用ziplist存储吧。
1
回复
晨儿哥的头像
删除
架构师
1. 为什么 set 集合在数量很小的时候不使用 ziplist 来存储?
set需要去重, 而ziplist不支持; set支持16/21/64bit的整数类型, 当插入高类型时候, 会有升级, 如果不插入, 则会一直用小类型来存储, 这都是ziplist不支持的
2
2
删除
zset也需要去重呀
点赞
回复
删除
hash的key也涉及到去重,这个又真么讲
点赞
回复
TiTi57868的头像
删除
“Redis 为了节约内存空间使用,zset 和 hash 容器对象在元素个数较少的时候,采用压缩列表 (ziplist) 进行存储”,这句话能说的详细点么?
1)zset 中的元素是一个二元组,这个二元组是怎么用ziplist存储的呢?是把二元组当做ziplist的T,还是把score和value分开存储?
2)hash的底层存储结构不是dict么?我的理解是当某个桶里的元素个数较少的时候,该桶的存储结构用的是ziplist,不知道对不对?
还请老钱解答下,谢谢!
展开
点赞
2
删除
百度搜了下 , 是分开存的, key后面存value, 他俩内存连续
点赞
回复
删除
前面几章老钱讲过,szet的ziplist的score和value是紧挨着存储的
点赞
回复
spacedong的头像
删除
后端工程师
为什么字符串是小于254时是一个字节呢?当这个字符串是小于或者等于255的时候,也是一个字节?这里不是很理解。
点赞
2
删除
一个字节最大能表示 2^8= 256-1,大于253就需要至少2个字节
点赞
回复
删除
这是因为:255已经定义为ziplist结束标记的值了。在ziplist的很多操作的实现中,都会根据数据项的第1个字节是不是255来判断当前是不是到达ziplist的结尾了,因此一个正常的数据的第1个字节(也就是的第1个字节)是不能够取255这个值的,否则就冲突了。
1
回复
懒猫cz的头像
删除
保洁 @ 金拱门(中国)有限公司
我有个疑问,老钱,你在计算这些结构存储时用不用考虑内存对齐的问题,实际占用内存可能比这个大吧
2
1
删除
Redis强制不做内存对齐
点赞
回复
Kee不想说话53081的头像
删除
元素个数少时会使用ziplist,那这个个数是多少呢 ?
点赞
4
删除
默认512,可以配置
点赞
回复
删除
这个在源码里面看到的么?在哪配呢?
默认512,可以配置
点赞
回复
查看更多回复
pyb1993的头像
删除
关于intset,我猜测是由于这点内存剩下来的非常少,所以图简单没有去改
如果一定要扣这个内存,我觉得可以这样搞:
encoding 占3种类型,3个比特,前两个表示类型
lenght本身不会太长,所以13个比特应该够了?(2^13 = 16384)
这样一共2个byte搞定。

关于为什么 set 集合在数量很小的时候不使用 ziplist 来存,我是这样「猜测」的:
1 首先ziplist的entry需要保存prevlen,这个就多占用了一些存储空间
2 其次在于遍历的时候,intset由于所有元素的占用空间大小一样,所以可以用O(1)的方式来定位任意index的元素,这样可以利用二分查找,从而确保集合最重要的特点不重复以及O(1)的查找速度得到近似的保证(N较小时),这个在ziplist里面得不到保证。
3 当然是否intset更省空间未必,因为如果存在一个int32的元素加一堆int8的case,其实ziplist还省一点.
展开
3
2
删除
元素少且是整数时候用intset解释合理
点赞
回复
删除
二分查找只能用于有序的数组
点赞
回复
肉松在掘金的头像
删除
为什么 set 集合在数量很小的时候不使用 ziplist 来存储?
set对元素顺序没有要求,而ziplist需保存前一个元素的指针,对于追求极致空间利用的redis来说承受不起
2
5
删除
照这么说的话,hash也是对元素顺序没有要求的,也可以不用ziplist来保存前一个元素的长度
点赞
回复
删除
有道理,我考虑得太简单了,静待antirez答疑
点赞
回复
查看更多回复