工作流(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_ 版本号字段实现乐观锁。即使所有的前置防御都失效,数据库层面的版本比对也能确保状态机的流转严格遵循"一次且仅一次"的修改

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

相关推荐
勤自省29 分钟前
ROS2分布式通信与Launch文件实战:从踩坑到打通(第12-20讲总结)
分布式·ubuntu·ros2·gazebo·launch·rqt·rviz2
qq_4523962318 小时前
第十三篇:《分布式压测:JMeter Master-Slave集群》
分布式·jmeter
小英雄大肚腩丶19 小时前
RabbitMQ消息队列
java·数据结构·spring boot·分布式·rabbitmq·java-rabbitmq
MXsoft61820 小时前
**一套平台管全域****IT****:分布式一体化监控的实战演进**
分布式
古怪今人20 小时前
etcd分布式键值存储系统 Windows下搭建etcd集群
数据库·分布式·etcd
LT101579744421 小时前
2026年微服务性能测试平台选型指南:分布式架构适配与服务联动测试
分布式·微服务·架构
颯沓如流星21 小时前
ZKube:优雅易用的 ZooKeeper 可视化管理工具
分布式·zookeeper·云原生
码农的神经元1 天前
考虑通信时延的直流微电网分布式电-氢混合储能协同控制仿真复现与改进
分布式·wpf
不会写程序的未来程序员1 天前
从快递物流到分布式架构:RocketMQ全栈进阶实战指南——从入门到高手的代码与原理解析
分布式·架构·rocketmq
虎头金猫1 天前
Beszel 轻量服务器监控:多台服务器状态统一看,搭起来比 Prometheus 省事太多
linux·运维·服务器·分布式·kafka·开源·prometheus