- 后端
【免费下载链接】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.
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.
相关推荐
MikroORM 结果缓存实战:从 find() 的 cache 选项、全局配置到自定义 CacheAdapter 实现
MikroORM 结果缓存实战:从 find 的 cache 选项、全局配置到自定义 CacheAdapter 实现 本篇指南基于 MikroORM 官方文档
后端EasyWeChat 5.x 缓存自定义完全指南:从默认文件缓存到 Redis 与 PSR-16 自定义实现
EasyWeChat 5.x 缓存自定义完全指南:从默认文件缓存到 Redis 与 PSR 16 自定义实现 EasyWeChat 5.x 内部大量依赖缓存来保
后端即时通讯EasyWeChat 3.x 缓存机制完全指南:从默认文件缓存到 Redis 与自定义驱动
EasyWeChat 3.x 缓存机制完全指南:从默认文件缓存到 Redis 与自定义驱动 EasyWeChat 3.x 通过集成 doctrine/cache
后端即时通讯
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考