Redis 原理-对象的结构

redis 数据结构

1660291520353

SDS

Redis 是用 C 语言写的,但是对于Redis的字符串,却不是 C 语言中的字符串(即以空字符’\0’结尾的字符数组),它是自己构建了一种名为 简单动态字符串(simple dynamic string,SDS)的抽象类型,并将 SDS 作为 Redis的默认字符串表示

因为C语言字符串存在很多问题:

  • 获取字符串长度的需要通过运算
  • 非二进制安全
  • 不可修改

例如,我们执行命令:

1653984583289

那么Redis将在底层创建两个SDS,其中一个是包含“name”的SDS,另一个是包含“虎哥”的SDS。

SDS是什么

Redis是C语言实现的,其中SDS是一个结构体,源码如下:

1653984624671

例如,一个包含字符串“name”的sds结构如下:第一次分配时并不会分配多余空间

1653984648404

SDS之所以叫做动态字符串,是因为它具备动态扩容的能力,例如一个内容为“hi”的SDS:

1653984787383

假如我们要给SDS追加一段字符串“,Amy”,这里首先会申请新内存空间:

如果新字符串小于1M,则新空间为扩展后字符串长度的两倍+1;

如果新字符串大于1M,则新空间为扩展后字符串长度+1M+1。称为内存预分配。

1653984822363

SDS的优点

  1. 获取字符串长度的时间复杂度为O(1)
  2. 支持动态扩容
  3. 支持内存预分配,减少用户线程与内核线程交互次数
  4. 二进制安全

一般来说,SDS 除了保存数据库中的字符串值以外,SDS 还可以作为缓冲区(buffer):包括 AOF 模块中的AOF缓冲区以及客户端状态中的输入缓冲区

intset

intset是 set 集合的一种实现方式,基于整数数组来实现,并且具备长度可变、有序等特征。

intset是什么?

结构如下:

1653984923322

其中的encoding包含三种模式,表示存储的整数大小不同:

1653984942385

为了方便查找,Redis会将intset中所有的整数按照升序依次保存在contents数组中,结构如图:

1653985149557

现在,数组中每个数字都在int16_t的范围内,因此采用的编码方式是INTSET_ENC_INT16,每部分占用的字节大小为:

  • encoding:4字节 (可以理解为标识每个元素的类型)
  • length:4字节
  • contents:2字节 * 3 = 6字节

intset自动升级

我们向该其中添加一个数字:50000,这个数字超出了int16_t的范围,intset会自动升级编码方式到合适的大小。
以当前案例来说流程如下:

  • 升级编码为 INTSET_ENC_INT32 , 每个整数占4字节,并按照新的编码方式及元素个数扩容数组
  • 倒序依次将数组中的元素拷贝到扩容后的正确位置
  • 将待添加的元素放入数组末尾
  • 最后,将 inset 的 encoding 属性改为INTSET_ENC_INT32,将length属性改为4

那么如果我们删除掉刚加入的int32类型时,会不会做一个降级操作呢?

不会。主要还是减少开销的权衡

1653985276621

源码如下:

1653985304075

1653985327653

总结:

Intset可以看做是特殊的整数数组,具备一些特点:

  • Redis会确保Intset中的元素唯一、有序
  • 具备类型升级机制,可以节省内存空间
  • 底层采用二分查找方式来查询

Dict

我们知道Redis是一个键值型(Key-Value Pair)的数据库,我们可以根据键实现快速的增删改查。而键与值的映射关系正是通过Dict来实现的。是 set 和 hash 的实现方式之一

Dict是什么?

Dict由三部分组成,分别是:哈希表(DictHashTable)、哈希节点(DictEntry)、字典(Dict)

1653985396560

当我们向 Dict 添加键值对时,Redis 首先根据key计算出hash值(h),然后利用 h & sizemask 来计算元素应该存储到数组中的哪个索引位置。我们存储k1=v1,假设k1的哈希值h =1,则1&3 =1,因此 k1=v1 要存储到数组角标1位置。

注意这里还有一个指向下一个哈希表节点的指针,我们知道哈希表最大的问题是存在哈希冲突,如何解决哈希冲突,有开放地址法和链地址法。这里采用的便是链地址法,通过next这个指针可以将多个哈希值相同的键值对连接在一起,用来解决哈希冲突

