MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

缓存淘汰策略LRU与LFU的实现与对比

2022-10-245.6k 阅读

缓存淘汰策略概述

在后端开发中,缓存是提升系统性能和响应速度的重要组件。然而,由于缓存的容量有限,当缓存空间被占满时,就需要一种策略来决定淘汰哪些数据,以便为新的数据腾出空间。这就是缓存淘汰策略的作用。常见的缓存淘汰策略有多种,其中LRU(Least Recently Used,最近最少使用)和LFU(Least Frequently Used,最不经常使用)是两种广泛应用且具有代表性的策略。

缓存淘汰策略的重要性

  1. 优化资源利用:缓存资源是宝贵的,合理的淘汰策略能够确保缓存中始终保留最有价值的数据,从而在有限的空间内提供最大的缓存命中率,提高系统的整体性能。例如,在一个新闻网站的缓存系统中,如果不采用合适的淘汰策略,可能会导致旧的、很少被访问的新闻文章一直占据缓存空间,而新的热门文章无法进入缓存,使得用户在访问新文章时需要从较慢的数据源获取数据,降低了用户体验。
  2. 适应工作负载变化:不同的应用场景和时间段,系统的工作负载会有所不同。一个好的缓存淘汰策略应该能够适应这些变化,动态地调整缓存中的数据。比如,在电商网站的促销活动期间,热门商品的访问量会大幅增加,缓存淘汰策略需要能够及时淘汰那些在促销期间较少被访问的商品信息,为热门商品腾出空间,以应对高并发的访问需求。

LRU(最近最少使用)策略

LRU策略的原理

LRU策略基于这样一个假设:如果一个数据在最近一段时间内没有被访问,那么在未来它被访问的概率也相对较低。所以当缓存空间不足时,LRU会优先淘汰最近最少使用的数据。

LRU策略的实现方式

  1. 基于链表和哈希表的实现
    • 数据结构设计
      • 双向链表:用于记录数据的访问顺序。链表的节点包含数据本身、前驱节点指针和后继节点指针。链表的头部表示最近被访问的数据,尾部表示最近最少被访问的数据。当一个数据被访问时,将其移动到链表头部;当需要淘汰数据时,从链表尾部移除节点。
      • 哈希表:用于快速定位数据在链表中的位置。哈希表的键是数据的标识(如缓存键),值是链表节点的指针。这样可以在O(1)的时间复杂度内找到数据对应的链表节点,以便进行移动操作。
    • 操作逻辑
      • 读取操作:当读取一个数据时,首先通过哈希表查找该数据对应的链表节点。如果找到,将该节点移动到链表头部,表示它是最近被访问的。如果未找到,则表示该数据不在缓存中,需要从数据源获取,获取后将其添加到链表头部,并在哈希表中插入相应的键值对。
      • 写入操作:当写入一个新数据时,同样先检查缓存中是否已存在该数据。如果存在,更新数据并将其对应的链表节点移动到链表头部;如果不存在,检查缓存是否已满。若缓存已满,从链表尾部移除最近最少使用的节点(同时从哈希表中删除对应的键值对),然后将新数据插入到链表头部,并在哈希表中插入新的键值对。

下面是用Python实现基于链表和哈希表的LRU缓存的代码示例:

class DLinkedNode:
    def __init__(self, key=0, value=0):
        self.key = key
        self.value = value
        self.prev = None
        self.next = None


class LRUCache:
    def __init__(self, capacity):
        self.cache = {}
        self.head = DLinkedNode()
        self.tail = DLinkedNode()
        self.head.next = self.tail
        self.tail.prev = self.head
        self.capacity = capacity
        self.size = 0

    def get(self, key):
        if key not in self.cache:
            return -1
        node = self.cache[key]
        self.moveToHead(node)
        return node.value

    def put(self, key, value):
        if key not in self.cache:
            node = DLinkedNode(key, value)
            self.cache[key] = node
            self.addToHead(node)
            self.size += 1
            if self.size > self.capacity:
                removed = self.removeTail()
                self.cache.pop(removed.key)
                self.size -= 1
        else:
            node = self.cache[key]
            node.value = value
            self.moveToHead(node)

    def addToHead(self, node):
        node.prev = self.head
        node.next = self.head.next
        self.head.next.prev = node
        self.head.next = node

    def removeNode(self, node):
        node.prev.next = node.next
        node.next.prev = node.prev

    def moveToHead(self, node):
        self.removeNode(node)
        self.addToHead(node)

    def removeTail(self):
        node = self.tail.prev
        self.removeNode(node)
        return node
  1. 使用标准库的实现(以Java为例):Java的LinkedHashMap类提供了一种方便的方式来实现LRU缓存。LinkedHashMap维护插入顺序或访问顺序,通过重写removeEldestEntry方法可以实现LRU淘汰策略。
import java.util.LinkedHashMap;
import java.util.Map;

public class LRUCacheJava<K, V> extends LinkedHashMap<K, V> {
    private final int capacity;

    public LRUCacheJava(int capacity) {
        super(capacity, 0.75f, true);
        this.capacity = capacity;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > capacity;
    }
}

