Redis-08-理解内存

内存消耗

内存消耗可以分为进程自身消耗和子进程消耗。

内存使用统计

可通过执行 info memory 命令获取内存相关指标,各项指标详细解释如下:

  • 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_gragmentation_ratio:used_memory_rss / used_memory 比值,表示内存碎片率
    • 当 mem_fragmentation_ratio > 1 时,说明 used_memory_rss - used_memory 多出的部分内存并没有用于数据存储,而是被内存碎片所消耗,如果两者相差很大,说明碎片率严重。
    • 当 mem_fragmentation_ratio < 1 时,这种情况一般出现在操作系统把 Redis 内存交换(Swap)到硬盘导致,出现这种情况时要格外关注,由于硬盘速度远远慢于内存,Redis 性能会变得很差,甚至僵死。
  • mem_allocator:Redis 所使用的内存分配器,默认为 jemalloc

内存消耗划分

  1. 自身内存:一个空的Redis进程消耗内存可以忽略不计。
  2. 对象内存:Redis 内存占用最大的一块,存储着用户所有的数据。
  3. 缓冲内存:主要包括客户端缓冲、复制积压缓冲区和AOF缓冲区。
    • 客户端缓冲指的是所有接入到 Redis 服务器 TCP 连接的输入输出缓冲。输入缓冲无法控制,最大空间为 1G,如果超过将断开连接。输出缓冲通过参数client-output-buffer-limit控制:
      • 普通客户端:默认配置 client-output-buffer-limit normal 1000
      • 从客户端:主节点会为每个从节点单独建立一条连接用于命令复制,默认配置是:client-output-buffer-limit slave 256mb 64mb 60
      • 订阅客户端:当使用发布订阅功能时,连接客户端使用单独的输出缓冲区,默认配置为:client-output-buffer-limit pubsub 32mb 8mb 60
    • 复制积压缓冲区:提供了一个可重用的固定大小缓冲区用于实现部分复制功能,根据 repl-backlog-size 参数控制,默认 1MB。对于复制积压缓冲区整个主节点只有一个,所有的从节点共享此缓冲区,因此可以设置较大的缓冲区空间
    • AOF 缓冲区:用于在 Redis 重写期间保存最近的写入命令,AOF 缓冲区空间消耗用户无法控制,消耗的内存取决于 AOF 重写时间和写入命令量,占用通常很小
  4. 内存碎片
    • Redis 默认的内存分配器采用 jemalloc,可选的分配器还有:glibc、tcmalloc
    • 内存分配器为了更好地管理和重复利用内存,分配内存策略一般采用固定范围的内存块进行分配
    • 以下场景容易出现高内存碎片问题:
      • 频繁做更新操作
      • 大量过期键删除,释放的空间无法得到充分利用
    • 常见的解决方式:
      • 数据对齐
      • 安全重启:重启节点可以做到内存碎片重新整理,因此可以利用高可用架构,如Sentinel 或 Cluster,将碎片率过高的主节点转换为从节点,进行安全重启

子进程内存消耗

  1. Redis 产生的子进程并不需要消耗 1 倍的父进程内存,实际消耗根据期间写入命令量决定,但是依然要预留出一些内存防止溢出
  2. 需要设置 sysctl vm.overcommit_memory = 1 允许内核可以分配所有的物理内存,防止Redis 进程执行 fork 时因系统剩余内存不足而失败
  3. 排查当前系统是否支持并开启 THP,如果开启建议关闭,防止 copy-on-write 期间内存过度消耗

内存管理

设置内存上限

Redis 默认无限使用服务器内存,为防止极端情况下导致系统内存耗尽,建议所有的 Redis 进程都要配置 maxmemory。

限制内存的主要目的有:

  • 用于缓存场景,当超出内存上限 maxmemory 时使用 LRU 等删除策略释放空间
  • 防止所用内存超过服务器物理内存。但需注意,maxmemory 限制的是 Redis 实际使用的内存量,也就是 used_memory 统计项对应的内存。由于内存碎片率的存在,实际消耗的内存可能会比 maxmemory 设置的更大,实际使用时要小心这部分内存溢出。

动态调整内存上限

Redis 的内存上限可以通过 config set mammemory 进行动态修改,即修改最大可用内存。

在保证物理内存可用的情况下,系统中所有 Redis 实例可以调整 maxmemory 参数来达到自由伸缩内存的目的。

内存回收策略

  1. 删除过期键对象

    Redis 所有的键都可以设置过期属性,内部保存在过期字典中。

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

    当 Redis 所用内存达到 maxmemory 上限时会触发相应的溢出控制策略。具体策略受maxmemory-policy 参数控制,Redis 支持 6 种策略,如下所示:
    1) noevicition:默认策略,不会删除任何数据,拒绝所有写入操作并返回客户端错误信息 (error) OOM command not allowed when used memory,此时 Redis 只响应读操作
    2) volatile-ttl:根据键值对象的 ttl 属性,删除最近将要过期数据。如果没有,回退到noeviction 策略
    3) volatile-lru:根据 LRU 算法删除设置了超时属性(expire)的键,直到腾出足够空间为止。如果没有可删除的键对象,回退到 noeviction 策略
    4) volatile-random:随机删除过期键,直到腾出足够空间为止
    5) allkeys-lru:根据 LRU 算法删除键,不管数据有没有设置超时属性,直到腾出足够空间为止
    6) allkeys-random:随机删除所有键,直到腾出足够空间为止

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

内存优化

redisObeject 对象

