undici Cache Store 深入指南:内存与 SQLite 缓存后端的设计与实现
undici Cache Store 深入指南:内存与 SQLite 缓存后端的设计与实现
导读
Cache Store 是 undici 缓存拦截器(cache interceptor)的存储后端,负责持久化与检索缓存响应,并依据请求与响应 Vary 头决定返回哪一份缓存。本指南基于 CacheStore.md 官方文档,结合 memory-cache-store.js、sqlite-cache-store.js 与缓存拦截器源码 cache.js,系统讲解 MemoryCacheStore、SqliteCacheStore 的构造参数、核心方法、驱逐策略与事件机制,并给出自定义 Store 的完整契约,让你能够在生产环境中为 undici 接入内存、SQLite 乃至 Redis 等任意缓存后端。
什么是 Cache Store
A cache store 是 cache interceptor 用来持久化与检索缓存响应的存储后端。当请求到来时,Store 通过把请求与响应上的 Vary 头进行比较,来决定该为请求提供哪一份已存储的响应,并且要求遵循 RFC 9111(HTTP 缓存)语义。该特性自 undici v7.0.0 起引入,目前标记为 Stability: 2 - Stable,属于稳定的公开 API。
undici 内置两种 Store:
MemoryCacheStore:把响应保存在进程内存中,适用于单进程、短生命周期或对持久化无要求的场景。SqliteCacheStore:把响应持久化到 SQLite 数据库,基于 Node.js 的node:sqliteAPI 实现,适用于需要跨进程重启保留缓存的场景。
两者都通过 cacheStores 导出对象暴露,见 index.js:
import { cacheStores } from 'undici'
const { MemoryCacheStore, SqliteCacheStore } = cacheStores
需要特别说明的是:SqliteCacheStore 依赖 node:sqlite API。该类始终会被导出,但在不支持 node:sqlite 的 Node.js 版本中,构造它会直接抛错。因此在使用 SQLite 后端前,请先确认运行环境满足要求。
与缓存拦截器的接入方式
Cache Store 由缓存拦截器消费。构造一个 Store 后,通过 interceptors.cache({ store }) 注入到 Dispatcher 组合中即可生效:
import { interceptors, cacheStores, Agent, setGlobalDispatcher } from 'undici'
const store = new cacheStores.MemoryCacheStore({ maxSize: 50 * 1024 * 1024 })
setGlobalDispatcher(
new Agent().compose(interceptors.cache({ store }))
)
从 cache.js 的源码可以看到,拦截器默认配置为:
const {
store = new MemoryCacheStore(),
methods = ['GET'],
cacheByDefault = undefined,
type = 'shared',
origins = undefined
} = opts
即:不传 store 时默认使用 MemoryCacheStore,默认只缓存 GET 请求,缓存类型为共享缓存(shared)。拦截器内部会调用 assertCacheStore(store, 'opts.store')(见 util/cache.js)校验 Store 是否实现了 get、createWriteStream、delete 三个方法,否则直接抛出 TypeError。
Class: MemoryCacheStore
MemoryCacheStore 继承自 EventEmitter,在内存中保存缓存响应。它同时约束三个上限:缓存响应总数(maxCount)、全部响应总大小(maxSize)、单条响应大小(maxEntrySize)。任一上限被突破时,Store 会按"最久未使用(LRU)优先"驱逐大约一半的条目,并触发 'maxSizeExceeded' 事件。
import { interceptors, cacheStores, Agent, setGlobalDispatcher } from 'undici'
const store = new cacheStores.MemoryCacheStore({ maxSize: 50 * 1024 * 1024 })
setGlobalDispatcher(
new Agent().compose(interceptors.cache({ store }))
)
new MemoryCacheStore([options])
构造参数(均为可选):
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
maxCount |
number | 1024 |
最多可存储的响应条数 |
maxSize |
number | 104857600(100 MiB) |
所有存储响应体的总大小上限(字节) |
maxEntrySize |
number | 5242880(5 MiB) |
单条响应体的大小上限(字节),超过则不入缓存 |
errorCallback |
function | 无 | 接收 Store 内部错误回调,参数为 err(Error) |
maxCount、maxSize、maxEntrySize 均必须是非负整数,否则抛出 TypeError。从 memory-cache-store.js 的实现可以看到,三个上限分别用私有字段保存:
#maxCount = 1024
#maxSize = 104857600 // 100MB
#maxEntrySize = 5242880 // 5MB
且每个字段都经过严格校验——必须是 number 类型、必须是整数、且不小于 0,违反任一条件即抛 TypeError:
throw new TypeError('MemoryCacheStore options.maxCount must be a non-negative integer')
内部使用 Map 保存条目,以 ${key.origin}:${key.path} 作为顶层键,同一 URL 下可以按 Vary 差异保存多条变体。
memoryCacheStore.size
类型:number。当前所有已存储响应体的总字节数。该值在写入、替换、驱逐、删除时同步增减,见 memory-cache-store.js。
memoryCacheStore.isFull()
返回 boolean:当 Store 已触及 maxSize 或 maxCount 上限时返回 true,否则返回 false。实现非常直观:
isFull () {
return this.#size >= this.#maxSize || this.#count >= this.#maxCount
}
memoryCacheStore.get(key)
key:{CacheKey},要查找的请求- 返回:{GetResult|undefined},命中则返回匹配的缓存响应,无新鲜条目则返回
undefined
查找命中需要同时满足三个条件:条目 method 与 key.method 相同、条目 deleteAt 时间在未来(未过期)、且条目 vary 表中列出的每个请求头都与 key.headers 中对应头相等。核心匹配逻辑位于 memory-cache-store.js:
function findEntry (key, entries, now) {
for (let i = 0; i < entries.length; i++) {
const entry = entries[i]
if (
entry.deleteAt > now &&
entry.method === key.method &&
varyMatches(key, entry)
) {
return entry
}
}
}
值得注意的一个实现细节:命中后,Store 会把该 URL 的条目列表从 Map 中先删除再重新写入(#entries.delete(topLevelKey) 后再 set),以此在插入顺序上把最近命中的条目移动到末尾,为 LRU 驱逐提供依据——这正是"最久未使用优先驱逐"的实现基础。
memoryCacheStore.createWriteStream(key, value)
key:{CacheKey},响应对应的请求value:{CacheValue},要存储的响应元数据- 返回:{Writable|undefined},用于写入响应体的可写流;响应不可缓存时返回
undefined
写入流的实现见 memory-cache-store.js。每个 chunk 写入时累计 entry.size,一旦超过 maxEntrySize 就直接 this.destroy() 销毁流(不落库);流结束时(final)把条目提交进 Map。提交时如果已存在匹配条目则替换并扣除旧条目大小,否则新增条目并让 #count += 1。
提交后立即检查是否超限:
if (store.#size > store.#maxSize || store.#count > store.#maxCount) {
// 触发 maxSizeExceeded 事件(若未触发过)
store.emit('maxSizeExceeded', {...})
store.#evict(entry)
}
这里体现了关键机制:写入新条目本身不拒绝,而是"先写入、再驱逐",保证刚写入的条目在驱逐时也能被保护(见下文 #evict)。
memoryCacheStore.delete(key)
key:{CacheKey},要移除缓存的请求- 返回:undefined
删除该 key.origin 与 key.path 对应的所有缓存条目(即整个顶层键),并同步扣减 #size 与 #count。若 key 不是对象则抛出 TypeError:
delete (key) {
if (typeof key !== 'object') {
throw new TypeError(`expected key to be object, got ${typeof key}`)
}
...
}
驱逐策略与 'maxSizeExceeded' 事件
驱逐逻辑位于 #evict(memory-cache-store.js)。当超限时,Store 以 maxSize / 2 与 maxCount / 2 为目标,从 Map 头部(即最久未命中的 URL)开始逐个驱逐条目,直到总大小与总条数都降到一半以下;驱逐时跳过刚写入的 keep 条目。若最终仍未降到上限以内,最后才把 keep 自身也驱逐,确保内存安全。
事件 'maxSizeExceeded'(自 v7.10.0 起)在超限时、驱逐发生前立即触发,负载字段:
| 字段 | 类型 | 说明 |
|---|---|---|
size |
number | 当前所有响应总大小(字节) |
maxSize |
number | 配置的 maxSize 上限 |
count |
number | 当前存储的响应条数 |
maxCount |
number | 配置的 maxCount 上限 |
该事件每次溢出只触发一次(由 #hasEmittedMaxSizeEvent 标志控制),直到 Store 重新降到两个上限以下才会再次允许触发。典型用途:监控缓存压力、上报指标或主动扩容。
store.on('maxSizeExceeded', ({ size, maxSize, count, maxCount }) => {
console.warn(`cache store overflow: ${count}/${maxCount} entries, ${size}/${maxSize} bytes`)
})
Class: SqliteCacheStore
SqliteCacheStore 使用 node:sqlite 的同步 API(DatabaseSync)把缓存响应持久化到 SQLite 数据库。构造时若当前 Node.js 版本没有 node:sqlite,会直接抛出异常。
import { interceptors, cacheStores, Agent, setGlobalDispatcher } from 'undici'
const store = new cacheStores.SqliteCacheStore({ location: './cache.db' })
setGlobalDispatcher(
new Agent().compose(interceptors.cache({ store }))
)
从 sqlite-cache-store.js 可见,内部常量 VERSION = 3 用于表名版本管理(当前建表为 cacheInterceptorV3),单条响应上限为 2 GB。
new SqliteCacheStore([options])
构造参数(均为可选):
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
location |
string | ':memory:' |
SQLite 数据库文件路径,传 ':memory:' 使用纯内存数据库 |
maxCount |
number | Infinity |
最多可存储的响应条数 |
maxEntrySize |
number | 2000000000(2 GB) |
单条响应体大小上限(字节),不能超过 2 GB |
maxCount、maxEntrySize 必须是非负整数,且 maxEntrySize 不得大于 2 GB,否则抛 TypeError(见 sqlite-cache-store.js)。与内存版不同,SQLite 版没有 maxSize(总大小)上限,因为磁盘空间由数据库自行管理。
SQLite 底层结构
构造时执行的建表语句(见 sqlite-cache-store.js)值得关注,它揭示了持久化的数据模型:
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA temp_store = memory;
PRAGMA optimize;
CREATE TABLE IF NOT EXISTS cacheInterceptorV3 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL,
method TEXT NOT NULL,
body BUF NULL,
deleteAt INTEGER NOT NULL,
statusCode INTEGER NOT NULL,
statusMessage TEXT NOT NULL,
headers TEXT NULL,
cacheControlDirectives TEXT NULL,
etag TEXT NULL,
vary TEXT NULL,
cachedAt INTEGER NOT NULL,
staleAt INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV3_getValuesQuery ON cacheInterceptorV3(url, method, deleteAt);
CREATE INDEX IF NOT EXISTS idx_cacheInterceptorV3_deleteByUrlQuery ON cacheInterceptorV3(deleteAt);
几点实现细节:
- 启用了 WAL 日志模式、
NORMAL同步级别与内存临时存储,兼顾持久化安全与写入性能。 url由${key.origin}/${key.path}拼成(见#makeValueUrl),headers、vary、cacheControlDirectives等结构化字段以 JSON 字符串形式存放,读取时JSON.parse还原。- 查询语句按
(url, method, deleteAt)过滤并按deleteAt升序排列,先命中的自然是较早过期的条目。 - 列名
body BUF NULL与#getValuesQuery中的字段顺序,直接决定了get()返回的body是Buffer。
sqliteCacheStore.close()
返回 undefined。关闭底层 SQLite 数据库连接。应用退出或不再使用缓存时调用,避免数据库文件句柄泄漏。
sqliteCacheStore.size
类型:number。当前数据库中存储的响应条数。实现为对表执行 SELECT COUNT(*):
get size () {
const { total } = this.#countEntriesQuery.get()
return total
}
sqliteCacheStore.get(key)
key:{CacheKey}- 返回:{GetResult|undefined},命中的缓存响应;其中
body是 {Buffer}
与内存版语义一致:比较请求方法与 vary 表中列出的每个请求头。实现位于 #findValue(sqlite-cache-store.js),按 url + method 查出候选行,跳过已过 deleteAt 的行(除非 canBeExpired),再逐行做 vary 头匹配。注意与内存版的一个差异:内存版把 deleteAt 是否在未来作为硬性匹配条件,SQLite 版则依赖查询 SQL 中 deleteAt 参与过滤并结合 now >= value.deleteAt 跳过。
sqliteCacheStore.set(key, value)
key:{CacheKey}value:{CacheValue},body必须是 {Buffer}、{Buffer} 数组或null- 返回:undefined
直接写入数据库,不经过流。流程为:拼接 body → 若 body 字节数超过 maxEntrySize 则直接返回不存储 → 查找已存在条目,存在则走 UPDATE 覆盖,不存在则 INSERT 新行并调用 #prune() 清理。该方法是 createWriteStream() 的内部落库入口。
#prune() 的清理策略(sqlite-cache-store.js)顺序如下:
- 若
maxCount有限且当前条数未超限,直接返回; - 先删除所有已过期条目(
deleteAt <= Date.now()); - 仍超限则按
cachedAt升序(最早缓存的优先)删除max(floor(maxCount * 0.1), 1)条。
sqliteCacheStore.createWriteStream(key, value)
key:{CacheKey}value:{CacheValue}- 返回:{Writable|undefined}
与内存版类似:chunk 写入时累计大小,超过 maxEntrySize 即销毁流;流结束时(final)把累积的 Buffer 数组交给 set() 落库:
final (callback) {
store.set(key, { ...value, body })
callback()
}
sqliteCacheStore.delete(key)
key:{CacheKey}- 返回:undefined
删除该 key 对应 URL 的全部缓存行(DELETE FROM ... WHERE url = ?)。key 不是对象时同样抛 TypeError。注意与内存版的差异:内存版按 origin:path 删除,SQLite 版按拼接的 origin/path URL 删除,效果等价。
实现自定义 Cache Store
Cache Store 的本质是一个实现了统一接口的对象,任何满足该契约的对象都能传给缓存拦截器——这意味着你可以很方便地实现基于 Redis、远程服务或其他持久化手段的第三方 Store。接口要求(由 assertCacheStore 强制校验):
| 方法 | 签名 | 说明 |
|---|---|---|
get(key) |
(CacheKey) => GetResult | undefined | Promise<GetResult | undefined> |
查找响应 |
createWriteStream(key, value) |
(CacheKey, CacheValue) => Writable | undefined |
返回接收响应体的可写流,不可存储时返回 undefined |
delete(key) |
(CacheKey) => undefined | Promise<undefined> |
删除该 key 的全部响应 |
get 与 delete 返回 Promise 的能力非常关键:它允许 Store 背后是异步资源(如 Redis 网络请求)。缓存拦截器会对这些 Promise 进行 await,包括在 revalidation 与 stale-while-revalidate 路径上(见 cache.js 中对 result.then 的判断处理)。
一个最小可用的自定义 Store 骨架:
class MyStore {
async get (key) {
// 返回 GetResult | undefined
}
createWriteStream (key, value) {
// 返回 Writable(可引用实现自 node:stream 的 Writable)
}
async delete (key) {
// 清理 key.origin + key.path 对应的全部条目
}
}
CacheKey
被查找或存储的请求描述:
| 字段 | 类型 | 说明 |
|---|---|---|
origin |
string | 请求源(origin) |
method |
string | 请求方法 |
path |
string | 请求路径 |
headers |
Record<string, string|string[]> | 请求头,用于满足 Vary 匹配(可选) |
CacheKey 由拦截器内部通过 makeCacheKey(opts, requestOrigin) 生成(见 util/cache.js),其中 path 会把 query 序列化拼接进去。所有 Store 方法在入口处都会调用 assertCacheKey 校验:origin、method、path 必须是字符串,headers 必须是对象,否则抛 TypeError。
CacheValue
被缓存响应的元数据(不含响应体):
| 字段 | 类型 | 说明 |
|---|---|---|
statusCode |
number | HTTP 状态码 |
statusMessage |
string | HTTP 状态消息 |
headers |
Record<string, string|string[]> | 响应头 |
vary |
Record<string, string|string[]|null> | 响应 Vary 头列出的头名 → 原始请求中该头的值;原请求无此头则为 null(可选) |
etag |
string | 响应实体标签(可选) |
cacheControlDirectives |
Object | 解析后的响应 Cache-Control 指令(可选) |
cachedAt |
number | 缓存时间(毫秒时间戳) |
staleAt |
number | 变为 stale 的时间(毫秒时间戳) |
deleteAt |
number | 必须被驱逐的时间(毫秒时间戳),一旦超过该时间 Store 不得再返回该响应 |
vary 映射是响应选择的核心。假设响应头如下:
Vary: content-encoding, accept
content-encoding: utf8
accept: application/json
则记录下来的映射为:
{
'content-encoding': 'utf8',
accept: 'application/json'
}
若原始请求没有携带 accept 头,则该值为 null:
{
'content-encoding': 'utf8',
accept: null
}
从 util/cache.js 的 parseVaryHeader 可以看到映射的生成规则:头名统一转为小写,Vary: * 会直接退化为返回整个请求头(此时任何请求都无法精确匹配,缓存命中率极低),无效的 token 会导致返回 undefined(表示该响应不可缓存)。匹配侧的 headerValueEquals(内存与 SQLite 版共用同一实现)对字符串、数组以及 null 缺失值做了严格的逐项比较。
CacheValue 同样有 assertCacheValue 校验:statusCode、cachedAt、staleAt、deleteAt 必须是 number,statusMessage 必须是 string,headers、vary 必须是对象,etag 必须是 string。
GetResult
get() 的返回值:包含 CacheValue 的全部字段,外加响应体:
| 字段 | 类型 | 说明 |
|---|---|---|
body |
Readable | Iterable | AsyncIterable | Buffer | string | 缓存的响应体(可选) |
两个内置 Store 的实现差异点:MemoryCacheStore.get() 返回的 body 是缓存的 Buffer 数组拼接结果(保持原始 Buffer 引用),而 SqliteCacheStore.get() 会从数据库行还原为全新的 Buffer(Buffer.from(value.body.buffer, ...),见 sqlite-cache-store.js)。
测试与验证
缓存 Store 的契约行为在仓库测试中有充分覆盖。例如 test/interceptors/cache.js 中大量用例直接构造 MemoryCacheStore 与 SqliteCacheStore 实例注入拦截器进行端到端验证,涵盖命中、过期、Vary 变体匹配、驱逐与 maxSizeExceeded 事件等场景;test/interceptors/origin-isolation.js 则验证了 Store 在跨源请求隔离下的行为。若你实现了自定义 Store,可参考这些测试来验证你的实现满足拦截器的调用契约。
总结
- 选择
MemoryCacheStore:进程内缓存、零依赖、无需管理数据库文件,适合默认场景;通过maxCount/maxSize/maxEntrySize三把尺子控制内存占用,超限时 LRU 驱逐并触发'maxSizeExceeded'事件。 - 选择
SqliteCacheStore:跨重启持久化、可共享到磁盘;需要 Node.js 支持node:sqlite;通过location指定数据库文件,maxCount控制条数,maxEntrySize上限为 2 GB。 - 需要其他后端:实现
get/createWriteStream/delete三方法(支持 Promise)即可接入缓存拦截器,CacheKey、CacheValue、GetResult是必须遵守的数据契约。
详细的方法签名与语义请继续阅读 CacheStore.md、类型声明 cache-interceptor.d.ts,以及两份内置实现 memory-cache-store.js 与 sqlite-cache-store.js。