1653985497735

Dict由三部分组成,分别是:哈希表(DictHashTable)、哈希节点(DictEntry)、字典(Dict)

1653985570612

深入理解

  • 哈希算法:Redis计算哈希值和索引值方法如下:
1
2
3
4
5
6
#1、使用字典设置的哈希函数,计算键 key 的哈希值
hash = dict->type->hashFunction(key);

#2、使用哈希表的sizemask属性和第一步得到的哈希值,计算索引值
index = hash & dict->ht[x].sizemask;

  • 解决哈希冲突:这个问题上面我们介绍了,方法是链地址法。通过字典里面的 *next 指针指向下一个具有相同索引值的哈希表节点。

  • 扩容和收缩:当哈希表保存的键值对太多或者太少时,就要通过 rerehash(重新散列)来对哈希表进行相应的扩展或者收缩。具体步骤:

    1. 计算新hash表的realeSize,值取决于当前要做的是扩容还是收缩:
      • 如果是扩容,则新size为第一个大于等于dict.ht[0].used + 1的2^n
      • 如果是收缩,则新size为第一个小于等于dict.ht[0].used的2^n (不得小于4)
    2. 按照新的realeSize申请内存空间,创建 dictht ,并赋值给dict.ht[1]
    3. 设置dict.rehashidx = 0,标示开始rehash
    4. 将dict.ht[0]中的每一个 dictEntry 都 rehash 到 dict.ht[1]
    5. 将dict.ht[1] 赋值给 dict.ht[0],给 dict.ht[1] 初始化为空哈希表,释放原来的dict.ht[0]的内存
    6. 将rehashidx赋值为-1,代表rehash结束
    7. 在rehash过程中,新增操作,则直接写入ht[1],查询、修改和删除则会在dict.ht[0]和dict.ht[1]依次查找并执行。这样可以确保ht[0]的数据只减不增,随着rehash最终为空
  • 触发扩容的条件

    1. 服务器目前没有执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且负载因子大于等于1。
    2. 服务器目前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且负载因子大于等于5。

ps:负载因子 = 哈希表已保存节点数量 / 哈希表大小。

  • 渐近式 rehash

什么叫渐进式 rehash?也就是说扩容和收缩操作不是一次性、集中式完成的,而是分多次、渐进式完成的。如果保存在Redis中的键值对只有几个几十个,那么 rehash 操作可以瞬间完成,但是如果键值对有几百万,几千万甚至几亿,那么要一次性的进行 rehash,势必会造成Redis一段时间内不能进行别的操作。所以Redis采用渐进式 rehash,这样在进行渐进式rehash期间,字典的删除查找更新等操作可能会在两个哈希表上进行,第一个哈希表没有找到,就会去第二个哈希表上进行查找。但是进行 增加操作,一定是在新的哈希表上进行的。

总结:

Dict的结构:

  • 类似java的HashTable,底层是数组加链表来解决哈希冲突
  • Dict包含两个哈希表,ht[0]平常用,ht[1]用来rehash

Dict的伸缩:

  • 当LoadFactor大于5或者LoadFactor大于1并且没有子进程任务时,Dict扩容
  • 当LoadFactor小于0.1时,Dict收缩
  • 扩容大小为第一个大于等于used + 1的2^n^
  • 收缩大小为第一个大于等于used 的2^n^
  • Dict采用渐进式rehash,每次访问Dict时执行一次rehash
  • rehash时ht[0]只减不增,新增操作只在ht[1]执行,其它操作在两个哈希表

ZipList

ZipList 是一种特殊的“双端链表” ,由一系列特殊编码的连续内存块组成。可以在任意一端进行压入/弹出操作, 并且该操作的时间复杂度为 O(1)。

ZipList是什么?

1653985987327

1653986020491

zlbytes : 字段的类型是uint32_t , 这个字段中存储的是整个ziplist所占用的内存的字节数

zltail : 字段的类型是uint32_t , 它指的是ziplist中最后一个entry的偏移量. 用于快速定位最后一个entry, 以快速完成pop等操作

