引言
在分布式微服务架构下,工作流引擎面临着新的挑战:并发冲突。当多个节点同时处理同一个任务时,如何保证流程状态的一致性?当多个节点同时执行定时任务时,如何避免重复执行?
本文将深入探讨分布式环境下工作流引擎的并发控制机制,带你了解工作流引擎如何保证"Exactly-Once(精确一次)"的执行语义。
1 分布式环境下的工作流引擎:并发控制的三层防御体系
在集群部署下,通常会面临两种典型的并发冲突场景:
1.1 场景一:用户请求的并发(双击/重试)
审批人手抖,对同一个待办任务连续点击了两次"同意"。前端没有做好防抖,或者网关触发了重试机制。两个一模一样的 HTTP 请求几乎同时到达了负载均衡器,被分别路由到了节点A 和节点B 。两个节点同时调用 taskService.complete(taskId)。
1.2 场景二:引擎后台任务的并发(定时器/异步任务)
流程图中配置了一个"超时自动催办"的定时事件(Timer Event),要求在每天上午 10:00 触发。此时,节点A、节点B、节点C内部的工作流后台线程都在运行,它们同时去数据库查询到期的定时任务,并试图执行。
如果不加控制,场景一会导致流程状态错乱甚至指针丢失;场景二会导致同一个定时任务被执行三次(发了三封催办邮件)。
面对这些问题,工作流引擎(如 Flowable/Camunda)在底层设计了多层防御机制来保证"Exactly-Once(精确一次)"的执行语义。
2 防御机制一:乐观锁(解决用户级并发)
针对场景一(两个节点同时处理同一个任务),工作流引擎的运行时表设计中引入了一个绝对核心的字段:REV_(Revision,版本号)。
你去查看引擎的任何一张运行时表(如 ACT_RU_EXECUTION,ACT_RU_TASK),都会发现这个字段。它就是引擎实现**乐观锁(Optimistic Locking)**的基石。
2.1 底层执行逻辑
-
节点A和节点B同时从数据库读取到了同一个 Task,此时发现该记录的
REV_ = 1 -
两个节点分别在各自的内存中完成业务逻辑计算,准备更新数据库,推动流程到下一步
-
节点A率先向数据库发起
UPDATE语句,语句中带有严格的版本校验:sqlUPDATE ACT_RU_TASK SET ASSIGNEE_ = '...', REV_ = 2 WHERE ID_ = 'task_123' AND REV_ = 1; -
节点A执行成功,数据库中的
REV_变成了 2 -
几毫秒后,节点B也向数据库发起同样的
UPDATE语句,条件同样是REV_ = 1 -
此时数据库发现没有符合条件的记录(因为已经被A改成2了),返回受影响行数为 0
2.2 结果拦截
当引擎底层的 MyBatis 发现 UPDATE 受影响行数为 0 时,会立即抛出 OptimisticLockingException(乐观锁异常)。节点B所在的事务将被强制回滚,修改全部失效。
通过数据库级别的乐观锁,引擎保证了即便多个实例同时处理,也只有最先提交的那个实例能够成功驱动流程。
3 防御机制二:悲观锁与任务认领(解决后台任务并发)
乐观锁是被动防御,适合处理用户触发的低频并发。但对于场景二(定时器和异步任务),集群中的所有节点都在主动轮询数据库,冲突概率极高,此时必须使用排他性的获取机制(Acquisition)。
工作流引擎内部有一个名为 JobExecutor(作业执行器)的组件,专门负责处理异步任务和定时器。为了防止多个节点的 JobExecutor 抢到同一个任务,引擎采用了**"锁定并认领(Lock and Claim)"**的机制。
3.1 底层执行逻辑
-
引擎内部有一张专门存放异步作业的表,比如
ACT_RU_TIMER_JOB。这张表里有两个关键字段:LOCK_OWNER_(锁拥有者,通常是节点的主机名或UUID)和LOCK_EXP_TIME_(锁过期时间) -
当节点A的后台线程去数据库拉取到期任务时,它不会直接
SELECT然后执行,而是发起一个带有排他逻辑的UPDATE:sqlUPDATE ACT_RU_TIMER_JOB SET LOCK_OWNER_ = 'Node-A', LOCK_EXP_TIME_ = '2023-10-01 10:05:00' WHERE ID_ = 'job_456' AND LOCK_OWNER_ IS NULL; -
这个
UPDATE操作利用了关系型数据库本身的行锁特性。如果节点A和节点B同时发起,数据库会保证只有一个节点能更新成功 -
更新成功的节点(比如节点A),就等于"认领"了这个任务。随后它才可以放心地把任务拉到自己的线程池中去执行
-
节点B因为
UPDATE失败,直接忽略该任务,去寻找下一个可用任务
3.2 容错处理(锁超时)
如果节点A认领了任务,但在执行过程中节点A宕机了,任务一直没完成怎么办?
这就是 LOCK_EXP_TIME_ 的作用。其他节点的后台线程在轮询时,不仅会找 LOCK_OWNER_ IS NULL 的任务,还会找 LOCK_EXP_TIME_ < 当前时间 的任务。一旦发现超时,其他节点会强行剥夺所有权,重新认领并执行,保证了系统的高可用。
4 防御机制三:业务层的分布式锁(防穿透保护)
虽然引擎底层的乐观锁和 Job 认领机制能够绝对保证数据库状态的正确性,但作为架构师,我们不能仅仅依赖引擎来擦屁股。
4.1 存在的问题
如果完全依赖引擎的乐观锁,当用户双击提交时,节点B会抛出 OptimisticLockingException。这个异常如果没有被妥善捕获,会直接以 HTTP 500 的形式抛给前端,用户体验极差。并且,在触发乐观锁异常之前,节点B可能已经执行了一些不可逆的业务代码(比如调用了外部系统的第三方接口)。
4.2 解决方案:在业务入口层引入 Redis 分布式锁
在调用工作流引擎的 API 之前,必须在业务接口层(Controller 或 Facade 层)建立第一道防线。
4.2.1 核心逻辑
-
接收到审批请求时,以任务 ID 为 Key,尝试在 Redis 中获取分布式锁:
javaboolean isLocked = redisTemplate.opsForValue() .setIfAbsent("workflow:task:lock:" + taskId, "locked", 5, TimeUnit.SECONDS); -
如果
isLocked为false,说明该任务正在被其他请求或节点处理,直接在入口处拦截,返回友好的业务提示(如:"任务正在处理中,请勿重复提交"),避免请求打到工作流引擎内部 -
如果获取到锁,再向下执行业务逻辑,最后调用
taskService.complete(taskId),并在finally块中释放锁
5 总结
在分布式微服务架构下,保证工作流引擎只有单个实例成功执行的核心,是一套由外向内、由悲观到乐观的多级并发控制体系:
- 入口防线(业务层):利用 Redis 分布式锁拦截重复的用户级请求,防止无效运算和外部接口的重复调用
- 后台防线(引擎层/悲观机制) :利用数据库的行锁机制和
LOCK_OWNER_字段,确保集群中多个后台JobExecutor在拉取定时/异步任务时绝对互斥 - 兜底防线(引擎层/乐观机制) :利用运行时表的
REV_版本号字段实现乐观锁。即使所有的前置防御都失效,数据库层面的版本比对也能确保状态机的流转严格遵循"一次且仅一次"的修改
理解了这套并发控制体系,你就能在集群环境中放心地部署和扩展你的工作流微服务,彻底告别数据错乱与幽灵并发。