架构师成长之路 - redis内存优化

464 阅读16分钟

Redis所有的数据都在内存中,而内存又是非常宝贵的资源。如何优化 内存的使用一直是Redis用户非常关注的问题。接下来深入到Redis细节中,探索内存优化的技巧。

redisObject对象

redisObject内部结构 Redis储存的数据都是使用redisObject对象封装的, 包括String,list, hash, set, zset在内的所有数据类型。以下是对每个字段的详细说明:

  1. type: 当前对象使用的数据类型,Redis主要支持5中数据类型:string, hash, list, set, zset。 可以使用type {key} 查看对象所属类型
# string 类型
127.0.0.1:6379> set stringKey hello                                                                           
OK
127.0.0.1:6379> type stringKey                                                                                
string

# list类型
127.0.0.1:6379> rpush mylist a b c                                                                            
(integer) 3
127.0.0.1:6379> type mylist                                                                                   
list

# hash类型
127.0.0.1:6379> hmset user:1000 username antirez birthyear 1977 verified 1                                    
OK
127.0.0.1:6379> type user:1000                                                                                
hash

# set类型
127.0.0.1:6379> sadd myset 1 2 3                                                                              
(integer) 3
127.0.0.1:6379> type myset                                                                                    
set

# zset类型
127.0.0.1:6379> zadd hackers 1940 "Alan Kay"                                                                  
(integer) 1
127.0.0.1:6379> type hackers                                                                                  
zset
127.0.0.1:6379> 
  1. encoding: Redis内部编码类型, encoding在Redis内部使用, 代表当前对象内部采用哪种数据结构实现。理解Redis内部编码方式对于优化内存非常重要。同一个对象采用不同的编码实现内存占用存在明显差距
# string类型的内部编码
127.0.0.1:6379> object encoding stringKey                                                                     
"embstr"
127.0.0.1:6379> set intKey 2                                                                                  
OK
127.0.0.1:6379> object encoding intKey                                                                        
"int"
127.0.0.1:6379> append stringKey 123                                                                          
(integer) 8
127.0.0.1:6379> object encoding stringKey                                                                     
"raw"

  1. lru: 记录对象最后一次的访问时间。当配置了maxmemory和 maxmemory-policy=volatile-lru或者allkeys-lru时,用于辅助LRU算法删除键数 据。可以使用object idletime{key}命令在不更新lru字段情况下查看当前键的 空闲时间。
127.0.0.1:6379> object idletime stringKey                                                                     
(integer) 248
127.0.0.1:6379> object idletime intKey                                                                        
(integer) 42871
  1. refcount: 记录当前对象被引用的次数,用于通过引用次数回收内 存,当refcount=0时,可以安全回收当前对象空间。使用object refcount{key} 获取当前对象引用。当对象为整数且范围在[0-9999]时,Redis可以使用共享 对象的方式来节省内存
127.0.0.1:6379> object refcount intKey                                                                        
(integer) 2147483647
  1. *ptr: 与对象的数据内容相关,如果是整数,直接存储数据;否则 表示指向数据的指针。Redis在3.0之后对值对象是字符串且长度<=39字节的 数据,内部编码为embstr类型,字符串sds和redisObject一起分配,从而只要 一次内存操作即可

缩减键值对对象

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

  1. key长度:如在设计键时,在完整描述业务情况下,键值越短越好。如 user:{uid}:friends:notify:{fid}可以简化为u:{uid}:fs:nt:{fid}
  2. value长度:值对象缩减比较复杂,常见需求是把业务对象序列化成二 进制数组放入Redis。首先应该在业务上精简业务对象,去掉不必要的属性 避免存储无效数据。其次在序列化工具选择上,应该选择更高效的序列化工 具来降低字节数组大小。以Java为例,内置的序列化方式无论从速度还是压 缩比都不尽如人意,这时可以选择更高效的序列化工具,如:protostuff、 kryo等。以下是Java常见序列化工具空间压缩对比,详情请访问: 传送门. Java常见序列化工具空间压缩对比
  3. 值对象除了存储二进制数据之外,通常还会使用通用格式存储数据比 如:json、xml等作为字符串存储在Redis中。这种方式优点是方便调试和跨语言,但是同样的数据相比字节数组所需的空间更大,在内存紧张的情况 下,可以使用通用压缩算法压缩json、xml后再存入Redis,从而降低内存占 用,例如使用GZIP压缩后的json可降低约60%的空间。当频繁压缩解压json等文本数据时,开发人员需要考虑压缩速度和计算 开销成本,这里推荐使用Google的Snappy压缩工具,在特定的压缩率情况下 效率远远高于GZIP等传统压缩工具,且支持所有主流语言环境。以下是常见压缩工具性能对比