LRU策略的优缺点

  1. 优点
    • 实现相对简单:基于链表和哈希表的实现逻辑清晰,代码实现难度相对较低。例如,上述Python和Java的代码示例都能够较为简洁地实现LRU缓存。
    • 较好的性能:在许多实际应用场景中,LRU能够有效地淘汰不常用的数据,保持缓存的高效性。因为它符合大多数情况下数据访问的局部性原理,即最近被访问的数据在未来一段时间内很可能再次被访问。
  2. 缺点
    • 对突发访问不适应:如果出现对某一数据的突发大量访问,即使该数据在之前很长时间内未被访问,LRU也会将其保留在缓存中,而可能淘汰掉一些在长期内更有价值的数据。例如,在一个监控系统中,偶尔会对某些历史数据进行大量查询,但这些数据平时很少被访问。按照LRU策略,这些历史数据在查询后会被频繁置于缓存头部,可能导致其他更常用的实时监控数据被淘汰。
    • 无法区分访问频率:LRU只关注数据的最近访问时间,而不关心数据的访问频率。两个数据可能最近一次访问时间相同,但一个数据可能是经常被访问的,另一个则是偶然被访问的,LRU无法区分这种差异。

LFU(最不经常使用)策略

LFU策略的原理

LFU策略基于数据的访问频率来决定淘汰哪些数据。它认为在过去一段时间内访问频率最低的数据,在未来被访问的可能性也最小。所以当缓存空间不足时,LFU会优先淘汰访问频率最低的数据。

LFU策略的实现方式

  1. 基本数据结构设计
    • 哈希表:用于存储键值对以及对应的访问频率信息。哈希表的键是缓存键,值是一个包含数据值、访问频率和链表节点指针的对象。
    • 链表:为每个访问频率维护一个双向链表。链表节点包含数据的键值对以及指向前驱和后继节点的指针。链表按访问频率从小到大排列,每个链表头部表示该频率下最近被访问的数据,尾部表示该频率下最近最少被访问的数据。
  2. 操作逻辑
    • 读取操作:当读取一个数据时,首先通过哈希表查找该数据。如果找到,增加其访问频率,将其从当前频率的链表中移除,并插入到新频率对应的链表头部。如果未找到,则从数据源获取数据,将其插入到频率为1的链表头部,并在哈希表中插入相应的键值对。
    • 写入操作:当写入一个新数据时,同样先检查缓存中是否已存在该数据。如果存在,更新数据并增加其访问频率,将其从当前频率的链表中移除并插入到新频率对应的链表头部;如果不存在,检查缓存是否已满。若缓存已满,从访问频率最低的链表尾部移除节点(同时从哈希表中删除对应的键值对),然后将新数据插入到频率为1的链表头部,并在哈希表中插入新的键值对。

以下是用Python实现LFU缓存的代码示例:

class Node:
    def __init__(self, key, value, freq=1):
        self.key = key
        self.value = value
        self.freq = freq
        self.prev = None
        self.next = None


class DoubleLinkedList:
    def __init__(self):
        self.head = Node(0, 0)
        self.tail = Node(0, 0)
        self.head.next = self.tail
        self.tail.prev = self.head
        self.size = 0

    def addToHead(self, node):
        node.next = self.head.next
        node.prev = self.head
        self.head.next.prev = node
        self.head.next = node
        self.size += 1

    def removeNode(self, node):
        node.prev.next = node.next
        node.next.prev = node.prev
        self.size -= 1

    def removeTail(self):
        if self.size == 0:
            return None
        node = self.tail.prev
        self.removeNode(node)
        return node

    def isEmpty(self):
        return self.size == 0


class LFUCache:
    def __init__(self, capacity):
        self.capacity = capacity
        self.cache = {}
        self.freqMap = {}
        self.minFreq = 0

    def get(self, key):
        if key not in self.cache:
            return -1
        node = self.cache[key]
        self.increaseFreq(node)
        return node.value

    def put(self, key, value):
        if self.capacity == 0:
            return
        if key in self.cache:
            node = self.cache[key]
            node.value = value
            self.increaseFreq(node)
        else:
            if len(self.cache) == self.capacity:
                self.removeMinFreqNode()
            newNode = Node(key, value)
            self.cache[key] = newNode
            if 1 not in self.freqMap:
                self.freqMap[1] = DoubleLinkedList()
            self.freqMap[1].addToHead(newNode)
            self.minFreq = 1

    def increaseFreq(self, node):
        freq = node.freq
        self.freqMap[freq].removeNode(node)
        if self.freqMap[freq].isEmpty():
            del self.freqMap[freq]
            if self.minFreq == freq:
                self.minFreq += 1
        node.freq += 1
        if node.freq not in self.freqMap:
            self.freqMap[node.freq] = DoubleLinkedList()
        self.freqMap[node.freq].addToHead(node)

    def removeMinFreqNode(self):
        if self.minFreq not in self.freqMap:
            return
        node = self.freqMap[self.minFreq].removeTail()
        if self.freqMap[self.minFreq].isEmpty():
            del self.freqMap[self.minFreq]
        del self.cache[node.key]

