本文已参与「新人创作礼」活动,一起开启掘金创作之路。
Redis 将所有的数据都保存在内存中
, 内存跟硬盘相比成本昂贵, 如何高效利用 Redis 内存变得非常重要.
高效利用 Redis 内存 :
- 内存消耗在哪里?
- 内存怎么管理?
- ✔️如何优化内存使用?
Redis 所有的数据都在内存中, 而内存又是非常宝贵的资源. 如何优化内存的使用一直是 Redis 中非常需要关注的问题.
redisObject 对象
Redis 存储的数据都使用 redisObject 进行了封装
, 包括 string, hash, list, set, zset 在内的所有数据类型都是.
type
:表示当前对象的value 的数据类型
, ( string, hash, list, set, zset ).
可以使用 type {key}
命令查看对象所属类型.
encoding
:表示 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 内存使用最直接的方式就是缩减键(key)和值(value)的长度
.
- key 长度:在完整描述业务情况下,
键越短越好
. 如 user:{uid}:friends:notify:{fid} 可以简化为 u:{uid}:fs:nt:{fid}. - value 长度:值对象缩减比较复杂, 常见需求是把业务对象序列化成
二进制数组
放入 Redis.
业务上精简对象
, 去掉不必要的属性,只存储使用到的字段. 选择更高效的序列化
工具来降低字节数组大小.
value 对象除了存储二进制数据之外, 通常还会使用 json, xml 等格式作为字符串储在 Redis 中. 这种方式优点是方便查看, 但是同样的数据相比字节数组占用空间更大, 在内存紧张的情况下, 可以使用通用压缩算法压缩
json, xml 后再存入 Redis, 从而降低内存占用, 例如使用 GZIP 压缩后的 json 可降低约 60% 的空间.
但是频繁压缩解压 json 等文本数据时, 需要考虑压缩速度和计算开销成本, 推荐使用 Google 的 Snappy
压缩工具, 在特定的压缩率情况下效率远远高于 GZIP 等传统压缩工具, 且支持所有主流语言环境.
压缩参考:
共享对象池
共享对象池是指 Redis 内部维护的 [0-9999] 的整数对象池
. 因为创建大量的整数类型 redisObject 存在内存开销, 每个 redisObject 内部结构至少占 16 字节, 甚至超过了整数自身空间消耗. 所以 Redis 内存维护一个 [0-9999] 的整数对象池, 用于节约内存. 除了整数值对象, 其他类型如 list, hash, set, zset 内部元素也可以使用整数对象池. 因此开发中在满足需求的前提下, 尽量使用整数对象以节省内存
.
整数对象池在 Redis 中通过变量 REDIS_SHARED_INTEGERS 定义, 不能通过配置修改. 可以通过 object refcount 命令查看对象引用数验证是否启用整数对象池技术, 如下:
redis> set foo 100
OK
redis> object refcount foo
(integer) 2
redis> set bar 100
OK
redis> object refcount bar
(integer) 3
复制代码
设置键 foo 等于 100 时, 直接使用共享池内整数对象, 因此引用数是 2, 再设置键 bar 等于 100 时, 引用数又变为 3.
使用共享对象池后, 相同的数据内存使用降低 30% 以上. 可见当数据大量使用 [0-9999] 的整数时, 共享对象池可以节约大量内存.
不过对象池的使用也有要求. 当设置 maxmemory 并启用 LRU 相关淘汰策略如:volatile-lru, allkeys-lru 时, Redis 将禁止使用共享对象池. 主要是因为 LRU 算法需要获取对象最后被访问时间, 以便淘汰最长未访问数据, 每个对象最后访问时间存储在 redisObject 对象的 lru 字段. 对象共享意味着多个引用共享同一个 redisObject, 这时 lru 字段也会被共享, 导致无法获取每个对象的最后访问时间
. 如果没有设置 maxmemory, 直到内存被用尽 Redis 也不会触发内存回收, 所以共享对象池可以正常工作.
综上所述, 共享对象池与 maxmemory+LRU 策略冲突
, 使用时需要注意.
字符串优化
字符串是 Redis 中最常用的数据类型. 所有的键都是字符串, 值对象数据除了整数之外都使用字符串存储. 因此深刻理解 Redis 字符串对于内存优化非常有帮助.
比如执行命令:lpush cache:type "redis" "memcache" "tair" "levelDB"
, Redis 首先创建 cache:type 键字符串, 然后创建链表对象, 链表对象内再包含四个字符串对象, 排除 Redis 内部用到的字符串对象之外至少创建 5 个字符串对象.
字符串结构
Redis 没有采用原生 C 语言的字符串类型,而是自己实现了字符串结构 : 内部简单动态字符串
(simple dynamic string, SDS
).动态意味着可以修改.
该字符串结构有如下特点:
① O(1) 时间复杂度获取字符串长度.
因为 C 字符串并不记录自身的长度信息,所以为了获取一个 C 字符串的长度,必须遍历整个字符串,这个操作的复杂度为 O(n),而 SDS 在 len 属性中记录了长度,所以复杂度仅为 O(1).
② 字符串内部结构是一个字符数组.
③ 内部实现空间预分配机制
, 降低字符串扩容缩容导致的内存再分配次数
.
如下图中:整个字符数组就是 buf,字符串大小就是 len,而预置空间则是 free.
④ 惰性空间释放机制
, 字符串缩减后的空间不释放
, 作为预分配空间保留.
⑤ 二进制安全. 二进制安全的 SDS 使得 Redis 不仅可以保存文本数据,还可以保存任意格式的二进制数据.
空间预分配机制
因为字符串(SDS)存在预分配机制, 日常开发中要小心预分配带来的内存浪费.
字符串使用预分配机制是为了防止修改操作需要不断重新分配内存和字节数据的拷贝
. 但缺点是会造成内存浪费.
在扩展 SDS 空间之前,SDS API 会先检查未使用空间 free 是否足够
,如果足够的话,API 就会直接使用未使用空间,而无须执行内存重分配. 通过这种预分配策略,SDS 将连续增长 N 次字符串所需的内存重分配次数从必定 N 次降低为最多 N 次.
空间预分配规则如下:
- 第一次创建, len 等于数据实际大小, free 等于 0, 即
不做预分配
. - 修改字符串后,若 len < 1M,
每次预分配 1 倍容量
.
比如:修改后字符串 len=120byte, 则会预分配 120byte, 总占用空间:120+120+1 =241byte.
- 修改字符串后,若 len > 1M, 则
每次只预分配 1MB 容量
. 为避免加倍的冗余空间过大而导致浪费,所以每次分配只会多 1M 的冗余空间.
比如:修改后字符串 len = 2m, 预分配 1MB, 总占用空间 2MB+1MB+1byte.
空间惰性释放机制
惰性空间释放用于优化 SDS 的字符串缩短操作:当 SDS 的 API 需要缩短 SDS 保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用 free 属性将这些字节的数量记录起来,并等待将来使用.
控制键的数量
当存储大量数据时, 通常会存在大量的键, 过多的键同样会消耗大量内存. 对于存储相同的数据内容通过 Redis 的数据结构降低外层键的数量
, 也可以节省大量内存. 把大量键分组映射到多个 hash 结构中可以降低键的数量.
(1) hash 结构降低键数量的操作流程:
- 根据键规模在客户端通过分组映射到一组 hash 对象中, 如存在 100 万个键, 可以映射到 1000 个 hash 中, 每个 hash 保存 1000 个元素.
- hash 的 field 可用于记录原始 key 字符串, 方便哈希查找.
- hash 的 value 保存原始值对象, 确保不要超过 hash-max-ziplist-value 限制.
比如:系统中使用固定前缀 + 订单号,记录该订单的支付状态,value 则是支付结果,eg: A_B_orderId,目前都是使用字符串类型存储的,所以在 redis 中该种形式的 key 存在非常多.
使用 hash 结构优化:hash 的 key 为 A_B,field 则为 orderId,value 还是支付结果,这样,多个 key 就变为了一个 key,通过减少 key 的数量来减少内存使用. ( 这里只是简单举例,还可以通过日期,或者其他分片规则,拆分到不同 hash 红 )
使用 hash 重构后节省内存量效果非常明显, 特别对于存储小对象的场景, 内存只有不到原来的 1/5.
(2) 这种内存优化技巧的关键点:
- hash 类型节省内存的原理是使用 ziplist 编码, 如果使用 hashtable 编码方式反而会增加内存消耗.
- ziplist 长度需要控制在 1000 以内, 否则由于存取操作时间复杂度在 O(n) 到 O(n^2) 之间, 长列表会导致 CPU 消耗严重, 得不偿失.
- ziplist
适合存储小对象
, 对于大对象, 不但内存优化效果不明显, 还会增加命令操作耗时. - 需要预估键的规模, 从而确定每个 hash 结构需要存储的元素数量.
- 根据 hash 长度和元素大小, 调整 hash-max-ziplist-entries 和 hash-maxziplist-value 参数, 确保 hash 类型使用 ziplist 编码.
(3) 关于 hash 键和 field 键的设计:
- 当键离散度较高时, 可以按字符串位截取, 把后三位作为哈希的 field, 之前部分作为哈希的键.
如:key=1948480 哈希 key=group:hash:1948, 哈希 field=480. - 当键离散度较低时, 可以使用哈希算法打散键, 如:使用 crc32(key) & 10000 函数把所有的键映射到 "0-9999" 整数范围内, 哈希 field 存储键的原始值.
- 尽量减少 hash 键和 field 的长度, 如使用部分键内容.
(4) 需要解决的问题
使用 hash 结构控制键的规模虽然可以大幅降低内存, 但同样会带来问题, 需要提前做好规避处理.
- 客户端需要预估键的规模并设计 hash 分组规则, 增加了客户端改造成本.
- hash 重构后所有的键无法再使用超时(expire)和 LRU 淘汰机制自动删除, 需要手动维护删除.
- 只适合小对象,对于大对象, 如 1KB 以上的对象, 使用 hash-ziplist 结构控制键数量反而得不偿失.
不过瑕不掩瑜, 对于大量小对象的存储场景, 非常适合使用 ziplist 编码的 hash 类型控制键的规模来降低内存.
可参考:
www.kuaixunai.com/thread-1446…
编码优化
内部编码
控制编码类型
编码类型转换在 Redis 写入数据时自动完成
, 转换是单向的
, 只能从小内存编码向大内存编码转换
,不能回退
.
注意 : 重启 Redis 后重新加载数据, Redis 会根据条件, 设置响应的内部编码.
redis> lpush list:1 a b c d
(integer) 4 // 存储4个元素
redis> object encoding list:1
"ziplist" // 采用ziplist压缩列表编码
redis> config set list-max-ziplist-entries 4
OK // 设置列表类型ziplist编码最大允许4个元素
redis> lpush list:1 e
(integer) 5 // 写入第5个元素e
redis> object encoding list:1
"linkedlist" // 编码类型转换为链表
redis> rpop list:1
"a" // 弹出元素a
redis> llen list:1
(integer) 4 // 列表此时有4个元素
redis> object encoding list:1
"linkedlist" // 编码类型依然为链表, 未做编码回退
复制代码
Redis 不支持编码回退, 主要是数据增删频繁时, 数据向压缩编码转换非常消耗 CPU.
ziplist 编码
ziplist 编码主要目的是为了节约内存
, 因此所有数据都是采用线性连续的内存结构. ziplist 编码是应用范围最广的一种, 可以分别作为 hash, list, zset 类型的底层数据结构实现.
特点:
- 内部表现为
数据紧凑排列
的一块连续内存数组
. - 可以模拟双向链表结构, 以 O(1) 时间复杂度入队和出队.
- 新增删除操作涉及
内存重新分配或释放
, 加大了操作的复杂性. - 读写操作涉及复杂的指针移动, 最坏时间复杂度为O(n^2).
- 适合
存储小对象和长度有限的数据
.
- ziplist 实现的数据类型相比原生结构, 命令操作更加耗时, 不同类型耗时排序:list<hash<zset.
ziplist 压缩编码的性能表现跟值长度
和元素个数
密切相关, 正因为如此 Redis 提供了 {type}-max-ziplist-value
和 {type}-max-ziplist-entries
相关参数来做控制 ziplist 编码转换. 最后再次强调使用 ziplist 压缩编码的原则:追求空间和时间的平衡.
针对性能要求较高的场景使用 ziplist, 建议长度不要超过1000, 每个元素大小控制在 512 字节以内.
intset 编码
intset 编码是集合(set)类型编码的一种, 内部表现为有序, 不重复的整数集. 当集合只包含整数且长度不超过 set-max-intset-entries 时被启用.
redis> sadd set:test 3 4 2 6 8 9 2
(integer) 6 // 乱序写入6个整数
Redis> object encoding set:test
"intset" // 使用intset编码
Redis> smembers set:test
"2" "3" "4" "6" "8" "9" // 排序输出整数结合
redis> config set set-max-intset-entries 6
OK // 设置intset最大允许整数长度
redis> sadd set:test 5
(integer) 1 // 写入第7个整数 5
redis> object encoding set:test
"hashtable" // 编码变为hashtable
redis> smembers set:test
"8" "3" "5" "9" "4" "2" "6" // 乱序输出
复制代码
以上命令可以看出 intset 对写入整数进行排序, 通过 O(log(n)) 时间复杂度实现查找和去重操作.
intset 保存的整数类型根据长度划分, 当保存的整数超出当前类型时, 将会触发自动升级操作且升级后不再做回退. 升级操作将会导致重新申请内存空间, 把原有数据按转换类型后拷贝到新数组.
使用 intset 编码的集合时, 尽量保持整数范围一致, 如都在int-16范围内. 防止个别大整数触发集合升级操作, 产生内存浪费.
intset 表现非常好, 同样的数据内存占用只有不到 hashtable 编码的十分之一. intset 数据结构插入命令复杂度为 O(n), 查询命令为 O(log(n)), 由于整数占用空间非常小, 所以在集合长度可控的基础上, 写入命令执行速度也会非常快, 因此当使用整数集合时尽量使用 intset 编码.
intset 编码必须存储整数
, 当集合内保存非整数数据时, 无法使用 intset 实现内存优化. 这时可以使用 ziplist-hash 类型对象模拟集合类型, hash 的 field 当作集合中的元素, value设置为1字节占位符即可. 使用 ziplist 编码的 hash 类型依然比使用 hashtable 编码的集合节省大量内存.
\