压缩格式压缩比压缩速率解压速率
gzip13.4%21 MB/s118 MB/s
lzo20.5%135 MB/s410 MB/s
snappy22.2%172 MB/s409 MB/s
bzip213.2%2.4 MB/s9.5 MB/s

共享对象池

  1. 共享对象池是指Redis内部维护[0-9999]的整数对象池。创建大量的整数 类型redisObject存在内存开销,每个redisObject内部结构至少占16字节,甚 至超过了整数自身空间消耗。所以Redis内存维护一个[0-9999]的整数对象 池,用于节约内存。除了整数值对象,其他类型如list、hash、set、zset内部 元素也可以使用整数对象池。因此开发中在满足需求的前提下,尽量使用整 数对象以节省内存。
  2. 整数对象池在Redis中通过变量REDIS_SHARED_INTEGERS定义,不能 通过配置修改。可以通过object refcount命令查看对象引用数验证是否启用整 数对象池技术

是否使用整数对象内存池对比

操作说明对象是否共享key大小value大小used_memused_memory_rss
插入200万20字节[0-9999]整数199.91MB205.28MB
插入200万20字节[0-9999]整数138.87MB142.28.28MB
  1. 使用共享对象池后,相同的数据内存使用降低30%以上。可见当数据大 量使用[0-9999]的整数时,共享对象池可以节约大量内存。需要注意的是对 象池并不是只要存储[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
  1. 开启maxmemory和LRU淘汰策略后对象池无效。 LRU算法需要获取对象最后被访问时间,以便淘汰最长未访问数据,每个对象最后访问时间存储在redisObject对象的lru字段。对象共享意味着多个 引用共享同一个redisObject,这时lru字段也会被共享,导致无法获取每个对 象的最后访问时间。如果没有设置maxmemory,直到内存被用尽Redis也不 会触发内存回收,所以共享对象池可以正常工作。综上所述,共享对象池与maxmemory+LRU策略冲突,使用时需要注 意。对于ziplist编码的值对象,即使内部数据为整数也无法使用共享对象池,因为ziplist使用压缩且内存连续的结构,对象共享判断成本过高
  2. 只有整数对象池的原因: 首先整数对象池复用的几率最大,其次对象共享的一个关键操作就是判 断相等性,Redis之所以只有整数对象池,是因为整数比较算法时间复杂度 为O(1),只保留一万个整数为了防止对象池浪费。如果是字符串判断相 等性,时间复杂度变为O(n),特别是长字符串更消耗性能(浮点数在 Redis内部使用字符串存储)。对于更复杂的数据结构如hash、list等,相等 性判断需要O(n2)。对于单线程的Redis来说,这样的开销显然不合理,因 此Redis只保留整数共享对象池。

字符串优化

字符串结构

Redis没有采用原生C语言的字符串类型而是自己实现了字符串结构,内部简单动态字符串(simple dynamic string,SDS) 字符串结构体SDS

Redis自身实现的字符串结构有如下特点: : O(1)时间复杂度获取:字符串长度、已用长度、未用长度。 : 可用于保存字节数组,支持安全的二进制数据存储。 : 内部实现空间预分配机制,降低内存再分配次数。 : 惰性删除机制,字符串缩减后的空间不释放,作为预分配空间保留。

预分配机制

因为字符串(SDS)存在预分配机制,日常开发中要小心预分配带来的内存浪费

阶段数据量擦作说明命令key大小value大小used_memused_memory_rssmem_fragmentation_ration
阶段一200W新插入200W数据set20字节60字节321.98MB331.44MB1.02
阶段二200W在阶段一的基础上再给每个对象追加60字节的数据append20字节60字节657.67MB752.80MB1.14
阶段三200W重新插入200W数据set20字节120字节474.56MB482.45MB1.02

从测试数据可以看出,同样的数据追加后内存消耗非常严重

接下来结合上述现象, 分析redis预分配原理 : 1. 阶段1插入新的字符串后,free字段保留空间为0,总占用空间=实际占 用空间+1字节,最后1字节保存‘\0’标示结尾,这里忽略int类型len和free字段 消耗的8字节 Redis预分配阶段一 : 2. 在阶段1原有字符串上追加60字节数据空间占用如下图所示。追加操作后字符串对象预分配了一倍容量作为预留空间,而且大量追加 操作需要内存重新分配,造成内存碎片率(mem_fragmentation_ratio)上升 Redis预分配阶段二 : 3. 阶段3直接插入同等数据后,相比阶段2节省了每个字符串对象预分配的 空间,同时降低了碎片率。 Redis预分配阶段三

字符串之所以采用预分配的方式是防止修改操作需要不断重分配内存和 字节数据拷贝。但同样也会造成内存的浪费。字符串预分配每次并不都是翻 倍扩容,空间预分配规则如下: 1)第一次创建len属性等于数据实际大小,free等于0,不做预分配。 2)修改后如果已有free空间不够且数据小于1M,每次预分配一倍容 量。如原有len=60byte,free=0,再追加60byte,预分配120byte,总占用空 间:60byte+60byte+120byte+1byte。 3)修改后如果已有free空间不够且数据大于1MB,每次预分配1MB数 据。如原有len=30MB,free=0,当再追加100byte,预分配1MB,总占用空 间:1MB+100byte+1MB+1byte。 4)尽量减少字符串频繁修改操作如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结构测试内存表现, 结果如下 ... 具体数据请关注微信公众号:Java添砖,回复"redis内存优化"

