☰
MikroORM 结果缓存(Result Cache)实战指南:机制、配置与自定义适配器
2026/9/26 6:21:10 网站建设 项目流程
  • 后端

【免费下载链接】mikro-orm

TypeScript ORM for Node.js based on Data Mapper, Unit of Work and Identity Map patterns. Supports MongoDB, MySQL, MariaDB, MS SQL Server, PostgreSQL and SQLite/libSQL databases.

项目地址:https://gitcode.com/gh_mirrors/mi/mikro-orm
点击查看免费下载

结果缓存是 MikroORM 内置的一套轻量级查询结果缓存机制,它允许EntityManager的查询方法与QueryBuilder的查询结果在指定时间内被复用,从而避免重复命中数据库。本文以官方文档 docs/docs/caching.md 为骨架,结合packages/core与packages/sql中的源码实现和 result-cache 测试套件 的验证行为,系统讲解缓存如何开启、缓存键如何生成、命中后如何还原实体,以及如何通过实现CacheAdapter接口接入 Redis 等外部存储。

结果缓存是什么:适用范围与默认行为

MikroORM 的结果缓存(result cache)是一套简单但实用的机制,适用于以下EntityManager方法:

  • find()
  • findOne()
  • findAndCount()
  • findOneOrFail()
  • count()

以及QueryBuilder的所有结果获取方法(包括execute())。当这些查询开启缓存后,相同条件的查询在缓存有效期内会直接复用上一次的结果,不再向数据库发起新的 SQL。

关于默认行为,官方文档明确指出两点:

  1. 默认使用内存缓存(MemoryCacheAdapter),该缓存对整个MikroORM实例共享;
  2. 默认过期时间为 1 秒(1000ms)。

需要特别说明的是,结果缓存默认是关闭的,必须显式开启(逐查询或全局配置),这与 Identity Map(身份映射)是两回事——后者始终存在,用于保证同一个实体在单位工作(Unit of Work)内是同一个实例。驱动层接口的注释也明确区分了这一点(见 IDatabaseDriver.ts 中cache选项的说明:“Result cache is by default disabled, not to be confused with the identity map.”)。

按查询启用缓存:cache选项的三种形态

在find()等方法的 options 中传入cache字段即可为该次查询单独开启缓存。cache字段支持三种形态(类型定义为boolean | number | [string, number],见 IDatabaseDriver.ts):

形态含义
cache: true使用默认缓存键(自动生成)和默认过期时间
cache: 50使用自动缓存键,但将过期时间覆盖为 50ms
cache: ['cache-key', 50]同时指定自定义缓存键和过期时间(60s 可写作60_000)

官方文档给出的find()示例:

