关于lrucache:LRU一文让你弄清-Redis-LRU-页面置换算法

Q:一天共事问,我放在 redis 中的 key,为什么有时候过一段时间数据就没有了,我并没有设置过期工夫呀?? A:你的 redis 淘汰策略是什么样的,这个 key 可能是被 redis 本身的淘汰策略干掉了 一看 redis 的 config 文件 redis.conf 果然,你配置的是 maxmemory_policy allkey-lfu ,这个是 Redis 中的淘汰策略,是会从 redis 数据集中筛选应用频率最低的数据进行淘汰的 Q:不明觉厉,摸摸头 A:我给你简略说一下对于 redis 的淘汰策略吧 首先,redis 删除数据的策略目前来看有三种 定时删除见名知意,定时,天然就像咱们定起床闹钟一样,此处是定一个删除 key 的闹钟,当咱们对一个 key 设置过期工夫的时候,会同时开启一个定时器,当工夫到到的时候,就会删除这个 key 这种形式,须要咱们额定开一个定时器,会耗费 CPU 资源 惰性删除惰性,一看就晓得这种删除形式是被动的,若一个 key 过期了,redis 不会被动去删除他,而是当这个 key 再次被拜访的时候,redis 看他有没有生效,若生效,则删除他 这种形式能够看出,如果一些过期的 key ,再没有被再次拜访之前,就会始终存在内存中,十分节约内存资源 被动删除顾名思义,这种形式是 redis 中会去设置各种策略,去依照不同的策略去删除一些不符合要求的数据,简略的,咱们来看看 Redis 的淘汰策略,把握主动权 Redis 的淘汰策略 能够看出 redis 的淘汰策略大体上有 5 种形式 LRU<!----> LFU<!----> RANDOM会从数据集中随机抉择数据进行删除,依照配置的策略不同 allkeys-random 则是将在所有数据集中进行随机 , volatile-random 是在曾经设置了过期工夫的数据中去随机淘汰 ...

September 24, 2023 · 2 min · jiezi

关于lrucache:缓存算法LRULFU随机替换等常见算法简介

