前言
提到缓存,就会想到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类型:
- Long id = 10000000000l;
- for (Long i=0l;i<200000l;i++){
- id+=i;
- System.out.println(i);
- try {
- String encode = MD5.encode(id+"");
- jCacheClient.set(encode,"1");
- sleeptime(1);//防止qps 过高
- } catch (Exception e) {
- e.printStackTrace();
- sleeptime(1000);
- }
- }
- flushdb
- +OK
- info memory
- $1165
- # Memory
- used_memory:135261368
- info memory
- $1166
- # Memory
- used_memory:156517632
- 使用 20.27m
hash类型
- Long id = 10000000000l;
- for (Long i=0l;i<200000l;i++){
- id+=i;
- try {
- String encode = MD5.encode(id+"");
- String prex = encode.substring(0,3);
- String key = encode.substring(3,32);
- jCacheClient.hset(prex,key,"1");
- sleeptime(1);
- } catch (Exception e) {
- e.printStackTrace();
- sleeptime(1000);
- }
- }
- flushdb
- +OK
- info memory
- $1165
- # Memory
- used_memory:135220400
- info memory
- $1166
- # Memory
- used_memory:142697280
- 内存使用 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 命令 我们可以查看 阀值的设置: