虽然现今内存都很便宜了,但是相对廉价的硬盘来说,还是贵了非常多。而且redis使用的很多场景下,往往数据是TB级别甚至PB级别,而我们的服务器的内存容量只有GB级别。为此我们需要优化redis内存的使用,但是优化内存的使用的前提是知道内存都消耗再哪里了。为此本篇先从内存消耗入手分析。

内存消耗

理解内存,首先要知道内存都消耗再哪里了。

内存监控

Redis提供了命令info memory命令,统计内存消耗分布。其输出如下的内容

指标

意义

used_memory

redis实际使用的内存

used_memory_human

以可读的方式显示used_memory

used_memory_rss

redis常驻内存大小

used_memory_peak

used_memory的峰值

used_memory_peak_human

以可读的方式显示used_memory_peak

used_memory_lua

lua引擎消耗的内存

mem_fragmentation_ratio

内存碎片率,为used_memory_rss/used_memory的比值

mem_allocator

redis使用的内存分配器,默认为jmelloc

1、正常的内存碎片率应该控制在1.2以内,如果超过1.5,说明碎片很严重了,那么就需要进行碎片整理,对于redis4.0以下的版本,那么很不幸,你只能重启redis了。如果使用的版本为redis4.0或者更高的版本,redis提供了参数:

Activedeflag yes //用于开启或者关闭redis的内存自动清理功能
Active-defrag-ignore-bytes 100mb //表示内存碎片字节数达到100MB开始自动清理
Active-defrag-threshold-lower 10 //表示碎片内存占总空间的比重达到10%以上,开始自动清理。注意Active-defrag-ignore-bytes与Active-defrag-threshold-lower需要同时满足才会自动清理碎片
Active-defrag-cycle-min 25 //表示碎片整理占用的CPU时间比例不低于25%,保证碎片整理工作可以得到足够的资源运行
Active-defrag-cycle-min 30 //表示碎片整理占用的CPU时间比例不高于30%,保证碎片整理工作,不过多的影响正常的请求

用于控制内存碎片整理。
2、内存碎片率若小于1,说明发生了swap,redis的部分内存被交换到了硬盘的交换空间。如果客户端访问了被交换出去的数据,那么时延将会是毫秒级别的,严重影响吞吐量。为此我们一般需要关闭Linux的swap功能。

内存消耗划分

used_memory(redis实际使用内存),主要包括如下部分:

redis tb级 redis tb级 内存_redis

对象内存

就是redis键值对占用的内存空间,键在redis中,本质上是一种string数据类型,其占用的存储空间不容忽视,我们应该避免使用过大的键,最好能控制在44字节以内。平时使用的时候可以使用英文字母的首字母缩写,如Unique Value缩小为UV。值类型需要根据实际使用的场景合理的使用相应的数据结构。博文的后面会介绍各种不同的数据类型使用的数据结构。
内存碎片问题。redis默认使用的是jmalloc内存分配器,jmalloc会以2的倍数分配内存,最小32B。比如实际需要20bytes的内存空间,那么jmalloc会分配32bytes的内存空间,剩下的12bytes没有使用到,就是内存碎片了。
另外当修改string数据类型的时候,redis为了避免频繁的分配内存空间,当实际占用的内存空间小于等于1MB,每次修改操作会多分配一倍的内存空间;当实际占用的内存空间大于1MB时,每次修改操作会多分配1MB的内存空间。如目前内存占用是32B,当调用append操作追加32B的内存,那么新的实际占用的内存空间是64B,redis会额外再分配64B的空闲内存空间。为此我们应该尽量避免使用append等修改命令,而是要使用set命令,这样redis每次都会按需分配内存空间。

缓冲区