缓存算法缓存算法是编程中的根本算法,用于解决缓存的更新和替换问题,通过正当地抉择缓存中数据的存储地位,能够进步零碎的访问速度和性能。本文介绍几个通用的缓存算法,这些算法实用于多种利用场景的缓存策略,其指标是在限定的缓存空间内,最大化缓存命中率,同时最小化缓存淘汰率。 随机替换 (Random Replacement,RR):随机抉择一个数据项淘汰。先进先出(First In First Out, FIFO):依据数据项进入缓存的工夫先后,淘汰最早进入缓存的数据项。最近起码应用(Least Recently Used, LRU):依据数据项最近被拜访的工夫,淘汰最久未被应用的数据项。起码应用(Least Frequently Used, LFU):依据数据项被拜访的频率,淘汰拜访次数起码的数据项。掂量指标掂量一个缓存算法的品质,通常看以下指标: 命中率(Hit Rate):即缓存中已缓存的数据被拜访的次数与所有拜访次数的比值,反映了缓存算法对于热点数据的缓存成果。缓存空间利用率(Cache Space Utilization):即缓存中曾经占用的空间与总空间的比值,反映了缓存算法对于缓存空间的利用效率。替换次数(Replacement Count):即缓存中数据被替换的次数,反映了缓存算法对于缓存数据的爱护能力。缓存访问速度(Cache Access Speed):即缓存中数据被拜访的速度,反映了缓存算法对于访问速度的晋升成果。不过值得注意的是,不同利用场景和需要会对缓存算法的指标有不同的要求,比方某些场景可能更重视命中率和访问速度,而另一些场景则可能更重视缓存空间利用率和替换次数。因而,在抉择缓存算法时,须要依据理论状况进行衡量和抉择。 随机替换 (RR)随机替换 (Random Replacement,RR) 算法的核心思想是随机抉择要被替换的缓存块,从而保障所有缓存块被替换的概率相等。在缓存空间无限的状况下,当须要替换缓存中的某个数据块时,RR 算法会从以后缓存中随机抉择一个数据块进行替换。 长处: 实现简略,容易了解和实现。在缓存大小较大时体现良好,可能缩小缓存替换的次数,进步缓存命中率。毛病: 算法性能不稳固,在缓存大小较小时,体现较差,因为随机替换可能导致频繁的缓存替换,升高了缓存的命中率。无奈适应不同数据拜访模式的需要,不能利用数据局部性进行缓存优化。实用场景: 随机替换算法实用于数据拜访模式比拟随机的场景,缓存大小比拟大,缓存替换代价比拟高的场景。例如,在内存比拟短缺的状况下,应用随机替换算法能够进步缓存命中率,缩小缓存替换的次数,进步零碎性能。然而,在缓存容量较小、数据拜访模式具备显著局部性的场景下,随机替换算法的体现会较差。 上面是一个Java 例子: import java.util.HashMap;import java.util.LinkedList;import java.util.Queue;import java.util.Random;public class CacheRR<K, V> { private int capacity; // 缓存容量 private HashMap<K, V> map; // 用于存储缓存数据 private Queue<K> queue; // 用于存储缓存数据的key,以便进行随机替换 public CacheRR(int capacity) { this.capacity = capacity; map = new HashMap<>(); queue = new LinkedList<>(); } /** * 从缓存中获取数据 * @param key 缓存数据的key * @return 缓存数据的value,若不存在则返回null */ public synchronized V get(K key) { return map.get(key); } /** * 往缓存中增加数据 * @param key 缓存数据的key * @param value 缓存数据的value */ public synchronized void put(K key, V value) { // 如果缓存已满,则进行随机替换 if (map.size() == capacity) { K randomKey = queue.poll(); map.remove(randomKey); } // 增加新数据 map.put(key, value); queue.offer(key); } /** * 获取缓存的大小 * @return 缓存的大小 */ public synchronized int size() { return map.size(); }}这段代码实现了一个基于随机替换(RR)算法的缓存,它应用了HashMap来存储缓存数据,并应用Queue来存储缓存数据的key。当缓存达到容量下限时,会从队列中随机抉择一个key进行替换,以保障替换的公平性。先进先出(FIFO)先进先出(First-In-First-Out, FIFO)缓存算法是一种比较简单的缓存淘汰算法,它将最早进入缓存的数据先进来,也就是先进入缓存的数据先被淘汰。 ...

April 25, 2023 · 4 min · jiezi

关于lrucache:哈希表-leetcode-416-LRU缓存机制-512

题目 解题的关键在于LRU构造体和双向链表构造体的设计以及双向链表的四个性能函数的拆分这使得题解优雅而简洁 (想起来上学期苦楚的课程) type LRUCache struct { size int capacity int cache map[int]*DLinkedNode head, tail *DLinkedNode}type DLinkedNode struct { key, value int prev, next *DLinkedNode}func initDLinkedNode(key, value int) *DLinkedNode { return &DLinkedNode{ key: key, value: value, }}func Constructor(capacity int) LRUCache { l := LRUCache{ cache: map[int]*DLinkedNode{}, head: initDLinkedNode(0, 0), tail: initDLinkedNode(0, 0), capacity: capacity, } l.head.next = l.tail l.tail.prev = l.head return l}//int get(int key) 如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1 。//对于 get 操作,首先判断 key 是否存在://如果 key 不存在,则返回 -1−1;//如果 key 存在,则 key 对应的节点是最近被应用的节点。通过哈希表定位到该节点在双向链表中的地位,并将其挪动到双向链表的头部,最初返回该节点的值。func (this *LRUCache) Get(key int) int { if _, ok := this.cache[key]; !ok { return -1 } node := this.cache[key] this.moveToHead(node) return node.value}//void put(int key, int value)如果关键字key 曾经存在,则变更其数据值value ;如果不存在,则向缓存中插入该组key-value 。如果插入操作导致关键字数量超过capacity ,则应该 逐出 最久未应用的关键字。//对于 put 操作,首先判断 key 是否存在://如果 key 不存在,应用 key 和 value 创立一个新的节点,在双向链表的头部增加该节点,并将 key 和该节点增加进哈希表中。而后判断双向链表的节点数是否超出容量,如果超出容量,则删除双向链表的尾部节点,并删除哈希表中对应的项;//如果 key 存在,则与 get 操作相似,先通过哈希表定位,再将对应的节点的值更新为 value,并将该节点移到双向链表的头部。func (this *LRUCache) Put(key int, value int) { if _, ok := this.cache[key]; !ok { node := initDLinkedNode(key, value) this.cache[key] = node this.addToHead(node) this.size++ if this.size > this.capacity { removed := this.removeTail() delete(this.cache, removed.key) this.size-- } } else { node := this.cache[key] node.value = value this.moveToHead(node) }}func (this *LRUCache) addToHead(node *DLinkedNode) { node.prev = this.head node.next = this.head.next this.head.next.prev = node this.head.next = node}func (this *LRUCache) removeNode(node *DLinkedNode) { node.prev.next = node.next node.next.prev = node.prev}func (this *LRUCache) moveToHead(node *DLinkedNode) { this.removeNode(node) this.addToHead(node)}func (this *LRUCache) removeTail() *DLinkedNode { node := this.tail.prev this.removeNode(node) return node}

May 12, 2022 · 2 min · jiezi

你与解决“缓存污染”只差这篇文章的距离

微信公众号:IT一刻钟大型现实非严肃主义现场一刻钟与你分享优质技术架构与见闻,做一个有剧情的程序员关注可第一时间了解更多精彩内容,定期有福利相送哟。什么是缓存污染?由于缓存的读取速度比非缓存要快上很多,所以在高性能场景下,系统在读取数据时,是首先从缓存中查找需要的数据,如果找到了则直接读取结果,如果找不到的话,则从内存或者硬盘中查找,再将查找到的结果存入缓存,以备下次使用。实际上,对于一个系统来说,缓存的空间是有限且宝贵的,我们不可能将所有的数据都放入缓存中进行操作,即便可以数据安全性也得不到保证,而且,如果缓存的数据量过大大,其速度也会变得越来越慢。这个时候就需要考虑缓存的淘汰机制,但是淘汰哪些数据,又保留哪些数据,这是一个问题。如果处理不得当,就会造成“缓存污染”问题。而缓存污染,是指系统将不常用的数据从内存移到缓存,造成常用数据的挤出,降低了缓存效率的现象。解决缓存污染的算法LFU算法LFU,英文名Least Frequently Used,字面意思就是最不经常使用的淘汰掉算法,是通过数据被访问的频率来判断一个数据的热点情况。其核心理念是“历史上这个数据被访问次数越多,那么将来其被访问的次数也多”。LFU中每个数据块都有一个引用计数器,所有数据块按照引用数从大到小的排序。步骤:新数据插入到尾部,并将计数设置为1;当队列中的数据被访问后,引用计数+1,然后重新排序,保持引用次数从大到小排序;当空间不足,需要淘汰数据时,将尾部引用计数最小的数据块删除。分析:由于是根据频数进行热点判断和淘汰,所以先天具备避免偶发性、周期性批量操作导致临时非热点数据大量涌入缓存,挤出热点数据的问题。虽然具备这种先天优势,但依旧存在另一种缓存污染问题,即历史热点数据污染当前热点数据,如果系统访问模式发生了改变,新的热点数据需要计数累加超过旧热点数据,才能将旧热点数据进行淘汰,造成热点效应滞后的问题。复杂度与代价:每次操作都需要进行计数和排序,并且需要维护每个数据块计数情况,会占用较高的内存与cpu。一个小思考,根据LFU算法,如何以O(1)时间复杂度实现get和put操作缓存?LFU-Aging算法LFU-Aging是基于LFU的改进算法,目的是解决历史热点数据对当前热点数据的污染问题。有些数据在开始时使用次数很多,但以后就不再使用,这类数据将会长时间留在缓存中,所以“除了访问次数外,还要考虑访问时间”,这也是LFU-Aging的核心理念。虽然算法将时间纳入了考量范围,但LFU-Aging并不是直接记录数据的访问时间,而是增加了一个最大平均引用计数的阈值,然后通过当前平均引用计数来标识时间,换句话说,就是将当前缓存中的平均引用计数值当作当前的生命年代,当这个生命年代超过了预设的阈值,就会将当前所有计数值减半,形成指数衰变的生命年代。分析:优点是当访问模式发生改变的时候,生命年代的指数衰变会使LFU-Aging能够更快的适用新的数据访问模式,淘汰旧的热点数据。复杂度与代价:在LFU的基础上又增加平均引用次数判断和统计处理,对cpu的消耗更高,并且当平均引用次数超过指定阈值(Aging)后,还需要遍历每一个数据块的引用计数,进行指数衰变。Window-LFU算法Window-LFU顾名思义叫做窗口期LFU,区别于原义LFU中记录所有数据的访问历史,Window-LFU只记录过去一段时间内(窗口期)的访问历史,相当于给缓存设置了有效期限,过期数据不再缓存。当需要淘汰时,将这个窗口期内的数据按照LFU算法进行淘汰。分析:由于是维护一段窗口期的记录,数据量会比较少,所以内存占用和cpu消耗都比LFU要低。并且这段窗口期相当于给缓存设置了有效期,能够更快的适应新的访问模式的变化,缓存污染问题基本不严重。复杂度与代价:维护一段时期内的数据访问记录,并对其排序。LRU算法LRU算法,英文名Least Recently Used,意思是最近最少使用的淘汰算法,根据数据的历史访问记录来进行淘汰数据,核心思想是“如果数据最近被访问过1次,那么将来被访问的概率会更高”,类似于就近优先原则。步骤:新数据插入到链表头部;每当命中缓存,便将命中的缓存数据移到链表头部;当链表满的时候,将链表尾部的数据丢弃。分析:偶发性的、周期性的批量操作会使临时数据涌入缓存,挤出热点数据,导致LRU热点命中率急剧下降,缓存污染情况比较严重。复杂度与代价:数据结构复杂度较低;每次需要遍历链表,找到命中的数据块,然后将数据移到头部。LRU-K算法LRU-K是基于LRU算法的优化版,其中K代表最近访问的次数,从某种意义上,LRU可以看作是LRU-1算法,引入K的意义是为了解决上面所提到的缓存污染问题。其核心理念是从“数据最近被访问过1次”蜕变成“数据最近被访问过K次,那么将来被访问的概率会更高”。LRU-K与LRU区别是,LRU-K多了一个数据访问历史记录队列(需要注意的是,访问历史记录队列并不是缓存队列,所以是不保存数据本身的,只是保存对数据的访问记录,数据此时依旧在原始存储中),队列中维护着数据被访问的次数以及时间戳,只有当这个数据被访问的次数大于等于K值时,才会从历史记录队列中删除,然后把数据加入到缓存队列中去。步骤:数据第一次被访问时,加入到历史访问记录队列中,访问次数为1,初始化访问时间戳;如果数据访问次数没有达到K次,则访问次数+1,更新时间戳。当队列满了时,按照某种规则(LRU或者FIFO)将历史记录淘汰。为了避免历史数据污染未来数据的问题,还需要加上一个有效期限,对超过有效期的访问记录,进行重新计数。(可以使用懒处理,即每次对访问记录做处理时,先将记录中的访问时间与当前时间进行对比,如果时间间隔超过预设的值,则访问次数重置为1并更新时间戳,表示重新开始计数)当数据访问计数大于等于K次后,将数据从历史访问队列中删除,更新数据时间戳,保存到缓存队列头部中(缓存队列时间戳递减排序,越到尾部距离当前时间越长);缓存队列中数据被再次访问后,将其移到头部,并更新时间戳;缓存队列需要淘汰数据时,淘汰缓存队列中排在末尾的数据,即:淘汰“倒数第K次访问离现在最久”的数据。分析:LRU-K降低了“缓存污染”带来的问题,命中率比LRU要高。实际应用中LRU-2是综合各种因素后最优的选择,LRU-3或者更大的K值命中率会高,但适应性差,一旦访问模式发生变化,需要大量的新数据访问才能将历史热点访问记录清除掉。复杂度与代价:LRU-K队列是一个优先级队列。由于LRU-K需要记录那些被访问过,但还没有放入缓存的对象,导致内存消耗会很多。URL-Two queues算法URL-Two queues算法类似于LRU-2,不同点在于URL-Two queues将LRU-2算法中的访问历史队列(注意这不是缓存数据的)改为一个FIFO缓存队列,即:URL-Two queues算法有两个缓存队列,一个是FIFO队列(First in First out,先进先出),一个是LRU队列。当数据第一次访问时,URL-Two queues算法将数据缓存在FIFO队列里面,当数据第二次被访问时,则将数据从FIFO队列移到LRU队列里面,两个队列各自按照自己的方法淘汰数据。步骤:新访问的数据先插入到FIFO队列中;如果数据在FIFO队列中一直没有被再次访问,则最终按照FIFO规则淘汰;如果数据在FIFO队列中被再次访问,则将数据从FIFO删除,加入到LRU队列头部;如果数据在LRU队列再次被访问,则将数据移到LRU队列头部;LRU队列淘汰末尾的数据。分析:URL-Two queues算法和LRU-2算法命中率类似,但是URL-Two queues会减少一次从原始存储读取或计算数据的操作。命中率要高于LRU。复杂度与代价:需要维护两个队列,代价是FIFO和LRU代价之和。五三LRU算法emmmm…这个名字其实是我取的,大概是这种算法还没有被命名?当然,这是一个玩笑话。我是在mysql底层实现里发现这个算法的,mysql在处理缓存淘汰时是用的这个方法,有点像URL-Two queues的变体,只是我们只需要维护一个队列,然后将队列按照5:3的比例进行分割,5的那部分叫做young区,3的那部分叫做old区。具体是怎么样的请先看我把图画出来:步骤:第一次访问的数据从队列的3/8处位置插入;如果数据再次被访问,则移动到队列头部;如果数据没有被再访问,会逐步被热点数据驱逐向下移;淘汰尾部数据。分析:五三LRU算法算作是URL-Two queues算法的变种,原理其实是一样的,只是把两个队列合二为一个队列进行数据的处理,所以命中率和URL-Two queues算法一样。复杂度与代价:维护一个队列,代价较低,但是内存占用率和URL-Two queues一样。Multi Queue算法Multi Queue算法根据访问频率将数据划分为多个队列,不同的队列具有不同的访问优先级,其核心思想是“优先缓存访问次数多的数据”。Multi Queue算法将缓存划分为多个LRU队列,每个队列对应不同的访问优先级。访问优先级是根据访问次数计算出来的,例如:Q0,Q1….Qn代表不同的优先级队列,Q-history代表从缓存中淘汰数据,但记录了数据的索引和引用次数。步骤:新插入的数据放入Q0;每个队列按照LRU管理数据,再次访问的数据移动到头部;当数据的访问次数达到一定次数,需要提升优先级时,将数据从当前队列删除,加入到高一级队列的头部;为了防止高优先级数据永远不被淘汰,当数据在指定的时间里访问没有被访问时,需要降低优先级,将数据从当前队列删除,加入到低一级的队列头部;需要淘汰数据时,从最低一级队列开始按照LRU淘汰;每个队列淘汰数据时,将数据从缓存中删除,将数据索引加入Q-history头部;如果数据在Q-history中被重新访问,则重新计算其优先级,移到目标队列的头部;Q-history按照LRU淘汰数据的索引。分析:Multi Queue降低了“缓存污染”带来的问题,命中率比LRU要高。复杂度与代价:Multi Queue需要维护多个队列,且需要维护每个数据的访问时间,复杂度比LRU高。Multi Queue需要记录每个数据的访问时间,需要定时扫描所有队列,代价比LRU要高。虽然Multi Queue的队列看起来数量比较多,但由于所有队列之和受限于缓存容量的大小,因此这里多个队列长度之和和一个LRU队列是一样的,因此队列扫描性能也相近。说在后面话还有哪些优秀的缓存淘汰算法,或者你有更好的想法或问题,欢迎留言给我!

April 10, 2019 · 1 min · jiezi

LruCacahe在美团DSP系统中的应用演进

背景DSP系统是互联网广告需求方平台,用于承接媒体流量,投放广告。业务特点是并发度高,平均响应低(百毫秒)。为了能够有效提高DSP系统的性能,美团平台引入了一种带有清退机制的缓存结构LruCache(Least Recently Used Cache),在目前的DSP系统中,使用LruCache + 键值存储数据库的机制将远端数据变为本地缓存数据,不仅能够降低平均获取信息的耗时,而且通过一定的清退机制,也可以维持服务内存占用在安全区间。本文将会结合实际应用场景,阐述引入LruCache的原因,并会在高QPS下的挑战与解决方案等方面做详细深入的介绍,希望能对DSP感兴趣的同学有所启发。LruCache简介LruCache采用的缓存算法为LRU(Least Recently Used),即最近最少使用算法。这一算法的核心思想是当缓存数据达到预设上限后,会优先淘汰近期最少使用的缓存对象。LruCache内部维护一个双向链表和一个映射表。链表按照使用顺序存储缓存数据,越早使用的数据越靠近链表尾部,越晚使用的数据越靠近链表头部;映射表通过Key-Value结构,提供高效的查找操作,通过键值可以判断某一数据是否缓存,如果缓存直接获取缓存数据所属的链表节点,进一步获取缓存数据。LruCache结构图如下所示,上半部分是双向链表,下半部分是映射表(不一定有序)。双向链表中value_1所处位置为链表头部,value_N所处位置为链表尾部。LruCache读操作,通过键值在映射表中查找缓存数据是否存在。如果数据存在,则将缓存数据所处节点从链表中当前位置取出,移动到链表头部;如果不存在,则返回查找失败,等待新数据写入。下图为通过LruCache查找key_2后LruCache结构的变化。LruCache没有达到预设上限情况下的写操作,直接将缓存数据加入到链表头部,同时将缓存数据键值与缓存数据所处的双链表节点作为键值对插入到映射表中。下图是LruCache预设上限大于N时,将数据M写入后的数据结构。LruCache达到预设上限情况下的写操作,首先将链表尾部的缓存数据在映射表中的键值对删除,并删除链表尾部数据,再将新的数据正常写入到缓存中。下图是LruCache预设上限为N时,将数据M写入后的数据结构。线程安全的LruCache在读写操作中,全部使用锁做临界区保护,确保缓存使用是线程安全的。LruCache在美团DSP系统的应用场景在美团DSP系统中广泛应用键值存储数据库,例如使用Redis存储广告信息,服务可以通过广告ID获取广告信息。每次请求都从远端的键值存储数据库中获取广告信息,请求耗时非常长。随着业务发展,QPS呈现巨大的增长趋势,在这种高并发的应用场景下,将广告信息从远端键值存储数据库中迁移到本地以减少查询耗时是常见解决方案。另外服务本身的内存占用要稳定在一个安全的区间内。面对持续增长的广告信息,引入LruCache + 键值存储数据库的机制来达到提高系统性能,维持内存占用安全、稳定的目标。LruCache + Redis机制的应用演进在实际应用中,LruCache + Redis机制实践分别经历了引入LruCache、LruCache增加时效清退机制、HashLruCache满足高QPS应用场景以及零拷贝机制四个阶段。各阶段的测试机器是16核16G机器。演进一:引入LruCache提高美团DSP系统性能在较低QPS环境下,直接请求Redis获取广告信息,可以满足场景需求。但是随着单机QPS的增加,直接请求Redis获取广告信息,耗时也会增加,无法满足业务场景的需求。引入LruCache,将远端存放于Redis的信息本地化存储。LruCache可以预设缓存上限,这个上限可以根据服务所在机器内存与服务本身内存占用来确定,确保增加LruCache后,服务本身内存占用在安全范围内;同时可以根据查询操作统计缓存数据在实际使用中的命中率。下图是增加LruCache结构前后,且增加LruCache后命中率高于95%的情况下,针对持续增长的QPS得出的数据获取平均耗时(ms)对比图:根据平均耗时图显示可以得出结论:QPS高于250后,直接请求Redis获取数据的平均耗时达到10ms以上,完全无法满足使用的需求。增加LruCache结构后,耗时下降一个量级。从平均耗时角度看,QPS不高于500的情况下,耗时低于2ms。下图是增加LruCache结构前后,且增加LruCache后命中率高于95%的情况下,针对持续增长的QPS得出的数据获取Top999耗时(ms)对比图:根据Top999耗时图可以得出以下结论:增加LruCache结构后,Top999耗时比平均耗时增长一个数量级。即使是较低的QPS下,使用LruCache结构的Top999耗时也是比较高的。引入LruCache结构,在实际使用中,在一定的QPS范围内,确实可以有效减少数据获取的耗时。但是QPS超出一定范围后,平均耗时和Top999耗时都很高。所以LruCache在更高的QPS下性能还需要进一步优化。演进二:LruCache增加时效清退机制在业务场景中,Redis中的广告数据有可能做修改。服务本身作为数据的使用方,无法感知到数据源的变化。当缓存的命中率较高或者部分数据在较长时间内多次命中,可能出现数据失效的情况。即数据源发生了变化,但服务无法及时更新数据。针对这一业务场景,增加了时效清退机制。时效清退机制的组成部分有三点:设置缓存数据过期时间,缓存数据单元增加时间戳以及查询中的时效性判断。缓存数据单元将数据进入LruCache的时间戳与数据一起缓存下来。缓存过期时间表示缓存单元缓存的时间上限。查询中的时效性判断表示查询时的时间戳与缓存时间戳的差值超过缓存过期时间,则强制将此数据清空,重新请求Redis获取数据做缓存。在查询中做时效性判断可以最低程度的减少时效判断对服务的中断。当LruCache预设上限较低时,定期做全量数据清理对于服务本身影响较小。但如果LruCache的预设上限非常高,则一次全量数据清理耗时可能达到秒级甚至分钟级,将严重阻断服务本身的运行。所以将时效性判断加入到查询中,只对单一的缓存单元做时效性判断,在服务性能和数据有效性之间做了折中,满足业务需求。演进三:高QPS下HashLruCache的应用LruCache引入美团DSP系统后,在一段时间内较好地支持了业务的发展。随着业务的迭代,单机QPS持续上升。在更高QPS下,LruCache的查询耗时有了明显的提高,逐渐无法适应低平响的业务场景。在这种情况下,引入了HashLruCache机制以解决这个问题。LruCache在高QPS下的耗时增加原因分析:线程安全的LruCache中有锁的存在。每次读写操作之前都有加锁操作,完成读写操作之后还有解锁操作。在低QPS下,锁竞争的耗时基本可以忽略;但是在高QPS下,大量的时间消耗在了等待锁的操作上,导致耗时增长。HashLruCache适应高QPS场景:针对大量的同步等待操作导致耗时增加的情况,解决方案就是尽量减小临界区。引入Hash机制,对全量数据做分片处理,在原有LruCache的基础上形成HashLruCache,以降低查询耗时。HashLruCache引入某种哈希算法,将缓存数据分散到N个LruCache上。最简单的哈希算法即使用取模算法,将广告信息按照其ID取模,分散到N个LruCache上。查询时也按照相同的哈希算法,先获取数据可能存在的分片,然后再去对应的分片上查询数据。这样可以增加LruCache的读写操作的并行度,减小同步等待的耗时。下图是使用16分片的HashLruCache结构前后,且命中率高于95%的情况下,针对持续增长的QPS得出的数据获取平均耗时(ms)对比图:根据平均耗时图可以得出以下结论:使用HashLruCache后,平均耗时减少将近一半,效果比较明显。对比不使用HashLruCache的平均耗时可以发现,使用HashLruCache的平均耗时对QPS的增长不敏感,没有明显增长。下图是使用16分片的HashLruCache结构前后,且命中率高于95%的情况下,针对持续增长的QPS得出的数据获取Top999耗时(ms)对比图:根据Top999耗时图可以得出以下结论:使用HashLruCache后,Top999耗时减少为未使用时的三分之一左右,效果非常明显。使用HashLruCache的Top999耗时随QPS增长明显比不使用的情况慢,相对来说对QPS的增长敏感度更低。引入HashLruCache结构后,在实际使用中,平均耗时和Top999耗时都有非常明显的下降,效果非常显著。HashLruCache分片数量确定:根据以上分析,进一步提高HashLruCache性能的一个方法是确定最合理的分片数量,增加足够的并行度,减少同步等待消耗。所以分片数量可以与CPU数量一致。由于超线程技术的使用,可以将分片数量进一步提高,增加并行性。下图是使用HashLruCache机制后,命中率高于95%,不同分片数量在不同QPS下得出的数据获取平均耗时(ms)对比图:平均耗时图显示,在较高的QPS下,平均耗时并没有随着分片数量的增加而有明显的减少,基本维持稳定的状态。下图是使用HashLruCache机制后,命中率高于95%,不同分片数量在不同QPS下得出的数据获取Top999耗时(ms)对比图:Top999耗时图显示,QPS为750时,分片数量从8增长到16再增长到24时,Top999耗时有一定的下降,并不显著;QPS为1000时,分片数量从8增长到16有明显下降,但是从16增长到24时,基本维持了稳定状态。明显与实际使用的机器CPU数量有较强的相关性。HashLruCache机制在实际使用中,可以根据机器性能并结合实际场景的QPS来调节分片数量,以达到最好的性能。演进四:零拷贝机制线程安全的LruCache内部维护一套数据。对外提供数据时,将对应的数据完整拷贝一份提供给调用方使用。如果存放结构简单的数据,拷贝操作的代价非常小,这一机制不会成为性能瓶颈。但是美团DSP系统的应用场景中,LruCache中存放的数据结构非常复杂,单次的拷贝操作代价很大,导致这一机制变成了性能瓶颈。理想的情况是LruCache对外仅仅提供数据地址,即数据指针。使用方在业务需要使用的地方通过数据指针获取数据。这样可以将复杂的数据拷贝操作变为简单的地址拷贝,大量减少拷贝操作的性能消耗,即数据的零拷贝机制。直接的零拷贝机制存在安全隐患,即由于LruCache中的时效清退机制,可能会出现某一数据已经过期被删除,但是使用方仍然通过持有失效的数据指针来获取该数据。进一步分析可以确定,以上问题的核心是存放于LruCache的数据生命周期对于使用方不透明。解决这一问题的方案是为LruCache中存放的数据添加原子变量的引用计数。使用原子变量不仅确保了引用计数的线程安全,使得各个线程读取的引用计数一致,同时保证了并发状态最小的同步性能开销。不论是LruCache中还是使用方,每次获取数据指针时,即将引用计数加1;同理,不再持有数据指针时,引用计数减1。当引用计数为0时,说明数据没有被任何使用方使用,且数据已经过期从LruCache中被删除。这时删除数据的操作是安全的。下图是使零拷贝机制后,命中率高于95%,不同QPS下得出的数据获取平均耗时(ms)对比图:平均耗时图显示,使用零拷贝机制后,平均耗时下降幅度超过60%,效果非常显著。下图是使零拷贝机制后,命中率高于95%,不同QPS下得出的数据获取Top999耗时(ms)对比图:根据Top999耗时图可以得出以下结论:使用零拷贝后,Top999耗时降幅将近50%,效果非常明显。在高QPS下,使用零拷贝机制的Top999耗时随QPS增长明显比不使用的情况慢,相对来说对QPS的增长敏感度更低。引入零拷贝机制后,通过拷贝指针替换拷贝数据,大量降低了获取复杂业务数据的耗时,同时将临界区减小到最小。线程安全的原子变量自增与自减操作,目前在多个基础库中都有实现,例如C++11就提供了内置的整型原子变量,实现线程安全的自增与自减操作。在HashLruCache中引入零拷贝机制,可以进一步有效降低平均耗时和Top999耗时,且在高QPS下对于稳定Top999耗时有非常好的效果。总结下图是一系列优化措施前后,命中率高于95%,不同QPS下得出的数据获取平均耗时(ms)对比图:平均耗时图显示,优化后的平均耗时仅为优化前的20%以内,性能提升非常明显。优化后平均耗时对于QPS的增长敏感度更低,更好的支持了高QPS的业务场景。下图是一系列优化措施前后,命中率高于95%,不同QPS下得出的数据获取Top999耗时(ms)对比图:Top999耗时图显示,优化后的Top999耗时仅为优化前的20%以内,对于长尾请求的耗时有非常明显的降低。LruCache是一个非常常见的数据结构。在美团DSP的高QPS业务场景下,发挥了重要的作用。为了符合业务需要,在原本的清退机制外,补充了时效性强制清退机制。随着业务的发展,针对更高QPS的业务场景,使用HashLruCache机制,降低缓存的查询耗时。针对不同的具体场景,在不同的QPS下,不断尝试更合理的分片数量,不断提高HashLruCache的查询性能。通过引用计数的方案,在HashLruCache中引入零拷贝机制,进一步大幅降低平均耗时和Top999耗时,更好的服务于业务场景的发展。作者简介王粲,2018年11月加入美团,任职美团高级工程师,负责美团DSP系统后端基础架构的研发工作。崔涛,2015年6月加入美团,任职资深广告技术专家,期间一手指导并从0到1搭建美团DSP投放平台,具备丰富的大规模计算引擎的开发和性能优化经验。霜霜,2015年6月加入美团,任职美团高级工程师,美团DSP系统后端基础架构与机器学习架构负责人,全面负责DSP业务广告召回和排序服务的架构设计与优化。招聘美团在线营销DSP团队诚招工程、算法、数据等各方向精英,发送简历至cuitao@meituan.com,共同支持百亿级流量的高可靠系统研发与优化。

December 24, 2018 · 1 min · jiezi