← 中文资源

简体中文版本

TTL 抖动

TTL 抖动在基础 TTL 上加随机偏移,避免成千上万键在同一秒过期,防止同步到期踩踏。

阅读英文原文

概念定义

缓存总是成批填充——部署、预热、流量尖峰。固定 TTL 会把填充突发变成到期突发:同一批一千个键在同一秒过期、一起 miss,源站看到的是自己亲手制造的洪峰。

工作机制

每次写入计算 TTL × (1 + 均匀随机分数) 并封顶乘数,到期便在整个窗口内近似均匀摊开。抖动可以复合:多层缓存各自加抖动,能防止整条栈上对齐的到期级联。

使用场景

一切成批写入的缓存:预热循环、部署后的 CDN 刷新、定时任务填充的应用缓存。抖动保持比例适当——够摊开、又不至于让容量规划失去可预测性。

常见误区

抖动解决的是“多键同步”而非“单键热”——热点键仍需合并或 SWR;平均陈旧度期望仍是 TTL 本身,抖动只重分布到期时刻。抖动必须发生在写方的写入时刻,读侧补加只影响新条目;范围过大会让到期分布失去可预测性。动态响应完全可以按请求计算随机化的 Cache-Control max-age。

代码示例

export interface CacheLookup<V> {
  hit: boolean;
  value?: V;
}

interface CacheEntry<V> {
  value: V | null;
  expiresAt: number;
}

export class NegativeCache<K, V> {
  private readonly entries = new Map<K, CacheEntry<V>>();
  private readonly ttlMs: number;
  private readonly now: () => number;

  constructor(ttlMs: number, now: () => number = () => Date.now()) {
    if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
      throw new RangeError("ttlMs must be a positive finite number");
    }
    if (typeof now !== "function") throw new TypeError("now must be a function");
    this.ttlMs = ttlMs;
    this.now = now;
  }

  get(key: K): CacheLookup<V> {
    const entry = this.entries.get(key);
    if (!entry) return { hit: false };
    if (this.now() >= entry.expiresAt) {
      this.entries.delete(key);
      return { hit: false };
    }
    if (entry.value === null) return { hit: true };
    return { hit: true, value: entry.value };
  }

  setMiss(key: K): void {
    this.entries.set(key, { value: null, expiresAt: this.now() + this.ttlMs });
  }

  setValue(key: K, value: V): void {
    if (value === null) throw new TypeError("value must not be null; use setMiss");
    this.entries.set(key, { value, expiresAt: this.now() + this.ttlMs });
  }

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

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

export async function withNegativeCache<K, V>(
  cache: NegativeCache<K, V>,
  key: K,
  loader: () => Promise<V | null>,
): Promise<V | null> {
  if (typeof loader !== "function") throw new TypeError("loader must be a function");
  const cached = cache.get(key);
  if (cached.hit) return cached.value ?? null;
  const value = await loader();
  if (value === null) cache.setMiss(key);
  else cache.setValue(key, value);
  return value;
}
import { LRUCache } from 'lru-cache';

const cache = new LRUCache({
  max: 500,
  ttl: 60_000,
  staleTtl: 300_000,
  allowStale: true,
  fetchMethod: async (key) => {
    const res = await fetch('https://api.example.com/rates/' + key);
    if (!res.ok) throw new Error('upstream ' + res.status);
    return res.json();
  },
});

const inflight = new Map();

async function getRate(currency) {
  const value = await cache.fetch(currency);
  return value;
}

async function revalidate(key, loader) {
  if (inflight.has(key)) return inflight.get(key);

  const task = loader()
    .then((fresh) => { cache.set(key, fresh); return fresh; })
    .finally(() => { inflight.delete(key); });

  inflight.set(key, task);
  return task;
}

const usd = await getRate('usd');
const again = await getRate('usd');
console.log('rate:', usd.rate, 'second read:', again.rate);