理解redis的内存

内存消耗

内存消耗统计

执行 info memory 获取相关指标

  • 当mem_fragmentation_ratio>1时,说明used_memory_rss-used_memory多出的部分内存并没有用于数据存储,而是被内存碎片所消耗,如果两者相差很大,说明碎片率严重。

  • 当mem_fragmentation_ratio<1时,这种情况一般出现在操作系统把Redis内存交换(Swap)到硬盘导致,出现这种情况时要格外关注,由于硬盘速度远远慢于内存,Redis性能会变得很差,甚至僵死。

内存消耗划分

Redis进程内消耗主要包括:自身内存+对象内存+缓冲内存+内存碎片,其中Redis空进程自身内存消耗非常少,通常used_memory_rss在3MB左右,used_memory在800KB左右,一个空的Redis进程消耗内存可以忽略不计。

image-20190429172621107

对象内存

对象内存是Redis内存占用最大的一块,存储着用户所有的数据。

缓存内存

缓冲内存主要包括:客户端缓冲、复制积压缓冲区、AOF缓冲区。

  • 客户端缓冲,是所有接入到Redis服务器TCP连接的输入输出缓冲,最大1G,超过将断开连接,通过数client-output-buffer-limit 控制
    • 普通客户端,除了复制和订阅的客户端之外的所有连接,Redis的默认配置是:client-output-buffer-limit normal000Redis,并没有对普通客户端的输出缓冲区做限制,一般普通客户端的内存消耗可以忽略不计,但是当有大量慢连接客户端接入时这部分内存消耗就不能忽略了,可以设置maxclients做限制。
    • 从客户端,主节点会为每个从节点单独建立一条连接用于命令复制,默认配置是:client-output-buffer-limit slave256mb64mb60。
  • 复制积压缓存区,默认1M,对于复制积压缓冲区整个主节点只有一个,所有的从节点共享此缓冲区,因此可以设置较大的缓冲区空间,如100MB,这部分内存投入是有价值的,可以有效避免全量复制。
  • AOF 缓存区,这部分空间用于在Redis重写期间保存最近的写入命令,AOF缓冲区空间消耗用户无法控制,消耗的内存取决于AOF重写时间和写入命令量,这部分空间占用通常很小。

内存碎片

Redis默认的内存分配器采用jemalloc,可选的分配器还有:glibc、tcmalloc。内存分配器为了更好地管理和重复利用内存,分配内存策略一般采用固定范围的内存块进行分配。例如jemalloc在64位系统中将内存空间划分为:小、大、巨大三个范围。每个范围内又划分为多个小的内存块单位。

比如当保存5KB对象时jemalloc可能会采用8KB的块存储,而剩下的3KB空间变为了内存碎片不能再分配给其他对象存储。内存碎片问题虽然是所有内存服务的通病,但是jemalloc针对碎片化问题专门做了优化,一般不会存在过度碎片化的问题,正常的碎片率(mem_fragmentation_ratio)在1.03左右。但是当存储的数据长短差异较大时,会出现碎片问题:

  • 频繁做更新操作
  • 大量过期键删除

解决方案:

  • 数据对其,在条件允许的情况下尽量做数据对齐,比如数据尽量采用数字类型或者固定长度字符串等。
  • 安全重启, 可以利用高可用架构,如Sentinel或Cluster,将碎片率过高的主节点转换为从节点,进行安全重启)

子进程内存消耗

子进程内存消耗主要指执行AOF/RDB重写时Redis创建的子进程内存消耗。Redis执行fork操作产生的子进程内存占用量对外表现为与父进程相同,理论上需要一倍的物理内存来完成重写操作。但Linux具有写时复制技术(copy-on-write),父子进程会共享相同的物理内存页,当父进程处理写请求时会对需要修改的页复制出一份副本完成写操作,而子进程依然读取fork时整个父进程的内存快照。

Linux Kernel在2.6.38内核增加了Transparent Huge Pages(THP)机制,而有些Linux发行版即使内核达不到2.6.38也会默认加入并开启这个功能,如Redhat Enterprise Linux在6.0以上版本默认会引入THP。虽然开启THP可以降低fork子进程的速度,但之后copy-on-write期间复制内存页的单位从4KB变为2MB,如果父进程有大量写命令,会加重内存拷贝量,从而造成过度内存消耗。

