内存优化
redisObject对象
redis存储的所有值对象在内部定义为redisObject结构体,也就是说redis存储的数据都使用redisObject来封装,其内部结构如图:
-
type字段:
- 表示当前对象使用的数据类型,redis主要支持五种数据类型:string、list、hash、set、zset。
- 可以使用
type {key}
命令查看对象所属类型,type命令返回的是值对象类型(键都是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-9999]时,redis可以使用共享对象的方式来节省内存
-
*ptr
字段- 与对象的数据内容相关,如果是整数就直接存储整数,否则表示执行数据的指针
- redis在3.0之后对值对象是字符串而且长度<=39字节的数据,内部编码为embstr类型,字符串和redisObject一起分配,从而只要一次内存操作
提示:高并发写入场景中,在条件允许的情况下建议字符串长度控制在39字节以内,减少创建redisObject内存分配次数从而提高性能
缩减键值对象
降低redis内存使用最直接的方法就是缩减键(key)和值(value)的长度
- key长度:如在设计键时,在完整描述业务情况下,键值越短越好。
- value长度:值对象缩减比较复杂
- 常见需求是把业务对象序列化成二进制数组放入Redis。
- 首先应该在业务上精简业务对象,去掉不必要的属性避免存储无效数据
- 其次在序列化工具选择上,应该选择更高效的序列化工具来降低字节数组大小
- 值对象除了存储二进制数据之外,通常还会使用通用格式存储比如json、xml等作为字符串存储在redis中
- 这种方式的优点是方便调试和跨语言,但是同样的数据相比字节数组所需的空间更大
- 在内存紧张的情况下,可以使用通用压缩算法压缩json、xml后再存入redis,从而降低内存占用
- 开发提示:当频繁压缩解压json等文本数据时,开发人员需要考虑压缩速度和计算开销成本,这里推荐使用google的Snappy压缩工具,在特定的压缩率情况下效率远远高于GZIP等传统压缩工具,且支持所有主流语言环境。
- 常见需求是把业务对象序列化成二进制数组放入Redis。
共享对象池
在可以使用长整型/整型代替字符串的场景下,尽量使用长整型/整型
对象共享池指的是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,如下图所示。
共享内存池可以节约大量内存。但是注意对象池并不是只要存储[0-9999]的整数就可以工作。当设置maxmemory并启用LRU相关淘汰策略比如volatile-lru,allkeys-lru时,Redis禁止使用共享对象池。测试如下:
redis> set key:1 99
OK //设置key:1=99
redis> object refcount key:1
(integer) 2 //使用了对象共享,引用数为2
redis> config set maxmemory-policy volatile-lru
OK //开启LRU淘汰策略
redis> set key:2 99
OK //设置key:2=99
redis> object refcount key:2
(integer) 3 //使用了对象共享,引用数变为3
redis> config set maxmemory 1GB
OK //设置最大可用内存
redis> set key:3 99
OK //设置key:3=99
redis> object refcount key:3
(integer) 1 //未使用对象共享,引用数为1
redis> config set maxmemory-policy volatile-ttl
OK //设置非LRU淘汰策略
redis> set key:4 99
OK //设置key:4=99
redis> object refcount key:4
(integer) 4 //又可以使用对象共享,引用数变为4
为什么开启maxmemory和LRU淘汰策略后对象池无效?
对于LRU淘汰策略:
- LRU算法需要获取对象最后被访问的时间,以便淘汰最长没有被访问的数据,每个对象最后访问时间存储在redisObject对象的lru字段
- 对象共享意味着多个引用共享同一个redisObject,这时lru字段也会被共享,导致无法获取每个对象的最后访问时间
对于maxmemory:
- 如果没有设置maxmemory,直到内存被用尽redis也不会触发内存回收,所以共享对象池可以正常工作。
- 如果开启了maxmemory,多个对象共享当内存耗尽时就不知道要删除哪一个key了
综上:共享对象池与maxmemory+LRU策略冲突
为什么只有整数对象池?
- 首先整数对象池被复用的几率最大
- 其次对象共享的一个关键操作就是判断相等性
- redis之所以只有整数对象池,是因为整数比较算法时间复杂度为O(1),只保留了一万个整数为了防止对象池浪费
- 如果是字符串判断相等性,时间复杂度变为O(n),特别是长字符串更消耗性能(浮点数在redis内部使用字符串存储)
- 对于更复杂的数据结构比如hash、list,时间复杂度 O ( n 2 ) O(n^2) O(n2)
- 对于单线程的redis来说,这样的开销显然不合理,因此redis值保留整数对象共享池
补充
- 目前redis的共享对象只包括10000个整数(0-9999);
- 可以通过调整
REDIS_SHARED_INTEGERS
参数提高共享对象的个数;例如将REDIS_SHARED_INTEGERS调整到20000,则0-19999之间的对象都可以共享。
以及:
- 对于ziplist编码的值对象,即使内部数据为整数也无法使用共享对象池
- 因为ziplist使用压缩而且内存连续的结构,对象共享判断成本过高
字符串优化
字符串对象是redis内部最常用的数据类型。
- 所有的键都是字符串类型
- 值对象数据除了整数之外都使用字符串存储
比如执行命令:lpush cache:type “redis” “memcache” “tair” “levelDB” ,Redis首先创建”cache:type”键字符串,然后创建链表对象,链表对象内再包含四个字符串对象,排除Redis内部用到的字符串对象之外至少创建5个字符串对象。
字符串结构
redis没有采用原生C语言的字符串类型而是自己实现了字符串结果,内部简单动态字符串(simple dynamic string),简称SDS。结构下图所示。
redis自身实现的字符串结构有如下特点:
- O(1)时间复杂度获取:字符串长度、已用长度、未用长度
- 可用于保存字节数组,支持安全的二进制数据存储
- 内部实现空间预分配机制,降低内存再分配次数
- 惰性删除机制,字符串缩减后的空间不释放,作为预分配空间保留
预分配机制
因为字符串SDS存在预分配机制,日常开发中要小心预分配带来的内存浪费。
字符串之所以采用预分配的方式是防止修改操作需要不断重分配内存和字节数据拷贝。但同样也会造成内存的浪费。字符串预分配每次并不都是翻倍扩容,空间预分配规则如下:
- 第一次创建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。
大量追加操作需要内存重新分配,造成内存碎片率(mem_fragmentation_ratio)上升
提示:开发中尽量减少字符串频繁修改操作比如append、setrange,改为直接使用set修改字符串,降低预分配带来的内存浪费和内存碎片化
字符串重构
字符串重构:
- 指不一定把每份数据作为字符串整体存储,像json这样的数据可以使用hash结构,使用二级结构存储也能帮我们节省内存。
- 同时可以使用hmget,hmset命令支持字段的部分读取修改,而不用每次整体存取。
例如下面的json数据:
{
"vid": "413368768",
"title": "搜狐屌丝男士",
"videoAlbumPic": "http://photocdn.sohu.com/60160518/vrsa_ver8400079_ae433_pic26.jpg",
"pid": "6494271",
"type": "1024",
"playlist": "6494271",
"playTime": "468"
}
分别使用字符串和hash结构测试内存表现,如下表所示。
根据测试结构,第一次默认配置下使用hash类型,内存消耗不但没有降低反而比字符串存储多出2倍,而调整hash-max-ziplist-value=66之后内存降低为535.60M。因为json的videoAlbumPic属性长度是65,而hash-max-ziplist-value默认值是64,Redis采用hashtable编码方式,反而消耗了大量内存。调整配置后hash类型内部编码方式变为ziplist,相比字符串更省内存且支持属性的部分操作
编码优化
了解编码
- redis对外提供string、list、hash、set、zset等类型,但是redis内部针对不同类型存在编码的概念,所谓编码就是具体使用哪种底层数据结构来实现。编码不同将直接影响数据的内存占用和读写效率
- 使用object encoding {key}命令获取编码类型。如下:
redis> set str:1 hello
OK
redis> object encoding str:1
"embstr" // embstr编码字符串
redis> lpush list:1 1 2 3
(integer) 3
redis> object encoding list:1
"ziplist" // ziplist编码列表
Redis针对每种数据类型(type)可以采用至少两种编码方式来实现,如下图:
问题:为什么redis需要对一种数据结构实现多种编码方式?
主要原因:redis作者想通过不同编码实现效率和空间的平衡
- 比如当我们的存储只有10个元素的列表,当使用双向链表数据结构时,比如需要维护大量的内部字段比如每个元素需要:前置指针,后置指针,数据指针等,造成空阿基你得浪费
- 如果采用连续内存结构的压缩列表(ziplist),将会节省大量内存
控制编码类型
编码类型转换在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,得不偿失。
控制key的数量
- 当使用redis存储大量数据时,通常会存在大量键,过多的键同样会消耗大量的内存。
- redis本质是一个数据结构服务器,它为我们提供多种数据结构,比如hash、list、set、zset等结构。
- 使用redis时不要进入一个误区,大量使用get/set这样的API,把redis当成memcache使用。
- 对于存储相同的数据内容利用redis的数据结构降低外层键的数量。
如下图所示,通过在客户端预估键规模,把大量键分组映射到多个hash结构中降低键的数量
hash结构降低键数量分析:
- 根据键规模在客户端通过分组映射到一组hash对象中,比如存在100万个键,可以映射到1000个hash中,每个hash保存1000个元素
- hash的field可以用于记录原始的key字符串,方便hash查找
- hash的value保存原始值对象,确保不要超过hash-max-ziplist-value限制。
其他
利用jemalloc特性进行优化
由于jemalloc分配内存时数值是不连续的,因此key/value字符串变化一个字节,可能会引起占用内存很大的变动,在设计时可以利用这一点。
例如,如果key的长度如果是8个字节,则SDS为17字节,jemalloc分配32字节;此时将key长度缩减为7个字节,则SDS为16字节,jemalloc分配16字节;则每个key所占用的空间都可以缩小一半。
避免过度设计
然而需要注意的是,不论是哪种优化场景,都要考虑内存空间与设计复杂度的权衡;而设计复杂度会影响到代码的复杂度、可维护性。
如果数据量较小,那么为了节省内存而使得代码的开发、维护变得更加困难并不划算;还是以前面讲到的90000个键值对为例,实际上节省的内存空间只有几MB。但是如果数据量有几千万甚至上亿,考虑内存的优化就比较必要了。