根据测试结构,第一次默认配置下使用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、zet等类型,但是Redis内部针对不同类型存在编码的概念,所谓编码就是具体使用哪种底层数据结构来实 现。编码不同将直接影响数据的内存占用和读写效率。使用object encoding{key}命令获取编码类型。 Redis针对每种数据类型(type)可以采用至少两种编码方式来实现,下表为type和encoding的对应关系。

类型编码方式数据结构
stringraw动态字符串编码
stringembstr优化内存分配的字符串编码
stringint整数编码
hashhashtable散列表编码
hashziplist压缩列表编码
listlinkedlist双向链表编码
listziplist压缩列表编码
listquicklist3.2之后的列表编码
sethashtable散列表编码
setintset整数集合编码
zsetskiplist跳跃表编码
zsetziplist压缩列表编码

Redis对一种数据结构实现多种编码方式,是作者想通过不同编码实现效率和空间的平衡。比如当 我们的存储只有10个元素的列表,当使用双向链表数据结构时,必然需要维 护大量的内部字段如每个元素需要:前置指针,后置指针,数据指针等,造 成空间浪费,如果采用连续内存结构的压缩列表(ziplist),将会节省大量 内存,而由于数据长度较小,存取操作时间复杂度即使为O(n2)性能也可 满足需求。

控制编码类型

编码类型转换在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" # 编码类型依然为链表,未做编码回退

以上命令体现了list类型编码的转换过程,其中Redis之所以不支持编码 回退,主要是数据增删频繁时,数据向压缩编码转换非常消耗CPU,得不偿 失。以上示例用到了list-max-ziplist-entries参数,这个参数用来决定列表长度 在多少范围内使用ziplist编码。当然还有其他参数控制各种数据类型的编码

类型编码决定条件
hashziplist满足所有条件: 1) value 的最大空间(字节)<= hash-max-ziplist-value 2) field个数 <= hash-max-ziplist-entries
hashhashtable满足任意条件: 1) value 的最大空间(字节)> hash-max-ziplist-value 2) field个数 > hash-max-ziplist-entries
listziplist满足所有条件: 1)value最大字节空间 <= list-max-ziplist-value 2)链表长度 <= list-max-ziplist-entries
listlinkedlist满足任意条件: 1)value最大字节空间 > list-max-ziplist-value 2)链表长度 > list-max-ziplist-entries
... 更多关于list, set和zset的编码内容,请关注微信公众号:Java添砖,回复"redis内存优化"