1
2
3
4
// 开启THP
:C * AOF rewrite: 1039 MB of memory used by copy-on-write
// 关闭THP:
C * AOF rewrite: 9 MB of memory used by copy-on-wri

这两个日志出自同一Redis进程,used_memory总量为1.5GB,子进程执行期间每秒写命令量都在200左右。当分别开启和关闭THP时,子进程内存消耗有天壤之别。如果在高并发写的场景下开启THP,子进程内存消耗可能是父进程的数倍,极易造成机器物理内存溢出,从而触发SWAP或OOMkiller

子进程内存消耗总结如下

  • Redis产生的子进程并不需要消耗1倍的父进程内存,实际消耗根据期间写入命令量决定,但是依然要预留出一些内存防止溢出。

  • 需要设置sysctl vm.overcommit_memory=1允许内核可以分配所有的物理内存,防止Redis进程执行fork时因系统剩余内存不足而失败。

  • 排查当前系统是否支持并开启THP,如果开启建议关闭,防止copy-onwrite期间内存过度消

内存管理

设置内存上限

用maxmemory参数限制最大可用内存,当缓存场景,内存超过该值时,使用LRU等删除释放空间。

maxmemory限制的是Redis实际使用的内存量,也就是used_memory统计项对应的内存。由于内存碎片率的存在,实际消耗的内存可能会比maxmemory设置的更大,实际使用时要小心这部分内存溢出。

比如一台24GB内存的服务器,为系统预留4GB内存,预留4GB空闲内存给其他进程或Redis fork进程,留给Redis16GB内存,这样可以部署4个maxmemory=4GB的Redis进程。得益于Redis单线程架构和内存限制机制,即使没有采用虚拟化,不同的Redis进程之间也可以很好地实现CPU和内存的隔离性。

image-20190429184543668

Redis默认无限使用服务器内存,为防止极端情况下导致系统内存耗尽,建议所有的Redis进程都要配置maxmemory。在保证物理内存可用的情况下,系统中所有Redis实例可以调整maxmemory参数来达到自由伸缩内存的目

内存回收策略

删除过期键对象

  • 惰性删除,惰性删除用于当客户端读取带有超时属性的键时,如果已经超过键设置的过期时间,会执行删除操作并返回空,这种策略是出于节省CPU成本考虑,不需要单独维护TTL链表来处理过期键的删除。但是单独用
    这种方式存在内存泄露的问题,当过期键一直没有访问将无法得到及时删除,从而导致内存不能及时释放。正因为如此,Redis还提供另一种定时任务删除机制作为惰性删除的补充。
  • 定时任务删除,Redis内部维护一个定时任务,默认每秒运行10次(通过配置hz控制)。定时任务中删除过期键逻辑采用了自适应算法,根据键的过期比例、使用快慢两种速率模式回收键。

流程说明:

1)定时任务在每个数据库空间随机检查20个键,当发现过期时删除对应的键。

2)如果超过检查数25%的键过期,循环执行回收逻辑直到不足25%或运行超时为止,慢模式下超时时间为25毫秒。

3)如果之前回收键逻辑超时,则在Redis触发内部事件之前再次以快模式运行回收过期键任务,快模式下超时时间为1毫秒且2秒内只能运行1次。

4)快慢两种模式内部删除逻辑相同,只是执行的超时时间不同。

内存溢出控制

当Redis所用内存达到maxmemory上限时会触发相应的溢出控制策略。具体策略受maxmemory-policy参数控制,Redis支持6种策略,如下所示:

1)noeviction:默认策略,不会删除任何数据,拒绝所有写入操作并返回客户端错误信息(error)OOM command not allowed when used memory,此时Redis只响应读操作。

2)volatile-lru:根据LRU算法删除设置了超时属性(expire)的键,直到腾出足够空间为止。如果没有可删除的键对象,回退到noeviction策略。

3)allkeys-lru:根据LRU算法删除键,不管数据有没有设置超时属性,直到腾出足够空间为止。

4)allkeys-random:随机删除所有键,直到腾出足够空间为止。

