换一种存储方式,居然能节约这么多内存()

前言
提到缓存,就会想到redis,提到 Redis,我们的脑子里马上就会出现一个词:快。那么我们也知道,redis 之所以这么快,因为数据是放在内存中的,但是内存是非常昂贵的,怎么来设计我们的应用的存储结构,让应用满足正常的业务的前提下来节约内存呢?首先我们从Redis的数据类型开始看起。
Redis 的数据类型及底层实现
说到redis的数据类型,大家肯定会说:不就是 String(字符串)、List(列表)、Hash(哈希)、Set(集合)和 Sorted Set(有序集合)吗?”
其实,这些只是 Redis 键值对中值的数据类型,也就是数据的保存形式。
而这里,我们说的数据结构,是要去看看它们的底层实现。简单说底层数据结构一共有 6 种:

  • 简单动态字符串
  • 双向链表
  • 压缩列表
  • 哈希表
  • 跳表和整数数组。
Redis 存储结构总览
其实在Redis中,并不是单纯将key 与value保存到内存中就可以的。它需要依赖上面讲的数据结构对其进行管理。
换一种存储方式,居然能节约这么多内存()
文章图片

因为这个哈希表保存了所有的键值对,所以,我也把它称为全局哈希表。哈希表的最大好处很明显,就是让我们可以用 O(1) 的时间复杂度来快速查找到键值对——我们只需要计算键的哈希值,就可以知道它所对应的哈希桶位置,然后就可以访问相应的 entry 元素。每个entry 都会有一个数据类型。
Redis 不同数据类型的编码
而且每个类型又对应了多种编码格式,不同的编码格式对应的底层数据结构是不同的。可以先做个了解,后面会具体用到。
编码 编码对应的底层数据结构
INT long 类型的整数
EMBSTR embstr 编码的简单动态字符串
RAW 简单动态字符串
HT 字典 HASH\_TABLE
LINKEDLIST 双端链表
ZIPLIST 压缩列表
INTSET 整数集合
SKIPLIST 跳跃表和字典
类型与编码映射
类型 编码 编码对应的底层数据结构
STRING INT long 类型的整数
STRING EMBSTR embstr 编码的简单动态字符串
STRING RAW 简单动态字符串
LIST ZIPLIST 压缩列表
LIST QUICKLIST 快速列表
LIST LINKEDLIST 双端链表
HASH ZIPLIST 压缩列表
HASH HT 字典
SET INTSET 整数集合
SET HT 字典
ZSET ZIPLIST 压缩列表
ZSET SKIPLIST 跳跃表和字典
具体的映射关系
1. 字符串类型(STRING)对象
编码方式 说明
编码方式说明OBJ\_ENCODING\_RAW 长度超过 44 个字节的字符串以这种方式编码
OBJ\_ENCODING\_INT 可以被转化为 long 类型整数的字符串以这种方式编码
OBJ\_ENCODING\_EMBSTR 不能被转化为 long 类型整数且长度不超过 44 个字节的字符串
|
2. 集合类型(SET)对象
编码方式 说明
编码方式说明OBJ\_ENCODING\_INTSET 添加的元素是整数,且保存的整数个数不超过配置项 set\_max\_intset\_entries (默认512)
OBJ\_ENCODING\_HT 添加的元素不是整数或者整数的个数超过配置项 set\_max\_intset\_entries (默认512)
|
3. 有序集合类型(ZSET)对象
有序集合类型的对象有两种编码方式:OBJ\_ENCODING\_SKIPLIST、OBJ\_ENCODING\_ZIPLIST。Redis 对于有序集合类型的编码有两个配置项:
  • zset\_max\_ziplist\_entries,默认值为 128,表示有序集合中压缩列表节点的最大数量。
  • zset\_max\_ziplist\_value,默认值为 64,表示有序集合中压缩列表节点的最大长度。
编码方式 说明
OBJ\_ENCODING\_ZIPLIST 添加的元素长度不超过 zset\_max\_ziplist\_value,且压缩列表节点数量不超过zset\_max\_ziplist\_entries
OBJ\_ENCODING\_SKIPLIST OBJ\_ENCODING\_SKIPLIST添加的元素长度超过 zset\_max\_ziplist\_value,或压缩列表节点数量超过zset\_max\_ziplist\_entries,会将压缩列表转化为跳表
注:当删除或更新元素,使得满足以上两个配置项时,编码方式是不会自动从 OBJ\_ENCODING\_SKIPLIST 转化为 OBJ\_ENCODING\_ZIPLIST 的,但 Redis 提供了函数 zsetConvertToZiplistIfNeeded 支持。
4. 哈希类型(HASH)对象
哈希类型对象有两种编码方式:OBJ\_ENCODING\_ZIPLIST、OBJ\_ENCODING\_HT。Redis 对于哈希类型对象的编码有两个配置项:
  • hash\_max\_ziplist\_entries,默认值 512, 表示哈希类型对象中压缩列表节点的最大数量。
  • hash\_max\_ziplist\_value,默认值 64,表示哈希类型对象中压缩列表节点的最大长度。
