TypeORM 查询结果缓存完全指南:机制原理、配置选型与源码级实践
缓存是缓解高频只读查询压力的常见手段。本篇基于 TypeORM 官方文档「Caching queries」并结合仓库
src/cache与src/query-builder中的真实实现,系统讲解 TypeORM 查询结果缓存:哪些 API 可被缓存、如何在数据源与单条查询两级开启并控制过期时间,如何通过 cache id 精确管理缓存,以及数据库表、Redis/ioredis 与自定义 Provider 三种存储方案的选型与源码依据。读完你可以在不引入额外缓存中间件的前提下,为 QueryBuilder 与 Repository 的只读查询加上安全、可控的二级缓存。
TypeORM 的查询结果缓存(Query Result Cache)解决的是"同一段 SQL 在同一时间内被反复执行"的问题:命中缓存时不再访问数据库,直接返回缓存结果,从而显著降低数据库负载。它适用于 getMany、getOne 等查询方法,既可以全局开关,也可以单条语句细粒度控制,甚至支持将缓存放进 Redis 或完全自定义的存储介质中。
可被缓存的查询范围
TypeORM 只会缓存"读结果"方法,写操作(insert、update、delete、softDelete、restore 等)不涉及缓存。根据 查询缓存文档 与源码定义,以下两类 API 支持结果缓存:
QueryBuilder上的getMany、getOne、getRawMany、getRawOne、getCount;Repository与EntityManager上的find*、count*系列方法。
从源码看,find*/count* 最终都会转译为 SelectQueryBuilder 并透传缓存配置:在 SelectQueryBuilder.ts 中,this.cache(this.findOptions.cache) 会把 find 选项中的 cache 属性写入表达式状态。也就是说,缓存真正生效的落点统一在 SelectQueryBuilder 的结果加载阶段。
缓存结果以"查询语句 + 绑定参数"作为天然键:在 SelectQueryBuilder.ts 的 loadRawResults 中,缓存查询标识 queryId 由拼接后的 SQL 与序列化参数组成:
const queryId =
sql + " -- PARAMETERS: " + JSON.stringify(parameters)
因此同一段 QueryBuilder 代码只要条件参数不同,就会生成不同的缓存键,互不串扰。
第一步:在数据源选项中开启缓存
默认情况下缓存是完全关闭的,必须在数据源选项中显式开启:
{
type: "mysql",
host: "localhost",
username: "test",
password: "test",
database: "test",
entities: [...],
// 开启查询结果缓存
cache: true
}
文档特别强调:首次开启缓存时必须先同步数据库结构——既可以通过 CLI 的 schema:sync/migration:run,也可以使用数据源选项中的 synchronize: true。这是因为默认的"数据库表"缓存方案需要先创建一张缓存表(见下文),而建表动作发生在结构同步阶段,而非首次查询时。
对应的类型定义位于 BaseDataSourceOptions.ts:cache 选项要么是布尔值 boolean,要么是一个完整配置对象,对象里包含 type、provider、tableName、options、alwaysEnabled、duration、ignoreErrors 七个字段,后文会逐一讲解。
如果未开启缓存却调用了缓存相关逻辑,QueryResultCacheFactory.ts 会抛出 TypeORMError,提示必须在连接选项中设置 cache: true 或提供缓存配置。
在 QueryBuilder 中开启单条查询缓存
开启数据源级缓存只是"解锁能力",要真正缓存某条查询,还需要在查询上加 .cache(...):
const users = await dataSource
.createQueryBuilder(User, "user")
.where("user.isAdmin = :isAdmin", { isAdmin: true })
.cache(true)
.getMany()
.cache() 方法有三种重载,见 SelectQueryBuilder.ts:
.cache(true | false):布尔值开关;.cache(milliseconds: number):开启并指定本条查询的缓存毫秒数;.cache(id: any, milliseconds?: number):开启、指定缓存 id,并可选指定毫秒数。
Repository 中对应的写法是在 find 选项中传 cache: true:
const users = await dataSource.getRepository(User).find({
where: { isAdmin: true },
cache: true,
})
首次执行后结果被写入缓存;在缓存有效期内再次执行相同代码,会直接从缓存返回,不再命中数据库。
默认过期时间:1000 毫秒
若未显式指定,默认缓存寿命是 1000 ms(1 秒)。这个默认值来自缓存写入逻辑,而非文档空谈:在 SelectQueryBuilder.ts 中,有效期取值为
this.expressionMap.cacheDuration || (cacheOptions.duration ?? 1000)
即"单条查询指定的时长 → 数据源全局 duration → 兜底 1000ms"。
官方文档用一个例子说明 1 秒窗口的含义:假设用户在 3 秒内打开用户页 150 次,期间真正执行数据库查询的次数只有 3 次左右;但代价是,在这 1 秒的缓存窗口内插入的新用户不会立即出现在查询结果里。换言之,缓存越短越新鲜,但节流效果越弱;缓存越长,数据一致性延迟越大——选择时长本质是在"数据库负载"与"数据新鲜度"之间做权衡。
手动调整缓存时长
通过 QueryBuilder 将本条查询的缓存延长到 1 分钟:
const users = await dataSource
.createQueryBuilder(User, "user")
.where("user.isAdmin = :isAdmin", { isAdmin: true })
.cache(60000) // 1 minute
.getMany()
等价地,Repository:
const users = await dataSource.getRepository(User).find({
where: { isAdmin: true },
cache: 60000,
})
也可以直接在数据源选项中设置全局默认时长,这样所有未显式指定时长的缓存查询都会沿用该值:
{
type: "mysql",
host: "localhost",
...
cache: {
duration: 30000 // 30 seconds
}
}
进阶技巧:分页 count 的缓存键
一个值得注意的源码细节:分页场景下 getManyAndCount 会对同一条查询分别执行取数与计数。为避免两条查询使用相同的 cache id 互相覆盖,SelectQueryBuilder.ts 在生成 count 查询时会把缓存 id 改写成 ${cacheId}-count。这说明 TypeORM 对缓存键的隔离处理是相当精细的。
细粒度缓存控制:cache id 与按需清除
上面的用法完全依赖"查询语句"作为缓存键,但很多业务场景需要主动定位并清除某类缓存。此时可以给查询指定一个显式的缓存 id:
const users = await dataSource
.createQueryBuilder(User, "user")
.where("user.isAdmin = :isAdmin", { isAdmin: true })
.cache("users_admins", 25000) // cache id 为 "users_admins",有效期 25 秒
.getMany()
Repository 的等价写法使用对象形式的 cache:
const users = await dataSource.getRepository(User).find({
where: { isAdmin: true },
cache: {
id: "users_admins",
milliseconds: 25000,
},
})
设置 cache id 后,你可以获得对缓存生命周期的细粒度控制。例如在插入新管理员用户后,主动清除对应的缓存,避免旧数据被继续返回:
await dataSource.queryResultCache.remove(["users_admins"])
重要提醒:TypeORM 不会自动同步写操作
需要强调一个容易踩坑的事实:TypeORM 的内置缓存不会在 insert/update/delete 时自动失效相关条目。缓存是否过期完全取决于 time + duration 是否早于当前时间。过期判定在两个内置实现中完全一致:
- 数据库方案:DbQueryResultCache.ts 中
time + duration < Date.now(); - Redis 方案:RedisQueryResultCache.ts 中
savedCache.time! + savedCache.duration < Date.now()。
因此在"写多读少"或对一致性要求较高的业务中,要么把缓存时长设得非常短,要么像官方示例那样在写操作后通过 remove([...]) 手动失效对应 id 的缓存,要么干脆不要开启缓存。
存储方案一:默认的数据库缓存表
默认情况下(type 不写或写 "database"),TypeORM 会在当前业务数据库中创建一张名为 query-result-cache 的独立表,把所有查询与序列化后的结果存进去。
表名可以通过 tableName 配置:
{
type: "mysql",
host: "localhost",
...
cache: {
type: "database",
tableName: "configurable-table-query-result-cache"
}
}
缓存表的真实结构(源码级)
这张表不是手工建的,而是在结构同步时由 DbQueryResultCache.ts 的 synchronize() 方法创建:若表已存在则直接返回,否则执行建表。表包含 6 个字段:
| 字段 | 含义 | 是否可空 | 说明 |
|---|---|---|---|
id |
主键,自增(Spanner 下为 uuid) | 否 | 由驱动 normalizeType 决定列类型 |
identifier |
用户自定义缓存 id | 是 | 未指定 cache id 时为空 |
time |
缓存创建时间戳 | 否 | 与 duration 配合判断是否过期 |
duration |
缓存有效毫秒数 | 否 | 过期判定:time + duration < now |
query |
缓存的完整查询标识 | 否 | SQL + 序列化参数 |
result |
序列化后的查询结果 | 否 | 命中后 JSON.parse 返回 |
各字段的实际列类型由 driver.mappedDataTypes.cacheId / cacheIdentifier / cacheTime / cacheDuration / cacheQuery / cacheResult 映射而来,也就是说不同数据库(MySQL、PostgreSQL、SQL Server、Oracle、Spanner 等)会为该表选择各自合适的类型。
读写与命中逻辑
- 读缓存:
getFromCache()优先按identifier查询;没有显式 id 时退化为按query精确匹配。源码中为 Oracle 使用了dbms_lob.compare比较 CLOB 字段,为 SQL Server 将参数包装成MssqlParameter——这些都是驱动层面的兼容处理。并且查询缓存表本身的读操作显式.cache(false),避免在开启alwaysEnabled时形成"读缓存还要查缓存"的无限递归(见 DbQueryResultCache.ts)。 - 写缓存:
storeInCache()的逻辑是"有则更新、无则插入"——若已存在相同identifier或相同query的记录则UPDATE,否则INSERT(见 DbQueryResultCache.ts)。Spanner 不支持自增列,因此插入前会先用RandomGenerator.uuidv4()生成主键。 - 清空与删除:
clear()直接clearTable清空整张表;remove(identifiers)则按 identifier 列表逐条DELETE(DbQueryResultCache.ts)。
读写分离架构下的主库写入
一个容易被忽略的实现细节:在 storeInCache() 开头(DbQueryResultCache.ts),TypeORM 会检查传入的 queryRunner 是否存在、以及其复制模式是否为 "slave";一旦发现是只读从库或没有 runner,就会改用 dataSource.createQueryRunner("master") 来写缓存。这意味着即使你的应用配置了读写分离,缓存写入也始终落在主库——设计意图是避免从库延迟导致缓存表读取到过期/缺失数据,但这也会给主库增加额外写入压力,高并发场景下需要权衡。
数据库表缓存的适用性
当缓存与业务数据存放在同一数据库时,优点是无额外基础设施、事务语义简单;缺点是"缓存本身也在消耗数据库 I/O",查询负载并没有完全离开数据库。这也是官方文档指出"如果单个数据库表存储缓存对你不够高效"的原因——文档随后便引导读者转向 Redis。
存储方案二:Redis / ioredis / ioredis 集群
把缓存搬到 Redis 可以彻底绕开业务数据库。文档中缓存对象里 type 写 "redis",并把连接参数放入 options:
{
type: "mysql",
host: "localhost",
...
cache: {
type: "redis",
options: {
socket: {
host: "localhost",
port: 6379
}
}
}
}
关于 options 的语义,需要结合本仓库当前使用的 redis 客户端版本理解:在本仓库的 RedisQueryResultCache.ts 中,type: "redis" 分支通过 redis.createClient(clientOptions) 创建客户端并调用 await client.connect()——这是 node-redis v4 之后的新版 API,因此 options 应遵循 node-redis 客户端配置 的写法(如 socket.host / socket.port)。而 type: "ioredis" 分支则使用 new this.redis(cacheOptions.port, cacheOptions.options) 或 new this.redis(cacheOptions.options) 的构造方式,对应 ioredis 的传统参数风格。
依赖说明:redis 相关客户端库是运行时按需加载的。
loadRedis()在try中PlatformTools.load(this.clientType),加载失败会抛出Cannot use cache because redis is not installed. Please run "npm i redis"之类的TypeORMError(RedisQueryResultCache.ts)。因此使用 Redis 缓存前需要自行安装对应依赖(npm 包名与type一致)。
连接 ioredis 集群
若使用 IORedis 的集群功能连接 redis-cluster,把 type 设为 "ioredis/cluster",在 options 下提供 startupNodes 与集群级 options:
{
type: "mysql",
host: "localhost",
username: "test",
cache: {
type: "ioredis/cluster",
options: {
startupNodes: [
{ host: "localhost", port: 7000 },
{ host: "localhost", port: 7001 },
{ host: "localhost", port: 7002 }
],
options: {
scaleReads: "all",
clusterRetryStrategy: function (times) { return null },
redisOptions: {
maxRetriesPerRequest: 1
}
}
}
}
}
文档同时指出,如果你习惯把节点数组作为 IORedis Cluster 构造器的第一个参数,TypeORM 也支持直接把数组传给 options:
{
...
cache: {
type: "ioredis/cluster",
options: [
{ host: "localhost", port: 7000 },
{ host: "localhost", port: 7001 },
{ host: "localhost", port: 7002 }
]
},
...
}
这与源码中的分支对应:connect() 会先判断 Array.isArray(cacheOptions.options)——是数组则直接 new Cluster(nodes);否则要求 options.startupNodes,两者都不满足时抛出 options.startupNodes required for ioredis/cluster(RedisQueryResultCache.ts)。
Redis 缓存的键与过期实现
Redis 方案的存储模型比数据库表更简单直接:
- 键:
identifier ?? query(有显式 cache id 用 id,否则用整段查询标识); - 值:把
QueryResultCacheOptions整体JSON.stringify后存入; - 过期:使用 Redis 原生 PX 毫秒过期,node-redis 客户端通过
client.set(key, value, { expiration: { type: "PX", value: duration } })实现,ioredis 则用client.set(key, value, "PX", duration)(RedisQueryResultCache.ts)。
这意味着过期判断由 Redis 自身完成,不依赖业务侧的 time + duration 计算,命中后直接 JSON.parse 返回(getFromCache,见 RedisQueryResultCache.ts)。clear() 对应 Redis 的 flushDb()/flushdb,remove(identifiers) 对应 client.del(identifiers)。
存储方案三:自定义 QueryResultCache Provider
内置的数据库表与 Redis 都无法满足需求时,文档提供了一条完整扩展路径:cache.provider 接收一个工厂函数,返回实现了 QueryResultCache 接口的新对象。
QueryResultCache 接口定义于 QueryResultCache.ts,实现者需要提供 7 个方法:
| 方法 | 职责 |
|---|---|
connect() |
建立与缓存提供方的连接 |
disconnect() |
断开连接 |
synchronize(queryRunner?) |
在结构同步阶段执行建表等初始化 |
getFromCache(options, queryRunner?) |
读缓存,未命中返回 undefined |
storeInCache(options, savedCache, queryRunner?) |
写缓存(有则更新、无则插入) |
isExpired(savedCache) |
判断某条缓存是否过期 |
clear(queryRunner?) |
清空全部缓存 |
remove(identifiers, queryRunner?) |
按 identifier 列表删除缓存 |
其中 getFromCache / storeInCache 往返的对象类型为 QueryResultCacheOptions.ts 定义的 QueryResultCacheOptions,包含 identifier、time、duration、query、result 五个字段。
自定义 Provider 的写法:
// 例如:实现一个基于文件或内存的缓存
class CustomQueryResultCache implements QueryResultCache {
constructor(private dataSource: DataSource) {}
// ...实现上述 7 个方法
}
然后在数据源配置中注入工厂函数:
{
...
cache: {
provider(dataSource) {
return new CustomQueryResultCache(dataSource)
}
}
}
从 QueryResultCacheFactory.ts 的工厂逻辑可以看到优先级:如果配置了 provider 函数,直接返回 provider(this.dataSource) 的产物,不再走内置的 database/redis 分支;否则才按 type 分发到 RedisQueryResultCache 或 DbQueryResultCache。这为对接外部缓存中间件(如 Memcached、内存 Map、云缓存服务)保留了干净的扩展点。
容错开关:ignoreErrors
缓存是性能优化手段,不应因缓存故障拖垮主流程。配置 ignoreErrors: true 后,当缓存读写抛出异常时,TypeORM 会吞掉错误并让查询直接落到数据库:
{
type: "mysql",
host: "localhost",
...
cache: {
type: "redis",
options: {
socket: {
host: "localhost",
port: 6379
}
},
ignoreErrors: true // Redis 挂了也不会影响业务查询
}
}
源码支撑在 SelectQueryBuilder.ts:读取缓存与写入缓存都包在 try/catch 中,捕获异常后检查 cacheOptions.ignoreErrors——为 false 则原样抛出,为 true 则置 cacheError = true,跳过缓存读取继续执行真实查询,且不再尝试回写缓存(L3899-L3923)。Redis 实现还额外做了配合:开启该选项时,客户端会把连接错误降级为 logger.log("warn", ...) 日志而非抛出(RedisQueryResultCache.ts)。
全局 alwaysEnabled 与 CLI 清空缓存
alwaysEnabled:一键缓存所有查询
除逐条 .cache(...) 外,数据源缓存配置里还有一个 alwaysEnabled 开关。文档主体未展开它,但其源码行为值得说明:在 SelectQueryBuilder.ts 中
const isCachingEnabled = cacheOptions.alwaysEnabled
? this.expressionMap.cache !== false
: this.expressionMap.cache === true
也就是说,alwaysEnabled: true 会让所有 Select 查询默认都走缓存,除非某条查询显式 .cache(false) 关闭;而不开启该选项时,只有显式 .cache(true) 的查询才会被缓存。注意开启该选项意味着全库所有只读查询都处于默认 1000ms(或你配置的 duration)的缓存中,务必评估数据新鲜度风险。
typeorm cache:clear
命令行工具提供了全局清空入口:
typeorm cache:clear
该命令定义在 CacheClearCommand.ts:命令名 cache:clear,核心动作是创建数据源后调用 dataSource.queryResultCache.clear(),底层分别落到数据库表的 clearTable 或 Redis 的 flushDb。
当缓存键没有设置成业务可控的 cache id、无法用 remove([...]) 精确打击时,cache:clear 就是兜底手段(代价是清空全部缓存条目,相当于瞬间缓存击穿,注意错峰执行)。
缓存选项速查表
综合 BaseDataSourceOptions.ts 的类型定义与文档,cache 对象完整字段如下:
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
cache |
boolean | 对象 |
关闭 | 布尔值仅表示开关;对象可配置以下字段 |
type |
"database" | "redis" | "ioredis" | "ioredis/cluster" |
"database" |
存储介质,database 为独立缓存表 |
tableName |
string |
"query-result-cache" |
仅 database 类型生效,自定义缓存表名 |
options |
any |
— | redis/ioredis 连接选项(node-redis v4 用 socket,ioredis 用传统参数) |
alwaysEnabled |
boolean |
false |
为 true 时所有查询默认缓存,可逐条 .cache(false) 关闭 |
duration |
number |
1000 |
全局默认缓存毫秒数,可被单条查询覆盖 |
provider |
(dataSource) => QueryResultCache |
— | 返回自定义缓存实现的工厂函数,优先级最高 |
ignoreErrors |
boolean |
false |
缓存读写失败时静默降级为直查数据库 |
实践建议与一致性边界
综合文档与源码,使用 TypeORM 查询结果缓存时有几点工程经验值得沉淀:
- 明确一致性预算。缓存窗口(默认 1s)内写入的数据不会立刻可见,接受不了该延迟的业务(如账户余额、库存)不要缓存;适合缓存的是用户列表、配置项、统计口径等"读多写少、可容忍短暂延迟"的查询。
- 写操作后手动失效。由于内置缓存不做写入自动失效,建议在业务代码中统一封装:凡是写入了受缓存影响的数据,就调用
queryResultCache.remove([...])清理对应 cache id,或使用cache:clear兜底。 - 优先用 cache id 而非裸查询键。裸查询键是"SQL+参数"的巨型字符串,无法精确寻址;显式 id 既能缩短键长,也让业务侧可控清除成为可能。
- 高并发写场景慎用 database 型缓存。缓存表与本库同库同实例,写缓存走主库(DbQueryResultCache.ts),可能放大主库压力,此时 Redis 方案更合适。
- Redis 客户端版本与
options写法强绑定。本仓库type: "redis"走的是 node-redis v4 的createClient().connect()风格,连接选项用socket.host/socket.port;若使用ioredis/ioredis/cluster则遵循 ioredis 构造器风格,务必先npm i redis或npm i ioredis。
相关源码索引
- 缓存核心接口与类型:QueryResultCache.ts、QueryResultCacheOptions.ts
- 缓存实现工厂(provider 优先、database/redis 分发):QueryResultCacheFactory.ts
- 数据库表实现(建表、读写、过期、清除):DbQueryResultCache.ts
- Redis/ioredis/集群实现:RedisQueryResultCache.ts
- 缓存配置项类型定义:BaseDataSourceOptions.ts
- 缓存生效主路径(键生成、默认时长、alwaysEnabled、ignoreErrors):SelectQueryBuilder.ts
.cache()方法重载实现:SelectQueryBuilder.ts- CLI 清空缓存命令:CacheClearCommand.ts
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0632
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
video-shotcraftAI宣传片skill,使用 Remotion 制作电影级产品视频:提供106 张镜头配方卡和可复用的视频魔板。适用于 Claude Code 与 Codex以及所有其他智能体Markdown00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00