cover_image

Node.js 缓存之 LRU Cache 高效实现

毛仪桓 网易传媒技术团队
2022年11月07日 06:08


1
前言

 
       网易新闻移动端站点部分页面是通过 Vue SSR 服务来渲染的,在得到同构、SEO、内容到达时间这些收益的同时,不得不面对 Vue SSR 服务带来的性能问题,Vue 创建组件实例和虚拟 DOM 节点的性能开销,是远远大于Handlebars 这种基于字符串拼接模板的。

       在优化性能的过程中,与同事聊到用空间换时间来提升性能的时候,同事丢来一句: “性能不够,缓存来凑”,虽是一句调侃,却可以看出缓存是提升性能的利器。我们引入了 LRU Cache 对页面和页面组件做不同的缓存策略,改善服务负载能力,缩短响应时间,提升系统性能。本文会和大家一起学习高效 LRU Cache 背后的原理和技术实现,便于在相关场景更好地去应用。


2
缓存介绍


1、缓存的作用
        
        缓存一般用来暂存数据处理结果,为下次访问提供使用。很多时候,数据的处理、数据的获取非常费时,频繁的数据请求或处理会消耗大量资源,缓存就是将这些处理后的数据存储起来,当再次请求此数据时,直接从缓存中获取而不重新进行数据获取或处理,从而降低资源的消耗提高响应速度。

2、缓存的分类
        缓存分本地缓存和分布式缓存。本地缓存是应用中的组件,与应用耦合,在应用同一个进程中请求非常快,适合无需进程相互通知的场景。分布式缓存是与应用分离的缓存服务,比如 redis,是一个独立的服务,与本地应用隔离,多个应用可以共享缓存。

3、缓存的删除规则
        在使用缓存时通常是有存储空间的限制,不论是内存缓存、文件缓存或者是独立的缓存数据库,当存储达到上限的时候是需要删除一部分数据来存放新的数据,我们将依照什么规则来删除呢? 常见的删除规则算法有FIFO(先进先出算法规则)、LRU(最近最久未使用算法规则)、LFU(最近最少使用算法规则)。

        LRU 是我平常用的最多的一种,现在我们一起来了解 LRU 缓存算法规则背后精巧的技术实现。


3
LRU 缓存高效实现
     


        LRU(Least Recently Used) 缓存算法实现思想是假设一个数据在最近时间内未被访问,那么在将来被访问的可能性也更小,当数据达到上限时优先删除最近时间内未被访问的数据。

1、快速理解LRU删除算法规则
        我们把 LRU 缓存想象成一个有头和尾的有序格子,新数据和最近被访问的数据的格子移动到格子末尾,而处于格子头部的在数据达到上限时将被删除,如下图所示:

图片

        为了加深对 LRU 缓存规则的理解,我们基于 JavaScript Map 对象来实现一个简单的 LRU 缓存

class Cache {    constructor(options = { max: 5 }) {        this.max = options.max;        this.cache = new Map();    }    set(key, value) {        if (this.cache.has(key)) {            this.cache.delete(key);        }        if (this.cache.size >= this.max) {            this.cache.delete(this.cache.keys().next().value);        }        this.cache.set(key, value);    }    get(key) {        if (this.cache.has(key)) {            let val = this.cache.get(key);            this.cache.delete(key);            this.cache.set(key, val);           return val;        }        return null;    }}

        没错,上面代码模拟出了 LRU 缓存为什么用 JavaScript Map 对象就可以实现一个 LRU 缓存呢? 因为 Map 对象在保存键值对时,能记住键原始插入的顺序,让我们很容易找到最久未被访问(最早设置)的数据。

        每次在设置和获取缓存时候都主动去改变当前 key 的插入顺序,这是关键。

this.cache.delete(key);this.cache.set(key, val);

        
        当数据达到上限,我们找到最久未被使用(最早插入)的 key,删除它,并重新设置。

if (this.cache.size >= this.max) {    this.cache.delete(this.cache.keys().next().value);}this.cache.set(key, value);
Map keys() 返回一个引用的迭代对象,它包含按照顺序插入 Map 对象中每个元素的 key 值


        基于刚实现的 LRU 缓存,我们来推测下面缓存 key 的 顺序

const cache = new Cache({ max: 5 });cache.set('a', 0);cache.set('b', 1);cache.set('c', 2);cache.set('d', 3);cache.set('e', 4);cache.set('f', 5);cache.set('b', 6);

       推测将得到的顺序如下图

图片

      在浏览器控制台进行验证, 得到和推测的答案完全一致

图片

2、如何从零实现一个高效的 LRU 缓存
        理解了 LRU 缓存算法规则,那如何来实现一个 高效的 LRU 缓存库呢? 我们将从三方面入手,首先定义基本要求,其次实现思路选择、原理剖析,最后代码实现。

2.1、基本要求

  • 存储长度,一个可配置储存 key 特定长度的参数。

  • 通用操作, 提供 get、set 方法,时间复杂度为 O(1)。

  • 特殊操作, 空间达到上限后,删除最久未被访问的数据。


