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 精巧的技术实现,都为以后淘汰不常被访问的、保留最新最频繁被访问这样的业务场景提供了实现思路。
热门跟贴