18.03-内存缓存
要点
- 内存缓存是最快的一层——读本地 Map,0 网络延迟,比 KV 还快一个数量级
- Module Worker 的模块级变量在实例生命周期内保持不变——这是天然的缓存容器
- 闭包 Map 是最简单的内存缓存实现,但需要自己管理大小和淘汰
- Worker 内存有硬限制(默认 128MB),缓存不能无限增长
- 每个 Worker 实例的内存是独立的——东京实例的缓存大阪读不到
- 内存缓存适合「小、热点、可容忍不一致」的数据;大对象、强一致、跨实例共享的场景要用 KV 或 Cache API
1. 为什么 Worker 的内存缓存值得讲
传统 Node.js 应用里,进程内缓存是常见做法——用一个全局 Map 或 node-cache 库存热点数据。Worker 环境也能这么做,但有几个独特之处:
- Worker 是 V8 Isolate,不是容器。Isolate 可以存活几分钟到几小时,处理成千上万个请求。模块级变量在整个生命周期内保持不变——直到 Isolate 被回收
- 没有 Redis 可用。Cloudflare 没有提供 Redis 服务,内存缓存 + KV/Cache API 是主要组合
- 内存限制严格。Worker 默认内存上限 128MB(可以在
wrangler.jsonc里调),缓存失控会直接 OOM - 每个 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 KB | 500 KB |
| 中等字符串(JSON 响应) | ~5 KB | 5 MB | 50 MB |
| Embedding(1536 维 float) | ~12 KB | 12 MB | 120 MB |
| 完整 LLM 响应 | ~20 KB | 20 MB | 200 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 API | KV |
|---|---|---|---|
| 读延迟 | 0.01ms | 0.1ms | 5ms |
| 作用域 | 单 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 的选型对比。