喵工智能
首发于喵工智能

LRU Cache算法以及在redis中的应用

前言

在上一篇文章介绍了什么是cache缓存(距离上一篇文章已经这么久了……检讨一下)。Cache其实在程序员平常工作中经常会遇到。当你有一些数据经常访问,可以把这些经常访问的数据缓存起来,以减少不断获取这些数据所引起的系统开销,获得更好的性能。

一般做法是把数据放到一些更高性能的存储介质,或者一些特定的数据结构中。但高性能的存储一般比较昂贵,容量有限,我们需要保证在这些存储中存的都是最热的数据,所以需要不断地把渐渐冷却的数据“踢”出去,让更热的数据进来,以保证最高性能。这些策略我们称之为替换策略。

上文有介绍到一些常用的替换策略:

覆盖一个现存的块的时候会使用替换策略,替换策略有很多种,主要有:
- LRU - Least Recently Used
LRU是很常用的替换策略,通常的实现会有一个age counter(替换index)与每个数组S相关。这个counter最大值就是S,当一个set被访问到,那么比它低的counter就被置为0,其他set自增1。
- FIFO - First-In First-Out
先进先出策略。
- LFU – Least Frequently Used
很高效的算法,但很耗资源,通常不用。
- Round-robin
有一个指针指向将要被替换的行,当行被替换,指针就会自增1,指针是环形的。
- Random
随机策略,用于全相联高速缓存。每个时序Round-robin就要更新,而不是每个替换操作。

本文讨论一下最常用的LRU替换策略。首先从leetcode的一道算法题来了解一下。

1. Leetcode LRU cache

Design and implement a data structure for Least Recently Used (LRU) cache. It should support the following operations: get and put.

get(key) - Get the value (will always be positive) of the key if the key exists in the cache, otherwise return -1.
put(key, value) - Set or insert the value if the key is not already present. When the cache reached its capacity, it should invalidate the least recently used item before inserting a new item.

Follow up:
Could you do both operations in O(1) time complexity?

Example:

LRUCache cache = new LRUCache( 2 /* capacity */ );

cache.put(1, 1);
cache.put(2, 2);
cache.get(1);       // returns 1
cache.put(3, 3);    // evicts key 2
cache.get(2);       // returns -1 (not found)
cache.put(4, 4);    // evicts key 1
cache.get(1);       // returns -1 (not found)
cache.get(3);       // returns 3
cache.get(4);       // returns 4


这道题目是LRU的简单版,理解它却对于理解LRU的使用很有帮助。

题目中要求有一个cache区,定义好大小后(例子中给的是2,只能存放2个key-value),要求put新的数据时,当超过了容量,就要把最“老”的key-value evict出去。put和get的操作都会更新缓存区中key-value的新老程度,并要求是否可以实现O(1)时间复杂度。(最近学习java,所以下面代码用java实现。)

算法实现有一下几点考虑:

  • O(1)时间复杂度,考虑使用hashmap,hashmap的set和get的时间复杂度是O(1)。
HashMap<Integer, ListNode> map;
LRUList valueList;
int LRUcapacity;
public LRUCache(int capacity) {
    map = new HashMap<>(capacity*2);
    valueList = new LRUList();//LRU数据结构在下面会讲到
    LRUcapacity = capacity;
}
  • 实现LRU key evict,可以考虑用双向链表来实现。每个node有key、value值,相应有前节点和后节点。
class ListNode{
    public ListNode(int key, int value){
        this.key = key;
        this.value = value;
    }
    ListNode pre;
    ListNode next;

    int value;
    int key;
}
  • 题目要求最近操作的数据会挪到表头,当操作后发现长度超过了capacity,就把表尾的数据evict掉。所以根据需求给LRUList增加一些操作putToStart,insertNewNode, removeEndNode
class LRUList {
    ListNode start = null;
    ListNode end = null;

    public void putToStart(ListNode node) {
        if(this.start != node){
            ListNode tempStart = this.start;

            if (this.end == node) {
                this.end = node.pre;
                node.pre.next = null;
                node.next = tempStart;
                node.pre = null;
                tempStart.pre = node;
                this.start = node;
                return;
            }

            node.pre.next = node.next;
            node.next.pre = node.pre;
            this.start = node;
            node.pre = null;
            node.next = tempStart;
            tempStart.pre = node;

        }
    }

    public void insertNewNode(ListNode node){
        if(this.start == null){
            this.start = node;
            this.end = node;

            return;
        }
        ListNode tempStart = this.start;
        tempStart.pre = node;
        node.next = tempStart;
        node.pre = null;
        this.start = node;

    }

    public void removeEndNode(int capacity){
        if(capacity == 1){
            this.end = null;
            this.start = null;
            return;
        }
        
        ListNode endPre = this.end.pre;
        endPre.next = null;
        this.end.pre = null;
        
        this.end = endPre;

    }
}
  • 调用操作实现put,get操作