LFU策略的优缺点

  1. 优点
    • 适应访问频率变化:LFU能够更好地适应数据访问频率的变化,优先淘汰真正不常用的数据。例如,在一个音乐播放应用中,某些歌曲可能偶尔会被播放一次,但大部分时间很少被播放,而一些热门歌曲会被频繁播放。LFU可以根据歌曲的播放频率来管理缓存,确保热门歌曲始终在缓存中,提高缓存命中率。
    • 对突发访问的稳定性:相比LRU,LFU对突发访问更具稳定性。即使出现对某一数据的突发大量访问,只要其长期访问频率不高,在缓存空间紧张时仍可能被淘汰,不会像LRU那样一直保留在缓存中影响其他更常用数据。
  2. 缺点
    • 实现复杂:LFU的实现相对LRU更为复杂,需要维护多个链表以及更复杂的频率管理逻辑。从上述Python代码示例可以看出,LFU的代码量和逻辑复杂度都高于LRU的实现。
    • 需要更多的内存开销:由于需要记录每个数据的访问频率信息,LFU比LRU需要更多的内存空间来存储这些额外的数据。例如,在存储大量小数据时,频率信息的存储开销可能变得较为显著。
    • 历史依赖性:LFU过于依赖历史访问频率,可能在数据访问模式发生剧烈变化时表现不佳。如果一个长期低频访问的数据突然变成高频访问,LFU可能不会立即将其提升到合适的缓存优先级,导致缓存命中率暂时下降。

LRU与LFU的对比

性能对比

  1. 缓存命中率:在大多数情况下,如果数据访问模式符合局部性原理,LRU能够提供较高的缓存命中率。然而,当数据访问频率差异较大且访问模式较为稳定时,LFU的缓存命中率可能更高。例如,在一个文件存储系统中,如果经常访问的文件和很少访问的文件区分明显,LFU可以更好地将经常访问的文件保留在缓存中,从而提高缓存命中率。但在数据访问模式变化频繁且无明显规律的场景下,LRU可能表现得更好,因为它对近期访问更为敏感,能够快速适应变化。
  2. 响应时间:LRU的操作时间复杂度在理想情况下(基于链表和哈希表实现)为O(1),对于读取和写入操作都能快速响应。LFU虽然在读取和写入操作上理论时间复杂度也可以达到O(1),但由于其实现更为复杂,实际的响应时间可能会略高于LRU,特别是在高并发场景下,LFU的复杂操作可能导致更多的锁竞争等问题,从而影响响应时间。

适用场景对比

  1. LRU适用场景
    • Web页面缓存:在Web应用中,用户的浏览行为通常具有局部性,近期访问的页面很可能再次被访问。例如,一个用户在电商网站上浏览商品详情页,可能会在短时间内多次查看相关商品的其他信息,LRU可以有效地将这些页面缓存起来,提高用户再次访问的速度。
    • 数据库查询结果缓存:对于数据库的查询操作,一些常用的查询结果在短期内可能会被重复查询。LRU可以根据查询的时间顺序,淘汰长时间未被使用的查询结果缓存,为新的查询结果腾出空间。
  2. LFU适用场景
    • 文件系统缓存:在文件系统中,某些文件的访问频率相对稳定,如系统配置文件可能很少被访问,而一些经常使用的应用程序文件会被频繁访问。LFU可以根据文件的访问频率来管理缓存,确保频繁访问的文件始终在缓存中,提高文件系统的性能。
    • 多媒体内容缓存:例如视频网站的缓存系统,热门视频会被大量用户频繁观看,而一些小众视频可能很少有人观看。LFU可以根据视频的观看频率来缓存视频内容,优先保留热门视频,提高缓存的利用率。

内存开销对比

  1. LRU:LRU主要通过链表和哈希表来实现,哈希表存储键值对和链表节点指针,链表用于记录访问顺序。除了数据本身,额外的内存开销主要是链表节点的指针和哈希表的存储。相对来说,LRU的内存开销相对较小。
  2. LFU:LFU不仅需要哈希表存储键值对和频率等信息,还需要为每个访问频率维护链表。每个数据节点除了存储数据本身和链表指针外,还需要记录访问频率。因此,LFU的内存开销相对较大,特别是在数据量较大且访问频率分布较分散的情况下。

实现难度对比

  1. LRU:LRU的实现逻辑相对简单,无论是基于链表和哈希表的手动实现,还是利用现有库(如Java的LinkedHashMap)实现,代码结构和操作逻辑都较为清晰,开发和维护成本相对较低。
  2. LFU:LFU的实现涉及到复杂的频率管理和多个链表的操作,代码逻辑复杂,需要更多的代码量来实现。同时,调试和维护LFU缓存的代码也更加困难,对开发人员的技术要求更高。

在实际的后端开发中,选择LRU还是LFU需要根据具体的应用场景、性能需求、内存限制以及开发成本等多方面因素进行综合考虑。如果数据访问具有较强的局部性且对实现复杂度和内存开销较为敏感,LRU可能是一个较好的选择;而如果数据访问频率差异明显且稳定性较高,对缓存命中率要求极高,即使增加一定的实现复杂度和内存开销,LFU也可能是更合适的策略。