news 2026/9/25 2:18:39

MikroORM 事务界定与并发控制实战:从 flush 到乐观锁与悲观锁

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MikroORM 事务界定与并发控制实战:从 flush 到乐观锁与悲观锁
  • 后端

【免费下载链接】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 官方博客《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; }

以下两类场景必须使用显式事务界定:

  1. 你希望在同一个工作单元中包含自定义的数据库原生操作(例如手动执行原生 SQL UPDATE 查询);
  2. 你要使用某些需要"处于活动事务中"的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),就很容易落入以下三类经典陷阱:

  1. 丢失更新问题(lost update):两个事务同时读同一行再各自写回,后提交者覆盖先提交者的修改;
  2. 脏读问题(dirty read):一个事务读取了另一个尚未提交事务写入的数据,若后者回滚,读到的就是"不存在"的数据;
  3. 错误汇总问题(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),不同数据库对部分变体的支持程度可能不同,使用前应确认目标平台的兼容性。

悲观锁可以在三个场景中启用:

  1. em.findOne(className, id, { lockMode })
  2. em.lock(entity, lockMode)
  3. 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.

项目地址:https://gitcode.com/gh_mirrors/mi/mikro-orm
点击查看免费下载
上一篇:如何快速上手Macaron-V1-Preview-749B:5步完成个人AI助理部署
下一篇:PP-LCNet_x0_25_textline_ori_onnx未来展望:轻量级AI模型的发展趋势

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/25 2:17:46

通用全球APQP的17项任务与PR评审:SQE和供应商的项目管理路线图

简介:面向供应商质量管理工程师的通用汽车全球APQP产品质量先期策划培训PPT,系统讲解在汽车产品开发中如何通过先期策划识别并解决潜在问题,确保按时交付合格产品。内容涵盖APQP的背景定义、目的与优点,以及全球化背景下统一程序的…

作者头像 李华
网站建设 2026/9/25 2:15:21

光学超材料逆向设计:INN与SNN融合实战指南

简介:这份资源聚焦光学超材料的逆向设计,结合INN与SNN两类神经网络,面向具备一定机器学习基础、希望将深度学习应用于电磁/光学器件设计的研究生与工程师。内容围绕全连接网络建模展开,输入输出层分别含8个与71个神经元&#xff0…

作者头像 李华