Redis(八):内存管理
创始人
2025-05-31 11:44:31

前言

上一篇介绍了 Redis 的数据持久化。这节开始介绍 Redis 的内存管理相关问题。

Redis 是基于内存的,那么必然就会涉及到内存空间的管理,需要采用一定的策略,将过期的数据删除,以及需要淘汰部分内存,来提高内存空间的利用率。

先来说说过期数据的问题,Redis 是可以对 key 设置过期时间的,那么当这些数据过期后,就应该采用对应的手段删除数据,来释放内存空间。

过期数据

Redis 使用过期字典(expires dict)保存数据的过期时间:

typedef struct redisDb {dict *dict;    /* 数据库键空间,存放着所有的键值对 */dict *expires; /* 键的过期时间 */....
} redisDb;
  • 过期字典的 key 是一个指针,指向某个键对象;
  • 过期字典的 value 是一个 long long 类型的整数,这个整数保存了 key 的过期时间;

当查询一个 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 可能会占用相当一部分 CPU 时间,会对服务器的响应时间和吞吐量造成影响。所以对 CPU 不友好
惰性删除

不主动删除过期键,每次从数据库访问 key 时,都检测 key 是否过期,如果过期则删除该 key。

优点:

  • 因为每次访问时,才会检查 key 是否过期,所以此策略只会使用很少的系统资源。所以对 CPU 最友好

缺点:

  • 如果一个 key 已经过期,而这个 key 又仍然保留在数据库中,那么只要这个过期 key 一直没有被访问,它所占用的内存就不会释放,造成了一定的内存空间浪费。所以对内存不友好
定期删除

每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期 key。

优点:

  • 通过限制删除操作执行的时长和频率,来减少删除操作对 CPU 的影响,同时也能删除一部分过期的数据减少了过期键对空间的无效占用。

缺点:

  • 内存清理方面没有定时删除效果好,同时没有惰性删除使用的系统资源少。
  • 难以确定删除操作执行的时长和频率。如果执行的太频繁,定期删除策略变得和定时删除策略一样,对CPU不友好;如果执行的太少,那又和惰性删除一样了,过期 key 占用的内存不会及时得到释放。
采用策略

Redis 选择「惰性删除+定期删除」这两种策略配合使用,以求在合理使用 CPU 时间和避免内存浪费之间取得平衡。

Redis 在访问或者修改 key 之前,都会调用 expireIfNeeded 函数对其进行检查,检查 key 是否过期:

  • 如果过期,则删除该 key,至于选择异步删除,还是选择同步删除,根据 lazyfree_lazy_expire 参数配置决定(Redis 4.0版本开始提供参数),然后返回 null 客户端;
  • 如果没有过期,不做任何处理,然后返回正常的键值对给客户端;

在 Redis 中,默认每 100ms 执行一次(可通过 Redis 的配置文件 redis.conf 进行配置)。随机抽查的数量由 ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 定义的,它是写死在代码中的,数值是 20。

定期删除过程:

  1. 从过期字典中随机抽取 20 个 key;
  2. 检查这 20 个 key 是否过期,并删除已过期的 key;
  3. 如果本轮检查的已过期 key 的数量,超过 5 个,也就是「已过期 key 的数量」占比「随机抽取 key 的数量」大于 25%,则继续重复步骤 1;如果已过期的 key 比例小于 25%,则停止继续删除过期 key,然后等待下一轮再检查。

Redis 为了保证定期删除不会出现循环过度,导致线程卡死现象,为此增加了定期删除循环流程的时间上限,默认不会超过 25ms。

Redis 并不会一次性删除大量过期数据,因为这样可能会对性能造成影响。

手动删除

Redis 中手动删除数据的命令主要有以下四个:

  • DEL:用于删除单个或多个键值对,主线程立刻同步删除;
  • UNLINK:和 DEL 命令类似,但是区别是异步删除;
  • FLUSHDB:删除当前数据库中所有的键值对;如果指定了 ASYNC 参数则为异步删除,否则为同步删除;
  • FLUSHALL:删除所有数据库中所有的键值对;如果指定了 ASYNC 参数则为异步删除,否则为同步删除;

UNLINK 和数据库异步删除操作是 Redis 4.0 后提供的功能。

异步删除会先将指定键的值标记为删除,然后等待后台线程在适当的时候删除这些键值对。在手动删除数据时,尤其是大量数据时,建议使用异步删除的方式,避免阻塞主线程。异步删除的缺点是可能造成短期的数据不一致。

内存淘汰

删除数据和释放内存是两个不同的概念:

  • 删除数据是指从 Redis 中删除一个键值对:通过过期策略删除过期数据、手动异步删除数据,都是在 Redis 层面将数据删除了,后续不能再使用这些数据;
  • 释放内存是指将一块内存空间返回给操作系统,以便其他进程使用

在 Redis 中,当一个键被删除时,它所占用的内存空间并不会立即返回给操作系统,而是由 Redis 内部的内存管理器进行管理

Redis 中所有的删除数据,都不是立即释放内存,只是将键值对标记为已删除,将键对象从 Redis 数据库中删除,并将其添加到一个专门的删除队列中。之后会异步地定期遍历这个队列,将已删除的键值对所占用的内存空间返回给内存池。当 Redis 需要分配内存时,管理器会先尝试从这个内存池中获取内存,这种方式可以避免频繁地向操作系统申请内存,从而提高 Redis 的性能。

Redis 的内存回收是通过使用引用计数惰性删除技术实现的。具体来说,当一个键被删除时,Redis 只是将该键对应的对象标记为已删除,并将键对象从 Redis 数据库中删除。如果该键对应的对象被其他键对象引用,Redis 会将该对象的引用计数减一,直到引用计数为零时才会真正地释放该对象占用的内存。

