当前位置: 首页 > 科技观察

换一种存储方式,居然能省那么多内存?

时间:2023-03-18 17:44:39 科技观察

前言一提到缓存,就会想到redis。一提到Redis,我们的脑海里马上就会出现一个词:快。那么我们也知道,redis之所以这么快,是因为数据是存放在内存中的,但是内存是非常昂贵的。如何设计我们应用程序的存储结构,使应用程序既能满足正常的业务需求,又能节省内存?首先,让我们从Redis的数据类型说起。Redis的数据类型和底层实现说到redis的数据类型,大家肯定会说:不只是String(字符串)、List(列表)、Hash(散列)、Set(集合)和SortedSet(有序集合))是吗?”其实这些只是Redis键值对的数据类型,也就是数据的存储形式。而这里我们说的数据结构,就是看他们的底层实现。简单的说,底层数据结构一共有6种类型:简单动态字符串双向链表压缩表哈希表跳表和整型数组Redis存储结构概述其实在Redis中,仅仅保存key和value是不够的在内存中。它需要依赖上面提到的数据结构来管理它。因为这个哈希表保存了所有的键值对,所以我也称它为全局哈希表。哈希表最大的好处是显而易见的,就是,我们可以使用O(1)时间复杂度来快速找到键值对——我们只需要计算key的hash值就知道其对应的hashbucket位置,然后我们就可以访问对应的entry元素了。每个条目都将具有A数据类型。Redis对不同的数据类型进行编码,每种类型对应多种编码格式。不同的编码格式对应不同的底层数据结构。您可以先了解它,然后再使用它。Encoding对应底层数据结构INTlong类型整型EMBSTRembstr编码简单动态字符串RAW简单动态字符串HT字典HASH_TABLELINKEDLIST双端链表ZIPLIST压缩列表INTSET整型集合SKIPLIST跳表和字典类型和编码映射类型encoding数据结构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,表示排序集中ziplist节点的最大长度。注意:当删除或更新元素满足以上两个配置项时,编码方式不会自动从OBJ_ENCODING_SKIPLIST转换为OBJ_ENCODING_ZIPLIST,但Redis提供了函数zsetConvertToZiplistIfNeeded的支持。4、哈希类型(HASH)对象哈希类型对象有两种编码方式:OBJ_ENCODING_ZIPLIST、OBJ_ENCODING_HT。Redis对于hash类型对象的编码有两个配置项:hash_max_ziplist_entries,默认值为512,表示hash类型对象中ziplist节点的最大个数。hash_max_ziplist_value,默认值为64,表示hash类型对象中ziplist节点的最大长度。注意:删除或更新元素以满足以上两个配置项时,编码方式不会自动从OBJ_ENCODING_HT转换为OBJ_ENCODING_ZIPLIST。关于压缩链表,因为这关系到我们后续的优化,所以先来看看什么是压缩链表。压缩列表其实类似于一个数组,数组中的每一个元素对应保存了一条数据。与数组不同的是,压缩列表在表头有三个字段zlbytes、zltail和zllen,分别表示列表的长度、列表尾部的偏移量和列表的条目数;压缩列表在表的末尾也有一个zlend,表示列表结束。prev_len,表示前一个条目的长度。prev_len有两个值:1个字节或5个字节。当该值为1字节时,表示前一个条目的长度小于254字节。虽然1字节的取值范围是0到255,但是压缩列表中zlend的默认值是255,所以默认使用255来表示整个压缩列表的结束,其他表示长度的地方不能使用.255是价值。因此,当前一个表项的长度小于254字节时,prev_len的值为1字节,否则为5字节。len:表示自身的长度,4个字节;encoding:表示编码方式,1字节;内容:保存实际数据。查询压缩链表压缩链表不是为了查询而设计的,而是为了减少内存使用和内存碎片。比如一个list中只存放了int,结构中需要额外增加两个指针prev和next,每增加一个结点都是如此。压缩列表只需要一个prev和next来收集这些数据。在压缩列表中,如果我们要查找并定位第一个元素和最后一个元素,可以直接通过三个头域的长度来定位,复杂度为O(1)。在查找其他元素的时候,效率就没那么高了,只能一个一个的查找,此时的复杂度是O(N)。为什么String类型内存开销大?对于kv类型的缓存数据,我们经常使用redis的string类型。比如在日常工作中,经常会缓存合作客户一周内是否在营销,key是一个32位的hash(用户代码),value是是否(0或1)营销。非常适合字符串的数据类型。但是随着营销数据量的不断增加,我们的Redis内存使用量也在不断增加。于是,我们遇到了大内存Redis实例由于生成RDB导致响应慢的问题。显然,String类型并不是一个好的选择,需要进一步寻找能够节省内存开销的数据类型方案。通过上面的文章,我们研究了string类型,我们会发现,当你保存一个64位有符号整数时,String类型会将其保存为一个8字节的Long类型整数,通常称为int编码。但是,当您保存的数据包含字符时,String类型将以简单动态字符串(SimpleDynamicString,SDS)结构保存。另一方面,当你保存字符串数据,且字符串小于等于44字节时,RedisObject中的元数据、指针和SDS是一块连续的内存区域,这样可以避免内存碎片。这种布局也称为embstr编码。int、embstr、raw三种编码方式如下:根据文章开头的示意图,redis全局哈希表中的entry元素其实就是dictEntry,dictEntry中有3个8字节的指针structure,指向key,value和下一个dictEntry,共有三个指针,共24字节,如下图所示:并且redis模式使用jemalloc进行内存管理。jemalloc在分配内存的时候,会根据我们申请的字节数,找一个比N大的,但是把最接近N的2的次方数作为分配的空间,这样可以减少频繁分配的次数。因此,存储像字符串这样的简单数据是一种内存浪费!!什么数据结构可以用来节省内存?那么,用什么数据结构可以节省内存呢?就是我们上面提到的压缩列表(ziplist),这是一个非常节省内存的结构。通过前面代码对应的底层数据结构,我们知道使用压缩链表实现的数据结构包括List、Hash、SortedSet等集合类型。基于压缩列表的最大好处是节省了dictEntry的开销。当你使用String类型时,有一个键值对的dictEntry。使用集合类型时,一个key对应一个数据集合,可以节省很多数据,但是只使用一个dictEntry,节省内存。通过研究哈希数据结构。我发现hash类型有一个节省内存空间的底层实现结构。但是hash类型保存的数据模式是一个key对应一系列value,不适合直接存储单值键值对。因此采用二次编码的方法【二次编码:以32位的前3位为key,后29位为字段】来保存集合类型的单值键值对,内存Redis实例的空间消耗显着降低。.实验数据我们模拟写入20w条数据,分别看string类型和hash类型的内存占用。字符串类型:Longid=10000000000l;for(Longi=0l;i<200000l;i++){id+=i;System.out.println(i);try{Stringencode=MD5.encode(id+"");jCacheClient.set(encode,"1");sleeptime(1);//防止qps过高}catch(Exceptione){e.printStackTrace();sleeptime(1000);}}flushdb+OKinfomemory$1165#Memoryused_memory:135261368infomemory$1166#Memoryused_memory:156517632使用20.27mhash类型Longid=10000000000l;for(Longi=0l;i<200000l;i++){id+=i;try{Stringencode=MD5.encode(id+"");Stringprex=encode.substring(0,3);Stringkey=encode.substring(3,32);jCacheClient.hset(prex,key,"1");sleeptime(1);}catch(Exceptione){e.printStackTrace();sleeptime(1000);}}flushdb+OKinfomemory$1165#Memoryuused_memory:135220400infomemory$1166#Memoryuused_memory:142697280内存使用7.13M只是换了一个存储结构,节省了大概2/3的内存。二次编码使用注意事项因为RedisHash类型的两种底层实现结构是压缩列表和哈希表。那么,Hash类型的底层结构什么时候使用压缩列表,什么时候使用哈希表呢?根据上表的内容,我们知道有两个阈值:hash-max-ziplist-entries:表示使用压缩列表时hash集合中保存的最大元素数。hash-max-ziplist-value:表示保存在压缩列表中时哈希集合中单个元素的最大长度。如果我们写入Hash集合的元素个数超过hash-max-ziplist-entries,或者写入的单个元素的大小超过hash-max-ziplist-value,Redis会自动将Hash类型的实现结构从Compressed改变列表到哈希表。一旦从压缩列表转换为哈希表,哈希类型将一直存储在哈希表中,不会再转换回压缩列表。在节省内存空间方面,哈希表的效率不如压缩列表。所以需要根据实际情况调整二次编码的实现,节省内存,提高redis的响应速度!通过configget命令,我们可以查看阈值设置: