《Redis开发与运维》读书笔记(八)理解内存

第八章 理解内存

8.1 内存消耗

8.1.1 内存使用统计

属性名 属性说明
used_memory Redis分配器分配的内存总量,也就是内部存储的所有数据内存占用量。
used_memory_human 以可读的格式返回used_memory
used_memory_rss 从操作系统的角度显示Redis进程占用的物理内存总量
used_memory_peak 内存使用的最大值,表示used_memory的峰值
used_memory_peak_human 以可读的格式返回used_memory_peak
used_memory_lua Lua引擎所消耗的内存大小
mem_fragmentation_ratio used_memory_rss/used_memory比值,表示内存碎片率
mem_allocator Redis所使用的内存分配器,默认为jemalloc
  • mem_fragmentation_ratio > 1:说明used_memory_rss - used_memory多出的内存没有用于数据存储,而是被内存碎片所消耗。
  • mem_fragmentation_ratio < 1:操作系统把内存交换到硬盘导致。

8.1.2 内存消耗划分

Redis进程内消耗主要包括:自身内存 + 对象内存 + 缓冲内存 + 内存碎片,其中Redis空进程自身内存消耗非常少,used_memory_rss在3MB左右,used_memory在800KB左右。

(1)对象内存

对象内存是Redis内存占用最大的一块,存储着用户所有的数据。每次创建键值对,至少创建两个类型对象:key对象和value对象。对象内存消耗简单理解为 sizeof(keys) + sizeof(values) 。

键对象都是字符串,value对象则复杂一些,包括5种基本类型。Bitmaps和HyperLogLog使用字符串实现,GEO使用有序集合实现。

(2)缓冲内存

包括:

  • 客户端缓冲:所有接入到Redis服务器TCP连接的输入输出缓冲。

    输入缓冲无法控制,最大1G,超过则会断开连接。

    输出缓冲则通过参数client-output-buffer-limit控制。

    • 普通客户端:当有大量慢连接客户端接入时,需要设置maxclients限制。特别是使用大量数据输出的命令且数据无法及时推送给客户端,如monitor命令,容易造成Redis服务器内存飙升。默认配置 client-output-buffer-limit normal 0 0 0
    • 从客户端:主节点为每个从节点单独建立一条连接用于命令复制。默认配置 client-output-buffer-limit slave 256mb 64mb 60 。主从网路延迟高或主节点挂载大量从节点时会消耗大量内存。
    • 订阅客户端:使用发布订阅功能时,连接客户端使用单独的输出缓冲区。默认配置 client-output-buffer-limit pubsub 32mb 8mb 60 。订阅服务的消息生产大于消费速度,缓冲区积压导致内存溢出。
  • 复制积压缓冲区:2.8版本后提供,可重用的固定大小缓冲区用于实现部分复制的功能。repl-backlog-size参数控制,默认1MB。从节点共享主节点的此区域,可以投入较大空间,从而避免全量复制。

  • AOF缓冲区:用于重写期间保存最近的写入命令,用户无法控制此空间消耗,取决于AOF重写时间和写入命令量。

(3)内存碎片

Redis默认内存分配器采用jemalloc,可选glibc、tcmalloc。内存分配策略一般采用固定范围的内存块进行分配,分为小、大和巨大三个范围,每个范围又划分为多个小的内存块单位。

  • 小:[8byte], [16byte, 32byte, 48byte, …, 128byte], [192byte, 256byte, …, 512byte], [768byte, 1024byte, …, 3840byte]
  • 大:[4KB, 8KB, 12KB, …, 4072KB]
  • 巨大:[4MB, 8MB, 12MB, …]

保存5KB的对象可能会采用8KB的块存储,剩下3KB变为内存碎片无法分配出去,这种内存碎片问题是所有内存服务的通病。jemalloc针对这个问题做了优化,控制在 {mem_fragmentation_ratio} 在1.03左右。但当存储的数据长短差异较大时,还是会出现高内存碎片问题:

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

解决方案:

  • 数据对齐:条件允许的话尽量做数据对齐,如数据尽量采用数字类型或固定长度字符串等。
  • 安全重启:重启节点可以做到内存碎片重新整理,因此可以利用高可用架构将碎片率过高的主节点转换为从节点,进行安全重启。

8.1.3 子进程内存消耗

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

Linux Kernel在2.6.38内核增加了Transparent Huge Pages(THP)机制,开启后会降低fork子进程的速度,但之后写时复制期间复制的内存页单位量从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-write

高并发场景下开启THP,子进程内存消耗可能是父进程的数倍,容易造成内存溢出,从而触发SWAP或OOM killer。

