☰
MikroORM 结果缓存(Result Cache)完全指南:从默认内存缓存到自定义 CacheAdapter
2026/9/25 11:48:18 网站建设 项目流程
  • 后端

【免费下载链接】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 内置了一套轻量级的结果缓存(Result Cache)机制,允许对EntityManager与QueryBuilder的查询结果按可配置的过期时间进行缓存,从而显著降低高频只读查询对数据库的压力。本文将基于 MikroORM v6.6 版本文档与当前仓库源码,完整讲解缓存的使用姿势、全局配置、缓存键生成原理、主动失效方法,以及如何通过实现CacheAdapter接口接入 Redis 等外部存储。

哪些查询可以被缓存

结果缓存机制与EntityManager的以下方法直接打通:

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

同时,QueryBuilder的各类结果方法(getResultList()、getSingleResult()、getCount()以及execute())同样支持缓存。

从源码看,这一能力由EntityManager内部的#resultCache字段承载(packages/core/src/EntityManager.ts),它在 ORM 初始化时通过this.config.getResultCacheAdapter()创建(同文件第 161 行)。也就是说,所有被缓存的方法最终都汇聚到同一个缓存适配器实例上。

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

在使用上述方法时,可以通过cache选项按查询粒度控制缓存行为,它接受三种形态:

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

三种形态的含义:

形态含义
cache: true开启缓存,使用自动生成的默认缓存键,过期时间取全局配置(默认 1000ms)
cache: 50开启缓存,使用默认缓存键,过期时间设为 50ms
cache: ['cache-key', 50]开启缓存,显式指定缓存键'cache-key'与 50ms 过期时间

在 tryCache 的实现中可以看到,当config是数组时,config[0]被直接用作缓存键(显式命名键会丢弃自动计算出的键),config[1]作为过期时间传入适配器的set()调用;当config是数字时,该数字即过期时间(见 storeCache)。

QueryBuilder 的缓存写法

QueryBuilder 的缓存能力通过链式方法.cache()开启,参数同样支持boolean | number | [string, number]:

const res = await em.createQueryBuilder(Book) .where({ author: { name: 'Jon Snow' } }) .cache() .getResultList();

对应的方法签名位于 packages/sql/src/query/QueryBuilder.ts:

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

QueryBuilder 的默认缓存键由 SQL、参数与执行方法共同决定:在packages/sql/src/query/QueryBuilder.ts第 2577 行可以看到键的构造为['qb.execute', query.sql, query.params, method]。这意味着只要 SQL 或绑定参数发生变化,缓存键就会不同,天然避免"一条 SQL 的结果被另一条 SQL 复用"的错误。

全局配置:resultCache 选项

默认情况下,ORM 使用进程内共享的内存缓存(MemoryCacheAdapter),对整个MikroORM实例生效,默认过期时间为 1 秒。可以通过MikroORM.init()的resultCache配置项调整默认过期时间,或替换为自定义缓存适配器:

const orm = await MikroORM.init({ resultCache: { // 以下为默认值 adapter: MemoryCacheAdapter, expiration: 1000, // 1s options: {}, // 也可以全局开启缓存 // global: 50, // 50ms }, // ... });

各字段的完整定义可以在 packages/core/src/utils/Configuration.ts 中找到:

  • expiration:默认缓存过期时间(毫秒),默认值1000;
  • adapter:缓存适配器类,默认MemoryCacheAdapter,任意实现CacheAdapter接口的类均可传入;
  • options:透传给适配器构造函数的参数对象,默认{};
  • global:全局开启缓存的总开关,可以是true、一个过期时间数值,或[key, expiration]元组。

全局开启(global)与按查询开启的关系

global配置的优先级低于查询级cache选项:在 tryCache 与 storeCache 中,只有当查询没有显式传cache选项时(config ??= this.config.get('resultCache').global),才会回退到全局配置。因此你可以先全局开启,再对个别热点查询单独覆盖过期时间。

getResultCacheAdapter:适配器如何被实例化

在 packages/core/src/utils/Configuration.ts 中,getResultCacheAdapter()会从配置中取出adapter类,将expiration与options合并后作为构造参数实例化:

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

这解释了为什么MemoryCacheAdapter的构造函数接收{ expiration: number }:expiration在这里被统一注入,适配器内部用它作为未显式指定过期时间时的兜底值。

默认的内存缓存实现解析

MemoryCacheAdapter是默认适配器,实现在 packages/core/src/cache/MemoryCacheAdapter.ts,其核心是一个Map<string, { data; expiration }>:

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) }); } // remove / clear 直接操作 Map }