2.2、实现思路

  • 时间戳方式,利用时间戳增加的规则,为每个缓存 的key 设置一个时间戳,每次访问都更新时间戳,当缓存达到上限时删除时间戳最小的缓存。在我们需要找最小的时间戳的时需要对所有缓存进行排序,时间复杂度O(n)。

  • 链表方式,最新插入和最新读取的 key,都移动到链表尾部,达到上限从链表头部开始删数据,但是每次查询都需要遍历,时间复杂度为 O(n)。

  • 哈希表 + 双链表 + 数组,通过哈希表来存储 key,解决快速查找时间复杂度问题。通过双链表来维护缓存的顺序,由于不必按顺序存储,链表在插入和删除的时候可以达到 O(1) 的复杂度,比线性表快得多,再结合数组通过下标查找速度快、随机访问性强的特点,实现一个时间复杂度为 O(1) 高效的 LRU 缓存。


        哈希表 + 双链表 + 数组方式时间复杂度最低,我们来详细了解它的实现方式,对它做进一步拆解

  • 在哈希表中查找元素 key

    • 如果 key 存在于哈希表中,它就存在于我们的缓存中

      • 从哈希表中找到该对应元素的索引

      • 将该元素移动到尾部

      • 调整元素之间的前后指针

    • 如果不在哈希表中

      • 我们为这个元素创建一个新节点

      • 将这个节点移动到尾部

      • 将 key 和索引存放在哈希表中

      • 调整新的指针关系

  • 如果缓存空间不足

    • 移除最近最少使用的元素节点,删除哈希表中的 key

    • 调整元素之间的指针关系

        以上拆解的过程中,缓存的在双链表中排序是最不容易理解部分,为了更好的去理解,将举例说明。在此之前我们先来看看什么是双链表。

        链表是一种常见的基础数据结构,是一种线性表,但是并不会按线性的顺序存储数据,而是在每一个节点里存到下一个节点的指针。双链表是链表的另一种形式, 每个数据结点中都有两个指针,分别指向直接后继和直接前驱。

如图

图片

        理解了双链表数据结构,根据 LRU 缓存删除规则,对缓存在双链表里的排序举例来说明

        假如有一个长度为 5 的缓存空间,在双链表中会有下面几种场景(每个场景都基于上一个场景的数据)

① 假设我依次设置了 5 个缓存内容,key 分别是 a,b,c,d,e 因为没有超过缓存数据长度的上限,所以在缓存中的排序如下图

图片

② 当再设置一个 f 时,因为长度超过上限,a 是最早设置的,所以将 a 替换成  f,把 a 的下一个元素 b 设置为头,a 被替换成 f,f 是新的尾,如下图:


图片

       其实在双链表中它应该是

图片

③ 再获取 key 为 d 未过期的缓存(设置同理,同一个 key 都是最近访问),这个时候对应的缓存顺序又该是什么呢?

图片

        获取缓存 key 为 d 的时候,将 缓存 key 为 d 的节点置为尾,并把 d 前后的节点 c、e 拼接起来,将原来尾节点 f 置为现在尾节点 d 的上一个节点是不是有点绕,我们看看下图通过位置移动后的顺序,更好的来理解他们的前后关系。

图片

④ 获取 key 为 f 的缓存,但是 key 为 f 的缓存过期了,在获取的那一刻将 f 置为空,同时拼接节点 f 前后的节点 e 和 d,如图所示


图片

⑤ 设置一个新的缓存 g,顺序又将是什么样的? g 将填充原来空闲出来的位置如下图所示

图片

        根据上面缓存在双链表里排序的例子我们发现,缓存的长度不变,位置不变,变的是头和尾标示、以及元素之间的指针关系

我们来一起实现一个 LRU 缓存

  • 创建必要方法和属性

    • 创建长度为 5 的 4 个数组列表,分别用来存储缓存 key、缓存内容、双链表 prev 指针、双链表 next 指针,一个空数组来存放过期的空闲位置

    • 创建一个 keyMap 对象,用来存放缓存 key,和 key 对应的索引

    • 创建两个指针,一个头,一个尾,用来标示双链表的头和尾

    • 创建两个计数器,一个记录数据长度,一个记录填充长度

    • 创建 set(key,value,ttl)方法设置缓存

    • 创建 get(key) 方法获取缓存

    • 创建 delete 删除方法

    • 创建 clear 清除所有方法

    • 创建 #createNewIndex 生成数组索引私有方法

    • 创建 #moveToTail(index) 移动到尾部私有方法

    • 创建 #connect(prev, next) 拼接节点 next、prev 指针私有方法

  • 核心逻辑梳理

    • 设置缓存

      • 获取索引设置数据或更新数据

      • 调整指针关系

    • 获取缓存

      • 缓存过期删除缓存记录空闲位置索引,调整指针关系

      • 命中缓存返回缓存调整指针关系

    • 指针关系(移动到尾、前后节点拼接)

      • 如果是当前的尾,指针不变

      • 如果是头,将当前索引的下一个节点设置为头,将自己设置为尾,将原来尾的 next 指针指向自己,将自己的 prev 指针指向原来的尾

      • 如果是其他位置,拼接当前节点前后节点的 next 、prev 指针, 及前节点的 next  指针指向后节点,后节点的 prev 指针指向前节点

    • 获取索引

      • 已有缓存(命中)的索引

      • 数据长度超上限,返回头的索引

      • 有空闲空间,返回空闲位置的索引

      • 未填充满,返回数据长度的累计


