工作流(4)——分布式与工作流

引言

在分布式微服务架构下,工作流引擎面临着新的挑战:并发冲突。当多个节点同时处理同一个任务时,如何保证流程状态的一致性?当多个节点同时执行定时任务时,如何避免重复执行?

本文将深入探讨分布式环境下工作流引擎的并发控制机制,带你了解工作流引擎如何保证"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_EXECUTIONACT_RU_TASK),都会发现这个字段。它就是引擎实现**乐观锁(Optimistic Locking)**的基石。

2.1 底层执行逻辑

  1. 节点A和节点B同时从数据库读取到了同一个 Task,此时发现该记录的 REV_ = 1

  2. 两个节点分别在各自的内存中完成业务逻辑计算,准备更新数据库,推动流程到下一步

  3. 节点A率先向数据库发起 UPDATE 语句,语句中带有严格的版本校验:

    sql 复制代码
    UPDATE ACT_RU_TASK 
    SET ASSIGNEE_ = '...', REV_ = 2 
    WHERE ID_ = 'task_123' AND REV_ = 1;
  4. 节点A执行成功,数据库中的 REV_ 变成了 2

  5. 几毫秒后,节点B也向数据库发起同样的 UPDATE 语句,条件同样是 REV_ = 1

  6. 此时数据库发现没有符合条件的记录(因为已经被A改成2了),返回受影响行数为 0

2.2 结果拦截

当引擎底层的 MyBatis 发现 UPDATE 受影响行数为 0 时,会立即抛出 OptimisticLockingException(乐观锁异常)。节点B所在的事务将被强制回滚,修改全部失效。

通过数据库级别的乐观锁,引擎保证了即便多个实例同时处理,也只有最先提交的那个实例能够成功驱动流程。

3 防御机制二:悲观锁与任务认领(解决后台任务并发)

乐观锁是被动防御,适合处理用户触发的低频并发。但对于场景二(定时器和异步任务),集群中的所有节点都在主动轮询数据库,冲突概率极高,此时必须使用排他性的获取机制(Acquisition)

工作流引擎内部有一个名为 JobExecutor(作业执行器)的组件,专门负责处理异步任务和定时器。为了防止多个节点的 JobExecutor 抢到同一个任务,引擎采用了**"锁定并认领(Lock and Claim)"**的机制。

3.1 底层执行逻辑

  1. 引擎内部有一张专门存放异步作业的表,比如 ACT_RU_TIMER_JOB。这张表里有两个关键字段:LOCK_OWNER_(锁拥有者,通常是节点的主机名或UUID)和 LOCK_EXP_TIME_(锁过期时间)

  2. 当节点A的后台线程去数据库拉取到期任务时,它不会直接 SELECT 然后执行,而是发起一个带有排他逻辑的 UPDATE

    sql 复制代码
    UPDATE 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;
  3. 这个 UPDATE 操作利用了关系型数据库本身的行锁特性。如果节点A和节点B同时发起,数据库会保证只有一个节点能更新成功

  4. 更新成功的节点(比如节点A),就等于"认领"了这个任务。随后它才可以放心地把任务拉到自己的线程池中去执行

  5. 节点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 核心逻辑

  1. 接收到审批请求时,以任务 ID 为 Key,尝试在 Redis 中获取分布式锁:

    java 复制代码
    boolean isLocked = redisTemplate.opsForValue()
        .setIfAbsent("workflow:task:lock:" + taskId, "locked", 5, TimeUnit.SECONDS);
  2. 如果 isLockedfalse,说明该任务正在被其他请求或节点处理,直接在入口处拦截,返回友好的业务提示(如:"任务正在处理中,请勿重复提交"),避免请求打到工作流引擎内部

  3. 如果获取到锁,再向下执行业务逻辑,最后调用 taskService.complete(taskId),并在 finally 块中释放锁

5 总结

在分布式微服务架构下,保证工作流引擎只有单个实例成功执行的核心,是一套由外向内、由悲观到乐观的多级并发控制体系

  1. 入口防线(业务层):利用 Redis 分布式锁拦截重复的用户级请求,防止无效运算和外部接口的重复调用
  2. 后台防线(引擎层/悲观机制) :利用数据库的行锁机制和 LOCK_OWNER_ 字段,确保集群中多个后台 JobExecutor 在拉取定时/异步任务时绝对互斥
  3. 兜底防线(引擎层/乐观机制) :利用运行时表的 REV_ 版本号字段实现乐观锁。即使所有的前置防御都失效,数据库层面的版本比对也能确保状态机的流转严格遵循"一次且仅一次"的修改

理解了这套并发控制体系,你就能在集群环境中放心地部署和扩展你的工作流微服务,彻底告别数据错乱与幽灵并发。

相关推荐
lifewange2 小时前
Hadoop 完整入门详解
大数据·hadoop·分布式
旷世奇才李先生2 小时前
Redis 7\.x实战:缓存设计与分布式锁实现
redis·分布式·缓存
Juicedata5 小时前
分布式架构下配额设计:JuiceFS 的实现与典型案例
分布式·架构
曾阿伦16 小时前
Spark flatMapToPair算子卡顿优化
大数据·分布式·spark
卷毛的技术笔记17 小时前
从“拆东墙补西墙”到“最终一致”:分布式事务在Spring Boot/Cloud中的破局之道
java·spring boot·分布式·后端·spring cloud·面试·rocketmq
大G的笔记本19 小时前
redis分布式锁过期问题和自动续期和主从延迟问题
redis·分布式
隔壁寝室老吴20 小时前
使用Flink2.0消费低版本的Kafka
分布式·kafka
Chasing__Dreams1 天前
Mysql--基础知识点--105--分布式事务
数据库·分布式·mysql