Redis缓冲区包括了客户端缓冲区、复制积压缓冲区与AOF重写缓冲区。客户端缓冲区又可以划分为普通客户端输入输出缓冲区、订阅客户端缓冲区、主从复制缓冲区。当写入缓冲区的数据的速度持续的大于往缓冲区读取数据的速度,缓冲区就会溢出,缓冲区溢出回导致数据丢失等异常,比如客户端缓冲区溢出,redis会关闭客户端连接。
那么可以不可以给缓冲区设置一个很大的上限或者不限制缓冲区的打消呢?这样就不会有溢出的风险了。我们往往会给redis设置一个内存上限并且设置相应的淘汰算法,当触发了设置的内存上限,redis会根据缓存淘汰算法,淘汰出一些键值对。如果缓冲区占用过多空间,将会导致大量的键值对被淘汰,从而导致大量的请求未命中缓存。
如果我们的redis没有设置内存使用上限,那么缓冲区如果占用过多的内存空间,可能会导致OOM或者swap。

1、客户端输入缓冲区。客户端输入缓冲区是用于暂存客户端输入的命令,他的大小不能动态修改,它写上在代码中了,最大可以占用1GB的内存空间。Redis提供了CLIENT LIST命令用于监控客户端输入缓冲区的使用情况:

CLIENT LIST
id=5 addr=127.0.0.1:50487 fd=9 name= age=4 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=26 qbuf-free=32742 obl=0 oll=0 omem=0 events=r cmd=client
其中qbuf表示医用缓冲区空间
qbuf-free表示剩余缓冲区空间
qbuf+qbuf-free就是总的缓冲区空间
addr表示客户端地址

如果qbuf长时间大于0且持续增长,则需要引起注意。那么有什么原因会导致redis的输入缓冲区持续增长呢?主要有如下的3点:
A.一次性写入大量的指令,我们需要避免一次性写入大量的key,控制管道与事务的大小。
B.Redis的负载过高,我们需要通过使用redis切片集群,横向的扩展redis,降低单个实例的负载。
C.Redis堵塞,我们需要避免redis的堵塞操作。

2、客户端输出缓冲区。Redis提供了如下的命令控制普通客户端的

client-output-buffer-limit normal 0 0 0
其中normal表示普通客户端,可以取值slave,pubsub值
第一个0表示缓冲区的最大值,当超过最大值时,redis会关闭连接,0表示不限制。
第三个0与第二个0分别表示持续多长时间缓冲区的大小达到多少阈值,则关闭连接,0表示不限制。

对于普通客户端。由于redis使用的场景都是请求应答型的,客户端会等待redis的响应结果。这个使用场景下如果不是操作的bigkey,一般不会占用太多的缓冲区。为此我们一般不限制普通客户端输出缓冲区的大小。但是在使用时需要避免全量获取bigkey的值,避免使用monitor命令。Monitor命令会输出当前执行的key,对于一个高负载的redis实例,monitor会输出大量的正在执行的命令,很快就会占用大量的内存。

对于订阅客户端缓冲区。由于pub-sub是基于发布订阅的异步模型,生产者发布完消息后,会暂存到客户端的缓冲区中,等待客户端获取消息。而客户端可能会获取不及时导致消息堵塞,为此需要限制缓冲区的大小。Redis默认设置为client-output-buffer-limit pubsub 32mb 8mb 60表示当缓冲区的大小超过32mb或者60秒内缓冲区增大超过8mb,则关闭客户端连接

还有一类客户端缓冲区,那就是主从复制缓冲区。由于主从复制受网络,从节点的处理速度影响较大,为此需要限制主从复制缓冲区的内存大小。Redis默认设置为client-output-buffer-limit slave 256mb 64mb 60。

考虑如下的场景:
A.主节点的写操作持续处于高位,由于从节点的处理能力跟不上,那么随着时间的推移,主节点的复制缓冲区数据会越积越多,最终溢出,从而导致主从连接被关闭。为此我们一般给主从节点配置相同规格的硬件。
B.但是即使主备节点都配置了相同的硬件,那么就可以万无一失了么?为了分摊读压力,我们一般把部分读请求路由到从节点,从而达到扩容的目的。但是这种情况下,如果备机有慢请求,那么可能会堵塞主备同步,从而导致缓冲区溢出。为此我们还需要避免堵塞备机。
C.网络因素,如果redis主从部署在比较差的网络环境,那么主节点的复制缓冲区还是可能溢出。为此我们需要避免redis跨异地机房部署。
但是即使遵循了如上的3个原则,网络可能还是会闪断,为此我们需要根据主节点的峰值吞吐量、网络情况与备节点的处理速度合理设置缓冲区大小,要不可能会导致复制风暴。
同时还要限制主从节点的个数,最好控制在2个以内,避免占用主节点过多的资源。