2.3、代码实现
       按照上面的梳理,将它转化为 JavaScript 代码

class Cache {    constructor(options) {        const { max = 5, ttl = 0 } = options || {};        this.max = max;        if (!this.max) {            throw new Error('长度必须大于0')        }        this.ttl = ttl || 0;        // 存储 key 及 key 对应的索引        this.keyMap = new Map();        // 存放key        this.keyList = new Array(this.max).fill(null);        // 存放value        this.valList = new Array(this.max).fill(null);        // 存放prev指针索引        this.prev = new Array(this.max);        // 存放next指针索引        this.next = new Array(this.max);        // 存放过期的索引        this.free = [];        // 双链表头索引        this.head = 0;        // 双链表尾索引        this.tail = 0;        // 计数        this.size = 0;        // 未达到上限的位置索引        this.fill = 1;    }    set(key, value, ttl = this.ttl) {        let index = this.keyMap.get(key);        // 增加新内容        if (index === undefined ) {            index = this.#createNewIndex();            this.keyList[index] = key;            this.valList[index] = { value, expire: ttl ? Date.now() + ttl * 1000 : -1 };            this.keyMap.set(key, index);            // 设置双链表 next 和 prev 指针            this.next[this.tail] = index;            this.prev[index] = this.tail;            this.tail = index;            this.size ++;        } else {            // 老内容, 更新, 改变链表指针            const oldVal = this.valList[index];            if (value !== oldVal) {                this.valList[index] = value;            }            this.#moveToTail(index);        }    }    get(key) {        const index = this.keyMap.get(key);        const now = Date.now();        let value = null;        if (index !== undefined) {            const result = this.valList[index];            // 过期删除            if (now > result.expire) {                this.delete(key);            } else {                // 移动到尾(改变指针)                value = result.value;                this.#moveToTail(index);            }        }        return value    }    delete(key) {        let deleted = false;        const index = this.keyMap.get(key);        if (index !== undefined) {            if (this.size === 1) {                this.clear();            } else {                this.keyMap.delete(key);                this.valList[index] = null;                this.keyList[index] = null;                this.free.push(index);                this.size --;                if (index === this.head) {                    // 指定新的头                    this.head = this.next[index];                } else if (index === this.tail) {                    // 指定新的尾                    this.tail = this.prev[index];                } else {                    // 串联置空节点前后节点指针                    this.#connect(this.prev[index], this.next[index]);                }            }        }        return deleted;    }    clear() {        this.keyMap.clear();        this.valList.fill(null);        this.keyList.fill(null);        this.head = 0;        this.tail = 0;        this.fill = 1;        this.free = [];        this.size = 0;    }    // 创建新的索引值, 用来关联key、value、prev指针、next指针    #createNewIndex() {        if (this.size === 0) {            return this.tail;        }        if (this.size === this.max) {            const head = this.head;            const key = this.keyList[head];            this.head = this.next[head];            this.keyMap.delete(key);            this.size --;            return head;        }        if (this.free.length) {            // 过期空闲的位置            return this.free.pop();        }        return this.fill++;    }    #moveToTail(index) {        if (index !== this.tail) {            if (index === this.head) {                this.head = this.next[index];            } else {                this.#connect(this.prev[index], this.next[index]);            }            this.#connect(this.tail, index);            this.tail = index;        }    }    #connect(prev, next) {        this.prev[next] = prev;        this.next[prev] = next;    }}


4
写在最后
     


        缓存是提高系统性能有效的一种方法
,比如 Web,无论是设置浏览器缓存、CDN 缓存、业务 Nginx 缓存、或是 SSR 服务中的页面缓存、页面组件缓存、数据接口缓存等,合理的利用缓存策略,就能极大的改善服务负载能力,提升系统性能。       

        学习了 LRU 缓存删除算法规则、双链表和数组这两种数据结构,借助数组可以用下标快速查找特点,结合哈希表、双链表各自的优点实现了一个含基本功能高效的 LRU 缓存库,所有操作中都没有循环遍历,整个操作时间复杂度为 O(1)。不论是 LRU 删除规则,还是 LRU 精巧的技术实现,都为以后淘汰不常被访问的、保留最新最频繁被访问这样的业务场景提供了实现思路。

继续滑动看下一个
网易传媒技术团队
向上滑动看下一个