【Redis学习】6.理解内存


1 内存消耗

内存是个精贵且非常重要的计算组成部分,如何利用有限的资源完成高效且快速数据的存取,那首先我们得了解Redis在内存中的布局,看看消耗内存空间的位置,其次才能针对性的进行内存管理,分配,优化。这样,才能使得利用更小内存,获得更大存储发挥功效,从而降低企业成本,获得更多收益。

1.1 使用统计

如何查看内存消耗呢?在启动Redis成功后,通过redis-cli连接上服务,运行命令

info memory

可以查看内存使用情况:

127.0.0.1:6379> info memory
# Memory
used_memory:1042576
used_memory_human:1018.14K
used_memory_rss:1277952
used_memory_rss_human:1.22M
used_memory_peak:1061856
used_memory_peak_human:1.01M
used_memory_peak_perc:98.18%
used_memory_overhead:1030734
used_memory_startup:981104
used_memory_dataset:11842
used_memory_dataset_perc:19.26%
total_system_memory:8589934592
total_system_memory_human:8.00G
used_memory_lua:37888
used_memory_lua_human:37.00K
maxmemory:0
maxmemory_human:0B
maxmemory_policy:noeviction
mem_fragmentation_ratio:1.23
mem_allocator:libc
active_defrag_running:0
lazyfree_pending_objects:0

其中,info命令可以显示redis服务器的许多信息,包括服务器基本信息、CPU、内存、持久化、客户端连接信息等等;memory是参数,表示只显示内存相关的信息。

执行命令之后有这么几个重要的指标

属性名属性说明
used_memoryRedis分配器分配的内存总量(单位:字节),包括使用的虚拟内存(即swap),指Redis存储的所有数据所占的内存
used_memory_human以可读的形式返回user_memory
used_memory_rssRedis进程占用的物理内存总量
used_memory_peakused_memory使用的峰值
used_memory_peak_human可读格式返回used_memory_peak
used_memory_luaLua引擎消耗的内存大小
mem_fragmentation_ratioused_memory_rss/used_memory比值,内存碎片率
mem_allocatorRedis所使用的内存分配器,在编译时指定;可以是 libc 、jemalloc或者tcmalloc,默认jemalloc
  • used_memory_rss:与topps命令看到的值是一致的;除了分配器分配的内存之外,used_memory_rss还包括进程运行本身需要的内存、内存碎片等,但是不包括虚拟内存
  • mem_fragmentation_ratio:一般来说,mem_fragmentation_ratio在1.03左右是比较健康的状态(对于jemalloc来说)。
    • 大于1:一般情况是大于1的,若该值很大,说明used_memory_rss - used_memory多出的部分内存并没有用于数据存储,而是被内存碎片所消耗,如果两者相差很大,说明碎片率严重;
    • 小于1:一般出现在操作系统把Redis内存交换(Swap)到硬盘导致(虚拟内存媒介也是磁盘,比内存速度要慢很多),出现这种情况时要格外关注,由于硬盘速度远远慢于内存,Redis性能会变得很差,甚至僵死。可增加Redis节点、增加Redis服务器的内存、优化应用等。

1.2 内存消耗划分

上图:

Redis内存划分示意图

1.2.1 对象内存

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

Redis所有的数据都采用key-value数据类型,每次创建键值对时,至少创建两个类型对象:key对象value对象

对象内存消耗可以简单理解为sizeof(keys)+sizeof(values)

  • 键对象都是字符串,在使用Redis时很容易忽略键对内存消耗的影响,应当避免使用过长的键。
  • value对象更复杂些,主要包含5种基本数据类型:字符串列表哈希集合有序集合。其他数据类型都是建立在这5种数据结构之上实现的,如:BitmapsHyperLogLog使用字符串实现,GEO使用有序集合实现等。每种value对象类型根据使用规模不同,占用内存不同。在使用时一定要合理预估并监控value对象占用情况,避免内存溢出。

1.2.2 自身内存

Redis主进程本身运行肯定需要占用内存,如代码常量池等等;这部分内存大约几兆,在大多数生产环境中与Redis数据占用的内存相比可以忽略。这部分内存不是由jemalloc分配,因此不会统计used_memory中。

used_memory_rss3MB左右used_memory800KB左右

这里的内存主要指AOF/RDB重写时Redis创建的子进程内存的消耗,Linux具有写时复制技术(copy-on-write),父子进程会共享相同的物理内存页,当父进程写请求时会对需要修改的页复制出一份副本来完成写操作。当然,这部分内存不属于Redis进程,也不会统计used_memoryused_memory_rss中。

