Redis 原理分析丨Redis 作为 LRU 缓存的实现原理

zxszcaijin 2016-10-21 10:23 移动互联网 来源:创头条 查看原文

在使用redis作为缓存的场景下,内存淘汰策略决定的redis的内存使用效率。在大部分场景下,我们会采用LRU(Least Recently Used)来作为redis的淘汰策略。本文将由浅入深的介绍redis lru策略的具体实现。

首先我们来科普下,什么是LRU ?(以下来自维基百科)

Discards the least recently used items first. This algorithm requires keeping track of what was used when, which is expensive if one wants to make sure the algorithm always discards the least recently used item. General implementations of this technique require keeping "age bits" for cache-lines and track the "Least Recently Used"  cache-line based on age-bits. In such an implementation, every time a cache-line is used, the age of all other cache-lines changes.

简而言之,就是每次淘汰最近最少使用的元素 。一般的实现,都是采用对存储在内存的元素采用 'age bits’ 来标记该元素从上次访问到现在为止的时长,从而在每次用LRU淘汰时,淘汰这些最长时间未被访问的元素。

这里我们先实现一个简单的LRU Cache,以便于后续内容的理解 。(来自leetcot,不过这里我重新用Python语言实现了)实现该缓存满足如下两点:

1.get(key) - 如果该元素(总是正数)存在,将该元素移动到lru头部,并返回该元素的值,否则返回-1。

2.set(key,value) - 设置一个key的值为value(如果该元素存在),并将该元素移动到LRU头部。否则插入一个key,且值为value。如果在设置前检查到,该key插入后,会超过cache的容量,则根据LRU策略,删除最近最少使用的key。

分析
这里我们采用双向链表来实现元素(k-v键值对)的存储,同时采用hash表来存储相关的key与item的对应关系。这样,我们既能在O(1)的时间对key进行操作,同时又能利用Double LinkedList的添加和删除节点的便利性。(get/set都能在O(1)内完成)。

具体实现(Python语言)

        class Node:

  1.       key=None

  2.       value=None

  3.       pre=None

  4.       next=None


  5. def __init__(self,key,value):

  6.       self.key=key

  7.       self.value=value


  8. class LRUCache:

  9.       capacity=0

  10.       map={} # key is string ,and value is Node object

  11.       head=None

  12.       end=None


  13. def __init__(self,capacity):

  14.       self.capacity=capacity


  15. def get(self,key):

  16.       if key in self.map:

  17.            node=self.map[key]

  18.            self.remove(node)

  19.            self.setHead(node)

  20.            return node.value

  21.       else:

  22.           return -1


  23. def getAllKeys(self):

  24.        tmpNode=None

  25.        if self.head:

  26.           tmpNode=self.head

  27.           while tmpNode:

  28.           print (tmpNode.key,tmpNode.value)

  29.           tmpNode=tmpNode.next


  30. def remove(self,n):

  31.       if n.pre:

  32.          n.pre.next=n.next

  33.       else:

  34.          self.head=n.next


  35.       if n.next:

  36.          n.next.pre=n.pre

  37.       else:

  38.          self.end=n.pre


  39. def setHead(self,n):

  40.       n.next=self.head

  41.       n.pre=None


  42.       if self.head:

  43.          self.head.pre=n


  44.       self.head=n


  45.       if not self.end:

  46.          self.end=self.head


  47. def set(self,key,value):

  48.        if key in self.map:

  49.           oldNode=self.map[key]

  50.           oldNode.value=value

  51.           self.remove(oldNode)

  52.           self.setHead(oldNode)

  53.        else:

  54.           node=Node(key,value)

  55.           if len(self.map) >= self.capacity:

  56.              self.map.pop(self.end.key)

  57.              self.remove(self.end)

  58.              self.setHead(node)

  59.           else:

  60.              self.setHead(node)


  61.           self.map[key]=node



  62. def main():

  63.        cache=LRUCache(100)


  64.        #d->c->b->a

  65.        cache.set('a','1')

  66.        cache.set('b','2')

  67.        cache.set('c',3)

  68.        cache.set('d',4)


  69.        #遍历lru链表

  70.        cache.getAllKeys()


  71.        #修改('a','1') ==> ('a',5),使该节点从LRU尾端移动到开头.

  72.        cache.set('a',5)

  73.        #LRU链表变为 a->d->c->b


  74.        cache.getAllKeys()

  75.        #访问key='c'的节点,是该节点从移动到LRU头部

  76.        cache.get('c')

  77.        #LRU链表变为 c->a->d->b

  78.        cache.getAllKeys()


  79.        if __name__ == '__main__': 

  80.           main()

通过上面简单的介绍与实现,现在我们基本已经了解了什么是LRU,下面我们来看看LRU算法在redis 内部的实现细节,以及其会在什么情况下带来问题。在redis内部,是通过全局结构体redisServer 保存redis启动之后相关的信息。比如:

  1.              struct redisServer {

  2.        pid_t pid; /* Main process pid. */

  3.        char *configfile; /* Absolute config file path, or NULL */

  4.        …..

  5.        unsigned lruclock:LRU_BITS; /* Clock for LRU eviction */

  6.        ...

  7.        };

