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

开发 前端
提到缓存,就会想到redis,提到 Redis,我们的脑子里马上就会出现一个词:快。首先我们从Redis的数据类型开始看起。

前言

提到缓存,就会想到redis,提到 Redis,我们的脑子里马上就会出现一个词:快。那么我们也知道,redis 之所以这么快,因为数据是放在内存中的,但是内存是非常昂贵的,怎么来设计我们的应用的存储结构,让应用满足正常的业务的前提下来节约内存呢?首先我们从Redis的数据类型开始看起。

Redis 的数据类型及底层实现

说到redis的数据类型,大家肯定会说:不就是 String(字符串)、List(列表)、Hash(哈希)、Set(集合)和 Sorted Set(有序集合)吗?”

其实,这些只是 Redis 键值对中值的数据类型,也就是数据的保存形式。

而这里,我们说的数据结构,是要去看看它们的底层实现。简单说底层数据结构一共有 6 种:

  • 简单动态字符串
  • 双向链表
  • 压缩列表
  • 哈希表
  • 跳表和整数数组。

Redis 存储结构总览

其实在Redis中,并不是单纯将key 与value保存到内存中就可以的。它需要依赖上面讲的数据结构对其进行管理。

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

因为这个哈希表保存了所有的键值对,所以,我也把它称为全局哈希表。哈希表的最大好处很明显,就是让我们可以用 O(1) 的时间复杂度来快速查找到键值对——我们只需要计算键的哈希值,就可以知道它所对应的哈希桶位置,然后就可以访问相应的 entry 元素。每个entry 都会有一个数据类型。

Redis 不同数据类型的编码

而且每个类型又对应了多种编码格式,不同的编码格式对应的底层数据结构是不同的。可以先做个了解,后面会具体用到。

编码编码对应的底层数据结构INTlong 类型的整数EMBSTRembstr 编码的简单动态字符串RAW简单动态字符串HT字典 HASH_TABLELINKEDLIST双端链表ZIPLIST压缩列表INTSET整数集合SKIPLIST跳跃表和字典

类型与编码映射

类型编码编码对应的底层数据结构STRINGINTlong 类型的整数STRINGEMBSTRembstr 编码的简单动态字符串STRINGRAW简单动态字符串LISTZIPLIST压缩列表LISTQUICKLIST快速列表LISTLINKEDLIST双端链表HASHZIPLIST压缩列表HASHHT字典SETINTSET整数集合SETHT字典ZSETZIPLIST压缩列表ZSETSKIPLIST跳跃表和字典

具体的映射关系

1. 字符串类型(STRING)对象

2. 集合类型(SET)对象

3. 有序集合类型(ZSET)对象

有序集合类型的对象有两种编码方式:OBJ_ENCODING_SKIPLIST、OBJ_ENCODING_ZIPLIST。Redis 对于有序集合类型的编码有两个配置项:

  • zset_max_ziplist_entries,默认值为 128,表示有序集合中压缩列表节点的最大数量。
  • zset_max_ziplist_value,默认值为 64,表示有序集合中压缩列表节点的最大长度。

注:当删除或更新元素,使得满足以上两个配置项时,编码方式是不会自动从 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_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类型:

  1. Long id = 10000000000l; 
  2.     for (Long i=0l;i<200000l;i++){ 
  3.         id+=i; 
  4.         System.out.println(i); 
  5.         try { 
  6.             String encode = MD5.encode(id+""); 
  7.             jCacheClient.set(encode,"1"); 
  8.             sleeptime(1);//防止qps 过高 
  9.         } catch (Exception e) { 
  10.             e.printStackTrace(); 
  11.             sleeptime(1000); 
  12.         } 
  13.     } 
  1. flushdb 
  2. +OK 
  3. info memory 
  4. $1165 
  5. # Memory 
  6. used_memory:135261368 
  7.  
  8.  
  9. info memory 
  10. $1166 
  11. # Memory 
  12. used_memory:156517632 
  13. 使用 20.27m 

hash类型

  1. Long id = 10000000000l; 
  2.     for (Long i=0l;i<200000l;i++){ 
  3.         id+=i; 
  4.         try { 
  5.             String encode = MD5.encode(id+""); 
  6.             String prex = encode.substring(0,3); 
  7.             String key = encode.substring(3,32); 
  8.             jCacheClient.hset(prex,key,"1"); 
  9.             sleeptime(1); 
  10.         } catch (Exception e) { 
  11.             e.printStackTrace(); 
  12.             sleeptime(1000); 
  13.         } 
  14.     } 
  1. flushdb 
  2. +OK 
  3. info memory 
  4. $1165 
  5. # Memory 
  6. used_memory:135220400 
  7.  
  8.  
  9. info memory 
  10. $1166 
  11. # Memory 
  12. used_memory:142697280 
  13. 内存使用 7.13M 

只是改了一个存储结构,内存节约了大概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 命令 我们可以查看 阀值的设置:

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

 

责任编辑:姜华 来源: 今日头条
相关推荐

2019-10-28 11:30:43

架构数据结构布隆过滤器

2021-09-28 12:25:30

数据库

2019-07-22 15:59:21

2023-07-07 19:23:08

微软文字Claude

2023-01-26 23:46:15

2009-02-26 10:29:00

2014-03-07 10:46:49

编程语言趣味

2017-08-11 14:21:33

软件开发前端框架

2023-07-17 08:21:52

漏洞版本项目

2020-12-01 08:19:15

Redis

2020-11-20 10:22:34

代码规范设计

2013-08-12 09:31:39

Windows操作系统

2018-06-26 15:00:24

Docker安全风险

2021-06-05 07:33:09

ID分布式架构

2024-02-20 08:09:51

Java 8DateUtilsDate工具类

2018-07-18 08:59:32

Redis存储模式

2023-11-13 08:49:54

2021-06-09 10:10:20

代码内存编程语言

2021-03-24 08:44:11

代码内存消耗语言

2023-09-11 11:53:51

物联网协议物联网
点赞
收藏

51CTO技术栈公众号