总结:

  • 子进程不需要消耗1倍的父进程内存,实际消耗根据期间写入命令量决定,但依然会预留一部分内存防止溢出。
  • 需要设置 sysctl vm.overcommit_memory=1 允许内核分配所有的物理内存,防止fork操作时因为系统剩余内存不足而失败。
  • 排查当前系统是否开启THP,建议关闭,防止写时复制期间内存过度消耗。

8.2 内存管理

8.2.1 设置内存上限

通过maxmemory参数限制最大可用内存,限制内存的目的:

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

maxmemory限制的是实际使用的内存量,但因为内存碎片的存在,实际消耗内存要更大。通过设置内存上限可以方便单台多实例的内存控制,如服务器内存24G,预留4G空间给其他进程和fork进程,剩下16G可以部署4个实例,不同进程间可以很好的实现CPU和内存隔离。

8.2.2 动态调整内存上限

通过 config set maxmemory 动态修改内存上限,假设实例1不够用需要扩容到6G,实例2则是用不到2G内存:

如果超过系统物理内存限制,需要采用在线迁移数据或通过复制切换服务器来达到扩容目的。

8.2.3 内存回收策略

内存回收机制体现在:

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

(1)删除过期键

所有键都可以设置过期属性,保存在过期字典中。进程内保存了大量键,精准维护各个键的过期删除机制导致消耗大量CPU,所以Redis采用惰性删除和定时任务删除机制来实现:

  • 惰性删除:客户端读取带有超时属性的键时,若超过键设置的过期时间,会执行删除操作并返回空。该策略不需要单独维护TTL链表来处理过期键的删除,可以节省CPU成本,但存在内存泄露的问题,若过期键一直不被访问就不能被及时释放。
  • 定时任务删除:Redis内部维护一个定时任务,默认每秒运行10次(可通过配置hz控制)。采用自适应算法,根据键的过期比例,使用快慢两种速率回收键。

  1. 定时任务在每个数据库空间随机检查20个键,当发现过期时删除对应的键。
  2. 如果超过检查数25%的键过期,循环执行回收逻辑直到不足25%或运行超时为止,慢模式下超时时间为25毫秒。
  3. 如果之前回收键逻辑超时,则在Redis触发内部事件之前再次以快模式运行回收过期键任务,快模式下超时时间为1毫秒且2秒内只能运行1次。
  4. 快慢两种模式内部删除逻辑相同,只是执行的超时时间不同。

(2)内存溢出控制策略

内存达到maxmemory上限时会触发相应的溢出控制策略,策略受参数maxmemory-policy控制:

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

可以通过 config set maxmemory-policy {policy} 动态配置,当Redis因为内存溢出删除键时,可以通过执行info stats命令查看evicted_keys指标找出当前服务器已删除的键数量。

每次执行命令时如果设置了maxmemory参数,都会尝试执行回收内存操作。当Redis一直工作在内存溢出(used_memory)的状态下且设置非noeviction策略,会频繁的触发回收内存的操作,回收内存逻辑伪代码:

从代码可以看到,频繁执行回收内存成本很高名主要包括查找可回收键和删除键的开销,如果当前Redis有从节点,回收内存操作对应的删除命令会同步到从节点,导致写放大的问题。

对于需要收缩Redis内存的场景,可以通过调小maxmemory来实现快速回收,比如对实际占用6G的进程设置maxmemory=4G,之后第一次执行命令时,如果使用非noeviction策略,它会一次性回收到maxmemory指定的内存量,从而达到快速回收内存的目的。此操作会导致数据丢失和短暂的阻塞问题,一般在缓存场景下使用。


8.3 内存优化

8.3.1 redisObject对象

所有值对象在内部定义为redisObject对象,包括string、hash、list、set、zset,内部结构:

  • type字段:表示当前对象使用的数据类型,可以使用 type {key} 命令查看对象所属类型,键都是string类型。
  • encoding字段:表示Redis内部编码类型,encoding在Redis内部使用,理解内部编码方式对于优化内存非常重要,不同的编码实现对于内存占用存在明显差异。
  • lru字段:记录对象最后一次被访问的时间,当配置了maxmemory和maxmemory-policy=volatile-lru或allkeys-lru时,用于辅助LRU算法删除键数据。可以使用 object idletime {key} 命令在不更新lru字段情况下查看当前键的空闲时间。可以使用 scan + object idletime 命令批量查询哪些键长时间未被访问。
  • refcount字段:记录当前对象被引用的次数,用于通过引用次数回收内存,当refcount=0时可以安全回收当前对象内存空间。使用 object refcount {key} 获取当前对象引用。当对象为整数且范围在[0-999]时,Redis可以使用共享对象的方式来节省内存。
  • *ptr字段:与对象的数据内容相关,如果是整数,直接存储数据;否则表示指向数据的指针。Redis在3.0之后对值对象是字符串且长度<=39字节的数据,内部编码为embstr类型,字符串sds和redisObject一起分配,从而只要一次内存操作即可。

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