serverCron运行频率hz等lruclock:LRU_BITS,其中存储了服务器自启动之后的lru时钟,该时钟是全局的lru时钟。该时钟100ms(可以通过hz来调整,默认情况hz=10,因此每1000ms/10=100ms执行一次定时任务)更新一次。

接下来我们看看LRU时钟的具体实现:

  1. server.lruclock = getLRUClock();

  2. getLRUClock函数如下:

  3. #define LRU_CLOCK_RESOLUTION 1000 /* LRU clock resolution in ms */

  4. #define LRU_BITS 24

  5. #define LRU_CLOCK_MAX ((1<<LRU_BITS)-1) /* Max value of obj->lru */

  6. /* Return the LRU clock, based on the clock resolution. This is a time

  7.  * in a reduced-bits format that can be used to set and check the

  8.  * object->lru field of redisObject structures. */


  9.   unsigned int getLRUClock(void) {

  10.         return (mstime()/LRU_CLOCK_RESOLUTION) & LRU_CLOCK_MAX;

  11.   }

因此lrulock最大能到(2**24-1)/3600/24 = 194天,如果超过了这个时间,lrulock重新开始。

对于redis server来说,server.lrulock表示的是一个全局的lrulock,那么对于每个redisObject都有一个自己的lrulock。这样每redisObject就可以根据自己的lrulock和全局的server.lrulock比较,来确定是否能够被淘汰掉。
redis key对应的value的存放对象:

  1. typedef struct redisObject {

  2.      unsigned type:4;

  3.      unsigned encoding:4;

  4.      unsigned lru:LRU_BITS; /* LRU time (relative to server.lruclock) or

  5.             * LFU data (least significant 8 bits frequency

  6.             * and most significant 16 bits decreas time). */

  7.      int refcount;

  8.      void *ptr;

  9.      } robj

那么什么时候,lru会被更新呢 ?访问该key,lru都会被更新,这样该key就能及时的被移动到lru头部,从而避免从lru中淘汰。下面是这一部分的实现:

/* Low level key lookup API, not actually called directly from commands

  1. * implementations that should instead rely on lookupKeyRead(),

  2. * lookupKeyWrite() and lookupKeyReadWithFlags(). */

  3.   robj *lookupKey(redisDb *db, robj *key, int flags) {

  4.   dictEntry *de = dictFind(db->dict,key->ptr);

  5.   if (de) {

  6.      robj *val = dictGetVal(de);


  7. /* Update the access time for the ageing algorithm.

  8. * Don'do it if we have a saving child, as this will trigger

  9. * a copy on write madness. */

  10. if (server.rdb_child_pid == -&&

  11.     server.aof_child_pid == -&&

  12.     !(flags & LOOKUP_NOTOUCH))

  13.    {

  14.    if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {

  15.    unsigned long ldt = val->lru >> 8;

  16.    unsigned long counter = LFULogIncr(val->lru & 255);

  17.    val->lru = (ldt << 8) | counter;

  18.    } else {

  19.    val->lru = LRU_CLOCK();

  20.   }

  21.   }

  22.    return val;

  23.   } else {

  24.   return NULL;

  25.  }

  26.  }

接下来,我们在来分析,key的lru淘汰策略如何实现,分别有哪几种:

  1. # MAXMEMORY POLICY: how Redis will select what to remove when maxmemory

  2. # is reached. You can select among five behaviors:

  3. #

  4. # volatile-lru -> Evict using approximated LRU among the keys with an expire set. //在设置了过期时间的key中,使用近似的lru淘汰策略

  5. # allkeys-lru -> Evict any key using approximated LRU. //所有的key均使用近似的lru淘汰策略

  6. # volatile-lfu -> Evict using approximated LFU among the keys with an expire set. //在设置了过期时间的key中,使用lfu淘汰策略

  7. # allkeys-lfu -> Evict any key using approximated LFU. //所有的key均使用lfu淘汰策略

  8. # volatile-random -> Remove a random key among the ones with an expire set. //在设置了过期时间的key中,使用随机淘汰策略

  9. # allkeys-random -> Remove a random key, any key. //所有的key均使用随机淘汰策略 

  10. # volatile-ttl -> Remove the key with the nearest expire time (minor TTL) //使用ttl淘汰策略

  11. # noeviction -> Don't evict anything, just return an error on write operations . //不允许淘汰,在写操作发生,但内存不够时,将会返回错误

  12. #

  13. # LRU means Least Recently Used

  14. # LFU means Least Frequently Used

  15. #

  16. # Both LRU, LFU and volatile-ttl are implemented using approximated

  17. # randomized algorithms.

