本文重点介绍camunda开源流程引擎的事务配置,以及在高并发多线程情况下,可能会发生多个线程尝试对相同流程实例数据进行更改的情况,Camunda如何通过数据库的乐观锁解决这种并发冲突的,并介绍了乐观锁和悲观锁的适用场景、性能影响等。
1、camunda流程引擎事务配置
camunda流程引擎可以配置为与事务管理器(或事务管理系统)集成。该流程引擎开箱即用,支持与 Spring 和 JTA 事务管理集成。
如果你使用的是springboot框架,事务的配置更加简单,只需要配置:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
Spring Boot 为我们提供了默认的事务管理器,当我们使用了 spring-boot-starter-jdbc 的启动器时,框架会自动注入 DataSourceTransactionManager 管理器。只需要在启动类上使用@EnableTransactionManagement注解来开启注解事务,即可不需要任何额外配置就可以用 @Transactional 注解进行事务的使用。但是当我们自己配置了事务管理器的时候,Spring Boot 将不再提供事务管理,而是使用我们定义的事务管理器。
1.1、事务和流程引擎上下文
当执行流程引擎命令时,引擎将创建流程引擎上下文。Context 会缓存数据库实体,因此对同一实体的多个操作不会导致多次数据库查询。这也意味着对这些实体的更改会被累积,并在命令返回后立即刷新到数据库。但需要注意的是,当前事务可能会在稍后时间提交。
如果一个流程引擎命令嵌套在另一个命令中,即一个命令在另一个命令中执行,则默认行为是重用现有的流程引擎上下文。这意味着嵌套命令将有权访问相同的缓存实体以及对它们所做的更改。
当嵌套的Command要在新的事务中执行时,需要创建一个新的流程引擎上下文来执行它。在这种情况下,嵌套命令将为数据库实体使用新的缓存,独立于先前(外部)命令缓存。这意味着,一个命令的缓存中的更改对另一个命令不可见,反之亦然。当嵌套命令返回时,更改将独立于外部命令的流程引擎上下文而刷新到数据库。
3、乐观锁
Camunda 引擎可用于多线程应用程序。在这样的设置中,当多个线程同时与流程引擎交互时,可能会发生这些线程尝试对相同数据进行更改的情况。例如:两个线程尝试同时(并发)完成相同的用户任务。这样的情况就是冲突:任务只能完成一次。
Camunda 引擎使用一种名为“乐观锁定”(或乐观并发控制)的众所周知的技术来检测和解决此类情况。
本节分为两部分:第一部分介绍乐观锁的概念。第二部分解释了Camunda中乐观锁的用法。
3.1、什么是乐观锁?
乐观锁定(也称为乐观并发控制)是一种并发控制方法,用于基于事务的系统。在数据读取频率高于更改频率的情况下,乐观锁定最为有效。许多线程可以同时读取相同的数据对象,而不会相互排斥。然后,通过检测冲突并防止在多个线程尝试同时更改相同数据对象的情况下进行更新来确保一致性。如果检测到此类冲突,则可以确保只有一个更新成功,而所有其他更新都会失败。
举例说明:假设我们有一个包含以下条目的数据库表:
ID | 版本 | 姓名 | 地址 | …… |
8 | 1 | 史蒂夫 | 令牌城Workflow大道3号 | …… |
…… | …… | …… | …… | …… |
上表显示单行保存用户数据。用户拥有唯一的 ID(主键)、版本、名称和当前地址。
我们现在构建一种情况,其中有 2 个事务尝试更新此条目,一个事务尝试更改地址,另一个事务尝试删除用户。预期的行为是,一旦一个事务成功,另一个事务就会中止,并出现错误,表明检测到并发冲突。然后,用户可以根据数据的最新状态决定重试交易:
如上图所示,Transaction 1读取用户数据,对数据执行某些操作,删除用户,然后提交。 Transaction 2同时启动并读取相同的用户数据,并且也对数据进行操作。当Transaction 2尝试更新用户地址时,检测到冲突(因为Transaction 1已经删除了用户)。
检测到冲突是因为Transaction 2执行更新时读取用户数据的当前状态。此时并发Transaction 1已经标记了要删除的行。数据库现在等待Transaction 1结束。结束后Transaction 2即可继续。此时,该行不再存在,更新成功,但报告已更改0行。应用程序可以对此做出反应并回滚,Transaction 2以防止该事务所做的其他更改生效。
应用程序(或使用它的用户)可以进一步决定是否Transaction 2应该重试。在我们的示例中,交易将找不到用户数据并报告用户已被删除。
乐观锁与悲观锁
悲观锁与读锁一起使用。读锁会在读取时锁定数据对象,从而防止其他并发事务也读取该数据对象。这样,就可以避免冲突的发生。
在上面的示例中,Transaction 1一旦读取用户数据就会锁定它。当尝试阅读时,也会Transaction 2被阻碍而无法取得进展。完成后Transaction 1,Transaction 2可以继续并读取最新状态。这样可以防止冲突,因为事务始终只针对最新的数据状态进行工作。
在写入与读取一样频繁且竞争激烈的情况下,悲观锁定非常有效。
但是,由于悲观锁是独占的,因此并发性会降低,从而降低性能。因此,乐观锁定检测冲突而不是阻止冲突发生,因此在高并发级别以及读取比写入更频繁的情况下更可取。此外,悲观锁会很快导致死锁。
3.2、Camunda 中的乐观锁
Camunda 使用乐观锁进行并发控制。如果检测到并发冲突,则会引发异常并回滚事务。执行UPDATE或DELETE语句时会检测到冲突。执行删除或更新语句会返回受影响的行数。如果该计数等于 0,则表明该行之前已被更新或删除。在这种情况下,会检测到冲突并OptimisticLockingException引发冲突。
3.2.1、Camunda 实现乐观锁的细节
大多数 Camunda Engine 数据库表都包含一个名为 的列REV_。此列代表修订版本。读取一行时,会按给定的“修订版”读取数据。修改(更新和删除)始终尝试更新当前命令读取的修订版本。更新增加修订版。执行修改语句后,将检查受影响的行数。如果计数为,1则推断执行修改时读取的版本仍然是当前版本。如果受影响的行数为0,则其他事务在该事务运行时修改了相同的数据。这意味着检测到并发冲突,并且不得允许该事务提交。随后,事务被回滚(或标记为仅回滚)并且OptimisticLockingException被抛出。
3.2.2、乐观锁定异常
可以OptimisticLockingException通过 API 方法抛出。考虑该completeTask(...)方法的以下调用:
taskService.completeTask(aTaskId); // may throw OptimisticLockingException
OptimisticLockingException如果执行方法调用导致数据并发修改,则上述方法可能会抛出异常。
作业执行也可能导致OptimisticLockingException抛出异常。由于这是预期的,因此将重试执行。
3.2.3、处理乐观锁异常
如果当前命令由作业执行器触发,OptimisticLockingException则会使用重试自动处理。由于预计会发生此异常,因此它不会减少重试计数。
如果当前Command是由外部API调用触发的,Camunda引擎会将当前事务回滚到最后一个保存点(等待状态)。现在用户必须决定如何处理异常,是否应该重试事务。还要考虑到,即使事务被回滚,它也可能具有尚未回滚的非事务副作用。
为了控制事务的范围,可以使用异步延续在活动之前和之后添加显式保存点。
3.2.4、引发乐观锁异常的常见位置
有一些常见的地方OptimisticLockingException可以扔。例如
- 竞争性外部请求:同时完成同一任务两次。
- 进程内的同步点:例如并行网关、多实例等。
以下模型显示了可以发生这种情况的并行网关OptimisticLockingException。
打开并行网关后有两个用户任务。在用户执行任务之后,关闭并行网关将执行合并为一个。在大多数情况下,其中一项用户任务将首先完成。然后,执行等待正在关闭的并行网关,直到第二个用户任务完成。
然而,两个用户任务也有可能同时完成。假设上面的用户任务已经完成。该交易假设他是第一个关闭并行网关的人。下面的用户任务是并发完成的,并且事务还假设他是关闭并行网关上的第一个。两个事务都尝试更新一行,这表明它们是正在关闭的并行网关上的第一个事务。在这种情况下,OptimisticLockingException会抛出一个异常。其中一个事务已回滚,另一个事务成功更新该行。
3.2.5、乐观锁定和非事务副作用
发生后OptimisticLockingException,事务将回滚。任何事务性工作都将被撤消。非事务性工作(例如创建文件或调用非事务性 Web 服务的效果)将无法撤消。这可能会导致不一致的状态。
此问题有多种解决方案,最常见的解决方案是使用重试进行最终整合。