1.2.3 缓冲内存

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

  • 客户端缓冲:所有接入到Redis服务器TCP连接的输入输出缓冲。客户端包括:普通的客户端(大量连接),从客户端(主要是复制的时候,异地跨机房,或者主节点下有多个从节点),订阅客户端(发布订阅功能,生产大于消费就会造成积压);
  • 复制积压缓冲:2.8版本之后提供的可重用的固定大小缓冲区用于实现部分复制功能,默认1MB,主要是在主从同步时用到;
  • AOF缓冲区:持久化用的,会先写入到缓冲区,然后根据响应的策略向磁盘进行同步,消耗的内存用户无法控制,取决于写入的命令量和重写时间,通常很小。

    这部分内存由jemalloc分配,因此会统计在used_memory中。

1.2.4 内存碎片

等于used_memory_rss 减去 used_memory

Redis默认的内存分配器采用jemalloc,可选的分配器还有:glibctcmalloc

jemalloc针对碎片化问题专门做了优化,一般不会存在过度碎片化的问题,正常的碎片率(mem_fragmentation_ratio)在1.03左右。但是当存储的数据长短差异较大时,以下场景容易出现高内存碎片问题:

  • 频繁做更新操作,例如频繁对已存在的键执行appendsetrange等更新操作。
  • 大量过期键删除,键对象过期删除后,释放的空间无法得到充分利用,导致碎片率上升。

解决方案:

  • 数据对齐:在条件允许的情况下尽量做数据对齐,比如数据尽量采用数字类型或者固定长度字符串等,但是这要视具体的业务而定,有些场景无法做到。
  • 安全重启:重启节点可以做到内存碎片重新整理,因此可以利用高可用架构,如SentinelCluster,将碎片率过高的主节点转换为从节点,进行安全重启。

2 内存管理

2.1 控制内存上限

Redis使用maxmemory参数限制最大可用内存。

限制内存的目的主要有:

  • 用于缓存场景,当超出内存上限maxmemory时使用LRU等删除策略释放空间。
  • 防止所用内存超过服务器物理内存。

有一点需要注意的是maxmemory配置的是Redis实际使用的内存量,即used_memory,由于有内存碎片的存在,所以实际的内存使用比used_memory要大

比如一台24GB内存的服务器,为系统预留4GB内存预留4GB空闲内存给其他进程或Redis fork进程,留给Redis16GB内存,这样可以部署4个maxmemory=4GB的Redis进程。

内存分配举例

Redis还可以动态的执行内存的调整,例,当发现Redis-2没有做好内存预估,实际只用了不到2GB内存,而Redis-1实例需要扩容到6GB内存才够用,这时可以分别执行如下命令进行调整:

Redis-1>config set maxmemory 6GB
Redis-2>config set maxmemory 2GB
内存分配调整后

这个例子过于理想化,如果此时Redis-3Redis-4实例也需要分别扩容到6GB,这时超出系统物理内存限制就不能简单的通过调整maxmemory来达到扩容的目的,需要采用在线迁移数据或者通过复制切换服务器来达到扩容的目的。具体细节见前几章的“哨兵”“集群”部分。

2.2 回收策略

Redis的内存回收机制主要体现在以下两个方面:

  • 删除到达过期时间的键对象。
  • 内存使用达到maxmemory上限时触发内存溢出控制策略

2.2.1 删除过期键对象

2.2.1.1 惰性删除

当客户端读取带有超时属性的键时,如果已经超过键设置的过期时间,会执行删除操作并返回空。

缺点显而易见:单独使用存在内存泄漏,即一直没有客户端读取,就没法删除。因而还需要下面的定时任务删除这些一直无用的数据。

2.2.1.2 定时任务删除

Redis内部维护一个定时任务,默认每秒运行10次(通过配置hz控制)。

定时任务中删除过期键逻辑采用了自适应算法,根据键的过期比例、使用快慢两种速率模式回收键,流程如下图所示:

Redis定时删除任务

2.2.2 内存溢出控制策略

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

策略说明
noeviction默认策略,不会删除任何数据,拒绝所有写入操作并返回客户端错误信息(error)OOM command not allowed when used memory,此时Redis只响应读操作。
volatile-lru根据LRU算法删除设置了超时属性(expire)的键,直到腾出足够空间为止。如果没有可删除的键对象,回退到noeviction策略。
allkeys-lru根据LRU算法删除键,不管数据有没有设置超时属性,直到腾出足够空间为止。
allkeys-random随机删除所有键,直到腾出足够空间为止。
volatile-random随机删除过期键,直到腾出足够空间为止。
volatile-ttl根据键值对象的ttl属性,删除最近将要过期数据。如果没有,回退到noeviction策略。

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

3 内存优化