这里暂不讨论LFU,TTL淘汰算法和noeviction的情况,仅仅讨论lru所有场景下的,淘汰策略具体实现。(LFU和TTL将在下一篇文章中详细分析)。

 LRU淘汰的场景:

     1.主动淘汰。

        1.1 通过定时任务serverCron定期的清理过期的key。

     2.被动淘汰

        2.1 每次写入key时,发现内存不够,调用activeExpireCycle释放一部分内存。

        2.2 每次访问相关的key,如果发现key过期,直接释放掉该key相关的内存。

首先我们来分析LRU主动淘汰的场景:

 serverCron每间隔1000/hz ms会调用databasesCron方法来检测并淘汰过期的key。

  1. void databasesCron(void){

  2.  /* Expire keys by random sampling. Not required for slaves

  3.  * as master will synthesize DELs for us. */

  4.  if (server.active_expire_enabled && server.masterhost == NULL)

  5.  activeExpireCycle(ACTIVE_EXPIRE_CYCLE_SLOW);

  6.     ..

  7. }

主动淘汰是通过activeExpireCycle 来实现的,这部分的逻辑如下:

遍历至多16个DB 。【由宏CRON_DBS_PER_CALL定义,默认为16】

随机挑选20个带过期时间的key。【由宏ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP定义,默认20】

如果key过期,则将key相关的内存释放,或者放入失效队列。

如果操作时间超过允许的限定时间,至多25ms。(timelimit = 1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/server.hz/100,,ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC=25,server.hz默认为10), 则此次淘汰操作结束返回,否则进入5。

如果该DB下,有超过5个key (ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4=5) 实际失效,则进入 2,否则选择下一个DB,再次进入2。

遍历完成,结束。


流程图如下
注:(图中大于等于%5的可以是实际过期的,应改为大于等于%25的key是实际过期的。iteration++是在遍历20个key的时候,每次加1)。

被动淘汰 - 内存不够,调用activeExpireCycle释放

该步骤的实现方式如下:

  1. processCommand 函数关于内存淘汰策略的逻辑:

  2. /* Handle the maxmemory directive.

  3. *

  4. * First we try to free some memory if possible (if there are volatile

  5. * keys in the dataset). If there are not the only thing we can do

  6. * is returning an error. */

  7. if (server.maxmemory) {

  8. int retval = freeMemoryIfNeeded();

  9. /* freeMemoryIfNeeded may flush slave output buffers. This may result

  10. * into a slave, that may be the active client, to be freed. */

  11. if (server.current_client == NULL) return C_ERR;


  12. /* It was impossible to free enough memory, and the command the client

  13. * is trying to execute is denied during OOM conditions? Error. */

  14. if ((c->cmd->flags & CMD_DENYOOM) && retval == C_ERR) {

  15. flagTransaction(c);

  16. addReply(c, shared.oomerr);

  17. return C_OK;

  18. }

  19. }

每次执行命令前,都会调用freeMemoryIfNeeded来检查内存的情况,并释放相应的内存,如果释放后,内存仍然不够,直接向请求的客户端返回OOM。

具体的步骤如下:

  1. 获取redis server当前已经使用的内存mem_reported。

  2. 如果mem_reported < server.maxmemory ,则返回ok。否则mem_used=mem_reported,进入步骤3。

  3. 遍历该redis的所slaves,mem_used减去所有slave占用的ClientOutputBuffer。

  4. 如果配置了AOF,mem_used减去AOF占用的空间。sdslen(server.aof_buf)+aofRewriteBufferSize()。

  5. 如果mem_used < server.maxmemory,返回ok。否则进入步骤6。

  6. 如果内存策略配置为noeviction,返回错误。否则进入7。 

  7. 如果是LRU策略,如果是VOLATILE的LRU,则每次从可失效的数据集中,每次随机采样maxmemory_samples(默认为5)个key,从中选取idletime最大的key进行淘汰。否则,如果是ALLKEYS_LRU则从全局数据中进行采样,每次随机采样maxmemory_samples(默认为5)个key,并从中选择idletime最大的key进行淘汰。

  8. 如果释放内存之后,还是超过了server.maxmemory,则继续淘汰,只到释放后剩下的内存小于server.maxmemory为止。

被动淘汰 - 每次访问相关的key,如果发现key过期,直接释放掉该key相关的内存:

每次访问key,都会调用expireIfNeeded来判断key是否过期,如果过期,则释放掉,并返回null,否则返回key的值。

总结

 1.redis做为缓存,经常采用LRU的策略来淘汰数据,所以如果同时过期的数据太多,就会导致redis发起主动检测时耗费的时间过长(最大为250ms),从而导致最大应用超时 >= 250ms。

  1. timelimit = 1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/server.hz/100 

  2. ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC=25

  3. server.hz>=1(默认为10)

  4. timelimit <= 250ms 

 2.内存使用率过高,则会导致内存不够,从而发起被动淘汰策略,从而使应用访问超时。

 3.合理的调整hz参数,从而控制每次主动淘汰的频率,从而有效的缓解过期的key数量太多带来的上述超时问题。

redis Iru实现策略戳我可以阅读原文哦!

另外小拍也为大家打包了三份相关的知识大礼,快来打包带走!

大礼包领取处↓ლ(╹◡╹ლ)





  • 为您推荐: