18.03-内存缓存

要点

  • 内存缓存是最快的一层——读本地 Map,0 网络延迟,比 KV 还快一个数量级
  • Module Worker 的模块级变量在实例生命周期内保持不变——这是天然的缓存容器
  • 闭包 Map 是最简单的内存缓存实现,但需要自己管理大小和淘汰
  • Worker 内存有硬限制(默认 128MB),缓存不能无限增长
  • 每个 Worker 实例的内存是独立的——东京实例的缓存大阪读不到
  • 内存缓存适合「小、热点、可容忍不一致」的数据;大对象、强一致、跨实例共享的场景要用 KV 或 Cache API

1. 为什么 Worker 的内存缓存值得讲

传统 Node.js 应用里,进程内缓存是常见做法——用一个全局 Mapnode-cache 库存热点数据。Worker 环境也能这么做,但有几个独特之处:

  1. Worker 是 V8 Isolate,不是容器。Isolate 可以存活几分钟到几小时,处理成千上万个请求。模块级变量在整个生命周期内保持不变——直到 Isolate 被回收
  2. 没有 Redis 可用。Cloudflare 没有提供 Redis 服务,内存缓存 + KV/Cache API 是主要组合
  3. 内存限制严格。Worker 默认内存上限 128MB(可以在 wrangler.jsonc 里调),缓存失控会直接 OOM
  4. 每个 Isolate 独立。你在东京 Isolate 写的 Map,大阪的 Isolate 看不到——跟 Cache API 一样是「节点级」

理解这些特性,才能用好内存缓存。

2. Module Worker 的模块级变量

Module Worker 的格式是这样的:

// src/index.ts
import { Hono } from 'hono'
 
const app = new Hono<{ Bindings: Env }>()
 
app.get('/', (c) => c.text('Hello'))
 
export default {
  fetch: app.fetch,
}

当你在模块顶层声明变量时,这个变量在整个 Isolate 生命周期内只初始化一次:

// src/index.ts
// 这个 Map 只在 Isolate 启动时创建一次
const memoryCache = new Map<string, { value: string; expiresAt: number }>()
 
export default {
  async fetch(request: Request, env: Env) {
    // 所有落到这个 Isolate 的请求都能读写这个 Map
    const cached = memoryCache.get('some-key')
    if (cached && cached.expiresAt > Date.now()) {
      return new Response(cached.value)
    }
    // ... 计算并存入缓存
  },
}

2.1 初始化只执行一次

模块级变量的初始化(包括顶层的 await)只在 Isolate 启动时执行一次。后续请求直接复用:

// src/lib/config.ts
// 顶层 await 只在 Isolate 启动时跑一次
const config = await loadConfigFromKV()
 
export function getConfig() {
  return config  // 后续请求直接返回,不再读 KV
}

这就是一个最简单的「启动时加载一次」的内存缓存。配置、字典、模型映射表这类几乎不变的数据,放在这里很合适。

2.2 Isolate 什么时候被回收

Cloudflare 不公开 Isolate 的精确生命周期。已知的触发回收的条件:

  • 长时间没有请求(几分钟到几小时)
  • 内存压力
  • Cloudflare 内部调度

所以内存缓存不能保证持久性——它适合「丢了也无所谓」的数据。重要数据还是要落到 KV 或 D1。

3. 最简单的内存缓存:闭包 Map

一个带 TTL 的内存缓存实现:

// src/lib/memory-cache.ts
interface CacheEntry<T> {
  value: T
  expiresAt: number
}
 
export class MemoryCache<T> {
  private store = new Map<string, CacheEntry<T>>()
  private maxSize: number
 
  constructor(maxSize = 1000) {
    this.maxSize = maxSize
  }
 
  get(key: string): T | undefined {
    const entry = this.store.get(key)
    if (!entry) return undefined
 
    // 过期了,删掉
    if (entry.expiresAt < Date.now()) {
      this.store.delete(key)
      return undefined
    }
 
    return entry.value
  }
 
  set(key: string, value: T, ttlMs: number): void {
    // 满了就清掉过期条目
    if (this.store.size >= this.maxSize) {
      this.evict()
    }
 
    this.store.set(key, {
      value,
      expiresAt: Date.now() + ttlMs,
    })
  }
 
  private evict(): void {
    const now = Date.now()
    for (const [key, entry] of this.store.entries()) {
      if (entry.expiresAt < now) {
        this.store.delete(key)
      }
    }
  }
}

用法:

// src/index.ts
import { MemoryCache } from './lib/memory-cache'
 
// 模块级,跨请求复用
const cache = new MemoryCache<string>(1000)
 
export default {
  async fetch(request: Request, env: Env) {
    const key = 'some-key'
    const cached = cache.get(key)
 
    if (cached) {
      return new Response(cached)
    }
 
    const result = await expensiveOperation()
    cache.set(key, result, 60_000)  // 缓存 1 分钟
 
    return new Response(result)
  },
}

3.1 这个实现的局限

上面的实现很粗糙——evict() 只在满了的时候跑,而且只删过期条目。如果所有条目都没过期但缓存满了,新条目就写不进去。

需要更智能的淘汰策略。

4. 淘汰策略:LRU

LRU(Least Recently Used)是最常用的淘汰策略——最久没访问的条目优先被淘汰。

Node.js 有 lru-cache 库,但 Worker 环境可以引入 WebAssembly 编译的库,或者自己实现一个简化版:

// src/lib/lru-cache.ts
export class LRUCache<T> {
  private store = new Map<string, T>()
  private maxSize: number
 
  constructor(maxSize: number) {
    this.maxSize = maxSize
  }
 
  get(key: string): T | undefined {
    const value = this.store.get(key)
    if (value === undefined) return undefined
 
    // 移到「最近使用」位置:先删再插
    this.store.delete(key)
    this.store.set(key, value)
    return value
  }
 
  set(key: string, value: T): void {
    if (this.store.has(key)) {
      this.store.delete(key)
    } else if (this.store.size >= this.maxSize) {
      // 淘汰最久没使用的(Map 的第一个 key)
      const oldestKey = this.store.keys().next().value
      if (oldestKey !== undefined) {
        this.store.delete(oldestKey)
      }
    }
    this.store.set(key, value)
  }
 
  delete(key: string): void {
    this.store.delete(key)
  }
 
  get size(): number {
    return this.store.size
  }
}

这个实现利用了 ES6 Map 保持插入顺序的特性——最近访问的条目总是放在最后,最久没访问的在最前面。

4.1 配合 TTL

LRU 只管「容量淘汰」,不管「过期」。实际使用通常两者结合:

interface Entry<T> {
  value: T
  expiresAt: number
}
 
export class LRUCacheWithTTL<T> {
  private store = new Map<string, Entry<T>>()
  private maxSize: number
 
  constructor(maxSize: number) {
    this.maxSize = maxSize
  }
 
  get(key: string): T | undefined {
    const entry = this.store.get(key)
    if (!entry) return undefined
 
    if (entry.expiresAt < Date.now()) {
      this.store.delete(key)
      return undefined
    }
 
    // 移到最近使用
    this.store.delete(key)
    this.store.set(key, entry)
    return entry.value
  }
 
  set(key: string, value: T, ttlMs: number): void {
    if (this.store.has(key)) {
      this.store.delete(key)
    } else if (this.store.size >= this.maxSize) {
      const oldestKey = this.store.keys().next().value
      if (oldestKey !== undefined) {
        this.store.delete(oldestKey)
      }
    }
    this.store.set(key, { value, expiresAt: Date.now() + ttlMs })
  }
}

4.2 用 lru-cache

不想自己实现可以直接用 lru-cache(支持 WebAssembly 编译的 Worker 环境):

pnpm add lru-cache
// src/lib/cache.ts
import { LRUCache } from 'lru-cache'
 
const cache = new LRUCache<string, string>({
  max: 1000,           // 最多 1000 条目
  ttl: 60_000,         // 默认 TTL 1 分钟
  allowStale: false,   // 过期后不返回
})
 
cache.set('key', 'value')
cache.get('key')

5. 内存限制与监控

Worker 默认内存上限 128MB。可以在 wrangler.jsonc 里调整:

{
  "compatibility_flags": ["memory_limits"],
  "limits": {
    "memory": "256Mi"  // 调到 256MB
  }
}

但内存不是免费的——Isolate 占用内存在 Cloudflare 的计费模型里有隐性成本。

5.1 内存占用估算

粗略估算一下缓存能放多少东西:

条目类型单条大小1000 条10000 条
短字符串(模型名)~50 字节50 KB500 KB
中等字符串(JSON 响应)~5 KB5 MB50 MB
Embedding(1536 维 float)~12 KB12 MB120 MB
完整 LLM 响应~20 KB20 MB200 MB

embedding 和大响应不适合放内存——1000 条 embedding 就要 12MB,而且这只是单个 Isolate。短字符串和小 JSON 放内存没问题。

5.2 监控内存使用

Worker 没有直接暴露内存 API,但可以通过 performance.memory(非标准,部分环境支持)或 Cloudflare 的 Real-User Metrics 看整体趋势。

更实际的做法是自己记录缓存指标

class MemoryCache<T> {
  private store = new Map<string, T>()
  private hits = 0
  private misses = 0
 
  get(key: string): T | undefined {
    const value = this.store.get(key)
    if (value !== undefined) {
      this.hits++
    } else {
      this.misses++
    }
    return value
  }
 
  getStats() {
    return {
      size: this.store.size,
      hits: this.hits,
      misses: this.misses,
      hitRate: this.hits / (this.hits + this.misses) || 0,
    }
  }
}

getStats() 暴露到 /admin/cache-stats 接口,定期看命中率。命中率太低说明缓存太小或 TTL 太短;命中率很高但内存占用大,可以考虑调小 maxSize

6. 多实例问题

一个 Cloudflare Worker 在全世界几百个数据中心运行,每个数据中心有多个 Isolate。你的内存缓存有多个副本

这意味着:

  • 同一个用户连续两次请求可能落到不同 Isolate,第二次 miss
  • 更新缓存只影响当前 Isolate,其他 Isolate 看不到
  • Isolate 被回收后缓存丢失

6.1 什么时候可以接受

以下场景可以接受多副本:

  • 配置数据——启动时加载,变化极少
  • 字典数据——模型名、国家代码等,永远不变
  • 热点查询结果——不同 Isolate 各自缓存,最终都会热起来
  • 可容忍短时间不一致的数据

6.2 什么时候不能接受

以下场景不能用内存缓存:

  • 用户 session——必须跨实例一致
  • 配额计数——必须全局准确
  • 任务状态——丢了会丢任务

这些要用 KV(最终一致)或 D1(强一致)。

7. 实战:给 AI 查询加内存缓存

一个典型的 AI 查询接口,加三层缓存:

// src/lib/query-cache.ts
import { LRUCacheWithTTL } from './lru-cache'
 
// L1:内存缓存(最快,单 Isolate)
const memoryCache = new LRUCacheWithTTL<string>(500)
 
export async function queryWithCache(
  env: { AI: Ai; KV_CACHE: KVNamespace },
  prompt: string
): Promise<string> {
  const key = `query:${await sha256(prompt)}`
 
  // L1:内存
  const mem = memoryCache.get(key)
  if (mem) return mem
 
  // L2:KV
  const kv = await env.KV_CACHE.get(key)
  if (kv) {
    memoryCache.set(key, kv, 60_000)  // 回填 L1
    return kv
  }
 
  // L3:真跑业务
  const result = await env.AI.run('@cf/meta/llama-3.1-8b-instruct', {
    messages: [{ role: 'user', content: prompt }],
  })
  const answer = (result as { response: string }).response
 
  // 回填 L2 和 L1
  await env.KV_CACHE.put(key, answer, { expirationTtl: 3600 })
  memoryCache.set(key, answer, 60_000)
 
  return answer
}

三层命中路径:

请求 → L1 内存(0.01ms) → L2 KV(5ms) → L3 AI 调用(500ms)

热点查询基本都落在 L1。冷查询穿透到 L2。只有完全新的查询才跑 AI。

8. 内存缓存 vs Cache API vs KV

维度内存Cache APIKV
读延迟0.01ms0.1ms5ms
作用域单 Isolate单数据中心全球
容量128MB 内无硬限制按 GB 计费
持久性Isolate 存活期内边缘节点存活期内永久(TTL 控制)
跨实例
适用启动配置、热点小数据热点 URL、SWR业务缓存、跨地域共享

三层叠着用:内存 → Cache API → KV → 业务。命中率逐层下降,延迟逐层上升。

9. 小结

内存缓存是 Worker 里最便宜、最快的一层:

  • Module Worker 的模块级变量在 Isolate 生命周期内保持不变,是天然的缓存容器
  • 闭包 Map 或 lru-cache 库实现带 TTL 的 LRU 缓存
  • 内存上限 128MB,缓存不能无限增长——embedding 和大响应不适合放内存
  • 每个 Isolate 内存独立,不能做跨实例共享
  • 适合「小、热点、可容忍不一致」的数据:配置、字典、热点查询
  • 不适合:session、配额、任务状态

三层缓存叠着用:内存(L1)→ Cache API(L2)→ KV(L3)。热点查询基本都落在 L1,冷查询穿透到 L2,完全新的查询才跑业务。

下一篇讲 Redis 缓存——Cloudflare 没有 Redis,但很多团队已经在用 Upstash 等外部 Redis 服务。看看 Redis 的缓存模式、怎么在 Worker 里接入、以及跟 KV 的选型对比。