Redis源码剖析之内存淘汰策略(Evict)

article/2025/8/16 20:11:55

文章目录

    • 何为Evict
    • 如何Evict
    • Redis中的Evict策略
    • 源码剖析
      • LRU具体实现
      • LFU具体实现
        • LFU计数器增长
        • LFU计数器衰减
      • evict执行过程
        • evict何时执行
        • evict.c
    • 总结

Redis作为一个成熟的数据存储中间件,它提供了完善的数据管理功能,比如之前我们提到过的 数据过期和今天我们要讲的数据淘汰(evict)策略。在开始介绍Redis数据淘汰策略前,我先抛出几个问题,帮助大家更深刻理解Redis的数据淘汰策略。

  1. 何为数据淘汰,Redis有了数据过期策略为什么还要有数据淘汰策略?
  2. 淘汰哪些数据,有什么样的数据选取标准?
  3. Redis的数据淘汰策略是如何实现的?

何为Evict

我先来回答第一个问题,Redis中数据淘汰实际上是指的在内存空间不足时,清理掉某些数据以节省内存空间。 虽然Redis已经有了过期的策略,它可以清理掉有效期外的数据。但想象下这个场景,如果过期的数据被清理之后存储空间还是不够怎么办?是不是还可以再删除掉一部分数据? 在缓存这种场景下 这个问题的答案是可以,因为这个数据即便在Redis中找不到,也可以从被缓存的数据源中找到。所以在某些特定的业务场景下,我们是可以丢弃掉Redis中部分旧数据来给新数据腾出空间。

如何Evict

第二个问题,既然我们需要有淘汰的机制,你们在具体执行时要选哪些数据淘汰掉?具体策略有很多种,但思路只有一个,那就是总价值最大化。我们生在一个不公平的世界里,同样数据也是,那么多数据里必然不是所有数据的价值都是一样的。所以我们在淘汰数据时只需要选择那些低价值的淘汰即可。

所以问题又来了,哪些数据是低价值的?这里不得不提到一个贯穿计算机学科的原理局部性原理,这里可以明确告诉你,局部性原理在缓存场景有这样两种现象,1. 最新的数据下次被访问的概率越高。 2. 被访问次数越多的数据下次被访问的概率越高。 这里我们可以简单认为被访问的概率越高价值越大。基于上述两种现象,我们可以指定出两种策略 1. 淘汰掉最早未被访问的数据。2. 淘汰掉访被访问频次最低的数据,这两种策略分别有个洋气的英文名LRU(Least Recently Used)和LFU(Least Frequently Used)。

Redis中的Evict策略

除了LRU和LFU之外,还可以随机淘汰。这就是将数据一视同仁,随机选取一部分淘汰。实际上Redis实现了以上3中策略,你使用时可以根据具体的数据配置某个淘汰策略。除了上述三种策略外,Redis还为由过期时间的数据提供了按TTL淘汰的策略,其实就是淘汰剩余TTL中最小的数据。另外需要注意的是Redis的淘汰策略可以配置在全局或者是有过期时间的数据上,所以Redis共计以下8中配置策略。

配置项具体含义
MAXMEMORY_VOLATILE_LRU仅在有过期时间的数据上执行LRU
MAXMEMORY_VOLATILE_LFU仅在有过期时间的数据上执行LFU
MAXMEMORY_VOLATILE_TTL在有过期时间的数据上按TTL长度淘汰
MAXMEMORY_VOLATILE_RANDOM仅在有过期时间的数据上随机淘汰
MAXMEMORY_ALLKEYS_LRU在全局数据上执行LRU
MAXMEMORY_ALLKEYS_LFU在全局数据上执行LFU
MAXMEMORY_ALLKEYS_RANDOM在全局数据上随机淘汰
MAXMEMORY_NO_EVICTION不淘汰数,当内存空间满时插入数据会报错

源码剖析

接下来我们就从源码来看下Redis是如何实现以上几种策略的,MAXMEMORY_VOLATILE_*和MAXMEMORY_ALLKEYS_*策略实现是一样的,只是作用在不同的dict上而已。另外Random的策略也比较简单,这里就不再详解了,我们重点看下LRU和LFU。

