Redis(5.0.3)内存淘汰LRU/LFU


简介

redis的LRU与LFU都是概率算法。并不是绝对准确的LRU或LFU算法。

image-20190531152612940

Redis在内存满载时触发内存淘汰,最开始是有概率淘汰掉一些不应该淘汰的key。但它会越来越准。这一切都是因为Redis采用了一个全局池子来存放待淘汰key,池子是带序的,按key的空闲时间从小到大。

每次入池子的key,都是从keyspace(db->dict或db->expires)中随机挑选的(随机挑选的个数由maxmemory-samples参数控制,默认5个)。在入池的时候,如果池子里已经有key,就采用插入的思想,把新key插在合适的位置保持池子有序。

例如池子末端有空间:e要放进[a,b,c,d,NULL]中c的位置,先挪成[a,b,c,c,d],然后把e覆盖进去,变成[a,b,e,c,d]。

池子末端没有空间:e要放进[a,b,c,d]中c的位置,先挪成[b,c,c,d],然后把e覆盖进去,变成[b,c,e,d]

key的空闲时间又是如何记录的?

LRU或LFU策略都共用了redisObject里的lru字段,它总长24位,前16位用于LRU,而LFU则用到24位。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#define LRU_BITS 24
typedef struct redisObject {
    // var:4 的写法用了位域
    unsigned type:4;
    unsigned encoding:4;
    unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
                            * LFU data (least significant 8 bits frequency
                            * and most significant 16 bits access time). */
    int refcount;
    void *ptr;
} robj;

而LFU也分成前16位和后8位来用,前16位和LRU保持一致,用来放置访问时间。后8位用来存放访问次数。

8位的上限是255,怎样存放访问次数?Redis用了一个基于概率的对数计数器。效果大概是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# +--------+------------+------------+------------+------------+------------+
# | factor | 100 hits   | 1000 hits  | 100K hits  | 1M hits    | 10M hits   |
# +--------+------------+------------+------------+------------+------------+
# | 0      | 104        | 255        | 255        | 255        | 255        |
# +--------+------------+------------+------------+------------+------------+
# | 1      | 18         | 49         | 255        | 255        | 255        |
# +--------+------------+------------+------------+------------+------------+
# | 10     | 10         | 18         | 142        | 255        | 255        |
# +--------+------------+------------+------------+------------+------------+
# | 100    | 8          | 11         | 49         | 143        | 255        |
# +--------+------------+------------+------------+------------+------------+

factor也是通过参数lfu-log-factor控制,默认是10。(更详细可以参考http://mysql.taobao.org/monthly/2018/09/08/)

另外还有个控制衰减因子的参数,lfu-decay-time,它默认是1。因为长时间不读取key的话,是需要进行衰减的,否则它一直停留在255,就不会被淘汰。

接下来介绍下一些关键的数据结构和方法。

evictionPoolEntry

存放待淘汰对象的池子,池子是全局对象evictionPoolEntry,它在evictionPoolAlloc()初始化。evictionPoolAlloc()被initServer()调用。

这里要注意的是cached字段,它是用来避免系统调用带来的开销。所以255以内的字符串对象,可以直接使用它来缓存。但是大于255的就只能申请内存了。而在淘汰池子里的对象时,很重要的一件事是把这些大于255的key占用内存空间释放掉。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#define EVPOOL_SIZE 16
struct evictionPoolEntry {
    unsigned long long idle;    /* Object idle time (inverse frequency for LFU) */
    sds key;                    /* Key name. */
  	// cached用途就是对长度小于255的key可以复用内存空间。否则每次都创建新的sds对象影响性能
    sds cached;                 /* Cached SDS object for key name. */
    int dbid;                   /* Key DB number. */
};

static struct evictionPoolEntry *EvictionPoolLRU;

void evictionPoolAlloc(void) {
    struct evictionPoolEntry *ep;
    int j;

    ep = zmalloc(sizeof(*ep)*EVPOOL_SIZE);
    for (j = 0; j < EVPOOL_SIZE; j++) {
        ep[j].idle = 0;
        ep[j].key = NULL;
        ep[j].cached = sdsnewlen(NULL,EVPOOL_CACHED_SDS_SIZE);
        ep[j].dbid = 0;
    }
    EvictionPoolLRU = ep;
}

LRU的更新函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
unsigned int LRU_CLOCK(void) {
    unsigned int lruclock;
    if (1000/server.hz <= LRU_CLOCK_RESOLUTION) {
        atomicGet(server.lruclock,lruclock);
    } else {
        lruclock = getLRUClock();
    }
    return lruclock;
}

val->lru = LRU_CLOCK();

读取对象LRU的空闲时间

1
2
3
4
5
6
7
8
9
unsigned long long estimateObjectIdleTime(robj *o) {
    unsigned long long lruclock = LRU_CLOCK();
    if (lruclock >= o->lru) {
        return (lruclock - o->lru) * LRU_CLOCK_RESOLUTION;
    } else {
        return (lruclock + (LRU_CLOCK_MAX - o->lru)) *
                    LRU_CLOCK_RESOLUTION;
    }
}

LFU的更新函数

1
2
3
4
5
6
7
8
void updateLFU(robj *val) {
    // 先根据之前的访问时间与衰减因子,将counter衰减一次
    unsigned long counter = LFUDecrAndReturn(val);
    // 然后根据这次的访问更新counter
    counter = LFULogIncr(counter);
    // 将访问时间与最新counter存起来
    val->lru = (LFUGetTimeInMinutes()<<8) | counter;
}

读取对象LFU的空闲时间

需要注意LFU的空闲时间是以次数来计算的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
idle = 255-LFUDecrAndReturn(o)

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;
}

更新LRU/LFU字段

每次读取key时,都会更新key对应的LRU/LFU字段

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
robj *lookupKey(redisDb *db, robj *key, int flags) {
    dictEntry *de = dictFind(db->dict,key->ptr);
    if (de) {
        robj *val = dictGetVal(de);
        if (server.rdb_child_pid == -1 &&
            server.aof_child_pid == -1 &&
            !(flags & LOOKUP_NOTOUCH))
        {
            if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
                // 开启了LFU才更新LFU字段
                updateLFU(val);
            } else {
                // 否则更新LRU字段
                val->lru = LRU_CLOCK();
            }
        }
        return val;
    } else {
        return NULL;
    }
}

什么时候检查内存?

每次执行client发过来的命令时,都会做检查(server.c/processCommand())。核心方法是freeMemoryIfNeeded()这个方法,它被包在freeMemoryIfNeededAndSafe()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
int processCommand(client *c) {
  // ...
  if (server.maxmemory && !server.lua_timedout) {
    int out_of_memory = freeMemoryIfNeededAndSafe() == C_ERR;
    // ...
  }
  // ...
}

int freeMemoryIfNeededAndSafe(void) {
    if (server.lua_timedout || server.loading) return C_OK;
    return freeMemoryIfNeeded();
}