public int get(int key) {
        if(map.containsKey(key)){
            ListNode node = map.get(key);
            valueList.putToStart(node);
            return node.value;
        }
        else
            return -1;

    }

    public void put(int key, int value) {
        if(map.containsKey(key)){
            ListNode node = map.get(key);
            node.value = value;
            valueList.putToStart(node);
        }
        else{
            if(map.size() == this.LRUcapacity){
                map.remove(valueList.end.key);
                valueList.removeEndNode(this.LRUcapacity);
            }

            ListNode newNode = new ListNode(key, value);
            map.put(key, newNode);
            valueList.insertNewNode(newNode);

        }
    }

另一个更简便的思路就是采用Java的LinkedHashMap来实现,可以定义长度,Java数据结构基本都帮你实现了,实现起来更没有难度了。

2. Redis中LRU实现

Redis是一个内存型数据库。Redis的基本设计思想把所有的数据都存在内存中,以获得高性能,广泛应用在公有云,数据库和公司业务cache中,比如新浪的热搜~

当使用redis做cache使用的时候,内存很昂贵,容量又不多,很多厂商使用redis都会使用最大内存使用量限制,这时候LRU替换策略就很有用了。

Redis提供了maxmemory参数字段来设置使用的最大内存使用率,如果使用量超过了maxmemory,就需要通过设置的策略来剔除一些数据。参考redis.conf文件中的说明:

Set a memory usage limit to the specified amount of bytes. When the memory limit is reached Redis will try to remove keys according to the eviction policy selected (see maxmemory-policy).

1. redis LRU主要相关的3个参数有:

  • maxmemory:这个参数是设置redis-server的最大内存使用量。当设置了maxmemory,就会根据下面的memory-policy替换策略来remove key-value来腾空间。
  • maxmemory-policy: redis的替换策略,从配置文件的说明可以看到有以下几种替换策略。
volatile-lru -> Evict using approximated LRU among the keys with an expire set.
allkeys-lru -> Evict any key using approximated LRU.
volatile-lfu -> Evict using approximated LFU among the keys with an expire set.
allkeys-lfu -> Evict any key using approximated LFU.
volatile-random -> Remove a random key among the ones with an expire set.
allkeys-random -> Remove a random key, any key.
volatile-ttl -> Remove the key with the nearest expire time (minor TTL)
noeviction -> Don't evict anything, just return an error on write operations.
  • maxmemory-samples: redis的LRU, LFU和 inimal TTL算法都不是精确的LRU算法,而是近似算法,为了节省内存。所以redis默认会随机选择5个key,然后从中选择使用最少用的key来移除。 设置5个是比较合适的,10个接近真LRU但是非常消耗CPU,3个很快但不是非常精确。

2. 实现逻辑

redis中有很多数据类型(以后会出一个redis系列),为了实现key-value新老判断,不能像上面算法题中简单的链表就能实现的。redis的逻辑实现如下:

  1. Redis采用了一个全局时钟在redisServer这个struct中的lruclock,这个时钟供每个object更新自己object的时间。其中存储了服务器自启动之后的lru时钟,该时钟是全局的lru时钟。
struct redisServer {
    /* General */
    pid_t pid;                  /* Main process pid. */
    char *configfile;           /* Absolute config file path, or NULL */
    char *executable;           /* Absolute executable file path. */
    char **exec_argv;           /* Executable argv vector (copy). */
    int hz;                     /* serverCron() calls frequency in hertz */
    redisDb *db;
    dict *commands;             /* Command table */
    dict *orig_commands;        /* Command table before command renaming. */
    aeEventLoop *el;
    unsigned int lruclock;      /* Clock for LRU eviction */
...

}

2. redis在启动的时候初始化ServerConfig的时候,会初始化lruclock, 每100ms在serverCron中更新LRUclock。

/* ----------------------------------------------------------------------------
 * Implementation of eviction, aging and LRU
 * --------------------------------------------------------------------------*/

/* Return the LRU clock, based on the clock resolution. This is a time
 * in a reduced-bits format that can be used to set and check the
 * object->lru field of redisObject structures. */
unsigned int getLRUClock(void) {
    return (mstime()/LRU_CLOCK_RESOLUTION) & LRU_CLOCK_MAX;
}

初始化server配置:

void initServerConfig(void) {
...
unsigned int lruclock = getLRUClock();
atomicSet(server.lruclock,lruclock);
...}

在serverCron中每100ms更新LRUClock:

int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
...
unsigned int lruclock = getLRUClock();
atomicSet(server.lruclock,lruclock);
...}

3. 每个object在初始化的时候,会从LRU_CLOCK函数中得到现在LRU时钟。