5)volatile-random:随机删除过期键,直到腾出足够空间为止。

6)volatile-ttl:根据键值对象的ttl属性,删除最近将要过期数据。如果没有,回退到noeviction策

内存溢出控制策略可以采用config set maxmemory-policy{policy}动态配置。

内存优化

redisObject对象

Redis存储的所有值对象在内部定义为redisObject结构体。

image-20190429185313356

Redis存储的数据都使用redisObject来封装,包括string、hash、list、set、zset在内的所有数据类型。

  • type字段:表示当前对象使用的数据类型。
  • encoding字段:表示Redis内部编码类型。
  • lru字段:记录对象最后一次被访问的时间

可以使用scan+object idletime命令批量查询哪些键长时间未被访问,找出长时间不访问的键进行清理,可降低内存占用

  • refcount字段:记录当前对象被引用的次数,用于通过引用次数回收内存,当refcount=0时,可以安全回收当前对象空间。使用object refcount{key}获取当前对象引用。当对象为整数且范围在[0-9999]时,Redis可以使用共享对象的方式来节省内存。

  • *ptr字段:与对象的数据内容相关,如果是整数,直接存储数据;否则表示指向数据的指针。Redis在3.0之后对值对象是字符串且长度<=39字节的数据,内部编码为embstr类型,字符串sds和redisObject一起分配,从而只要一次内存操作即可。

高并发写入场景中,在条件允许的情况下,建议字符串长度控制在39字节以内,减少创建redisObject内存分配次数,从而提高性性能。

缩减键值对象

降低Redis内存使用最直接的方式就是缩减键(key)和值(value)的长度。

  • key,在设计键时,越短越好。
  • value ,去掉不必要的熟悉,避免存储无效数据。其次在序列化工具选择上,应该选择更高效的序列化工
    具来降低字节数组大小。

共享对象池

共享对象池是指Redis内部维护[0-9999]的整数对象池。创建大量的整数类型redisObject存在内存开销,每个redisObject内部结构至少占16字节,甚至超过了整数自身空间消耗。所以Redis内存维护一个[0-9999]的整数对象池,用于节约内存。除了整数值对象,其他类型如list、hash、set、zset内部元素也可以使用整数对象池。因此开发中在满足需求的前提下,尽量使用整数对象以节省内存。

整数对象池在Redis中通过变量REDIS_SHARED_INTEGERS定义,不能通过配置修改。可以通过object refcount命令查看对象引用数验证是否启用整数对象池技术。

字符串优化

字符串结构

Redis自身实现的字符串结构有如下特点:

  • O(1)时间复杂度获取:字符串长度、已用长度、未用长度。
  • 可用于保存字节数组,支持安全的二进制数据存储。
  • 内部实现空间预分配机制,降低内存再分配次数。
  • 惰性删除机制,字符串缩减后的空间不释放,作为预分配空间保留

预分配机制

因为字符串(SDS)存在预分配机制,日常开发中要小心预分配带来的
内存浪费。

image-20190429190348183

字符串预分配每次并不都是翻倍扩容,空间预分配规则如下:

  • 第一次创建len属性等于数据实际大小,free等于0,不做预分配。
  • 修改后如果已有free空间不够且数据小于1M,每次预分配一倍容量。如原有len=60byte,free=0,再追加60byte,预分配120byte,总占用空间:60byte+60byte+120byte+1byte。
  • 修改后如果已有free空间不够且数据大于1MB,每次预分配1MB数据。如原有len=30MB,free=0,当再追加100byte,预分配1MB,总占用空间:1MB+100byte+1MB+1byte。

尽量减少字符串频繁修改操作如append、setrange,改为直接使用set修改字符串,降低预分配带来的内存浪费和内存碎片化。

字符串重构

字符串重构:指不一定把每份数据作为字符串整体存储,像json这样的数据可以使用hash结构,使用二级结构存储也能帮我们节省内存。同时可以使用hmget、hmset命令支持字段的部分读取修改,而不用每次整体存取。

image-20190429190743994

