mapbox 的瓦片缓存(TileCache)

625 阅读9分钟

在 mapbox 中 TileCache 所使用的算法是 LRU,我们可以在注释中看到。

​ LRU算法全称是最近最少使用算法(Least Recently Use),它广泛的应用于缓存机制中(比如我们在查看各个地图类库中,在其中总能找到 LRUCache的身影)。当缓存使用的空间达到上限后,就需要从已有的数据中淘汰一部分以维持缓存的可用性,而淘汰数据的选择就是通过LRU算法完成的。

常见实现方案

  1. 数组

    方案:为每一个数据附加一个额外的属性——时间戳,当每一次访问数据时,更新该数据的时间戳至当前时间。当数据空间已满后,则扫描整个数组,淘汰时间戳最小的数据。

    不足:维护时间戳需要耗费额外的空间,淘汰数据时需要扫描整个数组。

  2. 链表

    方案:访问一个数据时,当数据不在链表中,则将数据插入至链表头部,如果在链表中,则将该数据移至链表头部。当数据空间已满后,则淘汰链表最末尾的数据。

    不足:插入数据或取数据时,需要扫描整个链表。

  3. 双向链表+哈希表

    方案:为了改进上面需要扫描链表的缺陷,配合哈希表,将数据和链表中的节点形成映射,将插入操作和读取操作的时间复杂度从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
    }
}

使用链表

  1. 双向链表:
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;
  }
}
  1. 最终实现:
// 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;
  }
}
  1. 测试

这里我们使用 Jest 或者 vitest

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

diagram-2686895173352219985

  1. 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);
    }
  }
}

  1. 对应的测试,这里我们使用 Jest 或者 vitest
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);
    });
});

其他相关参考:

github.com/mapbox/mapb…

github.com/mapbox/mapb…