3、复制积压缓冲区。Redis从2.8版本开始提供复制积压缓冲区的功能,它是一个环形缓冲区,用于实现增量复制的功能。由参数repl-backlog_size控制,默认1MB。这个默认大小太小了,而且这个缓冲区是整个实例共享的,我们可以设置的大一些,比如256MB。这个内存的投入是值得的,如果设置的太小了,可能会导致频繁的全量复制。

4、AOF重写缓冲区。这个缓存空间redis没有提供控制的参数,受redis实例的大小、硬盘吞吐量与写命令的TPS等的影响,通过合理的控制单个redis实例的大小,这部分内存消耗的空间通常较小。我们可以通过监控redis日志,AOF重写消耗的这部分空间

AOF rewrite: 50 MB of memory used by copy-on-write
Residual parent diff successfully flushed to the rewritten AOF (2 MB)

而且redis很多实际使用的场景,是不需要开启AOF功能的。

持久化子进程内存消耗

主要是fork子进程后的写时复制COW导致的内存消耗

内存管理

内存上限管理

Redis提供了参数maxmemory用于控制redis实际使用的内存大小,也就是used_memory的统计项。需要注意的是used_memory没有包括内存碎片与写时复制COW技术的内存开销,为此单机多实例部署的时候需要预留一定的内存空间,同时做好内存碎片与内存使用情况监控。

内存回收策略

过期KEY淘汰策略

Redis可以通过给键设置过期时间,当过期后,redis可以淘汰出键值对,具体是通过如下的2种方式淘汰
1、惰性删除。当客户端访问的时候,如果键的过期了,就会将相应的键值对删除。但是这种方式删除,会造成大量未被访问的过期的键未被删除,为此redis提供了如下的定时删除的策略。同bigkey的删除操作可能会堵塞redis一样,惰性删除bigkey也一样,Redis 4.0 开始,如果删除的可以是bigkey,那么会使用异步删除策略。这也是我们需要避免使用bigkey的原因之一。
2、定时删除。默认情况下,Redis 每 100 毫秒会删除一些过期 key,具体的算法如下:

采样 ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 个数的 key,并将其中过期的 key 全部删除;
如果超过 25% 的 key 过期了,则重复删除的过程,直到过期 key 的比例降至 25% 以下。

ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 是 Redis 的一个参数,默认是 20。这一策略对清除过期 key、释放内存空间很有帮助。但是,如果触发了上面这个算法的第二条,Redis 就会一直删除以释放内存空间。所以,一旦该条件触发,Redis 的线程就会一直执行删除,这样一来,就没办法正常服务其他的键值操作了,就会进一步引起其他键值操作的延迟增加,Redis 就会变慢。
我们可以通过给key设置过期时间时,额外添加一个比较短的过期时间,比如1分钟以内的随机值,避免大量key同时过期。

内存溢出内存回收策略

Redis提供了如下8种内存回收策略

redis tb级 redis tb级 内存_redis_02

对于过期了的key-value,如上的所有淘汰算法都会淘汰过期的key-value,即使设置为noeviction策略。

