设计 LRU 缓存结构

230 阅读6分钟

LRU(Least Recently Used)是一种常见的缓存替换策略,它认为最近最少使用的数据应该被淘汰。在实现 LRU 缓存结构时,需要考虑以下几个问题:

  1. 如何保存数据?
  2. 如何判断数据是否存在缓存中?
  3. 如何淘汰数据?
  4. 如何维护数据的使用顺序?

下面是一个简单的实现,使用 Map 和双向链表来实现 LRU 缓存结构:

class LRUCache {
  constructor(capacity) {
    this.capacity = capacity;
    this.map = new Map();
    this.head = { key: null, value: null, prev: null, next: null };
    this.tail = { key: null, value: null, prev: this.head, next: null };
    this.head.next = this.tail;
  }

  get(key) {
    if (this.map.has(key)) {
      const node = this.map.get(key);
      this.moveToHead(node);
      return node.value;
    } else {
      return -1;
    }
  }

  put(key, value) {
    if (this.map.has(key)) {
      const node = this.map.get(key);
      node.value = value;
      this.moveToHead(node);
    } else {
      const node = { key, value, prev: this.head, next: this.head.next };
      this.map.set(key, node);
      this.head.next.prev = node;
      this.head.next = node;
      if (this.map.size > this.capacity) {
        const removed = this.removeTail();
        this.map.delete(removed.key);
      }
    }
  }

  moveToHead(node) {
    this.removeNode(node);
    this.addToHead(node);
  }

  removeNode(node) {
    node.prev.next = node.next;
    node.next.prev = node.prev;
  }

  addToHead(node) {
    node.prev = this.head;
    node.next = this.head.next;
    this.head.next.prev = node;
    this.head.next = node;
  }

  removeTail() {
    const removed = this.tail.prev;
    this.removeNode(removed);
    return removed;
  }
}

上面的代码定义了一个 LRUCache 类,其中 capacity 是缓存的容量,map 是用于存储缓存数据的 Map,headtail 分别是双向链表的头结点和尾结点。在 get 方法中,如果缓存中存在要获取的数据,则将数据移到链表头部并返回;否则返回 -1。在 put 方法中,如果缓存中存在要添加的数据,则将数据移到链表头部;否则创建新节点并添加到链表头部,如果缓存已满,则移除链表尾部的节点。在 moveToHead 方法中,将节点移到链表头部;在 removeNode 方法中,移除节点;在 addToHead 方法中,将节点添加到链表头部;在 removeTail 方法中,移除链表尾部的节点并返回。通过双向链表来维护数据的使用顺序,最近使用的数据将被放在链表头部,而最近未使用的数据将被放在链表尾部。通过 Map 来快速判断数据是否存在缓存中,以及获取数据对应的节点。在 getput 方法中,都需要将最近使用的数据移到链表头部,以保证链表头部始终是最近使用的数据。当缓存已满时,需要移除链表尾部的数据,以保证缓存不会超过容量限制。

需要注意的是,在实际应用中,LRU 缓存结构可能需要满足更复杂的需求,例如线程安全、自动扩容等,这时需要根据具体情况进行设计和实现。

使用场景

LRU 缓存结构在前端中有很多应用场景,以下是一些常见的例子:

  1. 图片缓存:在前端中,图片是一种常见的资源,为了提高用户体验和性能,可以使用 LRU 缓存来缓存最近访问过的图片,以减少网络请求和加载时间。

  2. 数据存储:前端应用程序通常需要在客户端存储一些数据,例如用户配置、状态等,可以使用 LRU 缓存来存储最常用的数据,以提高访问速度。

  3. 路由缓存:在前端单页应用中,路由切换时可能需要加载大量数据或组件,可以使用 LRU 缓存来缓存最近访问过的路由,以提高用户体验和性能。

  4. 字体缓存:在前端中,字体是一种常见的资源,为了提高用户体验和性能,可以使用 LRU 缓存来缓存最近访问过的字体,以减少网络请求和加载时间。

  5. API 调用缓存:在前端中,经常需要调用后端 API 来获取数据,可以使用 LRU 缓存来缓存最近访问过的 API 响应,以减少网络请求和提高性能。

  6. 表单数据缓存:在前端中,表单数据是一种常见的数据类型,可以使用 LRU 缓存来缓存最近访问过的表单数据,以提高用户体验和性能。

需要注意的是,LRU 缓存结构适用于访问模式具有局部性原理的应用场景,即最近访问的数据很可能在未来也会被访问。如果访问模式不符合这个原理,则 LRU 缓存结构的效果可能不如预期。

示例

使用 LRU 缓存来缓存 API 响应通常需要经过以下步骤:

  1. 创建一个 LRU 缓存对象,并设置容量大小。可以使用第三方库实现 LRU 缓存,例如 lru-cachetiny-lru

  2. 在 API 调用前,先判断需要调用的 API 是否已经在缓存中存在。如果存在,则直接从缓存中获取响应数据,否则向服务器发起 API 请求。

  3. 在 API 响应返回后,将响应数据添加到缓存中,同时将最近访问的响应数据移到链表头部,以保证链表头部始终是最近访问的响应数据。

  4. 当缓存已满时,需要移除链表尾部的响应数据,以保证缓存不会超过容量限制。

下面是一个使用 lru-cache 库实现 API 调用缓存的示例代码:

const LRU = require('lru-cache');
const cache = new LRU({ max: 100 }); // 创建一个容量为 100 的 LRU 缓存对象

async function fetchData(url) {
  if (cache.has(url)) { // 如果 API 响应已经在缓存中存在
    return Promise.resolve(cache.get(url)); // 直接从缓存中获取 API 响应并返回
  }
  const response = await fetch(url); // 否则向服务器发起 API 请求
  const data = await response.json();
  cache.set(url, data); // 将 API 响应添加到缓存中
  return data;
}

在上述代码中,通过 cache.has(url) 方法来判断需要调用的 API 是否在缓存中存在,通过 cache.get(url) 方法来从缓存中获取响应数据,通过 cache.set(url, data) 方法来将响应数据添加到缓存中。当缓存已满时,lru-cache 库会自动移除最近最少使用的响应数据。

注意

在使用 LRU 缓存时,有一些需要注意的点,包括:

  1. 容量大小的设置:缓存容量需要根据具体场景进行设置,设置过小可能无法缓存所有需要缓存的数据,设置过大可能浪费系统资源。需要根据实际情况进行权衡和调整。

  2. 数据的大小:缓存中存储的数据大小应该适中,过大的数据可能会占用大量内存空间,导致系统性能下降,过小的数据则可能会增加缓存的命中率,但是也会增加缓存的清理频率。

  3. 数据更新策略:当缓存中的数据需要更新时,需要选择合适的更新策略。可以使用 LRU 策略,即将最近访问的数据移动到链表头部,也可以使用 LFU 策略,即移除最近访问次数最少的数据。根据具体场景选择合适的更新策略可以提高缓存的效率。

  4. 缓存过期时间:缓存中存储的数据可能会过期,需要考虑缓存过期时间的问题。可以设置一个统一的过期时间,或者根据每个数据的实际情况来设置不同的过期时间。

总之,在使用 LRU 缓存时,需要根据具体场景进行权衡和调整,选择合适的容量大小、数据大小、数据更新策略等,以提高缓存的效率和命中率。