8.1 内存消耗
内存消耗可以分为进程自身消耗和子进程消耗。
8.1.1 内存使用统计
info memory 命令.
需要重点关注的指标:
- used_memory_rss
- used_memory
- mem_fragmentation_ratio
当 mem_fragmentation_ratio>1 时, 说明 used_memory_rss-used_memory 多出的部分内存并没有用于数据存储, 而是被内存碎片所消耗, 如果两者相差很大, 说明碎片率严重。
当 mem_fragmentation_ratio<1 时, 这种情况一般出现在操作系统把 Redis 内存交换 (Swap) 到硬盘导致, 出现这种情况时要格外关注, 由于硬盘速度远远慢于内存, Redis 性能会变得很差,甚至僵死。
8.1.2 内存消耗划分
Redis 进程内消耗:
- 自身内存
- 对象内存
- 缓冲内存
- 内存碎片
Redis 空进程自身内存消耗非常少, 通常 used_memory_rss 在 3MB 左右, used_memory 在 800KB 左右
1. 对象内存
存储着用户所有的数据:
- 对象内存消耗可以简单理解为 sizeof(keys) + sizeof(values)
- key 都是字符串
- value
- 5种基本类型
- Bitmaps
- HyperLogLog
- GEO 使用 zset
2. 缓冲内存
包括:
- 客户端缓冲
- 复制积压缓冲区
- AOF 缓冲区
客户端缓冲指的是所有接入到 Redis 服务器 TCP 连接的输入输出缓冲。输入缓冲无法控制, 最大空间为 1G, 如果超过将断开连接。输出缓冲通过参数 client-output-buffer-limit 控制.
普通客户端: 除了复制和订阅的客户端之外的所有连接, Redis 的默认配置是:
- client-output-buffer-limit normal 0 0 0 (无限制)
- 复制和订阅的默认配置:
client-output-buffer-limit normal 0 0 0
client-output-buffer-limit replica 256mb 64mb 60
client-output-buffer-limit pubsub 32mb 8mb 60
- 当有大量慢连接客户端接入时这部分内存消耗就不能忽略了, 可以设置 maxclients 做限制
从客户端: 主节点会为每个从节点单独建立一条连接用于命令复制, 默认配置是: client-output-buffer-limit slave256mb64mb60。
- 当主从节点之间网络延迟较高或主节点挂载大量从节点时这部分内存消耗将占用很大一部分,建议主节点挂载的从节点不要多于2个
- 主从节点不要部署在较差的网络环境下, 如异地跨机房环境,防止复制客户端连接缓慢造成溢出。
订阅客户端: 当使用发布订阅功能时, 连接客户端使用单独的输出缓冲区, 默认配置为: client-output-buffer-limit pubsub 32mb 8mb 60, 当订阅服务的消息生产快于消费速度时,输出缓冲区会产生积压造成输出缓冲区空间溢
出。
复制积压缓冲区: Redis 在2.8版本之后提供了一个可重用的固定大小缓冲区用于实现部分复制功能, 根据 repl-backlog-size 参数控制, 默认1MB。对于复制积压缓冲区整个主节点只有一个, 所有的从节点共享此缓冲区, 因此
可以设置较大的缓冲区空间, 如 100MB, 这部分内存投入是有价值的, 可以有效避免全量复制
AOF 缓冲区**: 这部分空间用于在 Redis 重写期间保存最近的写入命令。AOF缓冲区空间消耗用户无法控制, 消耗的内存取决于 AOF 重写时间和写入命令量, 这部分空间占用通常很小。
3. 内存碎片
Redis 的内存分配器:
- jemalloc (默认)
- glibc
- tcmalloc
jemalloc 针对碎片化问题专门做了优化, 一般不会存在过度碎片化的问题, 正常的碎片 (mem_fragmentation_ratio) 在1.03左右。但是当存储的数据长短差异较大时, 以下场景容易出现高内存碎片问题:
- 频繁做更新操作, 例如频繁对已存在的键执行 append、setrange 等更新操作
- 大量过期键删除, 键对象过期删除后, 释放的空间无法得到充分利用, 导致碎片率上升
出现高内存碎片问题时常见的解决方式如下:
- 数据对齐: 在条件允许的情况下尽量做数据对齐, 比如数据尽量采用数字类型或者固定长度字符串等, 但是这要视具体的业务而定,有些场景无法做到。
- 安全重启: 重启节点可以做到内存碎片重新整理, 因此可以利用高可用架构, 如 Sentinel 或 Cluster, 将碎片率过高的主节点转换为从节点,进行安全重启。
8.1.3 子进程内存消耗
- 子进程内存消耗主要指执行 AOF/RDB 重写时 Redis 创建的子进程内存消耗。
- Redis执行 fork 操作产生的子进程内存占用量对外表现为与父进程相同, 理论上需要一倍的物理内存来完成重写操作。但Linux具有写时复制技术 (copy-on-write), 父子进程会共享相同的物理内存页, 当父进程处理写请求时会对需要修改的页复制出一份副本完成写操作, 而子进程依然读取 fork 时整个父进程的内存快照。
- Linux 中 Transparent Huge Pages (THP) 机制: 虽然开启 THP 可以降低 fork 子进程的速度, 但之后 copy-on-write 期间复制内存页的单位从 4KB 变为2 MB, 如果父进程有大量写命令, 会加重内存拷贝量, 从而造成过度内存消耗
子进程内存消耗总结如下:
- Redis 产生的子进程并不需要消耗1倍的父进程内存, 实际消耗根据期间写入命令量决定, 但是依然要预留出一些内存防止溢出
- 需要设置 sysctl vm.overcommit_memory=1 允许内核可以分配所有的物理内存, 防止 Redis 进程执行 fork 时因系统剩余内存不足而失败
- 排查当前系统是否支持并开启 THP, 如果开启建议关闭, 防止 copy-on-write 期间内存过度消耗
8.2 内存管理
8.2.1 设置内存上限
maxmemory 配置项:
- 限制的是 Redis 实际使用的内存量 (used_memory)
8.2.2 动态调整内存上限
config set maxmemory 命令
超出系统物理内存限制就不能简单的通过调整 maxmemory 来达到扩容的目的, 需要采用在线迁移数据或者通过复制切换服务器来达到扩容的目的.
8.2.3 内存回收策略
1. 删除过期键对象
维护每个键精准的过期删除机制会导致消耗大量的 CPU, 对于单线程的 Redis 来说成本过高, 因此 Redis 采用惰性删除和定时任务删除机制实现过期键的内存回收。
- 惰性删除: 访问时检查超时, 单独使用这种方式会导致内存泄漏, 因为如果 key 一直没有被访问, 那么内存不会被释放
- 定时任务删除: 默认每秒运行10次 (通过配置 hz 控制)。定时任务中删除过期键逻辑采用了自适应算法, 根据键的过期比例、使用快慢两种速率模式回收键
- 慢模式下超时时间为25毫秒
- 快模式下超时时间为1毫秒且2秒内只能运行1次
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}
info stats 命令查看 evicted_keys 指标找出当前 Redis 服务器已剔除的键数量.
当 Redis 一直工作在内存溢出 (used_memory>maxmemory) 的状态下且设置非 noeviction 策略时, 会频繁地触发回收内存的操作, 影响 Redis 服务器的性能:
- 频繁执行回收内存成本很高, 主要包括查找可回收键和删除键的开销, 如果当前 Redis 有从节点,回收内存操作对应的删除命令会同步到从节点, 导致写放大的问题:
**
8.3 内存优化
8.3.1 redisObject 对象
Redis 存储的所有值对象在内部定义为 redisObject 结构体:
- type 字段: 表示当前对象使用的数据类型, Redis 主要支持5种数据类型: string、hash、list、set、zset。可以使用 type {key} 命令查看对象所属类型, type 命令返回的是值对象类型, 键都是 string 类型
- encoding 字段: 表示 Redis 内部编码类型, encoding 在 Redis 内部使用, 代表当前对象内部采用哪种数据结构实现。理解 Redis 内部编码方式对于优化内存非常重要, 同一个对象采用不同的编码实现内存占用存在明显差异
- lru 字段: 记录对象最后一次被访问的时间, 当配置了 maxmemory 和 maxmemory-policy=volatile-lru 或者 allkeys-lru 时, 用于辅助 LRU 算法删除键数据。可以使用 object idletime {key} 命令在不更新 lru 字段情况下查看当前键的空闲时间
- refcount 字段: 记录当前对象被引用的次数, 用于通过引用次数回收内存, 当 refcount=0 时, 可以安全回收当前对象空间。使用 object refcount {key} 获取当前对象引用。当对象为整数且范围在[0-9999]时, Redis 可以使用共享对象的方式来节省内存
- *ptr字段: 与对象的数据内容相关
- 如果是整数, 直接存储数据;
- 表示指向数据的指针。Redis 在3.0之后对值对象是字符串且长度<=39字节的数据, 内部编码为 embstr 类型, 字符串 sds 和 redisObject 一起分配, 从而只要一次内存操作即可
其它:
- 可以使用 scan+object idletime 命令批量查询哪些键长时间未被访问
- 高并发写入场景中, 在条件允许的情况下, 建议字符串长度控制在39字节以内, 减少创建 redisObject 内存分配次数, 从而提高性能
8.3.2 缩减键值对象
降低 Redis 内存使用最直接的方式就是缩减键 (key) 和值 (value) 的长度。
- key: 越短越好
- value
- 序列化前精简属性
- 采用高效的序列化工具
在内存紧张的情况下, 可以使用通用压缩算法压缩 json、xml 后再存入 Redis, 从而降低内存占用, 例如使用 GZIP 压缩后的 json 可降低约60%的空间.
8.3.3 共享对象池
共享对象池是指 Redis 内部维护[0-9999]的整数对象池.
除了整数值对象, 其他类型如 list、hash、set、zset 内部元素也可以使用整数对象池。因此开发中在满足需求的前提下, 尽量使用整数对象以节省内存。
整数对象池在 Redis 中通过变量 REDIS_SHARED_INTEGERS 定义, 不能通过配置修改。可以通过 object refcount 命令查看对象引用数验证是否启用整数对象池技术:
使用整数对象池的内存优化效果:
当设置 maxmemory 并启用 LRU 相关淘汰策略如: volatile-lru, allkeys-lru 时, Redis 禁止使用共享对象池.
**
为什么开启 maxmemory 和 LRU 淘汰策略后对象池无效?
- 对象共享意味着多个引用共享同一个 redisObject, 这时 lru 字段也会被共享, 导致无法获取每个对象的最后访问时间
对于 ziplist 编码的值对象, 即使内部数据为整数也无法使用共享对象池, 因为 ziplist 使用压缩且内存连续的结构, 对象共享判断成本过高.
为什么只有整数对象池?
- 整数对象池复用的几率最大
- 对象共享的一个关键操作就是判断相等性
- Redis 之所以只有整数对象池, 是因为整数比较算法时间复杂度为 O(1)
- 只保留一万个整数为了防止对象池浪费
- 如果是字符串判断相等性, 时间复杂度变为 O(n)
- 对于更复杂的数据结构如 hash、list 等, 相等性判断需要 O(n 2 )
8.3.4 字符串优化
1. 字符串结构
内部简单动态字符串 (simple dynamic string, SDS):
特点:
- O(1) 时间复杂度获取: 字符串长度、已用长度、未用长度。
- 可用于保存字节数组, 支持安全的二进制数据存储。
- 内部实现空间预分配机制, 降低内存再分配次数。
- 惰性删除机制, 字符串缩减后的空间不释放, 作为预分配空间保留。
2. 预分配机制
日常开发中要小心预分配带来的内存浪费:
**
阶段2中, value 大小应该是 120B.
阶段1每个字符串对象空间占用如图8-10所示:
在阶段1原有字符串上追加60字节数据空间占用如图8-11所示:
直接插入与阶段2相同数据的空间占用,如图8-12所示:
字符串预分配每次并不都是翻倍扩容, 空间预分配规则如下:
- 第一次创建 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 修改字符串, 降低预分配带来的内存浪费和内存碎片化。
3. 字符串重构
指不一定把每份数据作为字符串整体存储, 像 json 这样的数据可以使用 hash 结构, 使用二级结构存储也能帮我们节省内存。同时可以使用 hmget、hmset 命令支持字段的部分读取修改, 而不用每次整体存取。
测试将 json 数据使用 hash 结构存储的性能:
- hash-max-ziplist-value 默认值是64, 优化后 hash 类型内部编码使用 ziplist
8.3.5 编码优化
1. 了解编码
编码不同将直接影响数据的内存占用和读写效率。使用 object encoding {key} 命令获取编码类型:
2. 控制编码类型
编码类型转换在 Redis 写入数据时自动完成, 这个转换过程是不可逆的,转换规则只能从小内存编码向大内存编码转换:
数据向压缩编码转换非常消耗 CPU.
以 hash 类型为例, 编码转换的运行流程:
config set 命令可以设置相关配置.
对于已经采用非压缩编码类型的数据如 hashtable、linkedlist 等, 设置参数后即使数据满足压缩编码条件, Redis 也不会做转换, 需要重启 Redis 重新加载数据才能完成转换。
3. ziplist 编码
线性连续的内存结构
ziplist 编码结构:
<zlbytes><zltail><zllen><entry-1><entry-2><....><entry-n><zlend>
ziplist 结构字段含义:
- zlbytes: 记录整个压缩列表所占字节长度, 方便重新调整 ziplist 空间。类型是 int-32, 长度为4字节
- zltail: 记录距离尾节点的偏移量, 方便尾节点弹出操作。类型是 int-32, 长度为4字节
- zllen: 记录压缩链表节点数量, 当长度超过216-2时需要遍历整个列表获取长度, 一般很少见。类型是 int-16, 长度为2字节
- entry: 记录具体的节点, 长度根据实际存储的数据而定
- prev_entry_bytes_length: 记录前一个节点所占空间, 用于快速定位上一个节点, 可实现列表反向迭代
- encoding: 标示当前节点编码和长度, 前两位表示编码类型: 字符串/整数, 其余位表示数据长度
- contents: 保存节点的值, 针对实际数据长度做内存占用优化
- zlend: 记录列表结尾, 占用一个字节
数据结构特点:
- 内部表现为数据紧凑排列的一块连续内存数组
- 可以模拟双向链表结构, 以 O(1) 时间复杂度入队和出队
- 新增删除操作涉及内存重新分配或释放,加大了操作的复杂性
- 读写操作涉及复杂的指针移动, 最坏时间复杂度为 O(n2)
- 适合存储小对象和长度有限的数据
测试:
表8-7 ziplist 在 hash, list, zset 内存和速度测试
针对性能要求较高的场景使用 ziplist, 建议长度不要超过1000, 每个元素大小控制在512字节以内.
命令平均耗时使用info Commandstats 命令获取, 包含每个命令调用次数、总耗时、平均耗时, 单位为微秒.
4. intset 编码
intset 编码是集合 (set) 类型编码的一种, 内部表现为存储有序、不重复的整数集。当集合只包含整数且长度不超过 set-max-intset-entries 配置时被启用。执行以下命令查看 intset 表现:
以上命令可以看出 intset 对写入整数进行排序, 通过 O(log(n)) 时间复杂度实现查找和去重操作
intset 编码结构:
intset 的字段结构含义:
- encoding: 整数表示类型, 根据集合内最长整数值确定类型, 整数类型划分为三种: int-16、int-32、int-64
- length: 表示集合元素个数
- contents: 整数数组,按从小到大顺序保存
**
intset 保存的整数类型根据长度划分, 当保存的整数超出当前类型时, 将会触发自动升级操作且升级后不再做回退。升级操作将会导致重新申请内存空间, 把原有数据按转换类型后拷贝到新数组。
8.3.6 控制键的数量
过多的键同样会消耗大量内存
对于存储相同的数据内容利用 Redis 的数据结构降低外层键的数量, 也可以节省大量内存
这种内存优化技巧的关键点:
- hash 类型节省内存的原理是使用 ziplist 编码, 如果使用 hashtable 编码方式反而会增加内存消耗
- ziplist 长度需要控制在1000以内, 否则由于存取操作时间复杂度在 O(n) 到 O(n2) 之间, 长列表会导致 CPU 消耗严重, 得不偿失
- ziplist 适合存储小对象, 对于大对象不但内存优化效果不明显还会增加命令操作耗时
- 需要预估键的规模, 从而确定每个 hash 结构需要存储的元素数量
- 根据 hash 长度和元素大小, 调整 hash-max-ziplist-entries 和 hash-max-ziplist-value 参数, 确保 hash 类型使用 ziplist 编码
关于 hash 键和 field 键的设计:
- 当键离散度较高时, 可以按字符串位截取, 把后三位作为哈希的 field, 之前部分作为哈希的键。如: key=1948480 哈希 key=group:hash:1948, 哈希field=480。
- 当键离散度较低时, 可以使用哈希算法打散键, 如: 使用 crc32(key)&10000 函数把所有的键映射到“0-9999”整数范围内, 哈希 field 存储键的原始值
- 尽量减少 hash 键和 field 的长度, 如使用部分键内容
使用 hash 结构控制键的规模虽然可以大幅降低内存, 但同样会带来问题:
- 客户端需要预估键的规模并设计 hash 分组规则, 加重客户端开发成本
- hash 重构后所有的键无法再使用超时 (expire) 和 LRU 淘汰机制自动删除, 需要手动维护删除
- 对于大对象, 如 1KB 以上的对象, 使用 hash-ziplist 结构控制键数量反而得不偿失
8.4 本章重点回顾
- Redis 实际内存消耗主要包括: 键值对象、缓冲区内存、内存碎片
- 通过调整 maxmemory 控制 Redis 最大可用内存。当内存使用超出时, 根据 maxmemory-policy 控制内存回收策略
- 内存是相对宝贵的资源, 通过合理的优化可以有效地降低内存的使用量, 内存优化的思路包括:
- 精简键值对大小, 键值字面量精简, 使用高效二进制序列化工具
- 使用对象共享池优化小整数对象
- 数据优先使用整数, 比字符串类型更节省空间
- 优化字符串使用, 避免预分配造成的内存浪费
- 使用 ziplist 压缩编码优化 hash、list 等结构, 注重效率和空间的平衡
- 使用 intset 编码优化整数集合
- 使用 ziplist 编码的 hash 结构降低小对象链规模