LRU具体实现

LRU的本质是淘汰最久没被访问的数据,有种实现方式是用链表的方式实现,如果数据被访问了就把它移到链表头部,那么链尾一定是最久未访问的数据,但是单链表的查询时间复杂度是O(n),所以一般会用hash表来加快查询数据,比如Java中LinkedHashMap就是这么实现的。但Redis并没有采用这种策略,Redis就是单纯记录了每个Key最近一次的访问时间戳,通过时间戳排序的方式来选找出最早的数据,当然如果把所有的数据都排序一遍,未免也太慢了,所以Redis是每次选一批数据,然后从这批数据执行淘汰策略。这样的好处就是性能高,坏处就是不一定是全局最优,只是达到局部最优。

typedef struct redisObject {unsigned type:4;  unsigned encoding:4;unsigned lru:LRU_BITS; int refcount;      void *ptr;            
} robj;

LRU信息如何存的? 在之前介绍redisObject的文章中 我们已提到过了,在redisObject中有个24位的lru字段,这24位保存了数据访问的时间戳(秒),当然24位无法保存完整的unix时间戳,不到200天就会有一个轮回,当然这已经足够了。

robj *lookupKey(redisDb *db, robj *key, int flags) {dictEntry *de = dictFind(db->dict,key->ptr);if (de) {robj *val = dictGetVal(de);if (!hasActiveChildProcess() && !(flags & LOOKUP_NOTOUCH)){if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {updateLFU(val);} else {val->lru = LRU_CLOCK();  // 这里更新LRU时间戳  }}return val;} else {return NULL;}
}

LFU具体实现

lru这个字段也会被lfu用到,所以你在上面lookupkey中可以看到在使用lfu策略是也会更新lru。Redis中lfu的出现稍晚一些,是在Redis 4.0才被引入的,所以这里复用了lru字段。 lru的实现思路只有一种,就是记录下key被访问的次数。但实现lru有个问题需要考虑到,虽然LFU是按访问频次来淘汰数据,但在Redis中随时可能有新数据就来,本身老数据可能有更多次的访问,新数据当前被访问次数少,并不意味着未来被访问的次数会少,如果不考虑到这点,新数据可能一就来就会被淘汰掉,这显然是不合理的。

Redis为了解决上述问题,将24位被分成了两部分,高16位的时间戳(分钟级),低8位的计数器。每个新数据计数器初始有一定值,这样才能保证它能走出新手村,然后计数值会随着时间推移衰减,这样可以保证老的但当前不常用的数据才有机会被淘汰掉,我们来看下具体实现代码。

LFU计数器增长

计数器只有8个二进制位,充其量数到255,怎么会够? 当然Redis使用的不是精确计数,而是近似计数。具体实现就是counter概率性增长,counter的值越大增长速度越慢,具体增长逻辑如下:

/* 更新lfu的counter,counter并不是一个准确的数值,而是概率增长,counter的数值越大其增长速度越慢* 只能反映出某个时间窗口的热度,无法反映出具体访问次数 */
uint8_t LFULogIncr(uint8_t counter) {if (counter == 255) return 255;double r = (double)rand()/RAND_MAX;double baseval = counter - LFU_INIT_VAL; // LFU_INIT_VAL为5if (baseval < 0) baseval = 0;double p = 1.0/(baseval*server.lfu_log_factor+1);  // server.lfu_log_factor可配置,默认是10 if (r < p) counter++;return counter;
}

从代码逻辑中可以看出,counter的值越大,增长速度会越慢,所以lfu_log_factor配置较大的情况下,即便是8位有可以存储很大的访问量。下图是不同lfu_log_factor在不同访问频次下的增长情况,图片来自Redis4.0之基于LFU的热点key发现机制。
在这里插入图片描述

LFU计数器衰减

如果说counter一直增长,即便增长速度很慢也有一天会增长到最大值255,最终导致无法做数据的筛选,所以要给它加一个衰减策略,思路就是counter随时间增长衰减,具体代码如下:

/* lfu counter衰减逻辑, lfu_decay_time是指多久counter衰减1,比如lfu_decay_time == 10* 表示每10分钟counter衰减一,但lfu_decay_time为0时counter不衰减 */
unsigned long LFUDecrAndReturn(robj *o) {unsigned long ldt = o->lru >> 8;unsigned long counter = o->lru & 255;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;
}

server.lfu_decay_time也是可配置的,默认是10 标识每10分钟counter值减去1。

evict执行过程

evict何时执行

在Redis每次处理命令的时候,都会检查内存空间,并尝试去执行evict,因为有些情况下不需要执行evict,这个可以从isSafeToPerformEvictions中可以看出端倪。

static int isSafeToPerformEvictions(void) {/* 没有lua脚本执行超时,也没有在做数据超时 */if (server.lua_timedout || server.loading) return 0;/* 只有master才需要做evict */if (server.masterhost && server.repl_slave_ignore_maxmemory) return 0;/* 当客户端暂停时,不需要evict,因为数据是不会变化的 */if (checkClientPauseTimeoutAndReturnIfPaused()) return 0;return 1;
}

evict.c

evict代码都在evict.c中。里面包含了每次evict的执行过程。

int performEvictions(void) {if (!isSafeToPerformEvictions()) return EVICT_OK;int keys_freed = 0;size_t mem_reported, mem_tofree;long long mem_freed; /* May be negative */mstime_t latency, eviction_latency;long long delta;int slaves = listLength(server.slaves);int result = EVICT_FAIL;if (getMaxmemoryState(&mem_reported,NULL,&mem_tofree,NULL) == C_OK)return EVICT_OK;if (server.maxmemory_policy == MAXMEMORY_NO_EVICTION)return EVICT_FAIL;  /* We need to free memory, but policy forbids. */unsigned long eviction_time_limit_us = evictionTimeLimitUs();mem_freed = 0;latencyStartMonitor(latency);monotime evictionTimer;elapsedStart(&evictionTimer);while (mem_freed < (long long)mem_tofree) {int j, k, i;static unsigned int next_db = 0;sds bestkey = NULL;int bestdbid;redisDb *db;dict *dict;dictEntry *de;if (server.maxmemory_policy & (MAXMEMORY_FLAG_LRU|MAXMEMORY_FLAG_LFU) ||server.maxmemory_policy == MAXMEMORY_VOLATILE_TTL){struct evictionPoolEntry *pool = EvictionPoolLRU;while(bestkey == NULL) {unsigned long total_keys = 0, keys;/* We don't want to make local-db choices when expiring keys,* so to start populate the eviction pool sampling keys from* every DB. * 先从dict中采样key并放到pool中 */for (i = 0; i < server.dbnum; i++) {db = server.db+i;dict = (server.maxmemory_policy & MAXMEMORY_FLAG_ALLKEYS) ?db->dict : db->expires;if ((keys = dictSize(dict)) != 0) {evictionPoolPopulate(i, dict, db->dict, pool);total_keys += keys;}}if (!total_keys) break; /* No keys to evict. *//* 从pool中选择最适合淘汰的key. */for (k = EVPOOL_SIZE-1; k >= 0; k--) {if (pool[k].key == NULL) continue;bestdbid = pool[k].dbid;if (server.maxmemory_policy & MAXMEMORY_FLAG_ALLKEYS) {de = dictFind(server.db[pool[k].dbid].dict,pool[k].key);} else {de = dictFind(server.db[pool[k].dbid].expires,pool[k].key);}/* 从淘汰池中移除. */if (pool[k].key != pool[k].cached)sdsfree(pool[k].key);pool[k].key = NULL;pool[k].idle = 0;/* If the key exists, is our pick. Otherwise it is* a ghost and we need to try the next element. */if (de) {bestkey = dictGetKey(de);break;} else {/* Ghost... Iterate again. */}}}}/* volatile-random and allkeys-random 策略 */else if (server.maxmemory_policy == MAXMEMORY_ALLKEYS_RANDOM ||server.maxmemory_policy == MAXMEMORY_VOLATILE_RANDOM){/* 当随机淘汰时,我们用静态变量next_db来存储当前执行到哪个db了*/for (i = 0; i < server.dbnum; i++) {j = (++next_db) % server.dbnum;db = server.db+j;dict = (server.maxmemory_policy == MAXMEMORY_ALLKEYS_RANDOM) ?db->dict : db->expires;if (dictSize(dict) != 0) {de = dictGetRandomKey(dict);bestkey = dictGetKey(de);bestdbid = j;break;}}}/* 从dict中移除被选中的key. */if (bestkey) {db = server.db+bestdbid;robj *keyobj = createStringObject(bestkey,sdslen(bestkey));propagateExpire(db,keyobj,server.lazyfree_lazy_eviction);/*我们单独计算db*Delete()释放的内存量。实际上,在AOF和副本传播所需的内存可能大于我们正在释放的内存(删除key),如果我们考虑这点的话会很绕。由signalModifiedKey生成的CSC失效消息也是这样。因为AOF和输出缓冲区内存最终会被释放,所以我们只需要关心key空间使用的内存即可。*/delta = (long long) zmalloc_used_memory();latencyStartMonitor(eviction_latency);if (server.lazyfree_lazy_eviction)dbAsyncDelete(db,keyobj);elsedbSyncDelete(db,keyobj);latencyEndMonitor(eviction_latency);latencyAddSampleIfNeeded("eviction-del",eviction_latency);delta -= (long long) zmalloc_used_memory();mem_freed += delta;server.stat_evictedkeys++;signalModifiedKey(NULL,db,keyobj);notifyKeyspaceEvent(NOTIFY_EVICTED, "evicted",keyobj, db->id);decrRefCount(keyobj);keys_freed++;if (keys_freed % 16 == 0) {/*当要释放的内存开始足够大时,我们可能会在这里花费太多时间,不可能足够快地将数据传送到副本,因此我们会在循环中强制传输。*/if (slaves) flushSlavesOutputBuffers();/*通常我们的停止条件是释放一个固定的,预先计算的内存量。但是,当我们*在另一个线程中删除对象时,最好不时*检查是否已经达到目标*内存,因为“mem\u freed”量只在dbAsyncDelete()调用中*计算,而线程可以*一直释放内存。*/if (server.lazyfree_lazy_eviction) {if (getMaxmemoryState(NULL,NULL,NULL,NULL) == C_OK) {break;}}/*一段时间后,尽早退出循环-即使尚未达到内存限制*。如果我们突然需要释放大量的内存,不要在这里花太多时间。*/if (elapsedUs(evictionTimer) > eviction_time_limit_us) {// We still need to free memory - start eviction timer procif (!isEvictionProcRunning) {isEvictionProcRunning = 1;aeCreateTimeEvent(server.el, 0,evictionTimeProc, NULL, NULL);}break;}}} else {goto cant_free; /* nothing to free... */}}/* at this point, the memory is OK, or we have reached the time limit */result = (isEvictionProcRunning) ? EVICT_RUNNING : EVICT_OK;cant_free:if (result == EVICT_FAIL) {/* At this point, we have run out of evictable items.  It's possible* that some items are being freed in the lazyfree thread.  Perform a* short wait here if such jobs exist, but don't wait long.  */if (bioPendingJobsOfType(BIO_LAZY_FREE)) {usleep(eviction_time_limit_us);if (getMaxmemoryState(NULL,NULL,NULL,NULL) == C_OK) {result = EVICT_OK;}}}latencyEndMonitor(latency);latencyAddSampleIfNeeded("eviction-cycle",latency);return result;
}

执行的过程可以简单分为三步,首先按不同的配置策略填充evictionPoolEntry,pool大小默认是16,然后从这16个key中根据具体策略选出最适合被删掉的key(bestkey),然后执行bestkey的删除和一些后续逻辑。

总结

可以看出,Redis为了性能,牺牲了LRU和LFU的准确性,只能说是近似LRU和LFU,但在实际使用过程中也完全足够了,毕竟Redis这么多年也是经历了无数项目的考验依旧屹立不倒。Redis的这种设计方案也给我们软件设计时提供了一条新的思路,牺牲精确度来换取性能

本文是Redis源码剖析系列博文,同时也有与之对应的Redis中文注释版,有想深入学习Redis的同学,欢迎star和关注。
Redis中文注解版仓库:https://github.com/xindoo/Redis
Redis源码剖析专栏:https://zxs.io/s/1h
如果觉得本文对你有用,欢迎一键三连


http://chatgpt.dhexx.cn/article/01NLl0Xw.shtml

相关文章

WiredTiger系列2:Eviction详解

Eviction Evict的实质主要是将内存中的page淘汰出内存&#xff0c;简单来说&#xff0c;当cache里面的“脏页”达到一定比例或cache使用量达到一定比例时&#xff0c;wt就会触发相应的evict page线程来将pages&#xff08;包含干净的pages和脏pages&#xff09;按一定的算法&a…

onload事件

onload事件&#xff1a; <!DOCTYPE html> <html lang"en"> <head><meta charset"UTF-8"><title>Title</title><script type"text/javascript">//onload事件的方法function onloadFun(){alert(静态注…

onUnload事件

1. 首先JavaScript会使我们有能力创建动态页面然后JavaScript是可以被侦测到事件行为的然而在网页中的每一个元素都将可以来产生某些可以触发JavaScript的函数的事件就可以打个比方说我们在用户中里面点击某个按钮时的产生的某一个onClick 事件来触发某个函数之后就在事件中在H…

script标签的onload事件的触发时机

onload事件在资源被加载完成后会被触发。对于script标签&#xff0c;在外部js文件被加载后代码会被立即执行。那么&#xff0c;外部js文件中的代码和该script标签的onload回调函数&#xff0c;它们的执行顺序是怎样的呢&#xff1f;没有找到官方的说明文档&#xff0c;所以自己…

load事件

javaScript中最常用到的一个事件就是load。当页面完全加载后&#xff08;包括所有图像、javaScript文件、css文件等外部资源&#xff09;&#xff0c;就会触发window上边的load事件。 window&#xff1a; window.addEventListener(load, function(e) {console.log(页面完全加…

onload 和 onunload 事件

onload 和 onunload 事件会在用户进入或离开页面时被触发。onload 事件可用于检测访问者的浏览器类型和浏览器版本&#xff0c;并基于这些信息来加载网页的正确版本。onload 和 onunload 事件可用于处理 cookie。 onmousedown,onmouseup 以及 onclick 构成了鼠标点击事件的所有…

window的onload事件的用法

1.最简单的用法 注&#xff1a;奇葩&#xff0c;我没用过 2.在JS语句调用&#xff08;正确使用姿势&#xff09; 或使用jquery onload 事件会在页面或图像加载完成后立即发生。 3.window.onload()的加载问题 由于HTML加载时由上往下的&#xff0c;在HTML加载的时候&#…

JS中window.onload事件详解

window.onload出现的原因&#xff1f;  我们都知道页面的代码顺序是从上往下进行加载&#xff0c;很多时候我们要对页面中的某一个模块进行操作&#xff0c;这时候我们常常使用javascript代码来进行操作。为了能够保证操作的模块或对象在js代码之前就加载了&#xff0c;我们不…

浏览器的onload事件

如下代码&#xff0c;因为代码从上到下执行&#xff0c;btn节点还未创建好就去获取会报错 <!DOCTYPE html> <html lang"en"><head><meta charset"UTF-8" /><meta http-equiv"X-UA-Compatible" content"IEedge&q…

JavaScript的window.onload事件的理解

window.onload()的作用 window.onload() 方法用于在网页加载完毕后立刻执行的操作&#xff0c;即当 HTML 文档加载完毕后&#xff0c;立刻执行某个方法。window.onload() 通常用于 元素&#xff0c;在页面完全载入后(包括图片、css文件等等)执行脚本代码。 只有一个要执行的函…

I2C协议——I2C框图和I2C通信过程

1.软件模拟和硬件模拟的概念 所谓软件模拟&#xff0c;即直接使用 CPU 内核按照 I2C 协议的要求控制 GPIO 输出高低电平。如控制产生 I2C 的起始信号时&#xff0c;先控制作为 SCL 线的 GPIO 引脚输出高电平&#xff0c;然后控制作为 SDA 线的 GPIO 引脚在此期间完成由高电平至…

通信协议——I2C协议/IIC协议解析

目录 I2C协议概述 I2C通信原理 I2C通信时序 I2C协议概述 同步通信 半双工&#xff08;分时&#xff09; 串行传输 电平信号 特点&#xff1a;①有两根传输线&#xff08;时钟线SCL、双向数据线SDA&#xff09; ②主从模式&#xff1a;通信双方为主设备&#xff08;Master&…

I2C基础

I2C基础 1 基本介绍2 特点3 硬件连接4 通信4.1 控制器4.2 协议 5 SPI VS I2C 1 基本介绍 IC (Inter-Integrated Circuit)。内部集成电路。拥有两根线&#xff0c;一根数据线SDA和一根时钟线SCL。 这两条线都是漏极开路或者集电极开路结构&#xff0c;使用时需要外加上拉电阻&a…

I2C总线协议

1. 简介 I2C (Inter-Integrated Circuit&#xff09;&#xff0c;是一种串行通信总线&#xff0c;用于连接微控制器及其外围设备&#xff0c;实现主控制器和从器件间的主从双向通信&#xff0c;是一种同步半双工通信(两端时钟频率一致&#xff0c;双向通信&#xff0c;但不能同…

I2C协议最细致的讲解

I2C通讯协议(Inter—lntegrated Circuit)是由Phiilps公司开发的&#xff0c;由于它引脚少&#xff0c;硬件实现简单&#xff0c;可扩展性强&#xff0c;不需要USART、CAN等通讯协议的外部收发设备&#xff0c;现在被广泛地使用在系统内多个集成电路(IC)间的通讯。 I2C物理层的特…

沧小海详解面试的必答题——I2C协议

目录 第一部分&#xff1a;I2C协议的概述 第二部分&#xff1a;I2C协议的阐述 第三部分&#xff1a;AT24C04简述 第四部分&#xff1a;基于verilog的程序设计&#xff08;暂无&#xff09; 对于大多从事FPGA行业的应届生来说&#xff0c;在面试过程中很可能会被问到关于I2C…

51单片机模拟I2C协议

什么是I2C 首先需要知道什么是I2C协议。I2C总线是由Philips公司开发的一种简单、双向二线制同步串行总线。它只需要两根线即可在连接于总线上的器件之间传送信息&#xff08;摘自百度百科&#xff09;。I2C主要有两条线&#xff0c;一条SDA数据线&#xff0c;一条SCL时钟线。由…

I2C协议原理讲解

一、物理层&#xff1a; 1、I2C通讯设备之间的常用连接方式&#xff1a; 2、特点&#xff1a; (1) 它是一个支持设备的总线。“总线”指多个设备共用的信号线。在一个I2C通讯总线中&#xff0c; 可连接多个I2C通讯设备&#xff0c;支持多个通讯主机及多个通讯从机。 (2) 一个…

一文搞懂I2C协议-硬件基础

I2C是什么 I2C总线是由飞利浦在80年代初设计的&#xff0c;以允许位于同一电路板上的组件之间能够轻松通信。其大大简化了电路的设计&#xff0c;早期的电视机中很多地方用到了I2C这种通信方式。飞利浦半导体于2006年迁移到了NXP。I2C名称翻译为“ Inter IC”。有时&#xff0…

I2C协议+实现源码

文章目录 摘要I2C通信协议简介补充空闲状态start和stop信号应答信号数据有效性规定数据传输延时 I2C协议的实现源码硬件说明头文件sys.h 主函数初始化I2C产生开始和停止信号等待应答信号产生或不产生应答I2C写操作I2C读操作 对24C02操作24C02的时序图头文件初始化IIC接口写数据…