zllen : 字段的类型是uint16_t , 它指的是整个ziplit中entry的数量. 这个值只占2bytes(16位): 如果ziplist中entry的数目小于65535(2的16次方), 那么该字段中存储的就是实际entry的值. 若等于或超过65535, 那么该字段的值固定为65535, 但实际数量需要一个个entry的去遍历所有entry才能得到。

zlend是一个终止字节, 其值为全F, 即0xff. ziplist保证任何情况下, 一个entry的首字节都不会是255

ZipListEntry

ZipList 中的Entry并不像普通链表那样记录前后节点的指针,因为记录两个指针要占用16个字节,浪费内存。而是采用了下面的结构:

1653986055253

  • previous_entry_length:前一节点的长度,占1个或5个字节。
    • 如果前一节点的长度小于254字节,则采用1个字节来保存这个长度值
    • 如果前一节点的长度大于254字节,则采用5个字节来保存这个长度值,第一个字节为0xfe,后四个字节才是真实长度数据
  • encoding:编码属性,记录 content 的数据类型(字符串还是整数)以及长度,占用1个、2个或5个字节
  • contents:负责保存节点的数据,可以是字符串或整数

第一种情况:一般结构 <prevlen> <encoding> <entry-data>

prevlen:前一个entry的大小,编码方式见下文;

encoding:不同的情况下值不同,用于表示当前entry的类型和长度;

entry-data:真是用于存储entry表示的数据;

第二种情况:在 entry 中存储的是 int 类型时,encoding 和 entry-data 会合并在encoding中表示,此时没有entry-data字段;

redis中,在存储数据时,会先尝试将 string 转换成 int 存储,节省空间;此时 entry 结构:<prevlen> <encoding>

  • prevlen 编码

当前一个元素长度小于254(255用于zlend)的时候,prevlen长度为1个字节,值即为前一个entry的长度,如果长度大于等于254的时候,prevlen用5个字节表示,第一字节设置为254,后面4个字节存储一个小端的无符号整型,表示前一个 entry 的长度;

1
2
<prevlen from 0 to 253> <encoding> <entry>      //长度小于254结构
0xFE <4 bytes unsigned little endian prevlen> <encoding> <entry> //长度大于等于254
  • encoding编码

encoding 的长度和值根据保存的是 int 还是 string,还有数据的长度而定;

前两位用来表示类型,当为“11”时,表示 entry 存储的是 int 类型,其它表示存储的是 string;

存储string时

|00xxxxxx| :此时encoding长度为1个字节,该字节的后六位表示entry中存储的string长度,因为是6位,所以entry中存储的string长度不能超过63;

|01xxxxxx|xxxxxxxx| 此时 encoding 长度为两个字节;此时 encoding 的后14位用来存储string长度,长度不能超过16383;

|10000000|xxxxxxxx|xxxxxxxx|xxxxxxxx|xxxxxxxx| 此时 encoding 长度为 5 个字节,后面的4个字节用来表示encoding中存储的字符串长度,长度不能超过2^32 - 1;

存储int时

|11000000| encoding为3个字节,后2个字节表示一个int16;

|11010000| encoding为5个字节,后4个字节表示一个int32;

|11100000| encoding为9个字节,后8字节表示一个int64;

|11110000| encoding为4个字节,后3个字节表示一个有符号整型;

|11111110| encoding为2字节,后1个字节表示一个有符号整型;

|1111xxxx| encoding长度就只有1个字节,xxxx表示一个0 - 12的整数值;

|11111111| zlend

ZipList的连锁更新问题

  • ZipList的每个 Entry 都包含 previous_entry_length 来记录上一个节点的大小,长度是1个或5个字节:
  • 如果前一节点的长度小于 254 字节,则采用 1 个字节来保存这个长度值
  • 如果前一节点的长度大于等于 254 字节,则采用 5 个字节来保存这个长度值,第一个字节为 0xfe,后四个字节才是真实长度数据
  • 现在,假设我们有N个连续的、长度为250~253字节之间的entry,因此entry的previous_entry_length属性用1个字节即可表示,如图所示:

1653986328124

ZipList这种特殊情况下产生的连续多次空间扩展操作称之为连锁更新(Cascade Update)。新增、删除都可能导致连锁更新的发生。虽然发生的条件非常苛刻,但不代表不会发生

小总结:

