Redis技术指南-5-理解内存
上一节:
Redis技术指南-4-复制和阻塞
上一节聊了Redis的复制和阻塞,这一节我们来理解一下Redis的内存。
理解内存
内存消耗分析
进程自身消耗和 子进程消耗
内存消耗命令
info memory 有很多属性
划分:
自身内存 + 对象内存 + 缓冲内存 + 内存碎片
- 对象内存: sizeof (keys) + sizeof (values)
- 缓冲内存:
- 客户端缓冲 --所有接入到Redis服务器TCP连接的输入输出缓冲。输入无法控制1G,包括普通客户端。从客户端(客户端缓冲)、订阅客户端
- 复制积压缓冲区 --部分复制 master一边同步slave,一边写入这个固定区域
- AOF重写缓冲区 --aof文件重写期间,fork子进程生成新aof文件期间,记录写入的命令
- 内存碎片
- redis默认分配器 jemalloc 认为小[8b ~ 3840] 大[4KB ~ 4072KB] 巨大[4MB ~ ]
子进程内存消耗总结:
- Redis产生的子进程并不需要消耗1倍的父进程内存,实际消耗根据期间写入命令量决定,但是依赖要预留出一部分内存防止溢出。
- 需要设置sysctl vm.overcommit_memory=1允许内核可以分配所有的物理内存,防止Redis进程执行fork时因系统剩余内存不足而失败。
- 排查当前系统是否支持开启THP(开启后,降低fork速度,但是写时复制的内存页会单位从4Kb->2M),如果开启建议挂关闭,防止copy-on-write期间copy内存副本过度消耗。
写时复制: AOF/RDB重写时,fork操作产生的子进程内存占用量对外表现为与父进程相同,理论上需要一倍物理内存来完成重写操作。
按时linux 写时复制技术(copy-on-write), 父子进程会共享相同的物理内存页,当父进程处理写请求时会对需要修改的页复制出一份副本完成写操作,而子进程依然读取fork时整个父进程的内存快照。
内存管理
- 设置内存上限
- 目的:用于缓存场景,当超出内存上线maxmemory时使用LRU等删除策略释放空间。
- 防止所用内存超过服务器物理内存。因为这里的maxmemory 就是 used_memory实际使用量,但是由于内存碎片率和fork进程其他进程。往往分配给redis的内存一定要少于物理机内存。
- 动态调整内存上限
- config set maxmemory 6GB
- 目的:用于缓存场景,当超出内存上线maxmemory时使用LRU等删除策略释放空间。
- 设置回收策略
- 体现方面:删除到达过期时间的键对象、内存使用达到maxmemory上限时触发内存溢出控制策略。
- 删除过期建对象
- 惰性删除: 如果访问时这个key是带超时属性的,检查是否过期,过期del返回空。
- 节省CPU成本,不用单独维护TTL链表来处理过期建的删除
- 但是存在内存泄漏的风险
- 定时任务删除:内部维护一个定时任务,每秒运行10次 有快慢设置
- 其实就是随机检查多少个键是否过期,过期删除即可。
- 惰性删除: 如果访问时这个key是带超时属性的,检查是否过期,过期del返回空。
- 内存溢出控制策略: 当内存达到maxmemory上限时,会触发这个策略 6种
- noeviction: 默认:不会删除数据,拒绝写入并且返回错误。只响应读操作。
- volatile-lru: 根据lru算法删除设置超时属性(expire)的键, 直到有空余空间。如果没有可用空间,退到noeviction
- allkeys-lru: 根据lru算法删除建,不管是否有过期属性,直到有空间。
- allkeys-random: 随机删除所有建,直到有可用空间
- volatile-random: 随机删除过期键,直到有可用空间
- volatile-ttl: 根据键对象的ttl属性,删除最近要过期的数据。如果没有,退回到noeviction
- 采用config set maxmemory-policy {policy} 动态设置
内存优化
redisObject对象
Redis存储的所有值对象在内部定义为redisObject结构体,内部如下图所示:
type: 返回值对象类型(string、list、hash、set、zset),键都是string
encoding: Redis内部编码
lru字段:记录对象最后一次被访问时间,方便回收策略中allkeys-lru 和 volatile-lru辅助。
refcount:对象被引用次数。一般是用来做1W以内的整数常量引用使用
*ptr:与数据内容有关。如果是整数,直接存储,否则指向数据的指针。
缩减键值对象
减低内存存储,缩短key,缩短value。但是注意value为字符串一般json序列化方式。
共享对象池
共享对象池是指Redis内部维护的[0-9999]的整数对象池。
原因:
创建大量的整数类型的redisObject存在内存开销,每个redisObject内部结构至少占16字节,甚至超过了整数自身空间消耗。故redis内部维护了一个[0-9999]的整数对象池,用于节约内存。除了整数值对象,其他类型如list、hash、set、zset内部元素也可以使用整数对象池。因此开发中在满足需求的前提下,尽量使用整数对象以节省内存。
内部结构:
整数对象池在Redis中通过变量REDIS_SHARED_INTEGERS定义,不能通过配置修改。可以通过object refcount 命令查看对象引用数验证是否启用整数对象池技术。
set foo 100
ok
object refcount foo
(integer) 2
set bar 100
ok
object refcount bar
(integer) 3
整数对象池共享机制
注意: 当设置maxmemory并启动用LRU相关淘汰策略如:volatile-lru, allkeys-lru时。Redis禁止使用共享对象池。为啥?
1、lru算法需要获取对象最后被访问时间,以便淘汰最长未访问数据,每个对象最后访问时间存储在redisObject的lru字段。
2、对象共享代表着多个引用共享一个redisObject,这时lru字段也会被共享,导致无法获取每个对象最后访问时间。
3、如果没有设置maxmemory,直到内存被用尽Redis也不会触发内存回收,所以共享对象池可以正常工作。
为什么只有整数对象池?
1、整数对象池复用性几率最大
2、对象共享的一个关键操作就是判断相等性,因为整数比较复杂度为O(1),只保留一万个是防止对象池浪费。如果字符串判断相等性,时间复杂度变为O(n), 而且长字符换比较消耗性能。(浮点数在redis中使用字符串存储)
对于其他的hash、list等,相等判断O(n^2)。对于单线程的Redis来说,这样的开销显然不合理。因此只保留了整数对象池。
字符串优化
- 字符串结构 : 没有使用C语言的字符串类类型,自己实现了自己的结构,叫简单动态字符串(simple dynamic string) (SDS).
- 特点:
- 1、O(1) 时间复杂度获取:字符串长度、已用长度、未用长度
- 2、可用于保存字节数组,支持安全的二进制数据存储
- 3、内部实现空间预分配机制,降低内存再分配次数。
- 4、惰性删除机制,字符串缩减后的空间不释放,作为预分配空间保留。
- 预分配机制
- 因为sds存在预分配机制,日常开发中要小心预分配带来的内存浪费。
- 我们可以清晰的看到追加 append 内存消耗非常严重。
- 字符串之所以采用预分配的方式是防止修改操作需要不断重新分配内存和字节数据拷贝。但同样也会造成内存的浪费。但是注意字符串预分配每次并不都是翻倍扩容。
- 空间预分配规则如下:
- 1、第一次创建len属性等于数据实际大小,free等于0, 不做预分配。
- 2、修改后如果已有free空间不够且已有数据小于1M,每次预分配一倍容量。
- 比如原有len=60byte, free = 0, 再追加60byte, 预分配120byte。总空间占用60 + 60 + 120byte + 1byte(字符串结尾分隔符’\O’) 这个时候len=120, free = 120
- 3、修改后如果已有free空间不够且已有数据大于1M(len),每次预分配1MB数据。
- 如原有len=30MB, free =0 , 当再追加100byte, 预分配1MB, 总空间占用30MB + 100byte + 1MB
- 字符串重构 : 指不一定把每份数据作为字符串整体存储,json -> hash 或者部分存储hmget、hmset
- 或者修改hash-max-ziplist-value = 66 比如长度大小。
编码优化
了解编码
提供五种以上的类型,但是内部的编码类型有很多个. object encoding key
采用type 和 多个内部编码的原因: 想通过不同的编码实现效率和空间的平衡。
控制编码类型
编码类型转换在Redis写入数据时自动完成,不可逆,只能从小内存编码 -> 大内存编码
为啥?数据向压缩编码转换是非常消耗CPU,得不偿失。
config set 设置参数使用压缩编码。如果是已经使用了非压缩编码的hashtable、linkedlist这个时候,即使表变更了参数满足了压缩编码条件,Redis也不会转化,只能重启Redis重新加载数据才能完成转换。
ziplist编码
目的:节约内存,所有数据采用线性连续的内存结构。应用范围广,可以作为hash、list、zset类型的底层数据结构。
结构:
字段含义:
- 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)时间复杂度入队和出队。
- add /delete涉及到内存重新分配或释放,加大了操作的复杂性
- 读写操作涉及到复杂的指针移动,最坏时间复杂度为O(n2)
- 适合存储小对象和长度有限的数据。
开发提示:
针对性能高的场景使用ziplist,长度不超过1000,每个元素大小控制在512字节年以内。
命令平均耗时:info commandstats
intset编码
是集合set类型编码的一种,内部表现为存储有序,不重复的整数集。
内部结构:
encoding: 整数表示类型,根据集合最长整数值确定类型,整数类型划分为:int-16、int-32、int-64
length: 表示集合元素个数。
contents: 整数数组,按从小到大顺序保存。
intset如果加入很大的整数类型,会自动升级且不会回退,重新分配内存,copy数据。
控制键的数量
关于hash键和field键的设计:
- 当键的离散度较高时,可以按字符串位截取,把后三位作为哈希的field,之前部分作为hash的键。
- 如key=1948480 key = group#️⃣1948, field = 480
- 当键离散度较低时,可以使用哈希算法打散键,如使用crc32(key) &10000 函数把所有的键映射到0-9999整数范围,哈希field存储键的原始值。
- 尽量减少hash键和field的长度,如使用部分键内容。