Redis主要通过控制内存上限和回收策略实现内存管理:
一.内存管理
1 设置内存上限
Redis使用maxmemory参数限制最大可用内存。限制内存的目的主要有:
·用于缓存场景,当超出内存上限maxmemory时使用LRU等删除策略释放空间。
·防止所用内存超过服务器物理内存。
需要注意,maxmemory限制的是Redis实际使用的内存量,也就是 used_memory统计项对应的内存。由于内存碎片率的存在,实际消耗的内存 可能会比maxmemory设置的更大,实际使用时要小心这部分内存溢出。通过 设置内存上限可以非常方便地实现一台服务器部署多个Redis进程的内存控 制。比如一台24GB内存的服务器,为系统预留4GB内存,预留4GB空闲内 存给其他进程或Redis fork进程,留给Redis16GB内存,这样可以部署4个 maxmemory=4GB的Redis进程。得益于Redis单线程架构和内存限制机制,即 使没有采用虚拟化,不同的Redis进程之间也可以很好地实现CPU和内存的 隔离性
2 动态调整内存上限
Redis的内存上限可以通过config set maxmemory进行动态修改,即修改最大可用内存,通过动态修改maxmemory,可以实现在当前服务器下动态伸缩Redis内 存的目的.
3 内存回收策略
Redis的内存回收机制主要体现在以下两个方面:
·删除到达过期时间的键对象。
·内存使用达到maxmemory上限时触发内存溢出控制策略。
1.删除过期键对象
Redis所有的键都可以设置过期属性,内部保存在过期字典中。由于进程内保存大量的键,维护每个键精准的过期删除机制会导致消耗大量的 CPU,对于单线程的Redis来说成本过高,因此Redis采用惰性删除和定时任务删除机制实现过期键的内存回收。
·惰性删除:惰性删除用于当客户端读取带有超时属性的键时,如果已经超过键设置的过期时间,会执行删除操作并返回空,这种策略是出于节省 CPU成本考虑,不需要单独维护TTL链表来处理过期键的删除。但是单独用这种方式存在内存泄露的问题,当过期键一直没有访问将无法得到及时删 除,从而导致内存不能及时释放。正因为如此,Redis还提供另一种定时任务删除机制作为惰性删除的补充。
·定时任务删除:Redis内部维护一个定时任务,默认每秒运行10次(通 过配置hz控制)。定时任务中删除过期键逻辑采用了自适应算法,根据键的 过期比例、使用快慢两种速率模式回收键,流程如下图所示。
流程说明:
1)定时任务在每个数据库空间随机检查20个键,当发现过期时删除对应的键。
2)如果超过检查数25%的键过期,循环执行回收逻辑直到不足25%或 运行超时为止,慢模式下超时时间为25毫秒。
3)如果之前回收键逻辑超时,则在Redis触发内部事件之前再次以快模 式运行回收过期键任务,快模式下超时时间为1毫秒且2秒内只能运行1次。
4)快慢两种模式内部删除逻辑相同,只是执行的超时时间不同。
2.内存溢出控制策略
当Redis所用内存达到maxmemory上限时会触发相应的溢出控制策略。 具体策略受maxmemory-policy参数控制,Redis支持6种策略,如下所示:
1)noeviction:默认策略,不会删除任何数据,拒绝所有写入操作并返回客户端错误信息(error)OOM command not allowed when used memory,此时Redis只响应读操作。
2)volatile-lru:根据LRU算法删除设置了超时属性(expire)的键,直到腾出足够空间为止。如果没有可删除的键对象,回退到noeviction策略。
3)allkeys-lru:根据LRU算法删除键,不管数据有没有设置超时属性,直到腾出足够空间为止。
4)allkeys-random:随机删除所有键,直到腾出足够空间为止。
5)volatile-random:随机删除过期键,直到腾出足够空间为止。
6)volatile-ttl:根据键值对象的ttl属性,删除最近将要过期数据。如果没有,回退到noeviction策略。
内存溢出控制策略可以采用config set maxmemory-policy{policy}动态配 置。Redis支持丰富的内存溢出应对策略,可以根据实际需求灵活定制,比 如当设置volatile-lru策略时,保证具有过期属性的键可以根据LRU剔除,而 未设置超时的键可以永久保留。还可以采用allkeys-lru策略把Redis变为纯缓 存服务器使用。当Redis因为内存溢出删除键时,可以通过执行info stats命令 查看evicted_keys指标找出当前Redis服务器已剔除的键数量。
每次Redis执行命令时如果设置了maxmemory参数,都会尝试执行回收内存操作。当Redis一直工作在内存溢出(used_memory>maxmemory)的状态下且设置非noeviction策略时,会频繁地触发回收内存的操作,影响Redis服务器的性能。回收内存逻辑伪代码如下:
def freeMemoryIfNeeded() :
int mem_used, mem_tofree, mem_freed;
// 计算当前内存总量,排除从节点输出缓冲区和AOF缓冲区的内存占用
int slaves = server.slaves;
mem_used = used_memory()-slave_output_buffer_size(slaves)-aof_rewrite_buffer_ size();
// 如果当前使用小于等于maxmemory退出
if (mem_used <= server.maxmemory) :
return REDIS_OK;
// 如果设置内存溢出策略为noeviction(不淘汰),返回错误。
if (server.maxmemory_policy == 'noeviction') :
return REDIS_ERR;
// 计算需要释放多少内存
mem_tofree = mem_used - server.maxmemory;
// 初始化已释放内存量
mem_freed = 0;
// 根据maxmemory-policy策略循环删除键释放内存
while (mem_freed < mem_tofree) :
// 迭代Redis所有数据库空间
for (int j = 0; j < server.dbnum; j++) :
String bestkey = null;
dict dict;
if (server.maxmemory_policy == 'allkeys-lru' ||
server.maxmemory_policy == 'allkeys-random'):
// 如果策略是 allkeys-lru/allkeys-random
// 回收内存目标为所有的数据库键
dict = server.db[j].dict;
else :
// 如果策略是volatile-lru/volatile-random/volatile-ttl
// 回收内存目标为带过期时间的数据库键
dict = server.db[j].expires;
// 如果使用的是随机策略,那么从目标字典中随机选出键
if (server.maxmemory_policy == 'allkeys-random' ||
server.maxmemory_policy == 'volatile-random') :
// 随机返回被删除键
bestkey = get_random_key(dict);
else if (server.maxmemory_policy == 'allkeys-lru' ||
server.maxmemory_policy == 'volatile-lru') :
// 循环随机采样maxmemory_samples次(默认5次),返回相对空闲时间最长的键
bestkey = get_lru_key(dict);
else if (server.maxmemory_policy == 'volatile-ttl') :
// 循环随机采样maxmemory_samples次,返回最近将要过期的键
bestkey = get_ttl_key(dict);
// 删除被选中的键
if (bestkey != null) :
long delta = used_memory();
deleteKey(bestkey);
// 计算删除键所释放的内存量
delta -= used_memory();
mem_freed += delta;
// 删除操作同步给从节点
if (slaves):
flushSlavesOutputBuffers();
return REDIS_OK;
从伪代码可以看到,频繁执行回收内存成本很高,主要包括查找可回收 键和删除键的开销,如果当前Redis有从节点,回收内存操作对应的删除命 令会同步到从节点,导致写放大的问题
二.内存优化
Redis所有的数据都在内存中,而内存又是非常宝贵的资源。如何优化 内存的使用一直是Redis用户非常关注的问题。下面深入到Redis细节中,探索内存优化的技巧。
1 redisObject对象
Redis存储的所有值对象在内部定义为redisObject结构体,内部结构如下图所示。
Redis存储的数据都使用redisObject来封装,包括string、hash、list、 set、zset在内的所有数据类型。理解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字段情况下查看当前键的空闲时间。
可以使用scan+object idletime命令批量查询哪些键长时间未被访问,找出长时间不访问的键进行清理,可降低内存占用。
·refcount字段:记录当前对象被引用的次数,用于通过引用次数回收内 存,当refcount=0时,可以安全回收当前对象空间。使用object refcount{key} 获取当前对象引用。当对象为整数且范围在[0-9999]时,Redis可以使用共享 对象的方式来节省内存。
·*ptr字段:与对象的数据内容相关,如果是整数,直接存储数据;否则 表示指向数据的指针。Redis在3.0之后对值对象是字符串且长度<=39字节的 数据,内部编码为embstr类型,字符串sds和redisObject一起分配,从而只要一次内存操作即可。
高并发写入场景中,在条件允许的情况下,建议字符串长度控制在39字节以内,减少创建redisObject内存分配次数,从而提高性能。
2 缩减键值对象
降低Redis内存使用最直接的方式就是缩减键(key)和值(value)的长度。
·key长度:如在设计键时,在完整描述业务情况下,键值越短越好。如 user:{uid}:friends:notify:{fid}可以简化为u:{uid}:fs:nt:{fid}。
·value长度:值对象缩减比较复杂,常见需求是把业务对象序列化成二 进制数组放入Redis。首先应该在业务上精简业务对象,去掉不必要的属性避免存储无效数据。其次在序列化工具选择上,应该选择更高效的序列化工 具来降低字节数组大小。以Java为例,内置的序列化方式无论从速度还是压 缩比都不尽如人意,这时可以选择更高效的序列化工具,如:protostuff、 kryo等
值对象除了存储二进制数据之外,通常还会使用通用格式存储数据比 如:json、xml等作为字符串存储在Redis中。这种方式优点是方便调试和跨语言,但是同样的数据相比字节数组所需的空间更大,在内存紧张的情况 下,可以使用通用压缩算法压缩json、xml后再存入Redis,从而降低内存占 用,例如使用GZIP压缩后的json可降低约60%的空间。
值对象除了存储二进制数据之外,通常还会使用通用格式存储数据比 如:json、xml等作为字符串存储在Redis中。这种方式优点是方便调试和跨语言,但是同样的数据相比字节数组所需的空间更大,在内存紧张的情况 下,可以使用通用压缩算法压缩json、xml后再存入Redis,从而降低内存占 用,例如使用GZIP压缩后的json可降低约60%的空间.
3 共享对象池
共享对象池是指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]的整数时,共享对象池可以节约大量内存。需要注意的是对 象池并不是只要存储[0-9999]的整数就可以工作。当设置maxmemory并启用 LRU相关淘汰策略如:volatile-lru,allkeys-lru时,Redis禁止使用共享对象池
为什么开启maxmemory和LRU淘汰策略后对象池无效?
LRU算法需要获取对象最后被访问时间,以便淘汰最长未访问数据,每个对象最后访问时间存储在redisObject对象的lru字段。对象共享意味着多个引用共享同一个redisObject,这时lru字段也会被共享,导致无法获取每个对象的最后访问时间。如果没有设置maxmemory,直到内存被用尽Redis也不会触发内存回收,所以共享对象池可以正常工作。
综上所述,共享对象池与maxmemory+LRU策略冲突,使用时需要注意。对于ziplist编码的值对象,即使内部数据为整数也无法使用共享对象池,因为ziplist使用压缩且内存连续的结构,对象共享判断成本过高,ziplist编码细节后面内容详细说明。
为什么只有整数对象池?
首先整数对象池复用的几率最大,其次对象共享的一个关键操作就是判断相等性,Redis之所以只有整数对象池,是因为整数比较算法时间复杂度 为O(1),只保留一万个整数为了防止对象池浪费。如果是字符串判断相等性,时间复杂度变为O(n),特别是长字符串更消耗性能(浮点数在 Redis内部使用字符串存储)。对于更复杂的数据结构如hash、list等,相等性判断需要O(n2)。对于单线程的Redis来说,这样的开销显然不合理,因此Redis只保留整数共享对象池。
4 字符串优化
字符串对象是Redis内部最常用的数据类型。所有的键都是字符串类型,值对象数据除了整数之外都使用字符串存储。比如执行命令:lpush cache:type"redis""memcache""tair""levelDB",Redis首先创建"cache:type"键字符串,然后创建链表对象,链表对象内再包含四个字符串对象,排除 Redis内部用到的字符串对象之外至少创建5个字符串对象。可见字符串对象 在Redis内部使用非常广泛,因此深刻理解Redis字符串对于内存优化非常有帮助。
尽量减少字符串频繁修改操作如append、setrange,改为直接使用set修改字符串,降低预分配带来的内存浪费和内存碎片化。
不一定把每份数据作为字符串整体存储,像json这样的 数据可以使用hash结构,使用二级结构存储也能帮我们节省内存。同时可以 使用hmget、hmset命令支持字段的部分读取修改,而不用每次整体存取。
5 编码优化
Redis对外提供了string、list、hash、set、zet等类型,但是Redis内部针对不同类型存在编码的概念,所谓编码就是具体使用哪种底层数据结构来实 现。编码不同将直接影响数据的内存占用和读写效率。使用object encoding{key}命令获取编码类型.Redis针对每种数据类型(type)可以采用至少两种编码方式来实现
Redis为什么对一种数据结构实现多种编码方式?
主要原因是Redis作者想通过不同编码实现效率和空间的平衡。比如当 我们的存储只有10个元素的列表,当使用双向链表数据结构时,必然需要维护大量的内部字段如每个元素需要:前置指针,后置指针,数据指针等,造 成空间浪费,如果采用连续内存结构的压缩列表(ziplist),将会节省大量 内存,而由于数据长度较小,存取操作时间复杂度即使为O(n2)性能也可满足需求。
6 控制键的数量
当使用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限制。
·同样的数据使用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-maxziplist-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的长度,如使用部分键内容。使用hash结构控制键的规模虽然可以大幅降低内存,但同样会带来问
题,需要提前做好规避处理。如下所示:
·客户端需要预估键的规模并设计hash分组规则,加重客户端开发成本。
·hash重构后所有的键无法再使用超时(expire)和LRU淘汰机制自动删除,需要手动维护删除。
·对于大对象,如1KB以上的对象,使用hash-ziplist结构控制键数量反而得不偿失。
小结:
1)Redis实际内存消耗主要包括:键值对象、缓冲区内存、内存碎片。
2)通过调整maxmemory控制Redis最大可用内存。当内存使用超出时, 根据maxmemory-policy控制内存回收策略。
3)内存是相对宝贵的资源,通过合理的优化可以有效地降低内存的使用量,内存优化的思路包括:
·精简键值对大小,键值字面量精简,使用高效二进制序列化工具。
·使用对象共享池优化小整数对象。
·数据优先使用整数,比字符串类型更节省空间。
·优化字符串使用,避免预分配造成的内存浪费。
·使用ziplist压缩编码优化hash、list等结构,注重效率和空间的平衡。
·使用intset编码优化整数集合。
·使用ziplist编码的hash结构降低小对象链规模。