ZipList特性:

  • 压缩列表的可以看做一种连续内存空间的”双向链表”
  • 列表的节点之间不是通过指针连接,而是记录上一节点和本节点长度来寻址,内存占用较低
  • 如果列表数据过多,导致链表过长,可能影响查询性能
  • 增或删较大数据时有可能发生连续更新问题

QuickList

QuickList是什么?

ZipList虽然节省内存,但申请内存必须是连续空间,但是我们要存储大量数据,内存中碎片比较多,很难找到一块大的连续空间。于是 ,大数据量下,内存申请效率低成了 ziplist 的最大问题,而 quickList 就是为了帮助 zipList 摆脱困境的。

  • 为了缓解内存申请效率低的问题,QuickList 提供了可限制 ZipList 的最大节点数 和 每个entry 的大小的方式。
  • 那么对于大数据量 ,我们可以采用分片的思想,存储在多个 ZipList 中 ,而QuickList 可以将这些ZipList 作为节点连接起来。

1653986474927

为了避免 QuickList 中的每个 ZipList 中 entry 过多,Redis 提供了一个配置项:list-max-ziplist-size 来限制。

  • 如果值为正,则代表ZipList的允许的entry个数的最大值
  • 如果值为负,则代表ZipList的最大内存大小,分5种情况:
    • -1:每个ZipList的内存占用不能超过4kb
    • -2:每个ZipList的内存占用不能超过8kb
    • -3:每个ZipList的内存占用不能超过16kb
    • -4:每个ZipList的内存占用不能超过32kb
    • -5:每个ZipList的内存占用不能超过64kb

其默认值为 -2:

1653986642777

以下是 QuickList 的和 QuickListNode 的结构源码:

1653986667228

QuickList内存布局

1653986718554

总结

QuickList的特点:

  • 是一个节点为ZipList的双端链表
  • 节点采用 ZipList ,解决了传统链表的内存占用问题
  • 控制了 ZipList 大小,解决连续内存空间申请效率问题
  • 中间节点可以压缩,进一步节省了内存

SkipList

跳跃表结构在 Redis 中的运用场景只有一个,那就是作为有序列表 (Zset) 的使用。跳跃表的性能可以保证在查找,删除,添加等操作的时候在对数期望时间内完成,这个性能是可以和平衡树来相比较的,而且在实现方面比平衡树要优雅,这就是跳跃表的长处。跳跃表的缺点就是需要的存储空间比较大,属于利用空间来换取时间的数据结构

SkipList 是什么?

SkipList(跳表)首先是链表,但与传统链表相比有几点差异:

  • 元素按照升序排列存储
  • 节点可能包含多个指针,指针跨度不同。

1653986771309

SkipListNode结构

  • ele字段,持有数据,是sds类型
  • score字段, 其标示着结点的得分, 结点之间凭借得分来判断先后顺序, 跳跃表中的结点按结点的得分升序排列.
  • backward指针, 这是原版跳跃表中所没有的. 该指针指向结点的前一个紧邻结点.
  • level 字段, 用以记录所有结点(除过头节点外);每个结点中最多持有32个zskiplistLevel结构. 实际数量在结点创建时, 按幂次定律随机生成(不超过32). 每个 zskiplistLevel 中有两个字段
  • forward字段指向比自己得分高的某个结点(不一定是紧邻的), 并且, 若当前zskiplistLevel实例在level[]中的索引为X, 则其forward字段指向的结点, 其 level[] 字段的容量至少是X+1. 这也是上图中, 为什么forward指针总是画的水平的原因.
  • span字段代表forward字段指向的结点, 距离当前结点的距离. 紧邻的两个结点之间的距离定义为1.

skiplist与平衡树、哈希表的比较

来源于:https://www.jianshu.com/p/8ac45fd01548

skiplist和各种平衡树(如AVL、红黑树等)的元素是有序排列的,而哈希表不是有序的。因此,在哈希表上只能做单个key的查找,不适宜做范围查找。所谓范围查找,指的是查找那些大小在指定的两个值之间的所有节点。

在做范围查找的时候,平衡树比skiplist操作要复杂。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在skiplist上进行范围查找就非常简单,只需要在找到小值之后,对第1层链表进行若干步的遍历就可以实现。

平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而 skiplist 的插入和删除只需要修改相邻节点的指针,操作简单又快速。

