首页
/ TypeORM 查询结果缓存完全指南:机制原理、配置选型与源码级实践

TypeORM 查询结果缓存完全指南:机制原理、配置选型与源码级实践

2026-09-08 15:03:37作者:申梦珏Efrain

缓存是缓解高频只读查询压力的常见手段。本篇基于 TypeORM 官方文档「Caching queries」并结合仓库 src/cachesrc/query-builder 中的真实实现,系统讲解 TypeORM 查询结果缓存:哪些 API 可被缓存、如何在数据源与单条查询两级开启并控制过期时间,如何通过 cache id 精确管理缓存,以及数据库表、Redis/ioredis 与自定义 Provider 三种存储方案的选型与源码依据。读完你可以在不引入额外缓存中间件的前提下,为 QueryBuilder 与 Repository 的只读查询加上安全、可控的二级缓存。

TypeORM 的查询结果缓存(Query Result Cache)解决的是"同一段 SQL 在同一时间内被反复执行"的问题:命中缓存时不再访问数据库,直接返回缓存结果,从而显著降低数据库负载。它适用于 getManygetOne 等查询方法,既可以全局开关,也可以单条语句细粒度控制,甚至支持将缓存放进 Redis 或完全自定义的存储介质中。

可被缓存的查询范围

TypeORM 只会缓存"读结果"方法,写操作(insertupdatedeletesoftDeleterestore 等)不涉及缓存。根据 查询缓存文档 与源码定义,以下两类 API 支持结果缓存:

  • QueryBuilder 上的 getManygetOnegetRawManygetRawOnegetCount
  • RepositoryEntityManager 上的 find*count* 系列方法。

从源码看,find*/count* 最终都会转译为 SelectQueryBuilder 并透传缓存配置:在 SelectQueryBuilder.ts 中,this.cache(this.findOptions.cache) 会把 find 选项中的 cache 属性写入表达式状态。也就是说,缓存真正生效的落点统一在 SelectQueryBuilder 的结果加载阶段。

缓存结果以"查询语句 + 绑定参数"作为天然键:在 SelectQueryBuilder.tsloadRawResults 中,缓存查询标识 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.tscache 选项要么是布尔值 boolean,要么是一个完整配置对象,对象里包含 typeprovidertableNameoptionsalwaysEnableddurationignoreErrors 七个字段,后文会逐一讲解。

如果未开启缓存却调用了缓存相关逻辑,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 是否早于当前时间。过期判定在两个内置实现中完全一致:

因此在"写多读少"或对一致性要求较高的业务中,要么把缓存时长设得非常短,要么像官方示例那样在写操作后通过 remove([...]) 手动失效对应 id 的缓存,要么干脆不要开启缓存。

存储方案一:默认的数据库缓存表

默认情况下(type 不写或写 "database"),TypeORM 会在当前业务数据库中创建一张名为 query-result-cache 的独立表,把所有查询与序列化后的结果存进去。

表名可以通过 tableName 配置:

{
    type: "mysql",
    host: "localhost",
    ...
    cache: {
        type: "database",
        tableName: "configurable-table-query-result-cache"
    }
}

缓存表的真实结构(源码级)

这张表不是手工建的,而是在结构同步时由 DbQueryResultCache.tssynchronize() 方法创建:若表已存在则直接返回,否则执行建表。表包含 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 列表逐条 DELETEDbQueryResultCache.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()tryPlatformTools.load(this.clientType),加载失败会抛出 Cannot use cache because redis is not installed. Please run "npm i redis" 之类的 TypeORMErrorRedisQueryResultCache.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/clusterRedisQueryResultCache.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()/flushdbremove(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,包含 identifiertimedurationqueryresult 五个字段。

自定义 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 分发到 RedisQueryResultCacheDbQueryResultCache。这为对接外部缓存中间件(如 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 查询结果缓存时有几点工程经验值得沉淀:

  1. 明确一致性预算。缓存窗口(默认 1s)内写入的数据不会立刻可见,接受不了该延迟的业务(如账户余额、库存)不要缓存;适合缓存的是用户列表、配置项、统计口径等"读多写少、可容忍短暂延迟"的查询。
  2. 写操作后手动失效。由于内置缓存不做写入自动失效,建议在业务代码中统一封装:凡是写入了受缓存影响的数据,就调用 queryResultCache.remove([...]) 清理对应 cache id,或使用 cache:clear 兜底。
  3. 优先用 cache id 而非裸查询键。裸查询键是"SQL+参数"的巨型字符串,无法精确寻址;显式 id 既能缩短键长,也让业务侧可控清除成为可能。
  4. 高并发写场景慎用 database 型缓存。缓存表与本库同库同实例,写缓存走主库(DbQueryResultCache.ts),可能放大主库压力,此时 Redis 方案更合适。
  5. Redis 客户端版本与 options 写法强绑定。本仓库 type: "redis" 走的是 node-redis v4 的 createClient().connect() 风格,连接选项用 socket.host/socket.port;若使用 ioredis/ioredis/cluster 则遵循 ioredis 构造器风格,务必先 npm i redisnpm i ioredis

相关源码索引

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
docsdocs
暂无描述
Markdown
900
5.83 K
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
860
1.35 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
927
1.85 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.89 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
533
602
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.37 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
396
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.04 K
526