Redis 只需要删除不再被引用的键对象,而不需要对整个 Redis 数据库进行上锁。这种设计使得 Redis 能够以非常高效的方式处理内存回收,并且不会影响 Redis 的性能。

内存淘汰策略

如果 Redis 的运行内存超过了设置的最大内存,则会使用内存淘汰策略删除符合条件的 key,以此来保障 Redis 高效的运行。

内存淘汰针对的是所有的数据,包括已经被删除但是还没有释放内存空间的数据。

Redis 内存淘汰策略共有八种,这八种策略大体分为「不进行数据淘汰」和「进行数据淘汰」两类策略。

1、不进行数据淘汰的策略

noeviction(Redis3.0 之后,默认的内存淘汰策略) :它表示当运行内存超过最大设置内存时,不淘汰任何数据,这时如果有新的数据写入,则会触发系统的内存回收策略,但是如果没用数据写入的话,只是单纯的查询或者删除操作的话,还是可以正常工作。

2、进行数据淘汰的策略

针对「进行数据淘汰」这一类策略,又可以细分为「在设置了过期时间的数据中进行淘汰」和「在所有数据范围内进行淘汰」这两类策略。

在设置了过期时间的数据中进行淘汰:

  • volatile-random:随机淘汰设置了过期时间的任意键值;
  • volatile-ttl:优先淘汰更早过期的键值;
  • volatile-lru(Redis3.0 之前,默认的内存淘汰策略):淘汰所有设置了过期时间的键值中,最久未使用的键值;
  • volatile-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰所有设置了过期时间的键值中,最少使用的键值;

在所有数据范围内进行淘汰:

  • allkeys-random:随机淘汰任意键值;
  • allkeys-lru:淘汰整个键值中最久未使用的键值;
  • allkeys-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰整个键值中最少使用的键值。

LRU

LRU(Least Recently Used,最近最少使用)和 LFU(Least Frequently Used,最近最不常使用)是两种常见的淘汰算法,比如在操作系统的虚拟内存管理中,就有用这两种算法进行页面置换。在 Redis 中,一开始使用的是比较简单的 LRU,后来才改进为效果更好的 LFU。但是 Redis 实现的 LRU 和 LFU,都是进行改进过的。

传统的 LRU 算法存在两个问题:

  • 需要用链表管理所有的缓存数据,这会带来额外的空间开销;
  • 当有数据被访问时,需要在链表上把该数据移动到头端,如果有大量数据被访问,就会带来很多链表移动操作,会很耗时,进而会降低 Redis 缓存性能。

Redis 实现的是一种近似 LRU 算法,目的是为了更好的节约内存,它的实现方式是在 Redis 的对象结构体中添加一个额外的字段,用于记录此数据的最后一次访问时间

当 Redis 进行内存淘汰时,会使用随机采样的方式来淘汰数据,它是随机取 N 个值(可配置),然后淘汰最久没有使用的那个

Redis 实现的 LRU 算法的优点:

  • 不用为所有的数据维护一个大链表,节省了空间占用;
  • 不用在每次数据访问时都移动链表项,提升了缓存的性能;

但是 LRU 算法有一个问题,无法解决缓存污染问题,比如应用一次读取了大量的数据,而这些数据只会被读取这一次,那么这些数据会留存在 Redis 缓存中很长一段时间,造成缓存污染。

LFU

对于采用了 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 无法及时处理请求,性能就会降低。

具体什么时候清理,会受到下面这两个参数的控制。这两个参数分别设置了触发内存清理的一个条件,如果同时满足这两个条件,就开始清理。在清理的过程中,只要有一个条件不满足了,就停止自动清理。

  • active-defrag-ignore-bytes 100mb:表示内存碎片的字节数达到 100MB 时,开始清理;
  • active-defrag-threshold-lower 10:表示内存碎片空间占操作系统分配给 Redis 的总空间比例达到 10% 时,开始清理。

为了尽可能减少碎片清理对 Redis 正常请求处理的影响,自动内存碎片清理功能在执行时,还会监控清理操作占用的 CPU 时间,而且还设置了两个参数,分别用于控制清理操作占用的 CPU 时间比例的上、下限,既保证清理工作能正常进行,又避免了降低 Redis 性能。这两个参数具体如下:

  • active-defrag-cycle-min 25: 表示自动清理过程所用 CPU 时间的比例不低于 25%,保证清理能正常开展;
  • active-defrag-cycle-max 75:表示自动清理过程所用 CPU 时间的比例不高于 75%,一旦超过,就停止清理,从而避免在清理时,大量的内存拷贝阻塞 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 的并发问题。

相关内容

热门资讯

今日重大通报“爱玩联盟拼三张到... 您好:爱玩联盟拼三张这款游戏可以开挂,确实是有挂的,需要软件加微信【4830828】很多玩家在这款游...
经验传递“ 亲友游戏 是不是... 您好,亲友游戏这个游戏其实有挂的,确实是有挂的,需要了解加客服微信【6676724】很多玩家在亲友游...
「我来分享」“八闽十三张有挂吗... 您好:八闽十三张这款游戏可以开挂,确实是有挂的,需要了解加客服微信【6670747】很多玩家在这款游...
今日分享-" 一定要... 有 亲,根据资深记者爆料一定要牛是可以开挂的,确实有挂(咨询软件无需打开...
[科技通报]“战皇牛牛到底有挂... 您好:战皇牛牛这款游戏可以开挂,确实是有挂的,需要了解加客服微信【9784099】很多玩家在这款游戏...