从内存占用上来说,skiplist比平衡树更灵活一些。一般来说,平衡树每个节点包含2个指针(分别指向左右子树),而skiplist每个节点包含的指针数目平均为1/(1-p),具体取决于参数p的大小。如果像Redis里的实现一样,取p=1/4,那么平均每个节点包含1.33个指针,比平衡树更有优势。

查找单个key,skiplist 和 平衡树 的时间复杂度都为O(log n),大体相当;而哈希表在保持较低的哈希值冲突概率的前提下,查找时间复杂度接近O(1),性能更高一些。所以我们平常使用的各 Map或 dictionary 结构,大都是基于哈希表实现的。

从算法实现难度上来比较,skiplist比平衡树要简单得多。

小总结:

SkipList的特点:

  • 跳跃表是一个双向链表,每个节点都包含score和ele值
  • 节点按照score值排序,score值一样则按照ele字典排序
  • 每个节点都可以包含多层指针,层数是1到32之间的随机数
  • 不同层指针到下一个节点的跨度不同,层级越高,跨度越大
  • 增删改查效率与红黑树基本一致,实现却更简单

RedisObject

RedisObject是什么?

Redis 中的任意数据类型的键和值都会被封装为一个 RedisObject,也叫做 Redis 对象,

从Redis的使用者的角度来看,⼀个Redis节点包含多个database(非cluster模式下默认是16个,cluster模式下只能是1个),而一个database维护了从 key space 到 object space 的映射关系。这个映射关系的key是string类型,⽽value可以是多种数据类型,比如:string, list, hash、set、sorted set等。我们可以看到,key 的类型固定是 string ,而 value 可能的类型是多个。
⽽从 Redis 内部实现的⾓度来看,database内的这个映射关系是用⼀个 dict 来维护的。dict 的 key 固定用⼀种数据结构来表达就够了,这就是动态字符串 sds。而 value 则比较复杂,为了在同⼀个 dict 内能够存储不同类型的 value,这就需要⼀个通⽤的数据结构,这个通用的数据结构就是 robj,全名是redisObject。

1653986956618

  • type : 占 4bit , 五个取值类型,表示对象类型 String , Hash , List , set , zset。
  • encoding : 占 4bit ,十一种编码方式
  • lru : 占用3字节,记录最后一次被访问的时间,主要用于 lru 算法,最近最少使用
  • refcount :占用 4字节,引用计数器,无人用就回收
  • *ptr:占用 8字节 ,只想存放实际数据的空间

redis 的头部占用 16 字节

encoding取值:

Redis中会根据存储的数据类型不同,选择不同的编码方式,共包含11种不同类型:

编号 编码方式 说明
0 OBJ_ENCODING_RAW raw编码动态字符串
1 OBJ_ENCODING_INT long类型的整数的字符串
2 OBJ_ENCODING_HT hash表(字典dict)
3 OBJ_ENCODING_ZIPMAP 已废弃
4 OBJ_ENCODING_LINKEDLIST 双端链表
5 OBJ_ENCODING_ZIPLIST 压缩列表
6 OBJ_ENCODING_INTSET 整数集合
7 OBJ_ENCODING_SKIPLIST 跳表
8 OBJ_ENCODING_EMBSTR embstr的动态字符串
9 OBJ_ENCODING_QUICKLIST 快速列表
10 OBJ_ENCODING_STREAM Stream流

五种数据结构

Redis中会根据存储的数据类型不同,选择不同的编码方式。每种数据类型的使用的编码方式如下:

数据类型 编码方式
OBJ_STRING int、embstr、raw
OBJ_LIST LinkedList和ZipList(3.2以前)、QuickList(3.2以后)
OBJ_SET intset、HT
OBJ_ZSET ZipList、HT、SkipList
OBJ_HASH ZipList、HT

redis 数据类型

String

字符串是Redis最基本的数据类型,不仅所有key都是字符串类型,其它几种数据类型构成的元素也是字符串。注意字符串的长度不能超过512M。

编码方式(encoding)

字符串对象的编码可以是 int ,raw 或者 embstr 。

  • int 编码:保存的是可以用 long 类型表示的整数值。
  • embstr 编码:保存长度小于44字节的字符串(redis3.2版本之前是39字节,之后是44字节)。
  • raw 编码:保存长度大于44字节的字符串(redis3.2版本之前是39字节,之后是44字节)。