8.3.2 缩减键值对象

降低Redis内存使用最直接的方式就是缩减键和值的长度:

  • key长度:键越短越好,如 user:{uid}:friends:notify:{fid} 简化为 u:{uid}:fs:nt:{fid} 。
  • value长度:常见需求是把业务对象序列化成二进制数组放入Redis。首先要从业务上精简,去掉不必要的属性。其次是选择更高效的序列化工具降低字节数组大小:

值对象除了存储二进制数据外,还会使用通用格式存储,如json、xml等,方便调试和跨语言,但不压缩的情况下会比字节数组更吃空间。推荐使用Google的Snappy压缩工具,要远优于GZIP等。

8.3.3 共享对象池

指Redis内部维护的[0-9999]的整数对象池。如果创建大量整数类型的redisObject,每个内部结构至少需要16个字节,通过整数对象池来节约内存,其他类型也可以使用。

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

1
2
3
4
5
6
7
8
9
10
# 因为直接使用共享池内整数对象所以引用数为2
redis> set foo 100
OK
redis> object refcount foo
(integer) 2
# 再引用变为3
redis> set bar 100
OK
redis> object refcount bar
(integer) 3

测试使用整数对象池是否能够优化内存,结果显示大概降低30%内存使用:

当设置maxmemory并启用LRU相关淘汰策略时会禁止使用共享对象池,测试命令:

为什么?

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

对于ziplist编码的值对象,即使内部数据是整数也无法使用共享对象池,因为其为压缩且内存连续的结构,对象共享导致判断成本过高。

为什么只有整数对象池?

  • 整数复用概率最大。
  • 对象共享的关键操作是等值判断,整数比较算法时间复杂度为O(1),字符串则为O(n),更复杂的结构如hash、list则为O(n^2^) 。避免过度开销,并只保留1万个整数防止浪费。

8.3.4 字符串优化

键对象都是字符串,值对象除了整数也都是字符串。如命令 lpush cache:type “redis” “memcache” “tair” “levelDB” :

  • 首先创建 “cache:type” 键字符串,
  • 然后创建链表对象,链表对象内再包含4个字符串对象。

(1)字符串结构

Redis没有采用原生C语言的字符串结构,而是自行实现内部简单动态字符串(simple dynamic string,SDS)。

SDS特性:

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

(2)预分配机制

预分配机制可能会导致内存浪费:

相同的数据在追加后内存消耗非常严重,阶段1插入数据后,free字段保留空间为0,总占用空间=实际占用+1字节(保存 '\0' 标识结尾),忽略了int类型len和free消耗的8字节。

阶段二追加60字节数据空间后, 字符串对象预分配了一倍容量作为预留空间,而且大量追加操作需要内存重新分配,造成内存碎片率上升。

阶段三直接插入120字节数据,相比阶段二节省了每个对象的预分配空间,同时降低了碎片率。

预分配的目的是放在修改操作需要不断的重新分配内存和字节数据拷贝。但同样会造成内存浪费。预分配不是每次都翻倍扩容,规则:

  1. 第一次创建len属性等于数据实际大小,free等于0,不做预分配。
  2. 修改后如果已有free空间不够且小于1M,每次预分配一倍容量。
  3. 修改后如果已有free空间不够且大于1M,每次预分配1M容量。

尽量减少字符串频繁修改操作,如append、setrange。改为直接使用set修改字符串。

(3)字符串重构

指不一定把每份数据作为字符串整体存储,如json这样的数据可以使用hash结构,使用二级结构也可以节省内存。同时可以使用hmget、hmset命令来对字段部分读取修改,无需每次整体存取。

测试字符串和hash内存表现:

第一次默认配置的hash内存消耗不降反增,因为json的videoAlbumPic长度为65,默认hash-max-ziplist-value为64,导致Redis采用hashtable编码格式,反而大量消耗内存。调整后内部编码方式变为ziplist。

8.3.5 编码优化

(1)了解编码

使用 object encoding {key} 来获取编码类型:

每种数据类型至少会有两种编码方式,从而通过选择不同编码方式来实现效率和空间的平衡:

如存储只有10个元素的列表,使用双向链表结构时,需要维护大量的内部字段,如前置指针、后置指针、数据指针等。采用连续内存结构的压缩列表可以节省大量内存,而且因为数据长度较小,存取时间复杂度即使为 O(n^2^) 也可以满足需求。

(2)控制编码类型

编码类型转换在Redis写入数据时自动完成,整个过程不可逆,转换只能从小内存编码转向大内存编码:

