在 mapbox 中 TileCache 所使用的算法是 LRU,我们可以在注释中看到。
LRU算法全称是最近最少使用算法(Least Recently Use),它广泛的应用于缓存机制中(比如我们在查看各个地图类库中,在其中总能找到 LRUCache的身影)。当缓存使用的空间达到上限后,就需要从已有的数据中淘汰一部分以维持缓存的可用性,而淘汰数据的选择就是通过LRU算法完成的。
常见实现方案
-
数组
方案:为每一个数据附加一个额外的属性——时间戳,当每一次访问数据时,更新该数据的时间戳至当前时间。当数据空间已满后,则扫描整个数组,淘汰时间戳最小的数据。
不足:维护时间戳需要耗费额外的空间,淘汰数据时需要扫描整个数组。
-
链表
方案:访问一个数据时,当数据不在链表中,则将数据插入至链表头部,如果在链表中,则将该数据移至链表头部。当数据空间已满后,则淘汰链表最末尾的数据。
不足:插入数据或取数据时,需要扫描整个链表。
-
双向链表+哈希表
方案:为了改进上面需要扫描链表的缺陷,配合哈希表,将数据和链表中的节点形成映射,将插入操作和读取操作的时间复杂度从O(N)降至O(1)
代码
使用 Map的简单实现:
class LRU{
constructor(length){
if(length < 1) throw new Error('invalid length')
this.data = new Map()
this.length = length
}
set(key,value){
const data = this.data
if(data.has(key)){
data.delete(key)
}
data.set(key, value)
if(data.size > this.length){ //如果超出了容量,则删除Map最老的(key 值最前的)元素
const delKey = data.keys().next().value
data.delete(delKey)
}
}
get(key){
const data = this.data
if(!data.has(key)) return null
const val = data.get(key)
// 每次访问时先取出再添加到队列最后
data.delete(key)
data.set(key, val)
return val
}
}
使用链表
- 双向链表:
type Key = string | number;
class DoubleQueueNode {
key: Key;
val: any;
pre: DoubleQueueNode;
next: DoubleQueueNode;
constructor(key: Key, val: any) {
this.key = key;
this.val = val;
}
}
- 最终实现:
// eslint-disable-next-line max-classes-per-file
type Key = string | number;
class DoubleQueueNode<T> {
key: Key;
val: any;
pre: DoubleQueueNode<T>;
next: DoubleQueueNode<T>;
constructor(key: Key, val: T) {
this.key = key;
this.val = val;
}
}
export default class LRUCache<T> {
private max: number; // 限制大小
private map: Map<Key, DoubleQueueNode<T>>; // 数据和链表中节点的映射
private head: DoubleQueueNode<T>; // 头结点
private tail: DoubleQueueNode<T>; // 尾结点
private onRemove: (element: T) => void;
constructor(max, onRemove: (element: T) => void) {
this.max = max;
this.onRemove = onRemove;
this.reset();
}
/**
* 当前容量
*/
get size() {
return this.map.size;
}
public reset() {
if (this.map) {
const iterator = this.map.entries();
for (let i = 0; i < this.map.size; i++) {
const [, value] = iterator.next().value;
this.onRemove(value);
}
}
this.map = new Map();
this.head = new DoubleQueueNode(0, 0);
this.tail = new DoubleQueueNode(0, 0);
this.head.next = this.tail;
return this;
}
public has(key: Key) {
const node: WithUndef<DoubleQueueNode<T>> = this.map.get(key);
return node !== undefined;
}
public get(key: Key) {
const node: WithUndef<DoubleQueueNode<T>> = this.map.get(key);
if (node === undefined) {
return null;
}
// 数据在链表中,则移至链表头部
this.moveToHead(node);
return node.val;
}
public getAndRemove(key: Key) {
if (!this.has(key)) {
return null;
}
return this.remove(key);
}
public add(key: Key, value: any) {
let oldValue;
const node: WithUndef<DoubleQueueNode<T>> = this.map.get(key);
if (node === undefined) {
// 淘汰数据
this.eliminate();
// 数据不在链表中,插入数据至头部
const newNode: DoubleQueueNode<T> = new DoubleQueueNode(key, value);
const temp: DoubleQueueNode<T> = this.head.next;
this.head.next = newNode;
newNode.next = temp;
newNode.pre = this.head;
temp.pre = newNode;
this.map.set(key, newNode);
oldValue = null;
} else {
// 数据在链表中,则移至链表头部
this.moveToHead(node);
oldValue = node.val;
node.val = value;
}
return oldValue;
}
public remove(key: Key) {
const deletedNode: WithUndef<DoubleQueueNode<T>> = this.map.get(key);
if (deletedNode === undefined) {
return null;
}
deletedNode.pre.next = deletedNode.next;
deletedNode.next.pre = deletedNode.pre;
this.onRemove(deletedNode.val);
this.map.delete(key);
return deletedNode.val;
}
public setMaxSize(max: number) {
this.max = max;
// 当前缓存的数量大于重新设置的最大缓存值时进行缓存淘汰
while (this.size > this.max) {
this.eliminate();
}
}
// 将节点插入至头部节点
private moveToHead(node: DoubleQueueNode<T>) {
node.pre.next = node.next;
node.next.pre = node.pre;
const temp: DoubleQueueNode<T> = this.head.next;
this.head.next = node;
node.next = temp;
node.pre = this.head;
temp.pre = node;
}
private eliminate() {
if (this.size < this.max) {
return;
}
// 将链表中最后一个节点去除
const last: DoubleQueueNode<T> = this.tail.pre;
this.onRemove(last.val);
this.map.delete(last.key);
last.pre.next = this.tail;
this.tail.pre = last.pre;
}
}
- 测试
import LRU from './LRUCache';
describe('LRU', () => {
test('complex flow', () => {
const cache = new LRU(10);
expect(cache.getAndRemove('1')).toBeNull();
cache.add('1', 1);
expect(cache.has('1')).toBe(true);
expect(cache.getAndRemove('1')).toBe(1);
expect(cache.getAndRemove('1')).toBeNull();
expect(cache.has('1')).toBe(false);
});
test('get without removing', done => {
const cache = new LRU(10);
cache.add('1', 1);
expect(cache.get('1')).toBe(1);
done();
});
test('duplicate add', done => {
const cache = new LRU(10);
cache.add('1', 1);
cache.add('1', 2);
expect(cache.has('1')).toBeTruthy();
expect(cache.getAndRemove('1')).toBe(2);
done();
});
test('expiry', () => {
const cache = new LRU(10);
cache.add('2', 2);
cache.getAndRemove('2');
// removing clears the expiry timeout
cache.add('2', null);
cache.add('1', 1);
cache.add('1', 12); // expires immediately and `onRemove` is called.
});
test('remove', () => {
const cache = new LRU(10);
cache.add('1', 1);
cache.add('2', 2);
cache.add('3', 3);
expect(cache.has('2')).toBeTruthy();
cache.remove('2');
expect(cache.has('2')).toBeFalsy();
expect(cache.remove('2')).toBe(null);
});
test('overflow', () => {
const cache = new LRU(1);
cache.add('1', 1);
cache.add('2', 2);
expect(cache.has('2')).toBeTruthy();
expect(cache.has('1')).toBeFalsy();
});
test('get', () => {
const cache = new LRU(2);
cache.add('1', 1);
cache.add('2', 2);
cache.get('1');
cache.add('3', 3);
expect(cache.has('2')).toBeFalsy();
expect(cache.has('1')).toBeTruthy();
expect(cache.has('3')).toBeTruthy();
});
test('.reset', () => {
const cache = new LRU(10);
cache.add('1', 1);
expect(cache.reset()).toBe(cache);
expect(cache.has('1')).toBe(false);
});
test('.setMaxSize', () => {
const cache = new LRU(10);
cache.add('1', 1);
cache.add('2', 2);
cache.add('3', 3);
cache.setMaxSize(15);
cache.setMaxSize(1);
cache.add('4', 4);
});
});
mapbox 的瓦片 LRU 缓存算法解析
TIP: mapbox的 LRU 算法 似乎并没有完整实现 LRU的节点移动,不过我们可以在 SourceCache 中的 _addTile 中发现,每次取得缓存后会进行移除,然后在 _removeTile 再次进行添加,这样就保证了每次访问缓存后的节点移动(即保证了访问后的元素在最后)
@startuml
participant source_cache
participant tile_cache
source_cache->source_cache: 计算当前覆盖瓦片
source_cache->source_cache: _updateRetainedTiles => _addTile\n判断缓存中是否存在,如果存在\n调用`cache.getAndRemove`获取瓦片
source_cache->tile_cache: cache.getAndRemove 从缓存取得数据并移除,保证访问的瓦片在队列最后
source_cache->source_cache: update => keysDifference => _removeTile\n通过比对当前所需瓦片和上一次所需瓦片\n得到需要删除的瓦片,\n需要删除的瓦片如果数据已加载\n并且状态`tile.state` 不是 `reloading` \n那么加入到缓存中
source_cache->tile_cache: cache.add 添加到缓存
tile_cache->tile_cache: setMaxSize 调整当前缓存大小\n如果当前缓存数量超过缓存最大值,取出队列的头部\n(最近最少使用)的瓦片移出缓存
tile_cache->tile_cache: add 添加缓存时如果当前缓存大小\n超过缓存最大值,取出队列的头部\n(最近最少使用)的瓦片移出缓存
tile_cache->tile_cache: expiryTimeout 添加缓存时如果指定了过期时间,那么当缓存过期时直接 `remove`
tile_cache->source_cache: _unloadTile <= onRemove
tile_cache->tile_cache: _getAndRemoveByKey 取出队列的头部\n(最近最少使用)的瓦片移出缓存
@enduml
- TileCache 的核心代码如下:
export type Tile = any;
export interface DataItem {
value: Tile;
timeout: ReturnType<typeof setTimeout>;
}
export default class LRU {
// 最大缓存数量
max: number;
data: {
[key: string]: DataItem[];
};
order: Array<string>;
onRemove: (element: Tile) => void;
/**
* @param max 最大允许的数量
* @param {Function} onRemove 缓存失效后的回调
*/
constructor(max: number, onRemove: (element: Tile) => void) {
this.max = max;
this.onRemove = onRemove;
this.reset();
}
/**
* 清空所有缓存
*/
reset() {
for (const key in this.data) {
// 这里需要注意的是每个对应的 `key` 缓存的数据可能是多个,所以 key 对应的值的数据组织形式是数组
for (const removedData of this.data[key]) {
// 如果有过期时间,需要移除对应的定时器
if (removedData.timeout) clearTimeout(removedData.timeout);
// 触发 onRemove 回调
this.onRemove(removedData.value);
}
}
// 清空缓存数据
this.data = {};
// 清空缓存队列
this.order = [];
return this;
}
/**
* 添加一个键值对组合到缓存中,如果缓存大小超出了给定的值,那么进行缓存淘汰
* @param {string} key lookup key for the item
* @param {*} data any value
*
* @param expiryTimeout
* @returns {TileCache} this cache
* @private
*/
add(key: string, data: Tile, expiryTimeout: number | void) {
// 如果对应缓存不存在,那么进行初始化
if (this.data[key] === undefined) {
this.data[key] = [];
}
// 对要缓存的数据进行包装,这里主要方便添加 TTL(缓存时间)
const dataWrapper = {
value: data,
timeout: undefined
};
// 如果设置了过期时间,那么对其添加一个 `setTimeout` 的定时器,到期后移除缓存
if (expiryTimeout !== undefined) {
dataWrapper.timeout = setTimeout(() => {
this.remove(key, dataWrapper);
}, expiryTimeout as number);
}
// 将包装后的数据添加到缓存中
this.data[key].push(dataWrapper);
// 这里需要注意多个相同的 key 会被重复添加,这里记录的是真实的缓存数量
this.order.push(key);
// 如果缓存超出大小限制,那么进行缓存淘汰
if (this.order.length > this.max) {
const removedData = this._getAndRemoveByKey(this.order[0]);
if (removedData) this.onRemove(removedData);
}
return this;
}
/**
* 判断对应键值的缓存是否存在
* @param {string} key the key to be looked-up
* @returns {boolean} whether the cache has this value
* @private
*/
has(key: string): boolean {
return key in this.data;
}
/**
* 根据 key 值获取对应的缓存并且从缓存中移除
* @param {string} key the key to look up
* @returns {*} the data, or null if it isn't found
* @private
*/
getAndRemove(key: string): Tile {
if (!this.has(key)) { return null; }
return this._getAndRemoveByKey(key);
}
/*
* 获取并删除具有指定键的值。
*/
_getAndRemoveByKey(key: string): Tile {
// 获取对应 `key` 的缓存获取并移除此数据
const data = this.data[key].shift();
// 清空对应缓存的定时器
if (data.timeout) clearTimeout(data.timeout);
// 如果对应缓存的数据长度为 0 时,移除对应的缓存值
if (this.data[key].length === 0) {
delete this.data[key];
}
// 从队列中移除对应的 key
this.order.splice(this.order.indexOf(key), 1);
return data.value;
}
/**
* 根据给定的键值获取缓存,如果找不到则返回 `null`
* @param {string} key the key to look up
* @returns {*}
* @private
*/
get(key): Tile {
if (!this.has(key)) { return null; }
// 获取所属 key 对应缓存的第一个缓存值(一般缓存的长度不会为 0,为 0 时不会存在对应的缓存 key)
const data = this.data[key][0];
return data.value;
}
/**
* 从缓存中删除键值对
* @param {string} key the key for the pair to delete
* @param {Tile} value If a value is provided, remove that exact version of the value.
* @returns {TileCache} this cache
* @private
*/
remove(key: string, value?: DataItem) {
// 如果缓存中不存在对应的 key,那么直接返回
if (!this.has(key)) { return this; }
// 如果为给定 value 值,那么数据的 index 置为 0,如果指定了 value 值获取对应的 index
const dataIndex = value === undefined ? 0 : this.data[key].indexOf(value);
const data = this.data[key][dataIndex];
// 移除对应的数据
this.data[key].splice(dataIndex, 1);
// 移除定时器
if (data.timeout) clearTimeout(data.timeout);
// 如果缓存长度为 0,移除对应的 key
if (this.data[key].length === 0) {
delete this.data[key];
}
// 触发 `onRemove` 回调
this.onRemove(data.value);
// 从队列中移除对应的 key
this.order.splice(this.order.indexOf(key), 1);
return this;
}
/**
* 重新设置缓存大小
* @param {number} max the max size of the cache
* @returns {TileCache} this cache
* @private
*/
setMaxSize(max: number): LRU {
this.max = max;
// 当前缓存的数量大于重新设置的最大缓存值时进行缓存淘汰
while (this.order.length > this.max) {
const removedData = this._getAndRemoveByKey(this.order[0]);
if (removedData) this.onRemove(removedData);
}
return this;
}
/**
* 根据过滤函数移除缓存
* @param {function} filterFn Determines whether the tile is filtered. If the supplied function returns false, the tile will be filtered out.
*/
filter(filterFn: (tile: Tile) => boolean) {
const removed = [];
for (const key in this.data) {
for (const entry of this.data[key]) {
if (!filterFn(entry.value)) {
removed.push(entry);
}
}
}
for (const r of removed) {
this.remove(r.value.tileID, r);
}
}
}
import LRU from './LRU';
describe('LRU', () => {
test('complex flow', () => {
const cache = new LRU(10, (removed) => {
expect(removed).toBe('dc');
});
expect(cache.getAndRemove('1')).toBeNull();
expect(cache.add('1', 1)).toBe(cache);
expect(cache.order).toEqual(['1']);
expect(cache.has('1')).toBe(true);
expect(cache.getAndRemove('1')).toBe(1);
expect(cache.getAndRemove('1')).toBeNull();
expect(cache.has('1')).toBe(false);
expect(cache.order).toEqual([]);
});
test('get without removing', done => {
const cache = new LRU(10, () => {
done('test "get without removing" failed');
});
expect(cache.add('1', 1)).toBe(cache);
expect(cache.get('1')).toBe(1);
expect(cache.order).toEqual(['1']);
expect(cache.get('1')).toBe(1);
done();
});
test('duplicate add', done => {
const cache = new LRU(10, () => {
done('test "duplicate add" failed');
});
cache.add('1', 1);
cache.add('1', 2);
expect(cache.order).toEqual(['1', '1']);
expect(cache.has('1')).toBeTruthy();
expect(cache.getAndRemove('1')).toBe(1);
expect(cache.has('1')).toBeTruthy();
expect(cache.getAndRemove('1')).toBe(2);
done();
});
test('expiry', () => {
const cache = new LRU(10, (removed) => {
expect(cache.has('2')).toBeTruthy();
expect(removed).toBe(1);
});
cache.add('2', 2, 0);
cache.getAndRemove('2');
// removing clears the expiry timeout
cache.add('2', null);
cache.add('1', 1);
cache.add('1', 12, 0); // expires immediately and `onRemove` is called.
});
test('remove', () => {
const cache = new LRU(10, () => {});
cache.add('1', 1);
cache.add('2', 2);
cache.add('3', 3);
expect(cache.order).toEqual(['1', '2', '3']);
expect(cache.has('2')).toBeTruthy();
cache.remove('2');
expect(cache.order).toEqual(['1', '3']);
expect(cache.has('2')).toBeFalsy();
expect(cache.remove('2')).toBeTruthy();
});
test('overflow', () => {
const cache = new LRU(1, (removed) => {
expect(removed).toBe(1);
});
cache.add('1', 1);
cache.add('2', 2);
expect(cache.has('2')).toBeTruthy();
expect(cache.has('1')).toBeFalsy();
});
test('.reset', () => {
let called;
const cache = new LRU(10, (removed) => {
expect(removed).toBe(1);
called = true;
});
cache.add('1', 1);
expect(cache.reset()).toBe(cache);
expect(cache.has('1')).toBe(false);
expect(called).toBeTruthy();
});
test('.setMaxSize', () => {
let numRemoved = 0;
const cache = new LRU(10, () => {
numRemoved++;
});
cache.add('1', 1);
cache.add('2', 2);
cache.add('3', 3);
expect(numRemoved).toBe(0);
cache.setMaxSize(15);
expect(numRemoved).toBe(0);
cache.setMaxSize(1);
expect(numRemoved).toBe(2);
cache.add('4', 4);
expect(numRemoved).toBe(3);
});
});