← 中文资源

简体中文版本

读穿缓存

读穿缓存在未命中时由缓存自身回源加载并存储结果,使应用始终只读缓存,而无需显式的数据加载步骤。

阅读英文原文

概念概述

读穿缓存(Read-Through Caching)指的是:读穿缓存在未命中时由缓存自身回源加载并存储结果,使应用始终只读缓存,而无需显式的数据加载步骤。

从更严格的定义看,Read-through caching is a pattern in which the cache itself fetches from the backing store on a miss and stores the result, so callers issue a single read against the cache. 该概念在工程实践中的价值,在于它能帮助团队用统一的语言描述一个具体的质量属性或设计约束,从而在评审与协作中减少歧义。

为什么需要它

It simplifies application code in gateways and data access layers that want caching to be transparent. 它与 Negative Versus Positive Caching、Cache Preloading and Warming、Distributed Caching 等概念经常一起出现,用于解决相近的工程问题。理解它的适用场景,有助于在多种备选方案之间做出有依据的取舍,而不是凭直觉选择技术方案。

常见误解

  • read-through automatically keeps entries fresh forever
  • it removes the need to choose a time to live

需要避免的反模式

  • using read-through for data that must never be stale
  • letting a single missing key trigger an expensive uncached fetch storm

实践要点

  1. The cache owns the load path, so a miss triggers a fetch, a store, and a return, and every caller sees the same interface whether the data was cached or not.
  2. Freshness still depends on a time to live or explicit invalidation, so read-through does not solve staleness by itself.
  3. Concurrent misses for the same key can still stampede the origin, so read-through is often combined with request deduplication or a short lock around the load.

参考来源

https://learn.microsoft.com/en-us/azure/architecture/patterns/cache-aside

代码示例

interface CacheEntry<T> {
  value: T;
  expiresAt: number;
}

function Cached<T>(ttlMs: number) {
  return function (
    target: object,
    propertyKey: string,
    descriptor: TypedPropertyDescriptor<(...args: never[]) => Promise<T>>,
  ) {
    const original = descriptor.value!;
    const store = new Map<string, CacheEntry<T>>();

    descriptor.value = async function (this: unknown, ...args: never[]) {
      const key = propertyKey + ':' + JSON.stringify(args);
      const hit = store.get(key);
      const now = Date.now();

      if (hit && hit.expiresAt > now) {
        (this as { __cacheHits?: number }).__cacheHits ??= 0;
        return hit.value;
      }

      const value = await original.apply(this, args);
      store.set(key, { value, expiresAt: now + ttlMs });
      return value;
    };

    return descriptor;
  };
}

class ExchangeRates {
  private hits = 0;

  @Cached(30_000)
  async ratesFor(base: string): Promise<Record<string, number>> {
    const res = await fetch('https://api.example.com/rates?base=' + base);
    return res.json();
  }

  @Cached(300_000)
  async currencies(): Promise<string[]> {
    return ['USD', 'EUR', 'JPY'];
  }
}

const rates = new ExchangeRates();
const first = await rates.ratesFor('USD');
const second = await rates.ratesFor('USD');
console.log('cached identity:', first === second);
import { createClient } from 'redis';

const redis = createClient({ url: process.env.REDIS_URL ?? 'redis://127.0.0.1:6379' });
redis.on('error', (err) => console.error('redis:', err.message));
await redis.connect();

const TTL_SECONDS = 300;

async function cached(key, loader) {
  const hit = await redis.get(key);
  if (hit !== null) {
    await redis.incr('cache:stats:hits');
    return JSON.parse(hit);
  }
  const fresh = await loader();
  await redis.set(key, JSON.stringify(fresh), { EX: TTL_SECONDS, NX: true });
  await redis.incr('cache:stats:misses');
  return fresh;
}

async function invalidate(prefix) {
  const pattern = prefix + ':*';
  for await (const key of redis.scanIterator({ MATCH: pattern, COUNT: 200 })) {
    await redis.del(key);
  }
}

const product = await cached('product:42', () => fetchProduct(42));
await invalidate('product');
await redis.quit();