上一篇介绍了 Redis 的数据持久化。这节开始介绍 Redis 的内存管理相关问题。
Redis 是基于内存的,那么必然就会涉及到内存空间的管理,需要采用一定的策略,将过期的数据删除,以及需要淘汰部分内存,来提高内存空间的利用率。
先来说说过期数据的问题,Redis 是可以对 key 设置过期时间的,那么当这些数据过期后,就应该采用对应的手段删除数据,来释放内存空间。
Redis 使用过期字典(expires dict)保存数据的过期时间:
typedef struct redisDb {dict *dict; /* 数据库键空间,存放着所有的键值对 */dict *expires; /* 键的过期时间 */....
} redisDb;
当查询一个 key 时,首先检查该 key 是否存在于过期字典中:如果存在,则会获取该 key 的过期时间,然后与当前系统时间进行比对,如果比系统时间大,那就没有过期,否则判定该 key 已过期;如果不存在于过期字典中,则正常读取键值;
设置 key 过期时间的命令一共有 4 个:
expire
:设置 key 在 n 秒后过期;pexpire
:设置 key 在 n 毫秒后过期;expireat
:设置 key 在某个时间戳(精确到秒)之后过期;pexpireat
:设置 key 在某个时间戳(精确到毫秒)之后过期;在业务应用中使用 EXPIREAT 命令,把数据的过期时间设置为具体的时间点,避免读到过期数据(由于主从复制延迟导致从库中数据过期时间较晚)。
在设置字符串时,也可以同时对 key 设置过期时间,共有 3 种命令:
set ex
:设置键值对的时候,同时指定过期时间(精确到秒);set px
:设置键值对的时候,同时指定过期时间(精确到毫秒);setex
:设置键值对的时候,同时指定过期时间(精确到秒)。查看某个 key 剩余的存活时间:TTL
取消 key 的过期时间:PERSIST
在设置 key 的过期时间时,同时创建一个定时事件,当时间到达时,由事件处理器自动执行 key 的删除操作。
优点:
缺点:
不主动删除过期键,每次从数据库访问 key 时,都检测 key 是否过期,如果过期则删除该 key。
优点:
缺点:
每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期 key。
优点:
缺点:
Redis 选择「惰性删除+定期删除」这两种策略配合使用,以求在合理使用 CPU 时间和避免内存浪费之间取得平衡。
Redis 在访问或者修改 key 之前,都会调用 expireIfNeeded 函数对其进行检查,检查 key 是否过期:
lazyfree_lazy_expire
参数配置决定(Redis 4.0版本开始提供参数),然后返回 null 客户端;在 Redis 中,默认每 100ms 执行一次(可通过 Redis 的配置文件 redis.conf 进行配置)。随机抽查的数量由 ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP
定义的,它是写死在代码中的,数值是 20。
定期删除过程:
Redis 为了保证定期删除不会出现循环过度,导致线程卡死现象,为此增加了定期删除循环流程的时间上限,默认不会超过 25ms。
Redis 并不会一次性删除大量过期数据,因为这样可能会对性能造成影响。
Redis 中手动删除数据的命令主要有以下四个:
UNLINK 和数据库异步删除操作是 Redis 4.0 后提供的功能。
异步删除会先将指定键的值标记为删除,然后等待后台线程在适当的时候删除这些键值对。在手动删除数据时,尤其是大量数据时,建议使用异步删除的方式,避免阻塞主线程。异步删除的缺点是可能造成短期的数据不一致。
删除数据和释放内存是两个不同的概念:
在 Redis 中,当一个键被删除时,它所占用的内存空间并不会立即返回给操作系统,而是由 Redis 内部的内存管理器进行管理。
Redis 中所有的删除数据,都不是立即释放内存,只是将键值对标记为已删除,将键对象从 Redis 数据库中删除,并将其添加到一个专门的删除队列中。之后会异步地定期遍历这个队列,将已删除的键值对所占用的内存空间返回给内存池。当 Redis 需要分配内存时,管理器会先尝试从这个内存池中获取内存,这种方式可以避免频繁地向操作系统申请内存,从而提高 Redis 的性能。
Redis 的内存回收是通过使用引用计数和惰性删除技术实现的。具体来说,当一个键被删除时,Redis 只是将该键对应的对象标记为已删除,并将键对象从 Redis 数据库中删除。如果该键对应的对象被其他键对象引用,Redis 会将该对象的引用计数减一,直到引用计数为零时才会真正地释放该对象占用的内存。
Redis 只需要删除不再被引用的键对象,而不需要对整个 Redis 数据库进行上锁。这种设计使得 Redis 能够以非常高效的方式处理内存回收,并且不会影响 Redis 的性能。
如果 Redis 的运行内存超过了设置的最大内存,则会使用内存淘汰策略删除符合条件的 key,以此来保障 Redis 高效的运行。
内存淘汰针对的是所有的数据,包括已经被删除但是还没有释放内存空间的数据。
Redis 内存淘汰策略共有八种,这八种策略大体分为「不进行数据淘汰」和「进行数据淘汰」两类策略。
1、不进行数据淘汰的策略
noeviction(Redis3.0 之后,默认的内存淘汰策略) :它表示当运行内存超过最大设置内存时,不淘汰任何数据,这时如果有新的数据写入,则会触发系统的内存回收策略,但是如果没用数据写入的话,只是单纯的查询或者删除操作的话,还是可以正常工作。
2、进行数据淘汰的策略
针对「进行数据淘汰」这一类策略,又可以细分为「在设置了过期时间的数据中进行淘汰」和「在所有数据范围内进行淘汰」这两类策略。
在设置了过期时间的数据中进行淘汰:
在所有数据范围内进行淘汰:
LRU(Least Recently Used,最近最少使用)和 LFU(Least Frequently Used,最近最不常使用)是两种常见的淘汰算法,比如在操作系统的虚拟内存管理中,就有用这两种算法进行页面置换。在 Redis 中,一开始使用的是比较简单的 LRU,后来才改进为效果更好的 LFU。但是 Redis 实现的 LRU 和 LFU,都是进行改进过的。
传统的 LRU 算法存在两个问题:
Redis 实现的是一种近似 LRU 算法,目的是为了更好的节约内存,它的实现方式是在 Redis 的对象结构体中添加一个额外的字段,用于记录此数据的最后一次访问时间。
当 Redis 进行内存淘汰时,会使用随机采样的方式来淘汰数据,它是随机取 N 个值(可配置),然后淘汰最久没有使用的那个。
Redis 实现的 LRU 算法的优点:
但是 LRU 算法有一个问题,无法解决缓存污染问题,比如应用一次读取了大量的数据,而这些数据只会被读取这一次,那么这些数据会留存在 Redis 缓存中很长一段时间,造成缓存污染。
对于采用了 LRU 策略的 Redis 缓存来说,扫描式单次查询会造成缓存污染。为了应对这类缓存污染问题,Redis4.0 版本开始增加了 LFU 淘汰策略。
在 Redis 实现的 LFU 算法中,对象头的 24 bits 的 lru 字段被分成两段来存储,高 16bit 存储 ldt(Last Decrement Time,记录访问时间戳),低 8bit 存储 logc(Logistic Counter,记录访问频次,最大值为 255,值越小表示使用频率越低,越容易被淘汰)。
在每次 key 被访问时,会先对 logc 做一个衰减操作,衰减的值跟前后访问时间的差距有关系,LFU 策略会计算当前时间和数据最近一次访问时间的差值,并把这个差值换算成以分钟为单位。然后再除以 lfu_decay_time 的值,所得的结果就是数据 counter 要衰减的值。这样实现的 LFU 算法是根据访问频率来淘汰数据的,而不只是访问次数。访问频率需要考虑 key 的访问是多长时间段内发生的。key 的先前访问距离当前时间越长,那么这个 key 的访问频率相应地也就会降低,这样被淘汰的概率也会更大。
// 获取当前键值对的上一次访问时间,lru右移8位,相当于保留的是前面16位的时间戳
unsigned long ldt = o->lru >> 8;
// 获取当前的访问次数,相当于后8位与255做与运算,即得到计数器
unsigned long counter = o->lru & 255;
// 计算衰减大小
unsigned long num_periods = server.lfu_decay_time ? LFUTimeElapsed(ldt) / server.lfu_decay_time : 0;
// 如果衰减大小不为0
if (num_periods)// 如果衰减大小小于当前访问次数,那么,衰减后的访问次数是当前访问次数减去衰减大小;否则,衰减后的访问次数等于0counter = (num_periods > counter) ? 0 : counter - num_periods;
对 logc 做完衰减操作后,就开始对 logc 进行增加操作,增加操作并不是单纯的 +1,而是根据概率增加,用计数器当前的值乘以配置项 lfu_log_factor 再加 1,取其倒数得到一个 p 值;然后,把这个 p 值和一个取值范围在(0,1)间的随机数 r 值比大小,只有 p 值大于 r 值时,计数器才加 1。如果 key 的 logc 越大 ,它的 logc 就越难再增加。
double r = (double)rand()/RAND_MAX;
// 减去新对象初始化的基数值 (LFU_INIT_VAL 默认是 5)
double baseval = counter - LFU_INIT_VAL;
...
// logc越大,baseval就越大,增加的难度就越大
double p = 1.0/(baseval*server.lfu_log_factor+1);
if (r < p) counter++;
Redis 可以使用 libc、jemalloc、tcmalloc 等多种内存管理器进行管理,一般默认使用 jemalloc(不同版本不一样)。
jemalloc 的分配策略之一,是按照一系列固定的大小划分内存空间,例如 8 字节、16 字节、32 字节、64 字节等等。当程序申请的内存最接近某个固定值时(如申请 30 个字节,实际会被分配到 32 个),jemalloc 会给它分配相应大小的空间。这样的分配方式是为了减少分配次数。
当数据删除后,Redis 释放的内存空间会由内存分配器管理,并不会立即返回给操作系统。所以,操作系统仍然会记录着给 Redis 分配了大量内存。大量内存碎片的存在,会造成 Redis 的内存实际利用率变低。
从 Redis 4.0-RC3 版本以后,Redis 自身提供了一种内存碎片自动清理的方法。
碎片清理是有代价的,操作系统需要把多份数据拷贝到新位置,把原有空间释放出来,这会带来时间开销。而且因为 Redis 是单线程,在数据拷贝时,Redis 只能等着,这就导致 Redis 无法及时处理请求,性能就会降低。
具体什么时候清理,会受到下面这两个参数的控制。这两个参数分别设置了触发内存清理的一个条件,如果同时满足这两个条件,就开始清理。在清理的过程中,只要有一个条件不满足了,就停止自动清理。
为了尽可能减少碎片清理对 Redis 正常请求处理的影响,自动内存碎片清理功能在执行时,还会监控清理操作占用的 CPU 时间,而且还设置了两个参数,分别用于控制清理操作占用的 CPU 时间比例的上、下限,既保证清理工作能正常进行,又避免了降低 Redis 性能。这两个参数具体如下:
可以使用 mem_fragmentation_ratio 来判断 Redis 当前的内存碎片率是否严重。
mem_fragmentation_ratio = used_memory_rss/ used_memory:例如,Redis 申请使用了 100 字节(used_memory),操作系统实际分配了 128 字节(used_memory_rss),此时,mem_fragmentation_ratio 就是 1.28。
如果 mem_fragmentation_ratio 小于 1,就表明,操作系统分配给 Redis 的内存空间已经小于 Redis 所申请的空间大小了,此时,运行 Redis 实例的服务器上的内存已经不够用了,可能已经发生 swap 了。这样一来,Redis 的读写性能也会受到影响,因为 Redis 实例需要在磁盘上的 swap 分区中读写数据,速度较慢。
本文介绍了 Redis 中的内存管理相关内容,Redis 可以对数据设置过期时间,并采用相应的策略对过期数据进行删除,还可以手动对数据进行删除。但是删除数据并不是立刻释放内存空间,而是交给内存管理器处理。虽然这种方式可以减少申请内存的次数,但是会导致内存碎片问题,进一步导致内存利用率变低。如果使用的内存到达设置的上限,还会触发相应的内存淘汰策略进行内存释放。
下一节将介绍 Redis 的并发问题。