编码方式 说明
OBJ\_ENCODING\_ZIPLIST 添加的元素长度不超过 hash\_max\_ziplist\_value,且压缩列表节点数量不超过hash\_max\_ziplist\_entries
OBJ\_ENCODING\_HT 添加的元素长度超过 hash\_max\_ziplist\_value,或压缩列表节点数量超过hash\_max\_ziplist\_entries,会将压缩列表转化为字典
注:当删除或更新元素,使得满足以上两个配置项时,编码方式是不会自动从 OBJ\_ENCODING\_HT 向 OBJ\_ENCODING\_ZIPLIST 转化。
【换一种存储方式,居然能节约这么多内存()】关于压缩链表
因为这个和我们后面的优化有关系,我们先来看看什么是压缩链表。
压缩列表实际上类似于一个数组,数组中的每一个元素都对应保存一个数据。和数组不同的是,压缩列表在表头有三个字段 zlbytes、zltail 和 zllen,分别表示列表长度、列表尾的偏移量和列表中的 entry 个数;压缩列表在表尾还有一个 zlend,表示列表结束。
换一种存储方式,居然能节约这么多内存()
文章图片

  • prev\_len,表示前一个 entry 的长度。prev\_len 有两种取值情况:1 字节或 5 字节。取值 1 字节时,表示上一个 entry 的长度小于 254 字节。虽然 1 字节的值能表示的数值范围是 0 到 255,但是压缩列表中 zlend 的取值默认是 255,因此,就默认用 255 表示整个压缩列表的结束,其他表示长度的地方就不能再用 255 这个值了。所以,当上一个 entry 长度小于 254 字节时,prev\_len 取值为 1 字节,否则,就取值为 5 字节。
  • len:表示自身长度,4 字节;
  • encoding:表示编码方式,1 字节;
  • content:保存实际数据。
压缩链表的查询
压缩列表的设计不是为了查询的,而是为了减少内存的使用和内存的碎片化。比如一个列表中的只保存int,结构上还需要两个额外的指针prev和next,每添加一个结点都这样。而压缩列表是将这些数据集合起来只需要一个prev和next。
在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,复杂度是 O(1)。而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 了。
为什么 String 类型内存开销大?
对于kv 类型的缓存数据,我们经常会用redis string 类型。比如日常工作中经常对合作方客户一周内是否营销进行缓存,key 为 32位的hash(用户编码),value 为是否(0或者1)营销。很符合string 的数据类型。
但是随着营销数据量的不断增加,我们的 Redis 内存使用量也在增加,结果就遇到了大内存 Redis 实例因为生成 RDB 而响应变慢的问题。很显然,String 类型并不是一种好的选择,需要进一步寻找能节省内存开销的数据类型方案。
通过上面的文章,我们对string 类型进行研究,会发现:
当你保存 64 位有符号整数时,String 类型会把它保存为一个 8 字节的 Long 类型整数,这种保存方式通常也叫作 int 编码方式。但是,当你保存的数据中包含字符时,String 类型就会用简单动态字符串(Simple Dynamic String,SDS)结构体来保存,
另一方面,当保存的是字符串数据,并且字符串小于等于 44 字节时,RedisObject 中的元数据、指针和 SDS 是一块连续的内存区域,这样就可以避免内存碎片。这种布局方式也被称为 embstr 编码方式。
int、embstr 和 raw 这三种编码模式,如下所示:
换一种存储方式,居然能节约这么多内存()
文章图片

根据文章开头的示意图,redis 全局hash 表中的 entry 元素 其实是dictEntry,dictEntry 结构中有三个 8 字节的指针,分别指向 key、value 以及下一个 dictEntry,三个指针共 24 字节,如下图所示:
换一种存储方式,居然能节约这么多内存()
文章图片

而且redis 模式使用jemalloc进行内存管理, jemalloc 在分配内存时,会根据我们申请的字节数 N,找一个比 N 大,但是最接近 N 的 2 的幂次数作为分配的空间,这样可以减少频繁分配的次数。所以,我们用string这种存储简单数据的方式比较浪费内存!!
用什么数据结构可以节省内存?
那么,用什么数据结构可以节约内存呢?就是我们上面讲的压缩列表(ziplist),这是一种非常节省内存的结构。
通过前文编码对应的底层数据结构我们了解到,使用压缩链表实现的数据结构有 List、Hash 和 Sorted Set 这样的集合类型。
基于压缩列表最大好处就是节省了 dictEntry 的开销。当你用 String 类型时,一个键值对就有一个 dictEntry。但采用集合类型时,一个 key 就对应一个集合的数据,能保存的数据多了很多,但也只用了一个 dictEntry,这样就节省了内存。
通过研究hash数据结构。我发现,hash类型有非常节省内存空间的底层实现结构,但是,hash类型保存的数据模式,是一个键对应一系列值,并不适合直接保存单值的键值对。所以就使用二级编码【二级编码:把32位前3位作为key,后29位作为field】的方法,实现了用集合类型保存单值键值对,Redis 实例的内存空间消耗明显下降了。
实验数据
我们模拟20w数据的写入,看看string 类型和hash 类型分别对内存的占用情况。
string类型:
hash类型
只是改了一个存储结构,内存节约了大概2/3.
二级编码使用注意事项
因为Redis Hash 类型的两种底层实现结构,分别是压缩列表和哈希表。
那么,Hash 类型底层结构什么时候使用压缩列表,什么时候使用哈希表呢?根据上面表格的内容,我们知道有两个阈值:
  • hash-max-ziplist-entries:表示用压缩列表保存时哈希集合中的最大元素个数。
  • hash-max-ziplist-value:表示用压缩列表保存时哈希集合中单个元素的最大长度。
如果我们往 Hash 集合中写入的元素个数超过了 hash-max-ziplist-entries,或者写入的单个元素大小超过了 hash-max-ziplist-value,Redis 就会自动把 Hash 类型的实现结构由压缩列表转为哈希表。一旦从压缩列表转为了哈希表,Hash 类型就会一直用哈希表进行保存,而不会再转回压缩列表了。在节省内存空间方面,哈希表就没有压缩列表那么高效了。
所以要根据实际情况调整二级编码的实现方式,节约内存,提高redis的响应速度!
通过 config get 命令 我们可以查看 阀值的设置:
换一种存储方式,居然能节约这么多内存()
文章图片

    推荐阅读