const res = await em.find(Book, { author: { name: 'Jon Snow' } }, { populate: ['author', 'tags'], cache: 50, // 设置过期时间为 50ms // cache: ['cache-key', 50], // 自定义缓存键 + 过期时间 // cache: true, // 使用默认缓存键和过期时间 });

同样的选项也适用于findOne()、findOneOrFail()、findAndCount()与count(),例如:

const res = await em.findOneOrFail(Book, { author: { name: 'Jon Snow' } }, { populate: ['author', 'tags'], cache: ['abc', 100], }); const total = await em.count(Book, { author: { name: 'Jon Snow' } }, { cache: 100 });

QueryBuilder 的.cache()方法

QueryBuilder提供链式方法.cache(),默认参数为true,同样支持数字与[key, expiration]元组:

const res = await em.createQueryBuilder(Book) .where({ author: { name: 'Jon Snow' } }) .cache() // 使用默认缓存键和过期时间 .getResultList();

.cache()的实现位于 packages/sql/src/query/QueryBuilder.ts,它只是把配置存入查询状态:

cache(config: boolean | number | [string, number] = true): this { this.ensureNotFinalized(); this.#state.cache = config; return this; }

getResultList()、getSingleResult()、getCount()、execute()等结果方法在真正执行 SQL 前都会先经过tryCache检查、执行后经storeCache写回(见 QueryBuilder.ts),因此只要链上调用过.cache(),所有结果方法都会生效。

全局启用与默认配置:resultCache配置详解

如果希望对所有查询统一启用缓存,可以在MikroORM.init()的配置中设置resultCache字段:

const orm = await MikroORM.init({ resultCache: { // 以下均为默认值 adapter: MemoryCacheAdapter, // 缓存适配器(类) expiration: 1000, // 默认过期时间:1s options: {}, // 传给适配器构造函数的额外选项 // 也可以全局启用缓存(对全部查询生效) // global: 50, // 全局缓存,过期时间 50ms }, // ... });

resultCache配置项的完整类型定义位于 packages/core/src/utils/Configuration.ts,各字段说明如下:

  • expiration?: number:默认缓存过期时间(毫秒),默认1000。当某个查询只写了cache: true而未显式指定数字时,使用该值。
  • adapter?: { new (...params: any[]): CacheAdapter }:缓存适配器类,默认MemoryCacheAdapter。需要实现CacheAdapter接口。
  • options?: Dictionary:透传给适配器构造函数的选项对象,默认{}。例如自定义适配器所需的连接配置、缓存目录等。
  • global?: boolean | number | [string, number]:是否对所有查询全局启用结果缓存。可以是true(用默认过期时间)、一个数字(全局过期时间),或[key, expiration]元组。开启后,即使单个查询没有传cache选项也会生效。

全局配置的实际取值逻辑在tryCache/storeCache中体现(EntityManager.ts 与 EntityManager.ts):

config ??= this.config.get('resultCache').global;

也就是说,查询级cache选项优先,未指定时回退到全局global配置。全局开关也可以在运行时动态修改,测试套件中就有orm.config.get('resultCache').global = 100;之后再次置为undefined的用法(见 result-cache.postgre.test.ts)。

适配器实例的创建由Configuration.getResultCacheAdapter()完成(Configuration.ts),它通过getCachedService按配置惰性创建并缓存单例,并将expiration与options合并后传给适配器构造函数:

getResultCacheAdapter(): CacheAdapter { return this.getCachedService(this.#options.resultCache.adapter!, { expiration: this.#options.resultCache.expiration, ...this.#options.resultCache.options, }); }

缓存键是如何生成的

理解缓存键的生成规则,有助于判断哪些查询会共享缓存、哪些不会。MikroORM 的缓存键生成分两条路径:

EntityManager系列方法:cacheKey()

find()等方法的自动缓存键由内部方法cacheKey()生成(EntityManager.ts),其核心逻辑为:

  1. 剔除与结果无关的选项:从 options 中删除ctx、strategy、flushMode、logging、loggerContext、signal、inflightQueryAbortStrategy等字段(注释说明 logger context 等可能包含同一查询的动态数据,不应参与键计算);
  2. 实体标识优先使用数据库侧信息:如果元数据存在,使用[schema, tableName, discriminatorValue](表名跨构建、跨进程稳定,且为单表继承(STI)加入判别值);元数据未知时才回退到实体类名;
  3. 组合键:[entityKey, method, opts, where],其中method是'em.find'、'em.findOne'、'em.count'等;
  4. RLS 会话上下文作用域:如果存在行级安全(Row Level Security)的会话上下文,会将其追加到键尾部,避免不同租户/角色之间的缓存串扰。

QueryBuilder:基于 SQL 与参数

QueryBuilder的缓存键由执行路径构造(QueryBuilder.ts):

const cacheKey: unknown[] = ['qb.execute', query.sql, query.params, method];

即“SQL 语句 + 绑定参数 + 结果方法名”的组合;同样地,存在会话上下文时也会追加到键中。由于键包含完整 SQL 与参数,相同查询条件但不同排序、分页的查询不会互相污染。

自定义键与会话作用域

当使用cache: ['cache-key', 50]显式指定键时,如果当前存在会话上下文,实际存储的键会追加|${JSON.stringify(sessionContext)}后缀(见 EntityManager.ts 的注释:“a named cache key discards the computed key ... scope it here too”),防止 fork 出来的 EntityManager 在相同命名键下串读其他会话的数据。

缓存命中后发生了什么

命中缓存并不是简单地把 JSON 数据原样返回,而是经历了一个实体还原过程。核心逻辑在tryCache()(EntityManager.ts):

const cached = await em.#resultCache.get(cacheKey); if (!cached) { return { key: cacheKey, data: cached }; } // ... if (Array.isArray(cached) && merge) { data = cached.map(item => em.#entityFactory.create<T>(entityName, item, createOptions)) as unknown as R; } else if (Utils.isObject<EntityData<T>>(cached) && merge) { data = em.#entityFactory.create<T>(entityName, cached, createOptions) as unknown as R; } else { data = cached; } await em.#unitOfWork.dispatchOnLoadEvent();

关键点:

  • 对缓存中的实体数据,通过实体工厂entityFactory.create()重新创建实体实例,并支持merge(合并进当前上下文)、refresh、schema等选项,recomputeSnapshot: true会重算快照;
  • 还原后还会派发onLoad生命周期事件;
  • 在find()的调用路径中(EntityManager.ts),命中缓存后返回实体列表,并继续执行entityLoader.populate()完成 populate 关联加载——也就是说缓存命中并不跳过 populate 流程,关联数据依然会被正确加载(当使用 JOINED 策略时,关联数据通常已随查询一并缓存,测试中命中后不会再产生新的 SQL)。

未命中时,查询正常执行,结果通过storeCache()写回缓存(EntityManager.ts),过期时间的计算规则为:元组取第二个元素、数字取自身、true则为undefined(交由适配器使用默认过期时间)。

清除缓存:clearCache()与显式键

结果缓存没有自动失效机制(除非达到过期时间),因此如果需要主动让缓存失效,必须为缓存指定显式键,之后调用em.clearCache(cacheKey):

// 以 'book-cache-key' 作为缓存键,过期时间 60s const res = await em.find(Book, { ... }, { cache: ['book-cache-key', 60_000] }); // 按名称清除该缓存项 await em.clearCache('book-cache-key');

clearCache()的实现(EntityManager.ts)会先移除指定键,如果存在会话上下文,还会一并移除${cacheKey}|${JSON.stringify(sessionContext)}变体,确保命名键在会话作用域下也能被彻底清理。

测试套件对清除行为做了明确验证(result-cache.postgre.test.ts):在缓存有效期内clearCache('abc')之后再次查询,SQL 调用数从 2 增加到 3,证明缓存已被清除、查询重新执行。

CacheAdapter接口与内置适配器

所有缓存后端都必须实现CacheAdapter接口。官方文档给出的接口定义(与仓库 packages/core/src/cache/CacheAdapter.ts 中的实现一致):

export interface CacheAdapter { /** * 获取 `name` 键下的缓存项。 */ get(name: string): Promise<any>; /** * 写入缓存项。`origin` 用于缓存失效判断,应反映数据来源的变化。 */ set(name: string, data: any, origin: string, expiration?: number): Promise<void>; /** * 移除指定缓存项。 */ remove(name: string): Promise<void>; /** * 清空所有缓存项。 */ clear(): Promise<void>; /** * 在 `MikroORM.close()` 中被调用,用于优雅关闭(例如 Redis 连接)。 */ close?(): Promise<void>; }

值得补充的是,仓库中实际接口比文档示例更进一步:get/set/remove/clear均支持同步与异步两种返回形态,并额外定义了用于元数据缓存的同步变体SyncCacheAdapter(带可选的combine()方法),见 CacheAdapter.ts。适配器同时服务于结果缓存与元数据缓存两条链路。

内置适配器一览

仓库packages/core/src/cache/目录下提供了四个内置适配器:

适配器文件用途
MemoryCacheAdapterMemoryCacheAdapter.ts结果缓存默认适配器,进程内 Map 存储,基于时间过期
NullCacheAdapterNullCacheAdapter.ts空操作适配器,所有读写均为 no-op,用于关闭缓存
FileCacheAdapterFileCacheAdapter.ts文件落盘缓存(JSON 文件),主要用于元数据缓存,支持combined合并模式
GeneratedCacheAdapterGeneratedCacheAdapter.ts基于预生成静态数据的适配器,由 CLIcache:generate命令产出

MemoryCacheAdapter源码级解读

默认的MemoryCacheAdapter(MemoryCacheAdapter.ts)实现非常简洁,值得逐行理解其过期策略:

export class MemoryCacheAdapter implements CacheAdapter { readonly #data = new Map<string, { data: any; expiration: number }>(); readonly #options: { expiration: number }; constructor(options: { expiration: number }) { this.#options = options; } get<T = any>(name: string): T | undefined { const data = this.#data.get(name); if (data) { if (data.expiration < Date.now()) { this.#data.delete(name); // 惰性过期:读取时才发现过期并删除 } else { return data.data; } } return undefined; } set(name: string, data: any, origin: string, expiration?: number): void { this.#data.set(name, { data, expiration: Date.now() + (expiration ?? this.#options.expiration) }); } }

要点:

  • 底层是Map<string, { data, expiration }>,没有后台清理线程,采用惰性过期——条目在get()时被检查,若已过期则删除并返回undefined(视为未命中);
  • set()时若无显式expiration,回退到构造函数收到的默认过期时间(即配置中的resultCache.expiration);
  • 因为存的是普通对象引用,缓存的实体数据与返回结果共享内存,这也是为什么tryCache中要重新通过实体工厂创建实例并重算快照。

编写自定义适配器(如 Redis)

官方文档特别在close?()的注释中点名了 Redis 场景。要接入外部存储,只需实现CacheAdapter接口并在配置中替换adapter即可,例如一个基于 Redis 的示意实现:

import type { CacheAdapter } from '@mikro-orm/core'; class RedisCacheAdapter implements CacheAdapter { constructor(private client: RedisClient, private defaultExpiration: number) {} async get(name: string) { const raw = await this.client.get(name); return raw ? JSON.parse(raw) : undefined; } async set(name: string, data: any, origin: string, expiration?: number) { const ttl = expiration ?? this.defaultExpiration; await this.client.set(name, JSON.stringify(data), 'EX', ttl / 1000); } async remove(name: string) { await this.client.del(name); } async clear() { // 按业务规则清空相关键(例如 scan + del) } async close() { await this.client.quit(); // 在 orm.close() 时优雅关闭连接 } } const orm = await MikroORM.init({ resultCache: { adapter: RedisCacheAdapter, expiration: 1000, options: { /* 传给构造函数的额外参数 */ }, }, });

注意expiration与options会被合并后传入构造函数(见前文getResultCacheAdapter()源码),因此自定义适配器可以从构造参数中读取默认过期时间。

关闭时的清理钩子

MikroORM.close()会依次调用元数据缓存适配器与结果缓存适配器的close()(MikroORM.ts):

async close(force = false): Promise<void> { await this.driver.close(force); await this.config.getMetadataCacheAdapter()?.close?.(); await this.config.getResultCacheAdapter()?.close?.(); }

close?()是可选的,未实现则跳过——这正是为 Redis 这类需要主动断开连接的外部存储准备的优雅关闭入口。

测试验证:缓存命中、过期与清除的完整证据

仓库在 tests/features/result-cache/result-cache.postgre.test.ts 中用 PostgreSQL 环境系统验证了上述全部行为,测试手法是通过 mock 查询日志计数来判断是否真的执行了 SQL:

  • find命中与过期(L38-L85):cache: 100的查询执行 1 次 SQL;50ms 后(未过期)再次查询仍为 1 次(命中);再推进 1ms(100ms 过期)后查询 SQL 计数变为 2(未命中,重新执行)。
  • 全局缓存(L87-L137):设置orm.config.get('resultCache').global = 100后,不带cache选项的查询同样被缓存;清除global后恢复原状。
  • findOneOrFail与显式键清除(L139-L197):使用cache: ['abc', 100],命中期间无新 SQL;调用em.clearCache('abc')后下一次查询 SQL 计数增加,证明显式键被成功清除。
  • count与QueryBuilder(L199-L279):count支持cache: 100;QueryBuilder 链式.cache(100)同样表现为“命中无新 SQL、过期后重新执行”,并且.cache()(无参数)与.cache(100)在键层面是等价的(测试最后用.cache()验证过期后重查)。
  • 元数据未知时的键回退(L281-L286):未发现元数据的实体,缓存键回退为['NotDiscovered', 'em.find', {}, {}]形式。

此外还有 MongoDB 平台的结果缓存测试 result-cache.mongo.test.ts,说明该机制对非 SQL 平台同样适用。

使用建议与边界

  • 结果缓存 ≠ 数据实时性:缓存只按过期时间失效,写入数据后需要主动clearCache()才能立即可见。因此它更适合低频变化、读多写少的查询(如分类列表、统计数据)。
  • 过期时间以毫秒为单位:cache: 50即 50ms,cache: ['book-cache-key', 60_000]即 60s;未显式指定时使用resultCache.expiration(默认 1000ms)。
  • 自定义键要能对应到清除动作:只有使用显式键的缓存才能通过em.clearCache(key)精准清除;自动键由内部规则生成,不建议手动猜测。
  • 多租户/RLS 场景:存在会话上下文时,缓存键会自动附加上下文作用域,显式键也会被追加后缀,避免跨租户串读——清除时clearCache()同样处理了对应变体。
  • 与元数据缓存的区别:本文介绍的是查询结果缓存;实体元数据的磁盘/内存缓存是另一套机制(metadataCache配置与FileCacheAdapter等适配器),详见 docs/docs/metadata-cache.md。
  • 与 Identity Map 的区别:结果缓存默认关闭且服务于跨请求复用,Identity Map 始终开启且服务于单次单位工作内的实体唯一性,二者不要混淆。
  • 后端

【免费下载链接】mikro-orm

TypeScript ORM for Node.js based on Data Mapper, Unit of Work and Identity Map patterns. Supports MongoDB, MySQL, MariaDB, MS SQL Server, PostgreSQL and SQLite/libSQL databases.

项目地址:https://gitcode.com/gh_mirrors/mi/mikro-orm
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询