← 中文资源

简体中文版本

请求去重

请求去重把针对同一资源的重复并发调用合并为一次上游请求,并与所有调用方共享结果,缓解冷启动洪峰。

阅读英文原文

概念概述

请求去重(Request Deduplication)指的是:请求去重把针对同一资源的重复并发调用合并为一次上游请求,并与所有调用方共享结果,缓解冷启动洪峰。

从更严格的定义看,Request deduplication is a technique that detects multiple in-flight requests for the same key and serves them from a single upstream operation, attaching the shared result to every caller. 该概念在工程实践中的价值,在于它能帮助团队用统一的语言描述一个具体的质量属性或设计约束,从而在评审与协作中减少歧义。

为什么需要它

It is valuable for expensive fetches that many concurrent clients request at once, such as a popular profile or a configuration document. 它与 Cache-Control Directives、Cache Key Normalisation、Read-Through Caching 等概念经常一起出现,用于解决相近的工程问题。理解它的适用场景,有助于在多种备选方案之间做出有依据的取舍,而不是凭直觉选择技术方案。

常见误解

  • deduplication caches the result for later requests
  • it works for every request regardless of method

需要避免的反模式

  • deduplicating requests with side effects such as payments
  • sharing an error result with all callers and never retrying

实践要点

  1. The first caller starts the upstream operation and later callers attach to its pending promise or future, so the origin sees one request instead of many.
  2. Deduplication covers only the window while a request is in flight; once it completes, the result is gone unless a separate cache retains it.
  3. It should apply to idempotent reads, and failure handling must decide whether a failed shared result is retried or surfaced to every waiter.

参考来源

https://en.wikipedia.org/wiki/Data_deduplication

代码示例

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;
}
export function memoize(fn, ttlMs = 60000) {
  const cache = new Map();
  return function memoized(key, ...rest) {
    const now = Date.now();
    const entry = cache.get(key);
    if (entry && now - entry.at < ttlMs) {
      return entry.value;
    }
    const value = fn(key, ...rest);
    cache.set(key, { value, at: now });
    return value;
  };
}