Redis 存储的所有值对象在内部定义为 redisObject 结构体,主要包含五个字段:

  1. type 字段:表示当前对象使用的数据类型

    • Redis 主要支持 5 种数据类型:string、hash、list、set、zset
    • 可以使用 type {key} 命令查看对象所属类型,type 命令返回的是值对象类型,键都是string 类型
  2. encoding 字段:表示 Redis 内部编码类型,encoding 在 Redis 内部使用,代表当前对象内部采用哪种数据结构实现

  3. lru 字段:记录对象最后一次被访问的时间

    • 当配置了 maxmemory 和 maxmemory-policy = volatile-lru 或者 allkeys-lru时,用于辅助 LRU 算法删除键数据
    • 可以使用 object idletime {key} 命令在不更新 lru 字段情况下查看当前键的空闲时间
    • 可以使用 scan + object idletime 命令批量查询哪些键长时间未被访问,找出长时间不访问的键进行清理,可降低内存占用
  4. refcount 字段:记录当前对象被引用的次数

    • 当 refcount = 0 时,可以安全回收当前对象空间
    • 使用 object refcount {key}获取当前对象引用次数
    • 当对象为整数且范围在 [0-9999] 时,Redis 可以使用共享对象的方式来节省内存
  5. *ptr 字段:与对象的数据内容相关,如果是整数,直接存储数据;否则表示指向数据的指针

    • 值对象是字符串且长度 <= 39字节的数据,内部编码为 embstr 类型,字符串 sds 和redisObject 一起分配,从而只要一次内存操作即可

缩减键值对象

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

  • key 长度:在设计键时,在完整描述业务情况下,键值越短越好
  • value 长度
    • 在业务上精简业务对象,去掉不必要的属性避免存储无效数据
    • 将业务对象序列化城二进制数组放入 Redis,且应该选择高效的序列化工具
    • 值对象除了存储二进制数据之外,通常还会使用通用格式(如 json 和 XML 等)作为字符串存储在 Redis 中,可使用通用压缩算法(如 Snappy)进行存储从而降低内存占用

共享对象池

共享对象池是指 Redis 内部维护 [0-9999] 的整数对象池。

  • 除了整数值对象,其他类型如 list、hash、set、zset 内部元素也可以使用整数对象池。但对于 ziplist 编码的值对象,即使内部数据为整数也无法使用共享对象池,因为 ziplist 是用压缩且内存连续的结构
  • 整数对象池在 Redis 中通过变量 REDIS_SHARED_INTEGERS 定义,不能通过配置修改
  • 可以通过 object refcount 命令查看对象引用数验证是否启用整数对象池技术
  • 当设置 maxmemory 并启用 LRU 相关淘汰策略如:volatile-lru,allkeys-lru 时,Redis 禁止使用共享对象池

字符串优化

  1. 字符串结构:简单动态字符串 (simple dynamic string, SDS)

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

    • 空间预分配机制:
      • 第一次创建时,len 属性等于数据实际大小,free = 0,不做预分配
      • 修改后如果已有 free 空间不够且数据小于 1MB,每次预分配数据实际大小一倍容量
      • 修改后如果已有 free 空间不够且数据大于 1MB,每次预分配 1MB 数据
    • 尽量减少字符串频繁修改操作如 append、setrange,改为直接使用 set 修改字符串,降低预分配带来的内存浪费和内存碎片化
  3. 字符串重构:指不一定把每份数据作为字符串整体存储,像 json 这样的数据可以使用 hash 结构,使用二级结构存储也能帮我们节省内存。同时可以使用 hmgethmset 命令支持字段的部分读取修改,而不用每次整体存取。

编码优化

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

  1. 编码类型转换

    • 编码类型转换在 Redis 写入数据时自动完成,这个转换过程是不可逆的,转换规则只能从小内存编码向大内存编码转换
    • 可以使用 config set 命令设置编码相关参数来满足使用压缩编码的条件。对于已经采用非压缩编码类型的数据如 hashtable、linkedlist 等,设置参数后即使数据满足压缩编码条件,Redis 也不会做转换,需要重启 Redis 重新加载数据才能完成转换。
  2. ziplist 编码

    ziplist 编码中所有数据都是采用线性连续的内存结构,主要的目的是为了节省内存。

    ziplist 内部结构:<zlbytes><zltail><zllen><entry-1><entry-2>...<entry-n><zlend>,每个 entry 保存具体的数据,内部结构为:<prev_entry_bytes_length><encoding><contents>

    ziplist 结构字段含义:

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

    ziplist 数据结构特点:

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

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

    intset 字段结构:<encoding><length><contents>

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

    intset 保存的整数类型根据长度划分,当保存的整数超出当前类型时,将会触发自动升级操作且升级后不再做回退。升级操作将会导致重新申请内存空间,把原有数据按转换类型后拷贝到新数组。因此,使用 intset 编码的集合时,尽量保持整数范围一致,防止个别大整数触发集合升级操作,产生内存浪费。

控制键的数量

对于存储相同的数据内容,利用 Redis 的数据结构降低外层键的数量,也可以节省大量内存。

ziplist 编码的 hash 结构降低键数量:

  • 通过在客户端预估键规模,把大量键分组映射到多个 hash 结构中降低键的数量
  • hash 的 field 可用于记录原始 key 字符串,方便哈希查找
  • hash 的 value 保存原始值对象,确保不要超过 hash-max-ziplist-value 限制

这种内存优化技巧的关键点:

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

关于 hash 键和 field 键的设计:

  • 当键离散度较高时,可以按字符串位截取,如把后三位作为哈希的 field,之前部分作为哈希的键
  • 当键离散度较低时,可以使用哈希算法打散键
  • 尽量减少 hash 键和 field 的长度,如使用部分键内容

hash 重构后所有的键无法再使用超时(expire)和 LRU 淘汰机制自动删除,需要手动维护删除。