1、LRU淘汰算法
LRU(Least Recently Used)最近最少使用算法,从名字中就可以看出LRU淘汰算法,是优先淘汰最近最少使用的数据。其核心思想是最近被访问的数据大概率在接下来还会被访问。
经典的LRU淘汰算法是用一个双向链表实现的,当插入新数据时,插入到对头;当访问已有的数据时,则移动到对头。这样队尾就是最近最少使用的数据了,每次淘汰数据的时候都从队尾淘汰。
Redis为了避免维护这个链表导致的内存开销,使用了一种可以近似达到LRU淘汰算法效果的算法。redisObject维护了一个24bit的lru字段,每次访问数据的时候,redis会记录访问时间到lru字段中。
当插入或更新数据时,如果内存达到maxmemory上限,则随机选择n个(默认5个,可以通过maxmemory-samples参数修改)键值对,根据lru值计算出最久未被访问的键值对,然后删除。如果内存空间达到要求,则停止,否则重复执行。
同时 Redis3.0 在算法中增加了淘汰池,进一步提升了近似 LRU 算法的效果。淘汰池是一个数组,它的大小是 maxmemory-samples,在每一次淘汰循环中,新随机出来的 key 列表会和淘汰池中的 key 列表进行融合,淘汰掉最旧的一个 key 之后,保留剩余较旧的 key 列表放入淘汰池中留待下一个循环。

2、LFU淘汰算法

LFU(Least Frequenstly Used),表示按最近访问频次淘汰数据。在谈LFU算法前,先聊聊缓存污染。

缓存污染指的是缓存中存在只被访问一次或者若干次后就不在访问,但还存在缓存中,占用缓存资源。如果这种数据比较少,那么对缓存影响不大,但是如果有大量的数据存在缓存中,但又不再被访问了,那么就会严重影响缓存的作用。

考虑一种扫描式访问且只访问一次的场景,对于LRU淘汰算法,在这种场景下,由于数据都是刚刚被访问了,所以会保存在缓存中不能很快的淘汰出去。所以LRU淘汰算法不能很好的应对这种场景,为此redis在4.0版本中引入了LFU淘汰算法。

Redis将redisObject的24bit的lru字段进一步拆分为了ldt(Last Decrement Time上次衰减时间)与logc(Logistic Count逻辑访问次数)。

redis tb级 redis tb级 内存_redis tb级_03

由于logc只有8个字节,为此redis不能每访问一次都增加logc的值,要不很快就到达255了。为此redis通过如下的对数递增算法,递增logc的值

/* Logarithmically increment a counter. The greater is the current counter value
 * the less likely is that it gets really implemented. Saturate it at 255. */
// 对数递增计数值
uint8_t LFULogIncr(uint8_t counter) {
    if (counter == 255) return 255; // 到最大值了,不能在增加了
    double baseval = counter - LFU_INIT_VAL; // 减去新对象初始化的基数值 (LFU_INIT_VAL 默认是 5)
    // baseval 如果小于零,说明这个对象快不行了,不过本次 incr 将会延长它的寿命
    if (baseval < 0) baseval = 0; 
    // 当前计数越大,想要 +1 就越困难
    // lfu_log_factor 为困难系数,默认是 10
    // 当 baseval 特别大时,最大是 (255-5),p 值会非常小,很难会走到 counter++ 这一步
    double p = 1.0/(baseval*server.lfu_log_factor+1);
    double r = (double)rand()/RAND_MAX; // 0 < r < 1
    if (r < p) counter++;
    return counter;
}

对于lfu_log_factor取不同的值,logc的增长情况,redis官网提供了如下的统计数据

redis tb级 redis tb级 内存_客户端_04

当lfu_log_factor取值10时,访问1M次,logc才增长到255。一般来说取10就足够了。
但是如果logc只增不减,那么对于那些只有短时间被频繁访问的键值对的logc的值将很快到达很大的值,设置是255,从而很难淘汰出缓存。为此redis还会通过如下的算法衰减logc的值

// 衰减 logc
unsigned long LFUDecrAndReturn(robj *o) {
    unsigned long ldt = o->lru >> 8; // 前 16bit
    unsigned long counter = o->lru & 255; // 后 8bit 为 logc
/*
num_periods 为即将衰减的数量,server.lfu_decay_time默认为1,表示每隔server.lfu_decay_time分钟,衰减1。当为0时,不衰减,值越大衰减的越慢
*/
    unsigned long num_periods = server.lfu_decay_time ? LFUTimeElapsed(ldt) / server.lfu_decay_time : 0;
    if (num_periods)
        counter = (num_periods > counter) ? 0 : counter - num_periods;
    return counter;
}

Redis通过对数增长与衰减logc,近似的实现了LFU算法。