- 后端
【免费下载链接】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 官方博客《Handling Transactions and Concurrency in MikroORM》为主线,系统讲解该 ORM 的持久化模型(persist/flush)、事务界定(隐式与显式)以及乐观锁/悲观锁两种并发控制策略,并结合当前仓库packages/core源码逐条印证 API 的实际行为与底层实现。读完本文后,你将能在真实的 Node.js 项目中正确划定事务边界、安全处理回滚后的实体状态,并为跨请求的长业务事务选择合适的锁策略。
先理解持久化模型:persist 与 flush
要理解 MikroORM 如何处理事务,必须先弄清两个方法:em.persist()和em.flush()。
em.persist(entity, flush?: boolean):将新实体标记为"待持久化"。调用后,该实体由指定的EntityManager管理,待flush被调用时才会真正写入数据库。第二个布尔参数可以立即触发flush,其默认值由配置项autoFlush控制。
2019 年原文说明:当时
autoFlush默认值为true,作者建议设置为false,或改用em.persistLater()(等价于em.persist(entity, false))与em.persistAndFlush()这类便捷方法。本文后续所有"持久化"示例都以autoFlush为false为前提。从当前仓库源码结构看,persistAndFlush/persistLater这两个快捷方法已不再出现在packages/core中,autoFlush配置项同样检索不到,代码中出现了与之职责相近的implicitTransactions相关逻辑(见 packages/core/src/errors.ts),说明这一选项在后续版本中已经演进。实际开发请以当前仓库 packages/core/src/EntityManager.ts 中的方法签名和 JSDoc 为准。
什么是"被管理的实体(managed entity)"?一个实体处于受管状态,当它从数据库加载而来(通过em.find()、em.findOne()或经由其他受管实体级联获得),或者通过em.persist()注册为新实体。
em.flush()会遍历所有受管实体,计算各自的变更集(change set),并执行相应的数据库查询。由于从数据库加载的实体会自动进入受管状态,你不需要对它们再调用persist,直接flush即可完成更新。
事务界定(Transaction Demarcation)
事务界定即划定事务边界。对大多数场景,MikroORM 已经替你做好了:所有写操作(INSERT/UPDATE/DELETE)都会被排队,直到em.flush()被调用时才统一执行,并且这些变更被包裹在同一个事务中。同时,MikroORM 也允许并鼓励你自己接管事务边界的控制。
方式一:隐式事务界定
不写任何显式事务代码,直接依赖EntityManager的隐式事务处理:
const user = new User(); user.name = 'George'; await orm.em.persistAndFlush(user);由于没有自定义的事务界定,em.flush()会自行开启事务并在结束时提交/回滚。只要你的数据操作都走领域模型和 ORM 完成,这就足够了——除非你通过QueryBuilder手动执行写查询,或使用了em.nativeInsert/Update/Delete这类原生辅助方法。
一个涉及多个实体的更复杂示例:
const author = await orm.em.findOne(Author, id, ['books.tags', 'books.publisher']); author.books[0].title = 'New book name'; author.books[0].tags[0].name = 'old'; author.books[0].tags.add(new BookTag('sale')); author.books[0].publisher.name = 'New publisher name'; await orm.em.flush();这里按 id 加载一个作者、他的书、书的标签以及出版商(为简化,假设该作者只有一本书,书有一个标签和一个出版商)。然后我们修改了多处数据:改书名、改标签名、新增一个标签、改出版商名。因为这些实体都来自EntityManager,本身就是受管的,所以无需persist,直接flush即可。
flush会计算全部差异并按需执行数据库查询,且全部封装在一个事务里。实际触发的查询序列如下:
START TRANSACTION; INSERT INTO `book_tag` (`name`) VALUES (?); UPDATE `book` SET `title` = ? WHERE `id` = ?; DELETE FROM `book_to_book_tag` WHERE `book_id` = ?; INSERT INTO `book_to_book_tag` (`book_id`, `book_tag_id`) VALUES (?, ?); INSERT INTO `book_to_book_tag` (`book_id`, `book_tag_id`) VALUES (?, ?); UPDATE `publisher` SET `name` = ? WHERE `id` = ?; UPDATE `book_tag` SET `name` = ? WHERE `id` = ?; COMMIT;这个"flush 包裹在事务中"的行为在源码中可以直接印证:UnitOfWork 在持久化数据库变更时,通过getConnection('write').transactional(...)把整批persistToDatabase包进一个数据库事务——这正是隐式事务界定"多个写查询共用一个事务"的底层出处。
方式二:显式事务界定
直接使用事务 API 控制边界:
await orm.em.beginTransaction(); try { //... do some work const user = new User(...); user.name = 'George'; await orm.em.persistAndFlush(user); await orm.em.commit(); } catch (e) { await orm.em.rollback(); throw e; }以下两类场景必须使用显式事务界定:
- 你希望在同一个工作单元中包含自定义的数据库原生操作(例如手动执行原生 SQL UPDATE 查询);
- 你要使用某些需要"处于活动事务中"的
EntityManagerAPI 方法(例如加锁)。这类方法在未处于事务中时会抛出ValidationError来提醒你。这一点在当前源码中同样成立:UnitOfWork.lockPessimistic 首先检查this.#em.isInTransaction(),不在事务中即抛出ValidationError.transactionRequired()。
更便捷的显式事务写法是使用em.transactional(cb):它会自动开启事务、执行你的异步回调、然后提交;回调中一旦抛出异常,事务会自动回滚,异常会被重新抛出。功能等价于上面 try/catch 的写法:
await orm.em.transactional(async _em => { //... do some work const user = new User(...); user.name = 'George'; _em.persistLater(user); });回调参数中你拿到的是一个分叉(fork)的EntityManager,它包含当前 Identity Map 的副本。事务内部的所有查询都应使用这个副本而非父级EntityManager;副本会在事务提交前被 flush。
从源码看,EntityManager.transactional 内部将回调委托给了专门的TransactionManager来处理,且当disableTransactions开启时会直接跳过事务执行——这也是理解事务生命周期时的关键入口。分叉 EM 与父级 UnitOfWork 之间持久化栈、孤儿栈的同步逻辑,可以在 EntityManager.fork 附近看到实现。
异常处理
- 使用隐式事务界定时,
em.flush()期间发生异常,事务会被自动回滚; - 使用显式事务界定时,异常发生后应立即回滚(如上例所示)。更推荐使用
em.transactional(cb),由它自动处理。
回滚带来的副作用值得特别注意:回滚之后,EntityManager中所有此前受管或被移除的实例都变成了detached(脱离)状态。脱离对象停留在"事务回滚那一刻"的内存状态——对象状态并不会随事务回滚而回滚,因此它们已经与数据库不同步了。应用可以继续持有这些对象,但必须清楚其状态可能已经不准确。
如果你在异常发生后还要开启新的工作单元,应该使用一个新的EntityManager:直接调用em.fork()获取一个已清空 Identity Map 的干净副本。
为什么需要并发控制?
如果事务是串行执行的(一次只跑一个),就不存在事务并发问题。但一旦允许多个事务并发执行且操作互相交织(interleaving),就很容易落入以下三类经典陷阱:
- 丢失更新问题(lost update):两个事务同时读同一行再各自写回,后提交者覆盖先提交者的修改;
- 脏读问题(dirty read):一个事务读取了另一个尚未提交事务写入的数据,若后者回滚,读到的就是"不存在"的数据;
- 错误汇总问题(incorrect summary):基于不一致的中间状态做聚合计算,得到错误的统计结果。
数据库事务只适合控制"单次请求内"的并发。数据库事务不应该跨越请求——也就是所谓的"用户思考时间(user think time)"。一个跨越多个请求的长"业务事务"必然由多个数据库事务组成,此时数据库事务本身已无法覆盖整段业务过程的并发控制,并发控制就成了应用自身需要承担的一部分职责。
为此,MikroORM 原生支持**悲观锁(Pessimistic)与乐观锁(Optimistic)**两种策略,让你对每个实体的锁定方式做非常细粒度的控制。
乐观锁:通过 version 字段自动防护
MikroORM 内置了基于 version 字段的自动乐观锁支持。凡是需要防止在长业务事务期间被并发修改的实体,都加一个 version 字段,类型只能是简单的数字或日期(时间戳)。当这样的实体在长会话结束时被持久化,其实体版本会与数据库中的版本比对;若不一致,则抛出异常,表明该实体已被他人修改过。
定义版本字段很简单:使用@Property装饰器并置version: true,仅允许Date与number两种类型:
export class User { // ... @Property({ version: true }) version: number; // ... }export class Book { // ... @Property({ version: true }) version: Date; // ... }建议优先使用版本号而非时间戳作为 version:在高并发环境下,时间戳可能因数据库平台的时间分辨率而出现"两个写入落在同一时间戳"的冲突,而单调递增的版本号没有这种隐患。
当em.flush()期间检测到版本冲突时,会抛出异常并回滚当前事务(或将其标记为必滚)。2019 年原文写的是抛出ValidationError;而在当前仓库源码中,版本不一致会抛出专门的OptimisticLockError,可以在 UnitOfWork.lockOptimistic 中看到:当实体上的版本值与传入的期望版本不相等时,抛出OptimisticLockError.lockFailedVersionMismatch(entity, version, previousVersion)。该异常可以被捕获并处理,典型应对方式包括:把冲突呈现给用户,或在新事务中重新加载对象后再重试。
在请求期间校验版本。考虑这样一个场景:从展示更新表单到真正修改实体之间,最坏情况可能长达整个会话超时时间。如果这段时间内实体被改动过,希望在获取实体的那一刻就直接感知到将触发乐观锁异常。
方式 A:在调用em.findOne()时直接带上lockMode: LockMode.OPTIMISTIC与期望版本:
const theEntityId = 1; const expectedVersion = 184; try { const entity = await orm.em.findOne(User, theEntityId, { lockMode: LockMode.OPTIMISTIC, lockVersion: expectedVersion }); // do the work await orm.em.flush(); } catch (e) { console.log('Sorry, but someone else has already changed this entity. Please apply the changes again!'); }在源码层面,EntityManager.findOne 在加载到实体后,如果传入了lockMode,会进一步调用this.lock(entity, options.lockMode, { lockVersion: options.lockVersion, ... }),把版本断言与加载合并在同一次调用中完成。
方式 B:先加载实体,再用em.lock()主动断言版本:
const theEntityId = 1; const expectedVersion = 184; const entity = await orm.em.findOne(User, theEntityId); try { // assert version await orm.em.lock(entity, LockMode.OPTIMISTIC, expectedVersion); } catch (e) { console.log('Sorry, but someone else has already changed this entity. Please apply the changes again!'); }前后端协作的完整乐观锁流程
正确使用乐观锁时,更新实体时必须把 version 作为额外参数传回服务端。一个典型的 REST API 流程:
const res = await fetch('api.example.com/book/123'); const book = res.json(); console.log(book.version); // prints the current version // user does some changes and calls the PUT handler const changes = { title: 'new title' }; await fetch('api.example.com/book/123', { method: 'PUT', body: { ...changes, version: book.version, }, });服务端对应实现:
// GET /book/:id async findOne(req, res) { const book = await this.em.findOne(Book, +req.query.id); res.json(book); } // PUT /book/:id async update(req, res) { const book = await this.em.findOne(Book, +req.query.id, { lockMode: LockMode.OPTIMISTIC, lockVersion: req.body.version }); book.assign(req.body); await this.em.flush(); res.json(book); }流程是:前端从 API 加载实体,响应中包含 version 属性;用户做修改后向 API 发起 PUT 请求,请求体带上 version 字段;API 的 PUT 处理器读出该 version 并传给em.findOne(),由 ORM 完成比对,版本不一致时请求即被拒绝,避免覆盖他人修改。
仓库中也有针对乐观锁行为的专门测试目录 tests/features/optimistic-lock,可用于验证版本冲突、flush 回滚等场景的实际行为。
悲观锁:数据库级行锁
MikroORM 在数据库层面支持悲观锁。任何实体都可以参与悲观锁,无需任何特殊元数据声明。悲观锁要求存在活动事务,因此必须配合显式事务界定使用。
原文(2019 年)列出了当时支持的两种悲观锁模式:
LockMode.PESSIMISTIC_WRITE:锁住底层数据库行,阻止其他事务对该行的读和写;LockMode.PESSIMISTIC_READ:只锁住其他试图以写模式更新或锁定这些行的并发请求。
而当前仓库 enums.ts 中的LockMode枚举已经扩展了更多变体,供需要更高并发吞吐或"快速失败"语义的场景使用:
| 枚举值 | 语义 | 典型 SQL 语义 |
|---|---|---|
PESSIMISTIC_WRITE | 排他锁 | FOR UPDATE |
PESSIMISTIC_READ | 共享锁 | FOR SHARE |
PESSIMISTIC_PARTIAL_WRITE | 排他锁,跳过已被锁定的行 | FOR UPDATE SKIP LOCKED |
PESSIMISTIC_WRITE_OR_FAIL | 排他锁,行被锁立即失败 | FOR UPDATE NOWAIT |
PESSIMISTIC_PARTIAL_READ | 共享锁,跳过已被锁定的行 | FOR SHARE SKIP LOCKED |
注意具体 SQL 方言由数据库平台决定(例如 MySQL 下PESSIMISTIC_READ会渲染为LOCK IN SHARE MODE),不同数据库对部分变体的支持程度可能不同,使用前应确认目标平台的兼容性。
悲观锁可以在三个场景中启用:
em.findOne(className, id, { lockMode })em.lock(entity, lockMode)QueryBuilder.setLockMode(lockMode)
实际使用示例——在加载时直接加排他锁:
await em.transactional(async _em => { await _em.findOne(Author, id, { lockMode: LockMode.PESSIMISTIC_WRITE }); }); // START TRANSACTION // SELECT `e0`.* FROM `author` AS `e0` WHERE `e0`.`id` = ? FOR UPDATE // COMMIT先加载实体、之后再加共享锁:
const author = orm.em.findOne(Author, id); // ... await orm.em.transactional(async em => { await em.lock(author, LockMode.PESSIMISTIC_READ); }); // SELECT `e0`.* FROM `author` AS `e0` WHERE `e0`.`id` = ? // START TRANSACTION // SELECT 1 FROM `author` AS `e0` WHERE `e0`.`id` = ? LOCK IN SHARE MODE // COMMIT注意第二个示例中em.lock()触发的是针对单行的一次数据存在性检查查询(SELECT 1 ... LOCK IN SHARE MODE),而不是重新SELECT *——这是当前实现的细节,说明锁语句会作用在实体对应的行上。锁的分发逻辑统一入口在 UnitOfWork.lock:OPTIMISTIC走lockOptimistic(版本比对,不查库),其余非NONE模式走lockPessimistic(要求活动事务,否则抛ValidationError.transactionRequired())。
与当前仓库源码的对照小结
- 隐式事务:
flush触发时,写操作经由 UnitOfWork 的transactional包裹成单个数据库事务,对应博客中的"方式一"。 - 显式事务:
em.transactional(cb)在 EntityManager.ts 中委托TransactionManager处理,回调参数为携带 Identity Map 副本的分叉 EM;em.fork()提供了异常后获取干净工作单元的通道。 - 乐观锁:version 字段冲突在当前版本抛出
OptimisticLockError(原博客时代为ValidationError的描述),断言逻辑见 UnitOfWork.ts;findOne携带lockVersion时会在 EntityManager.ts 内部自动执行同一断言。 - 悲观锁:
LockMode枚举(enums.ts)比 2019 年博客描述的两种模式多出SKIP LOCKED/NOWAIT系列变体;未处于事务中调用悲观锁会抛出ValidationError.transactionRequired()(UnitOfWork.ts)。 - 测试佐证:乐观锁行为的集成测试位于 tests/features/optimistic-lock,可作为验证上述行为的可执行依据。
更完整的 API 参考可继续阅读仓库文档 docs/docs/transactions.md。需要提醒的是,本文基于 2019 年博客梳理概念与用法,而当前仓库已经演进到较新的版本:部分快捷方法与配置项(如autoFlush)已被重构,落地代码前请以 packages/core/src/EntityManager.ts 中最新的类型签名与 JSDoc 为准。
- 后端
【免费下载链接】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 5.9 事务与并发控制完全指南:从隐式事务到乐观/悲观锁
MikroORM 5.9 事务与并发控制完全指南:从隐式事务到乐观/悲观锁 事务与并发控制是 MikroORM 这类基于 Unit of Work(工作单元)与
后端Doctrine ORM 事务与并发控制实战:事务界定、异常处理与乐观/悲观锁
Doctrine ORM 事务与并发控制实战:事务界定、异常处理与乐观/悲观锁 本文以 Doctrine ORM(当前仓库 gh_mirrors/or/orm
数据库ORM后端如何在Mac上轻松制作Windows启动盘:WinDiskWriter完整指南
如何在Mac上轻松制作Windows启动盘:WinDiskWriter完整指南 还在为macOS上制作Windows启动盘而烦恼吗?WinDiskWriter是
后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考