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

发布时间:2026/9/25 2:18:41
MikroORM 事务界定与并发控制实战:从 flush 到乐观锁与悲观锁 后端【免费下载链接】mikro-ormTypeScript 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; }以下两类场景必须使用显式事务界定你希望在同一个工作单元中包含自定义的数据库原生操作例如手动执行原生 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 UPDATEPESSIMISTIC_READ共享锁FOR SHAREPESSIMISTIC_PARTIAL_WRITE排他锁跳过已被锁定的行FOR UPDATE SKIP LOCKEDPESSIMISTIC_WRITE_OR_FAIL排他锁行被锁立即失败FOR UPDATE NOWAITPESSIMISTIC_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.lockOPTIMISTIC走lockOptimistic版本比对不查库其余非NONE模式走lockPessimistic要求活动事务否则抛ValidationError.transactionRequired()。与当前仓库源码的对照小结隐式事务flush触发时写操作经由 UnitOfWork 的transactional包裹成单个数据库事务对应博客中的方式一。显式事务em.transactional(cb)在 EntityManager.ts 中委托TransactionManager处理回调参数为携带 Identity Map 副本的分叉 EMem.fork()提供了异常后获取干净工作单元的通道。乐观锁version 字段冲突在当前版本抛出OptimisticLockError原博客时代为ValidationError的描述断言逻辑见 UnitOfWork.tsfindOne携带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-ormTypeScript 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 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是后端上一篇如何快速上手Macaron-V1-Preview-749B5步完成个人AI助理部署下一篇PP-LCNet_x0_25_textline_ori_onnx未来展望轻量级AI模型的发展趋势创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考