理解编码转换流程和相关配置之后,可以使用config set命令设置编码相 关参数来满足使用压缩编码的条件。对于已经采用非压缩编码类型的数据如 hashtable、linkedlist等,设置参数后即使数据满足压缩编码条件,Redis也不 会做转换,需要重启Redis重新加载数据才能完成转换。

ziplist编码

ziplist编码主要目的是为了节约内存,因此所有数据都是采用线性连续 的内存结构。ziplist编码是应用范围最广的一种,可以分别作为hash、list、 zset类型的底层数据结构实现。首先从ziplist编码结构开始分析,它的内部结 构类似这样:<....> 。一个ziplist可以包含多个entry(元素),每个entry保存具体的数据 (整数或者字节数组), 具体结构如下:

Redis ziplist内部编码

ziplist结构字段含义:

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

根据以上对ziplist字段说明,可以分析出该数据结构特点如下:

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

下面通过测试展示了ziplist编码在不同数据类型中内存和速度的表现 ... 具体数据请关注微信公众号:Java添砖,回复"redis内存优化"

测试数据采用100W个36字节数据,划分为1000个键,每个类型长度统 一为1000。从测试结果可以看出: 1)使用ziplist可以分别作为hash、list、zset数据类型实现。 2)使用ziplist编码类型可以大幅降低内存占用。 3)ziplist实现的数据类型相比原生结构,命令操作更加耗时,不同类型 耗时排序:list<hash<zset。

ziplist压缩编码的性能表现跟值长度和元素个数密切相关,正因为如此 Redis提供了{type}-max-ziplist-value和{type}-max-ziplist-entries相关参数来做 控制ziplist编码转换。最后再次强调使用ziplist压缩编码的原则:追求空间和 时间的平衡

intset编码

... 更多内容请关注微信公众号:Java添砖,回复"redis内存优化"

控制键的数量

当使用Redis存储大量数据时,通常会存在大量键,过多的键同样会消 耗大量内存。Redis本质是一个数据结构服务器,它为我们提供多种数据结 构,如hash、list、set、zset等。使用Redis时不要进入一个误区,大量使用 get/set这样的API,把Redis当成Memcached使用。对于存储相同的数据内容 利用Redis的数据结构降低外层键的数量,也可以节省大量内存。通过在客户端预估键规模,把大量键分组映射到多个hash结构中降低 键的数量。

hash结构降低键数量分析 : 根据键规模在客户端通过分组映射到一组hash对象中,如存在100万个 键,可以映射到1000个hash中,每个hash保存1000个元素。 : hash的field可用于记录原始key字符串,方便哈希查找。 : hash的value保存原始值对象,确保不要超过hash-max-ziplist-value限制。

下面测试这种优化技巧的内存表现, 具体内容如下所示: ... 具体数据请关注微信公众号:Java添砖,回复"redis内存优化"

通过这个测试数据,可以说明: ·同样的数据使用ziplist编码的hash类型存储比string类型节约内存。 ·节省内存量随着value空间的减少越来越明显。 ·hash-ziplist类型比string类型写入耗时,但随着value空间的减少,耗时 逐渐降低。

使用hash重构后节省内存量效果非常明显,特别对于存储小对象的场 景,内存只有不到原来的1/5。下面分析这种内存优化技巧的关键点:

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

关于hash键和field键的设计:

  1. 当键离散度较高时,可以按字符串位截取,把后三位作为哈希的 field,之前部分作为哈希的键。如:key=1948480哈希key=group:hash:1948,哈希field=480。
  2. 当键离散度较低时,可以使用哈希算法打散键,如:使用 crc32(key)&10000函数把所有的键映射到“0-9999”整数范围内,哈希field 存储键的原始值
  3. 尽量减少hash键和field的长度,如使用部分键内容。

参考文章:

  1. 压缩格式gzip/snappy/lzo/bzip2 比较与总结.