不支持编码回退的理由是,数据增删频繁时数据向压缩编码转换非常消耗CPU。

控制编码的参数:

hash类型进行编码转换的流程:

可以使用 config set 命令设置编码转换相关参数,对于已经采用非压缩编码类型的数据,设置参数后即使满足压缩条件也不会做转换,只能重启Redis重新加载数据才能完成转换。

(3)ziplist编码

所有数据采用线性连续的内存结构,其编码结构如下图,一个ziplist包含多个entry,每个entry包含具体的数据:

字段含义:

  • zlbytes:记录整个压缩列表所占字节长度,方便重新调整ziplist空间。类型为int-32,长度为4字节。
  • zltail:记录距离尾节点的偏移量,方便尾节点弹出操作。类型为int-32,长度为4字节。
  • zllen:记录压缩链表节点数量,长度超过2^16^ - 2时需要遍历整个列表获取长度。类型为int-16,长度为2字节。
  • entry:记录具体的节点,长度根据实际存储的数据而定。
    • prev_entry_bytes_length:记录前一个节点所占空间,用于快速定位上一个节点,可实现列表反向迭代。
    • encoding:标识当前节点编码和长度,前两位表示编码类型:字符串/整数,其余位表示数据长度。
    • contents:保存节点的值,针对实际数据长度做内存占用优化。
  • zlend:记录列表结尾,占用一个字节。

ziplist特点:

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

ziplist可以大幅降低内存占用,但相比原生结构操作要更耗时,其中耗时排序list < hash < zset。针对性能要求较高的场景,建议长度不要超过1000,每个元素大小控制在512字节以内。

命令平均耗时使用 info Commandstats 获取,包含调用次数、总耗时、平均耗时,单位为微秒。

(4)intset编码

属于集合set的编码类型,内部表现为存储有序、不重复的整数集(只能存储整数)。当集合只包含整数且长度不超过 set-max-intset-entries 时被启用。

intset对写入整数进行排序,通过O(log(n))时间复杂度实现查找和去重操作,内部结构:

  • encoding:整数表示类型,根据集合内最长整数值确定,包括:int-16、int-32、int-64 。
  • length:表示集合元素个数。
  • contents:整数数组,按从小到大顺序保存。

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

测试发现intset表现良好,插入命令时间复杂度为O(n),查询命令为O(log(n)),使用集合时可以尽量使用该编码。当集合内保存非整数类型时,可以使用ziplist-hash来模拟集合,hash的field当做集合的元素,value设置为1字节占位符即可。使用ziplist编码的hash要比使用hashtable的集合节省大量内存

8.3.6 控制键的数量

过多的键也会消耗大量内存,使用Redis不能进入一个误区,当做Memcached来用,大量的使用get/set。存储相同的数据内容利用Redis的数据结构降低外层键的数量,如图所示,在客户端预估键规模,把大量键分组映射到多个hash结构中降低键的数量。

  • 根据键规模在客户端通过分组映射到一组hash对象中,如存在100万个键,可以映射到1000个hash中,每个hash保存1000个元素。
  • hash的field用于记录原始的key字符串,方便hash查找。
  • hash的value保存原始值对象,确保不要超过ziplist限制。

通过测试说明:

  • 同样的数据,使用ziplist编码的hash类型要比string类型节省内存。
  • 节省内存量随着value空间减少越来越明显。
  • hash-ziplist类型比string类型写入耗时,但随着value空间减少,耗时逐渐降低。

内存优化的关键点:

  1. hash类型节省内存的原理是使用ziplist,如果使用hashtable反而会增加消耗。
  2. ziplist的长度要控制在1000以内,否则因为时间复杂度较高导致CPU消耗严重。
  3. ziplist适合存储小对象,对于大对象的优化效果不明显,只会导致操作耗时增加。
  4. 需要预估键的规模,从而确定每个hash结构需要存储的元素数量。
  5. 根据hash长度和元素大小,调整hash-max-ziplist-entries和hash-max-ziplist-value参数,确保使用ziplist编码。

关于hash键和field键的设计:

  1. 当键离散度较高时,可以按字符串位截取,后三位作为哈希的field,之前部分则作为哈希的键。如key=1948480哈希 key=group:hash:1948 ,哈希filed=480。
  2. 当键离散度较低时,可以使用哈希算法打散键,如使crc32(key)&10000函数将键都映射到[0-9999]范围内,哈希field存储键的原始值。
  3. 尽量减少hash键和field键的长度,如使用部分键内容。

潜在问题:

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

如果想使用超时删除功能,开发人员可以存储每个对象的写入时间,再通过定时任务使用hscan命令扫描数据,找到超时数据并删除。


参考:

🔗 《Redis开发与运维》