← 中文资源

简体中文版本

惰性过期与清扫的 TTL Map

通用 Map 支持逐条存活时间、读取时惰性过期、手动清扫、过期回调与按插入顺序淘汰。

阅读英文原文

功能说明

该示例在原生 Map 上增加逐条 TTL。set 可为单个键覆盖默认存活时间,get 与 has 在读取时检查是否过期并顺手删除,sweep 主动清理所有到期条目,size 读取前也会先清扫。可选 onExpire 回调可用于记录指标或触发刷新。适用于缓存验证码、短期会话、去重窗口等需要自动失效的数据。

关键代码

每个键保存值与到期时间戳,时间源可注入便于测试。set 在写入已存在的键前先判断旧值是否已过期,若过期就先触发过期回调;写入新键且达到 maxSize 时,按 Map 的插入顺序删除最旧条目。读取路径用惰性删除避免为每个条目启动定时器。sweep 在遍历中删除当前键,JavaScript Map 允许这种操作;命中时调用回调并返回清理数量。

使用注意

该实现不会后台自动清理,若长期没有读取或 sweep,过期条目会占用内存,应配合定时任务或请求间隙调用 sweep。size 带副作用并会触发过期回调,不要在高频路径频繁读取。maxSize 按插入顺序而非访问顺序淘汰,行为接近 FIFO,不是 LRU。过期回调抛错会中断 sweep,重要场景应在回调内部自行捕获异常。

验证方式

使用假时钟:写入后未到期时 get 返回值,推进到到期点后 get 返回 undefined 且 size 减少;测试逐条 ttlMs 覆盖默认值;写入 maxSize 加一个键,断言最旧键被淘汰;注册 onExpire 统计回调次数,分别触发读取过期与 sweep;测试非正 ttlMs、非法 maxSize 以及已过期键被重新 set 时的回调行为。

代码示例

export interface TtlMapOptions<K, V> {
  ttlMs: number;
  now?: () => number;
  maxSize?: number;
  onExpire?: (key: K, value: V) => void;
}

interface Entry<V> {
  value: V;
  expiresAt: number;
}

export class TtlMap<K, V> {
  private readonly entries = new Map<K, Entry<V>>();
  private readonly ttlMs: number;
  private readonly now: () => number;
  private readonly maxSize: number;
  private readonly onExpire?: (key: K, value: V) => void;

  constructor(options: TtlMapOptions<K, V>) {
    if (!Number.isFinite(options.ttlMs) || options.ttlMs <= 0) {
      throw new RangeError("ttlMs must be a positive finite number");
    }
    const maxSize = options.maxSize ?? Number.POSITIVE_INFINITY;
    if (maxSize !== Number.POSITIVE_INFINITY && (!Number.isInteger(maxSize) || maxSize < 1)) {
      throw new RangeError("maxSize must be a positive integer");
    }
    this.ttlMs = options.ttlMs;
    this.now = options.now ?? Date.now;
    this.maxSize = maxSize;
    this.onExpire = options.onExpire;
  }

  set(key: K, value: V, ttlMs: number = this.ttlMs): this {
    if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
      throw new RangeError("ttlMs must be a positive finite number");
    }
    if (this.entries.has(key)) {
      this.removeIfExpired(key);
    } else if (this.entries.size >= this.maxSize) {
      this.evictOldest();
    }
    this.entries.set(key, { value, expiresAt: this.now() + ttlMs });
    return this;
  }

  get(key: K): V | undefined {
    const entry = this.entries.get(key);
    if (!entry) return undefined;
    if (this.isExpired(entry)) {
      this.expire(key, entry);
      return undefined;
    }
    return entry.value;
  }

  has(key: K): boolean {
    const entry = this.entries.get(key);
    if (!entry) return false;
    if (this.isExpired(entry)) {
      this.expire(key, entry);
      return false;
    }
    return true;
  }

  delete(key: K): boolean {
    return this.entries.delete(key);
  }

  sweep(): number {
    const now = this.now();
    let removed = 0;
    for (const [key, entry] of this.entries) {
      if (now >= entry.expiresAt) {
        this.entries.delete(key);
        this.notify(key, entry);
        removed += 1;
      }
    }
    return removed;
  }

  get size(): number {
    this.sweep();
    return this.entries.size;
  }

  private isExpired(entry: Entry<V>): boolean {
    return this.now() >= entry.expiresAt;
  }

  private expire(key: K, entry: Entry<V>): void {
    this.entries.delete(key);
    this.notify(key, entry);
  }

  private notify(key: K, entry: Entry<V>): void {
    this.onExpire?.(key, entry.value);
  }

  private removeIfExpired(key: K): void {
    const entry = this.entries.get(key);
    if (entry && this.isExpired(entry)) this.expire(key, entry);
  }

  private evictOldest(): void {
    const first = this.entries.keys().next();
    if (!first.done) this.entries.delete(first.value);
  }
}