robj *createObject(int type, void *ptr) {
    robj *o = zmalloc(sizeof(*o));
    o->type = type;
    o->encoding = OBJ_ENCODING_RAW;
    o->ptr = ptr;
    o->refcount = 1;

    /* Set the LRU to the current lruclock (minutes resolution), or
     * alternatively the LFU counter. */
    if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
        o->lru = (LFUGetTimeInMinutes()<<8) | LFU_INIT_VAL;
    } else {
        o->lru = LRU_CLOCK();
    }
    return o;
}

然后在数据库每操作一个object都会更新这个robj的lruclock:

robj *lookupKey(redisDb *db, robj *key, int flags) {
    dictEntry *de = dictFind(db->dict,key->ptr);
    if (de) {
        robj *val = dictGetVal(de);

        /* Update the access time for the ageing algorithm.
         * Don't do it if we have a saving child, as this will trigger
         * a copy on write madness. */
        if (server.rdb_child_pid == -1 &&
            server.aof_child_pid == -1 &&
            !(flags & LOOKUP_NOTOUCH))
        {
            if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
                unsigned long ldt = val->lru >> 8;
                unsigned long counter = LFULogIncr(val->lru & 255);
                val->lru = (ldt << 8) | counter;
            } else {
                val->lru = LRU_CLOCK();
            }
        }
        return val;
    } else {
        return NULL;
    }
}

LRU_CLOCK时钟实现如下,如果当前的精度小于系统中刷新LRU时钟的频率,那么就重新计算LRU时钟,否则就返回系统更新的LRU。

/* This function is used to obtain the current LRU clock.
 * If the current resolution is lower than the frequency we refresh the
 * LRU clock (as it should be in production servers) we return the
 * precomputed value, otherwise we need to resort to a system call. */
unsigned int LRU_CLOCK(void) {
    unsigned int lruclock;
    if (1000/server.hz <= LRU_CLOCK_RESOLUTION) {
        atomicGet(server.lruclock,lruclock);
    } else {
        lruclock = getLRUClock();
    }
    return lruclock;
}

这样逻辑实现后,每个robj中都带有lru时钟信息。

4. 如果配置设置了maxmemory,每一个读写命令过来,都要通过freeMemoryIfNeeded判断是否需要剔除,因此processCommand中有如下实现:

if (server.maxmemory) {
        int retval = freeMemoryIfNeeded();
        /* freeMemoryIfNeeded may flush slave output buffers. This may result
         * into a slave, that may be the active client, to be freed. */
        if (server.current_client == NULL) return C_ERR;

        /* It was impossible to free enough memory, and the command the client
         * is trying to execute is denied during OOM conditions? Error. */
        if ((c->cmd->flags & CMD_DENYOOM) && retval == C_ERR) {
            flagTransaction(c);
            addReply(c, shared.oomerr);
            return C_OK;
        }
    }

5. freeMemoryIfNeed中, 判断如果容量还足够就直接退出。如果需要开始剔除数据,就会根据不同的替换策略来剔除数据。这里我们只讨论LRU-allkeys的实现,实现如下。

  • redis会初始化一个evictPool,默认EVPOOLS_SIZE为16,可以存放16个ecitonPoolEntry。
  • evictPoolEntry的struct定义如下:
struct evictionPoolEntry {
unsigned long long idle;    /* Object idle time (inverse frequency for LFU) */ 
sds key;                    /* Key name. */
sds cached;                 /* Cached SDS object for key name. */   
int dbid;                   /* Key DB number. */
};
  • redis会从每个DB中随机挑选出5个(maxmemory-samples设置的值),然后计算它的idle时间,放入evictionPool中去,按照idle时间从小到大排序。
  • 在freeMemoryIfNeed函数中,选择从pool的最后一个元素开始往前遍历,找到第一个元素(idle时间最长的那个元素),就作为需要剔除的bestkey,然后把内容free掉。至此,完整LRU的逻辑实现就完成了。
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);
    }

    /* Remove the entry from the pool. */
    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. */
    }
}

总结

LRU在高性能计算和存储中出现频率很频繁,学会LRU算法的基本思想就可以在以后的应用中融汇贯通。

而Redis最为一款优秀的内存数据库,用途非常广泛,其缓存代码设计和实现很值得学习,实现步骤主要有:

  1. 用一个全局时钟作为参照
  2. 对每个object初始化和操作的时候都更新它各自的lru时钟
  3. 随机挑选几个key,根据lru时钟计算idle的时间排序放入EvictionPool中,最终挑选idle时间最长的free,以释放空间。至于为什么随机和只选择5个,是为了性能考虑,如果做到全局一个一个排序就非常消耗CPU,而实际应用中没必要这么精确。

性能和精确本身就是一个trade off的博弈游戏呀~以上

编辑于 2018-07-26

文章被以下专栏收录