1.精简键值对大小,键值字面量精简,使用高效二进制序列化工具;

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

  • key长度:如在设计键时,在完整描述业务情况下,键值越短越好。如user:{uid}:friends:notify:{fid}可以简化为u:{uid}:fs:nt:{fid}
  • value长度:值对象缩减比较复杂,常见需求是把业务对象序列化成二进制数组放入Redis。
    • 首先应该在业务上精简业务对象,去掉不必要的属性避免存储无效数据。
    • 其次在序列化工具选择上,应该选择更高效的序列化工具来降低字节数组大小。以Java为例,内置的序列化方式无论从速度还是压缩比都不尽如人意,这时可以选择更高效的序列化工具,如:protostuffkryo等。

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

是否使用整数对象池内存对比

为什么开启maxmemory和LRU淘汰策略后对象池无效?

LRU算法需要获取对象最后被访问时间,以便淘汰最长未访问数据,每个对象最后访问时间存储在redisObject对象的lru字段。对象共享意味着多个引用共享同一个redisObject,这时lru字段也会被共享,导致无法获取每个对象的最后访问时间。如果没有设置maxmemory,直到内存被用尽Redis也不会触发内存回收,所以共享对象池可以正常工作。

综上所述,共享对象池maxmemory+LRU策略冲突,使用时需要注意。对于ziplist编码的值对象,即使内部数据为整数也无法使用共享对象池,因为ziplist使用压缩且内存连续的结构,对象共享判断成本过高。

为什么只有整数对象池?

  • 首先整数对象池复用的几率最大
  • 其次对象共享的一个关键操作就是判断相等性,Redis之所以只有整数对象池,是因为整数比较算法时间复杂度为O(1),只保留一万个整数为了防止对象池浪费;
  • 再次,如果是字符串判断相等性,时间复杂度变为O(n),特别是长字符串更消耗性能(浮点数在Redis内部使用字符串存储)。对于更复杂的数据结构如hashlist等,相等性判断需要O($n^2$)。对于单线程的Redis来说,这样的开销显然不合理,因此Redis只保留整数共享对象池。

3.优化字符串使用,避免预分配造成的内存浪费;
在开始学习Redis数据结构时,我们已经了解到Redis对于字符串的存储结构以及编码,对于字符串,其数据结构离不开SDS,如下图所示:

SDS样例

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

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

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

之所以采用预分配的方式是防止修改操作需要不断重分配内存和字节数据拷贝。但这是一把双刃剑,同样也会造成内存的浪费。字符串预分配每次并不都是翻倍扩容,空间预分配规则如下:

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

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

4.使用ziplist压缩编码优化hashlist等结构,注重效率和空间的平衡;
编码形式多种多样,基本上每种数据结构,Redis作者都给出了至少两种方式,参见【Redis学习】1.常用的数据结构

为什么对一种数据结构实现多种编码方式?

主要原因是Redis作者想通过不同编码实现效率空间平衡。比如当我们的存储只有10个元素的列表,当使用双向链表数据结构时,必然需要维护大量的内部字段如每个元素需要:前置指针后置指针数据指针等,造成空间浪费,如果采用连续内存结构的压缩列表(ziplist),将会节省大量内存,而由于数据长度较小,存取操作时间复杂度即使为O($n^2$)性能也可满足需求。

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

5.使用intset编码优化整数集合;

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

6.使用ziplist编码的hash结构降低小对象链规模。
这种内存优化技巧的关键点:

  • hash类型节省内存的原理是使用ziplist编码,如果使用hashtable编码方式反而会增加内存消耗。
  • ziplist长度需要控制在1000以内,否则由于存取操作时间复杂度在O(n)到O($n^2$)之间,长列表会导致CPU消耗严重,得不偿失。
  • ziplist适合存储小对象,对于大对象不但内存优化效果不明显还会增加命令操作耗时。
  • 需要预估键的规模,从而确定每个hash结构需要存储的元素数量。
  • 根据hash长度元素大小,调整hash-max-ziplist-entrieshash-max-ziplist-value参数,确保hash类型使用ziplist编码

关于hashfield键的设计:

  • 离散度较高时,可以按字符串位截取,把后三位作为哈希的field,之前部分作为哈希的键。如:key=1948480哈希key=group:hash:1948,哈希field=480。
  • 离散度较低时,可以使用哈希算法打散键,如:使用crc32(key)&10000函数把所有的键映射到“0-9999”整数范围内,哈希field存储键的原始值。
  • 尽量减少hashfield的长度,如使用部分键内容。

虽然可以大幅降低内存,但同样会带来问题,需要提前做好规避处理。

  • 客户端需要预估键的规模并设计hash分组规则,加重客户端开发成本。
  • hash重构后所有的键无法再使用超时(expire)和LRU淘汰机制自动删除,需要手动维护删除。
  • 对于大对象,如1KB以上的对象,使用hash-ziplist结构控制键数量反而得不偿失。

文章作者: Kezade
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Kezade !
评论
  目录