几个值得注意的实现细节:

  • 过期时间是基于Date.now()的惰性过期:条目过期后并不会被主动清理,而是在下次get命中时检查并删除;
  • set时若未传入expiration,则回退到构造时注入的全局默认过期时间(即配置里的expiration: 1000);
  • 由于它只存在于进程内存中,多进程部署下每个进程各自持有一份缓存,不跨进程共享,这也是文档强调"shared for the whole MikroORM instance"(对整个实例共享)的原因。

缓存命中后的实体重建

从缓存读到的并不只是"原样返回"的裸数据。在 tryCache 中,当缓存命中的数据是数组或对象且需要合并(merge)时,会通过EntityFactory.create()将缓存数据重新水合为实体实例:

const createOptions = { merge: true, convertCustomTypes: false, refresh, recomputeSnapshot: true, };

也就是说,缓存的数据以 POJO 形式存储,命中后重新创建实体。测试用例 tests/features/result-cache/GH3294.test.ts 专门验证了隐藏属性(hidden properties)在缓存命中后依然可访问;tests/features/result-cache/GH7112.test.ts 则验证了带加解密的自定义类型在缓存命中后仍能正确解密。

缓存键(Cache Key)的生成规则

当使用默认缓存键(即cache: true或cache: 50)时,键由 cacheKey 方法计算,其组成为:

const key: unknown[] = [entityKey, method, opts, where];
  • entityKey:优先取元数据中的[schema, tableName, discriminatorValue](表名在压缩/混淆下比类名更稳定),否则回退到类名;
  • method:方法名(如em.find);
  • opts:过滤掉ctx、strategy、flushMode、logging、loggerContext、signal等与结果无关的动态选项;
  • where:查询条件;
  • 若存在会话上下文(Session Context,用于行级安全 RLS),还会追加sessionContext,将缓存结果按租户/角色隔离,避免跨上下文串数据。

最终tryCache会将这个数组JSON.stringify后作为适配器的键。显式命名键(cache: ['key', 50])会直接跳过这套计算,完全以你给定的字符串为准。

主动清除缓存: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的实现位于 packages/core/src/EntityManager.ts,它会调用适配器的remove(name);在存在会话上下文时,还会同时移除带上下文后缀的变体键。测试用例 tests/features/result-cache/result-cache.mongo.test.ts 展示了这一完整流程:写入cache: ['abc', 50]后,缓存命中不再发查询,调用em.clearCache('abc')后缓存立即失效,下一次查询重新打到数据库。

自定义缓存适配器:实现 CacheAdapter 接口

自定义适配器只需实现CacheAdapter接口。接口的当前完整定义(v6.6 仓库源码)位于 packages/core/src/cache/CacheAdapter.ts:

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

编写一个接入外部存储(如 Redis)的适配器示例:

import { CacheAdapter } from '@mikro-orm/core'; class RedisCacheAdapter implements CacheAdapter { // 假设 redis 为已初始化的客户端实例 constructor(private readonly options: { expiration: number; redis: any }) {} async get(name: string) { const val = await this.options.redis.get(name); return val ? JSON.parse(val) : undefined; } async set(name: string, data: any, origin: string, expiration?: number) { const ttl = expiration ?? this.options.expiration; await this.options.redis.set(name, JSON.stringify(data), 'PX', ttl); } async remove(name: string) { await this.options.redis.del(name); } async clear() { // 按业务前缀批量删除,这里省略 } async close() { await this.options.redis.quit(); } }

然后在配置中启用:

const orm = await MikroORM.init({ resultCache: { adapter: RedisCacheAdapter, expiration: 60_000, options: { redis }, }, });

实现要点:

  • 方法返回类型允许同步或 Promise(void | Promise<void>、T | Promise<T | undefined>),仓库中的MemoryCacheAdapter是纯同步实现,而FileCacheAdapter、GeneratedCacheAdapter等同样遵循该接口;
  • origin参数在结果缓存场景下通常为空字符串(EntityManager的storeCache写入时传''),但在元数据缓存场景中,FileCacheAdapter会用它校验缓存条目是否来自同一源文件,并基于源文件内容哈希判断是否需要失效(见 packages/core/src/cache/FileCacheAdapter.ts);
  • close()是可选的,MikroORM.close()关闭时会回调它,适合 Redis 等需要释放连接的存储;
  • 接口同时存在同步变体SyncCacheAdapter(见 packages/core/src/cache/CacheAdapter.ts),用于元数据缓存这类不需要异步访问的场景,它还额外提供可选的combine()用于生成合并缓存文件。

用 NullCacheAdapter 彻底关闭缓存

如果只想在不改动代码的情况下全局禁用缓存,可以使用仓库内置的 NullCacheAdapter:它的get永远返回null,set/remove/clear均为空操作,相当于一个"不存储任何数据"的缓存适配器。

结果缓存与元数据缓存的区别

需要注意,本文讨论的resultCache与配置项metadataCache是两套完全独立的机制:

  • 结果缓存(resultCache):缓存查询结果,命中后跳过数据库查询,默认使用MemoryCacheAdapter,按查询开启或全局开启;
  • 元数据缓存(metadataCache):缓存实体元数据(Entity Metadata)的发现与反射结果,用于加速启动阶段的实体扫描,默认使用FileCacheAdapter写入文件(如./temp目录下的 JSON)。

两者共享CacheAdapter接口,但用途、键规则和生命周期完全不同。本文主题仅涉及前者,元数据缓存的细节可参考文档 docs/docs/metadata-cache.md。

实践建议

  • 热点只读查询优先开启缓存,例如配置字典、统计聚合、低频变化的关联数据;
  • 注意数据新鲜度:缓存不会感知数据库变更(无内置失效钩子),业务上"读多写少、容忍短暂滞后"的数据才适合;
  • 需要主动失效时务必用显式缓存键(cache: ['key', expiration]),否则em.clearCache()无法定位到你想要清除的条目;
  • 多实例部署时默认内存缓存不共享,若需要跨实例共享,请实现并配置基于 Redis 等共享存储的自定义CacheAdapter;
  • expiration单位是毫秒(如50表示 50ms、60_000表示 60s),数值过小会频繁穿透到数据库。

延伸阅读

  • 本文基于版本化文档 docs/versioned_docs/version-6.6/caching.md,当前主线版本的同一文档位于 docs/docs/caching.md;
  • 缓存适配器实现:MemoryCacheAdapter、FileCacheAdapter、NullCacheAdapter;
  • 缓存核心逻辑:EntityManager.ts(cacheKey/tryCache/storeCache/clearCache)、QueryBuilder.ts(cache()方法);
  • 配置项定义与默认值:Configuration.ts;
  • 集成测试:tests/features/result-cache/result-cache.mongo.test.ts、tests/features/result-cache/GH3294.test.ts、tests/features/result-cache/GH7112.test.ts。
  • 后端

【免费下载链接】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
点击查看免费下载
上一篇:memU 使用 SQLite 存储时报 database is locked 怎么排查?
下一篇:OpenCore Legacy Patcher:让老旧Mac重获新生的终极指南

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

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

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

立即咨询