这次码哥给大家分享一些优化技巧。当你在面试或工作中遇到以下问题时,那就用上今天学到的这招,一招搞定!如何用更少的内存保存更多的数据?我们应该从Redis如何保存数据的原理出发,分析一下键值对的存储结构和原理。从而继续扩展各个数据类型的底层数据结构,针对不同的场景使用更合适的数据结构和编码,以实现更少的内存占用。Redis为了保存数据,需要先申请内存。当数据过期或内存淘汰时,需要回收内存,从而扩大内存碎片优化。最后说一下key、value的使用规范和技巧,Bitmap等高阶数据类型,利用这些技巧巧妙的解决有限内存存储更多数据的问题……这组组合拳直接打动了神。具体的细节,我们一一看《兄弟代码》。主要优化技巧如下:键值对优化。小数据集的编码优化。使用共享对象池。使用Bit位或字节级别的操作。使用哈希类型优化。内存碎片优化。使用32位Redis。在优化之前,我们先了解一下Redis是如何存储数据的。Redis如何存储键值对。Redis以redisDb为中心。redis7.0的源码在https://github.com/redis/redis/blob/7.0/src/server.h:redisDbdict:最重要的属性之一就是依赖这个定义保存对象数据key-valuepairs,dcit的底层结构是哈希表。expires:保存所有key的过期信息。blocking_keys和ready_keys主要用于实现BLPOP等阻塞命令。watched_keys用于实现watch命令,记录一些被监视的key,这些key与交易相关。id是当前数据库的id,redis支持单服务多数据库,默认有16个。clusterSlotToKeyMapping:集群模式下,一个数组,存放key和hashslot的映射关系。Redis使用“dict”结构来存储所有的键值对(key-value)数据,它是一个全局的哈希表,所以对于key的查询可以在O(1)时间内得到。所谓哈希表,我们可以把它类比成Java中的HashMap,它其实就是一个数组,数组的每一个元素称为一个哈希桶。dict结构如下,源码在https://github.com/redis/redis/blob/7.0/src/dict.h:structdict{//具体类型的处理函数dictType*type;//两个全局哈希表指针数组,与progressiverehash相关dictEntry**ht_table[2];//记录dict中已有数据的个数。无符号长ht_used[2];//用于记录progressiverehash进度的标志,-1表示当前没有执行rehashlongrehashidx;//小于0意味着rehash被暂停int16_tpauserehash;signedcharht_size_exp[2];};dictType:存储的函数,如hash函数,key和value的拷贝等。ht_table:长度为2的数组,一般使用ht_table[0]存储数据,执行rehash时,使用ht_table[1]来完成。key的哈希值最终会映射到ht_table中的某个位置。如果发生哈希冲突,则拉出一个哈希链表。大家注意dictEntry类型的ht_table。ht_table数组的每个位置也称为哈希桶,存储所有的键值对。码哥,Redis支持这么多数据类型,hash桶怎么存?哈希桶的每个元素的结构由dictEntry定义:typedefstructdictEntry{//pointertokeyvoid*key;union{//指向实际值的指针void*val;uint64_tu64;int64_ts64;双d;}v;//哈希碰撞拉出的链表structdictEntry*next;}dictEntry;key指向键值对key的指针,key为字符串类型。值是一个联合(union)。当其值为uint64_t、int64_t或double类型时,不需要额外存储,有利于减少内存碎片。(为了省内存我操碎了心)当然val也可以是一个void指针,一个指向值的指针,这样可以存放任何类型的数据。next指向另一个dictEntry结构,多个dictEntry可以通过next指针连接成一个链表。从这里可以看出,ht_table是使用链地址的方式来处理键冲突的:当多个不同的键有相同的哈希值时,ha希腊表将这些键用一个链表连接起来。哈希桶本身并不保存值,而是一个指向特定值的指针,从而实现了哈希桶可以存储不同数据类型的需求。在哈希桶中,键值对的值由一个名为redisObject的对象定义,源码地址:https://github.com/redis/redis/blob/7.0/src/server.h。typedefstructredisObject{无符号类型:4;无符号编码:4;无符号lru:LRU_BITS;int重新计票;void*ptr;}robj;type:记录了对象的类型,string,set,hash,Lis,SortedSet等,根据这个类型可以决定使用哪种数据类型,使用什么样的API操作。encoding:编码方式,表示ptr指向的数据类型的具体数据结构,即这个对象使用什么数据结构作为底层实现来保存数据。同一个对象使用不同的编码,内存占用有明显差异,内部编码对于内存优化非常重要。lru:LRU_BITS:LRU策略下最后一次访问对象的时间。如果是LFU策略,低8位表示访问频率,高16位表示访问时间。refcount:表示引用计数。由于C语言没有内存回收功能,Redis在自己的对象系统中加入了这个属性。当一个对象的引用计数为0时,表示该对象还没有被任何对象引用过。然后它可以被垃圾收集。ptr指针:指向对象底层实现数据结构,指向值的指针。下图是redisDb、dict、dictEntry、redisObject的关系图:redis存储结构“码哥”多说几句,void*key和void*value指针指向redisObject,Redis中的各个对象由redisObject表示。了解了Redis的存储原理和不同数据类型的存储数据结构之后,我们继续看如何优化性能。1.键值对优化当我们执行setkeyvalue命令时,*key指针指向SDS字符串保存key,而value的值保存在*ptr指针指向的数据结构中,而消耗的内存是:key+value。第一个优化技巧:减少Redis内存使用最残酷的方法是减少键(keys)和值(values)的长度。在《Redis 很强,不懂使用规范就糟蹋了》中,讲到了键值对的使用规范。对于key的命名,采用“业务模块名:表名:唯一数据id”的方式定位问题。例如:users:firends:996表示用户系统中id=996的好友信息。我们可以简写为:u:fs:996。对于关键优化:使用单词缩写来优化内存使用。value的优化比较多:过滤不必要的数据:不要把所有的信息都保存的大而全,想办法去掉一些不必要的属性,比如缓存登录用户信息,通常只需要存nickname,gender,account号码等简化数据:例如用户的会员类型:0表示“屌丝”,1表示“VIP”,2表示“VVIP”。而不是存储VIP字符串。数据压缩:对数据内容进行压缩,如使用GZIP、Snappy。使用性能好、内存占用小的序列化方式。例如,Java内置的序列化无论是速度还是压缩比都不好。我们可以选择protostuff、kryo等方法。如下图,Java中常用序列化工具的空间压缩比:使用json存储和二进制数据存储有什么优缺点?json格式的优点:调试方便,跨语言;缺点:同样的数据比字节数组占用更多的空间。如果需要json格式,先通过压缩算法对json进行压缩,然后将压缩后的数据存储到Redis中。例如GZIP压缩json可以减少60%左右的空间。2.小型数据集合的编码优化键对象都是字符串类型,值对象主要有五种基本数据类型:String、List、Set、Zset、Hash。数据类型与底层数据结构的关系如下:在最新版本(不稳定版本,时间2022-7-3)中,ziplist的压缩列表被quicklist(3.2版本引入)取代,而doublylinkedlist被listpack取代。此外,相同的数据类型将根据键的数量和值的大小而具有不同的底层编码类型。Redis2.2版本之后,在存储一定条件下的集合数据(Hash、List、Set、SortedSet)时,会采用内存压缩技术,以更少的内存存储更多的数据。当这些集合中的数据元素个数小于某个值且元素值占用的字节大小小于某个值时,存储的数据将以非常节省内存的方式进行编码,理论上节省在内存至少多10倍(平均节省5倍以上)。比如Hash类型的数据不是很多。虽然哈希表的时间复杂度是O(1),ziplist的时间复杂度是O(n),但是使用ziplist来保存数据会节省内存,而且在数据量小的情况下效率会更高不会减少很多。所以我们需要尽可能的控制collection元素的个数和每个元素的内存大小,这样才能充分利用compactencoding来减少内存占用。此外,这些代码对用户和API不敏感。当采集数据超过配置文件中配置的最大值时,Redis会自动转为普通码。数据类型对应的编码规则如下:Stringstringint:整数且长度小于20的数字,直接存入*ptr。embstr:开辟一块连续分配的内存(字符串长度小于等于44字节)。raw:动态字符串(大于44字节的字符串,以及小于512MB的字符串)。Listlistziplist:元素个数小于hash-max-ziplist-entries配置,所有元素的值都小于hash-max-ziplist-value配置。ziplistlinkedlist:在3.0版本之前,当列表类型不能满足ziplist的条件时,Redis会使用linkedlist作为列表的内部实现。quicklist:Redis3.2引入,作为List数据类型的底层实现,不再使用双端链表linkedlist和ziplist实现。Setcollectionintsetintegercollection:元素均为整数,元素个数小于set-max-intset-entries配置。hashtableHashtable:当集合类型不能满足intset的条件时,会使用hashtable编码。Hash哈希表ziplist:元素个数小于hash-max-ziplist-entries配置,任意值占用的字节大小小于hash-max-ziplist-value。hashtable:当hash类型不能满足intset的条件时使用hashtable。SortedSetziplist:元素个数小于zset-max-ziplist-entries且每个元素的值小于``zset-max-ziplist-value`配置。skiplist:当不满足ziplist条件时,sortedcollection会使用skiplist作为内部实现。下面是Redisredis.conf配置文件的默认编码阈值配置:hash-max-ziplist-entries512hash-max-ziplist-value64zset-max-ziplist-entries128zset-max-ziplist-value64set-max-intset-entries512图片是reidsObject对象的类型与编码的对应关系:类型与编码编码兄弟,为什么要对一种数据类型实现多种不同的编码方式呢?主要原因是通过不同的编码来达到效率和空间的平衡。比如当我们存储的只是100个元素的列表时,使用双向链表数据结构时,需要维护大量的内部字段。比如每个元素都需要:前指针、后指针、数据指针等,造成空间浪费。如果使用连续内存结构的压缩列表(ziplist),会节省大量内存,而且由于数据长度小,访问操作的时间复杂度即使是O(n),因为n的小值与O(1)相同并且明显不同。数据编码优化技术ziplist存储list时,每个元素都会被看做一个entry,存储hash时,key和value会被看成是相邻的两个entry。在存储zset时,member和score将被视为两个相邻的条目。当不满足上述条件时,ziplist将升级为linkedlist、hashtable或skiplist编码。由于目前Redis的版本大多运行在3.2以上,所以List类型的编码是quicklist。Quicklist是ziplist和linkedlist的混合体。它将linkedlist分成section,每个section使用ziplist进行紧凑存储,多个ziplist使用双向指针串联起来。综合考虑空间碎片化和读写性能两个维度,采用了新的encodingquicklist。ziplist的不足每次修改都可能触发realloc和memcopy,可能导致链式更新(可能需要移动数据)。因此,修改操作的效率较低,当ziplist中的元素较多时,这个问题更为突出。优化方法:key尽量控制在44字节以内,使用embstr编码。集合类型的值对象的元素个数不宜过多,充分利用ziplist编码实现内存压缩。3.对象共享池Integer我们在工作中经常用到。Redis启动时默认生成一个0~9999的整型对象共享池,用于对象重用,减少内存占用。例如执行setcodebrother18;设吴彦祖18;key等于“码哥”的值和“吴彦祖”指向同一个对象。如果value可以用整数表示,尽量使用整数,这样即使大量键值对的value存储了0~9999范围内的大量整数,在例子中,实际上只有一条数据。小伙伴们,有两个大坑要注意,会导致对象共享池失效。Redis中设置了Maxmemory来限制最大内存占用,并启用了LRU策略(allkeys-lru或volatile-lru策略)。码哥,为什么?因为LRU需要记录每个key-value对的访问时间,而且他们都共享一个整型对象,所以LRU策略不能算。集合类型的编码采用ziplist编码,集合内容为整型,不能共享整型对象。为什么?采用ziplist紧凑型内存结构存储数据,判断整型对象是否共享的效率很低。4.使用位或字节级别的操作。比如在一些“二进制状态统计”的场景中使用Bitmap,网页UV使用HyperLogLog,可以大大减少内存占用。码哥,什么是二进制状态统计?也就是说集合中元素的值只有0和1,在签到和用户是否登录的场景下,只需要记录签到(1)或非登录(0),登录(1)或非登录(0))。如果我们在判断用户是否登录(key->userId,value->0表示离线,1-登录)的场景中使用RedisString类型实现,如果存储了100万用户的登录状态,如果string形式化存储需要存储100万个字符串,内存开销太大。String类型除了记录实际数据外,还需要额外的内存来记录数据长度、空间占用等信息。Bitmap的底层数据结构使用String类型的SDS数据结构来存储位数组。Redis使用每个字节数组的8位,每个位代表一个元素的二进制状态(0或1)。Bitmap可以看作是一个位单元数组,数组的每个单元只能存储0或1,数组的下标在Bitmap中称为偏移量。为了直观显示,我们可以理解为buf数组的每个字节用一行来表示,每一行有8位,8个格子代表这个字节中的8位,如下图所示:8位组成一个字节,所以Bitmap会大大节省存储空间。这就是Bitmap的优势。关于Bitmap的详细解答,可以移步->《??Redis 实战篇:巧用 Bitmap 实现亿级数据统计??》。5、使用Hash类型优化,尽可能将数据抽象成哈希表。例如,如果系统中有一个用户对象,我们不需要为用户的昵称、姓名、邮箱、地址等设置单独的键,而是将这些信息存储在一个哈希表中。如下图:hset用户:深圳:999姓名代码兄弟hset用户:深圳:999年龄18hset用户:深圳:999爱好为什么使用String类型,为每个属性设置一个key会占用大量内存?因为Redis有很多数据类型,不同的数据类型有相同的元数据来记录(比如最后一次访问的时间,引用次数等)。因此,Redis会使用一个RedisObject结构来统一记录这些元数据,并使用*prt指针指向实际数据。当我们为每个属性创建一个key时,会创建大量的redisObject对象来占用内存。redisObject的内存使用情况如下:如果redisObject使用Hash类型,每个用户只需要设置一个key。6、内存碎片优化Redis释放的内存空间可能不是连续的,这些不连续的内存空间很可能处于空闲状态。虽然有空闲空间,但Redis不能用来存储数据,这样不仅会减少Redis实际可以存储的数据量,还会降低Redis运行机器的成本回报率。例如,Redis需要一块32字节的连续内存空间来存储一组整数。虽然目前有64字节空闲,但都是不连续的,无法保存。内存碎片是如何形成的?原因有二:操作系统内存分配机制:内存分配策略决定了无法实现按需分配。因为分配器是按照固定大小分配内存的。修改和删除键值对,导致内存空间的扩展和释放。分片优化可以减少内存占用,提高访问效率。在4.0以下的版本中,我们只能使用重启恢复:重启加载RDB或者通过高可用主从切换重新加载数据来减少碎片。在4.0以上版本,Redis提供了自动和手动碎片整理功能。原理是将数据复制到一个新的内存空间,然后释放旧的空间。这有一定的性能损失。因为Redis是单线程的,只有在复制数据时Redis才能等待,导致Redis无法处理请求,性能会下降。手动整理碎片并执行内存清除命令。内存自动碎片整理使用configsetactivedefragyes命令或者在redis.conf中configureactivedefragyes将activedefrag配置为yes启动自动清理功能。这个配置还不够。至于何时清理,需要看下面两个配置:active-defrag-ignore-bytes200mb:当内存碎片大小达到200MB时,开始清理。active-defrag-threshold-lower6:表示当内存碎片空间占操作系统分配给Redis的总空间的6%时,开始清理。只有满足这两个条件,Redis才会进行内存碎片的自动清理。另外,为了防止碎片清理影响Redis正常处理指令,Redis有两个参数来控制清理操作占用CPU时间比例的上下限。active-defrag-cycle-min15:自动碎片整理进程使用的CPU时间比例不低于15%,确保碎片整理能够有效进行。active-defrag-cycle-max50:表示自动清理进程使用的CPU时间比例不能超过50%。一旦超过,清理就会停止,避免清理时大量内存副本阻塞Redis执行命令。7、使用32位的Redis使用32位的redis,对于每个key,会占用更少的内存,因为32位的程序,指针占用的字节更少。但是32的整个Redis实例使用的内存会被限制在4G以内。我们可以使用集群模式,将多个小内存节点组成一个集群来保存更多的数据。另外,内存小的nodefork可以更快的生成rdb。RDB和AOF文件不区分32位和64位(包括字节序),所以可以使用64位的Redis恢复32位的RDB备份文件,反之亦然。综上所述,完成作品后,对这套神技我只想说一个“绝”字。希望本文能帮助大家用全局的视角解决内存优化问题。我是“码哥”,下次Redis见。