根据测试结构,第一次默认配置下使用hash类型,内存消耗不但没有降低反而比字符串存储多出2倍,而调整hash-max-ziplist-value=66之后内存降低为535.60M。因为json的videoAlbumPic属性长度是65,而hash-max-ziplist value默认值是64,Redis采用hashtable编码方式,反而消耗了大量内存。调整配置后hash类型内部编码方式变为ziplist,相比字符串更省内存且支持属性的部分操作。

编码优化

Redis对外提供了string、list、hash、set、zet等类型,但是Redis内部针对不同类型存在编码的概念,所谓编码就是具体使用哪种底层数据结构来实现。编码不同将直接影响数据的内存占用和读写效率。使用objectencoding{key}命令获取编码类型。

image-20190429190825515

ziplist编码主要目的是为了节约内存,因此所有数据都是采用线性连续的内存结构。ziplist编码是应用范围最广的一种,可以分别作为hash、list、zset类型的底层数据结构实现。首先从ziplist编码结构开始分析,它的内部结
构类似这样:<zlbytes><zltail><zllen><entry-1><entry-2><....><entry-n><zlend>。一个ziplist可以包含多个entry(元素),每个entry保存具体的数据(整数或者字节数组)

image-20190429191404837

ziplist结构字段含义:

  • zlbytes:记录整个压缩列表所占字节长度,方便重新调整ziplist空间。类型是int-32,长度为4字节。

  • zltail:记录距离尾节点的偏移量,方便尾节点弹出操作。类型是int-32,长度为4字节。

  • zllen:记录压缩链表节点数量,当长度超过216-2时需要遍历整个列表获取长度,一般很少见。类型是int-16,长度为2字节。

  • entry:记录具体的节点,长度根据实际存储的数据而定。

    • prev_entry_bytes_length:记录前一个节点所占空间,用于快速定位上一个节点,可实现列表反向迭代。
    • encoding:标示当前节点编码和长度,前两位表示编码类型:字符串/整数,其余位表示数据长度。
    • contents:保存节点的值,针对实际数据长度做内存占用优化。
  • zlend:记录列表结尾,占用一个字节。

根据以上对ziplist字段说明,可以分析出该数据结构特点如下:

  • 内部表现为数据紧凑排列的一块连续内存数组。
  • 可以模拟双向链表结构,以O(1)时间复杂度入队和出队。
  • 新增删除操作涉及内存重新分配或释放,加大了操作的复杂性。
  • 读写操作涉及复杂的指针移动,最坏时间复杂度为O(n2)。
  • 适合存储小对象和长度有限的数。

image-20190429191749141

测试数据采用100W个36字节数据,划分为为1000。从测试结果可以看出

  • 使用ziplist可以分别作为hash、list、zset数据类型实现。
  • 使用ziplist编码类型可以大幅降低内存占用。
  • ziplist实现的数据类型相比原生结构,命令操作更加耗时,不同类型耗时排序:list<hash<zset

针对性能要求较高的场景使用ziplist,建议长度不要超过1000,每个元素大小控制在512字节以内。

intset编码是集合(set)类型编码的一种,内部表现为存储有序、不重复的整数集。当集合只包含整数且长度不超过set-max-intset-entries配置时被启用。执行以下命令查看intset表现。

intset的字段结构含义:

1)encoding:整数表示类型,根据集合内最长整数值确定类型,整数类型划分为三种:int-16、int-32、int-64。2)length:表示集合元素个数。

3)contents:整数数组,按从小到大顺序保存

intset保存的整数类型根据长度划分,当保存的整数超出当前类型时,将会触发自动升级操作且升级后不再做回退。升级操作将会导致重新申请内存空间,把原有数据按转换类型后拷贝到新数组。

使用intset编码的集合时,尽量保持整数范围一致,如都在int-16范围内。防止个别大整数触发集合升级操作,产生内存浪费。

控制键的数量

当使用Redis存储大量数据时,通常会存在大量键,过多的键同样会消耗大量内存。Redis本质是一个数据结构服务器,它为我们提供多种数据结构,如hash、list、set、zset等。使用Redis时不要进入一个误区,大量使用get/set这样的API,把Redis当成Memcached使用。对于存储相同的数据内容利用Redis的数据结构降低外层键的数量,也可以节省大量内存。