1660454081842

int 编码是用来保存整数值,而embstr是用来保存短字符串,raw编码是用来保存长字符串。

raw编码

1653987103450

*ptr 指向实际SDS存储位置。内存不连续

embstr 编码

1653987172764

内存连续,意味着redis在申请内存空间时只需要调用一次申请内存函数,减少用户态内核态交换,效率高。

int编码

如果存储的字符串是整数值,并且大小在LONG_MAX范围内,则会采用INT编码:直接将数据保存在RedisObject的ptr指针位置(刚好8字节),不再需要SDS了。

小总结

1653987202522

List

list 列表,它是简单的字符串列表,按照插入顺序排序,你可以添加一个元素到列表的头部(左边)或者尾部(右边),它的底层实际上是个链表结构。

编码方式(encoding)

列表对象的编码是quicklist。 (之前版本中有 linkedList 和ziplist这两种编码。进一步的, 目前Redis定义的10个对象编码方式宏名中, 有两个被完全闲置了, 分别是: OBJ_ENCODING_ZIPMAPOBJ_ENCODING_LINKEDLIST。 从Redis的演进历史上来看, 前者是后续可能会得到支持的编码值(代码还在), 后者则应该是被彻底淘汰了)

内存布局

1653987313461

Set

集合对象 set 是 string 类型(整数也会转换成string类型进行存储)的无序集合。注意集合和列表的区别:集合中的元素是无序的,因此不能通过索引来操作元素;集合中的元素不能有重复。

编码方式(encoding)

集合对象的编码可以是 intset 或者 hashtable; 底层实现有两种, 分别是 intset 和 dict 。 显然当使用 intset 作为底层实现的数据结构时, 集合中存储的只能是数值数据, 且必须是整数; 而当使用dict作为集合对象的底层实现时, 是将数据全部存储于dict的键中, 值字段闲置不用.

内存布局

1653987454403

编码转换

当集合同时满足以下两个条件时,使用 intset 编码:

  1. 集合对象中所有元素都是整数
  2. 集合对象所有元素数量不超过512

不能满足这两个条件的就使用 hashtable 编码。第二个条件可以通过配置文件的 set-max-intset-entries 进行配置。

Zset

和上面的集合对象相比,有序集合对象是有序的。与列表使用索引下标作为排序依据不同,有序集合为每个元素设置一个分数(score)作为排序依据。

编码方式(encoding)

  • SkipList & HT(Dict):SkipList 可以排序,并且可以同时存储score和ele值(member);HT 可以键值存储,并且可以根据key找 value
  • ZipList :当 节点 entry 数量 小于128 并且 每个节点大小小于 64kb 时采用

内存结构

SkipList & HT(Dict)

1653992172526

ZipList

1653992299740

当元素数量不多时,HT 和 SkipList 的优势不明显,而且更耗内存。因此zset还会采用 ZipList 结构来节省内存,不过需要同时满足两个条件:

  • 元素数量小于 zset_max_ziplist_entries,默认值128
  • 每个元素都小于 zset_max_ziplist_value 字节,默认值64

ziplist本身没有排序功能,而且没有键值对的概念,因此需要有zset通过编码实现:

  • ZipList是连续内存,因此score和element是紧挨在一起的两个entry, element在前,score在后
  • score越小越接近队首,score越大越接近队尾,按照score值升序排列

Hash

哈希对象的键是一个字符串类型,值是一个键值对集合。

编码方式(encoding)

哈希对象的编码可以是 ziplist 或者 hashtable;对应的底层实现有两种, 一种是 ziplist, 一种是 dict。

内存布局

1653992413406

Hash结构与Redis中的Zset非常类似:

  • 都是键值存储
  • 都需求根据键获取值
  • 键必须唯一

区别如下:

  • zset 的键是 member,值是score;hash的键和值都是任意值
  • zset 要根据 score 排序;hash则无需排序

当Hash中数据项比较少的情况下,Hash 底层才⽤压缩列表 ziplist 进⾏存储数据,随着数据的增加,底层的 ziplist 就可能会转成dict,具体配置如下:

hash-max-ziplist-entries 512

hash-max-ziplist-value 64