Activiti服务任务实战:绑定、事务与异步配置全解析
做了这么多年流程引擎相关的开发我越来越觉得前面几篇聊的流程定义、部署、任务办理这些基础操作其实只是热身。真正的分水岭是流程图上那个服务任务节点怎么跟真实业务代码连接起来。页面上的审批流转是一回事要让某个节点自动调用业务系统把数据写进库里再继续往下推完全是另一回事。这篇系列第4篇我准备集中火力讲服务任务的实战接入。会围绕Java Spring环境用的还是常见的Activiti版本思路5.x/6.x/7.x的API有少量差异但底层逻辑一致。如果你刚把流程引擎跑通正准备把自己项目的服务类接进流程图这篇文章应该对你有用。如果你已经在生产环境被服务任务的坑折腾过也能从里面找到一些对照和启发。1. 服务任务绑定的三种方式别再一张图里全塞JavaDelegate1.1 类名直写原型验证能用正经项目慎用服务任务在BPMN里面对应的是serviceTask标签。最直接的方式是给这个标签设置activiti:class属性让引擎直接反射创建类的实例去执行serviceTask idbusinessTask name业务处理 activiti:classcom.example.delegate.MyBusinessDelegate/流程跑到这个节点时引擎拿到类名通过反射构造出实例调用里面带DelegateExecution参数的execute方法。整个过程不需要任何Spring容器的参与。从代码上看确实是最简单的接入方式。但这种方式有一个很隐蔽的限制既然引擎不走Spring容器那你在这个Delegate类里写的所有依赖注入都不生效。假如这个Delegate需要调用某个业务Service你是不能直接Autowired进来的只能通过静态工具类或者自己从ApplicationContext里拿代码侵入性一下就上去了。另一个麻烦是单测也难写流程引擎帮你new出来的对象跟你在测试代码里构造的对象生命周期和行为方式完全不是一个路子。我一般在做原型验证或者临时性的Demo时偶尔用一下class直写但到了正式项目里用它的次数很少。核心业务逻辑如果用class直写还硬塞依赖后面排查链路会特别长改动也很痛苦。1.2 delegateExpression和expression真实项目的默认选择真正用得多的是activiti:delegateExpression和activiti:expression两种方式。delegateExpression的配置长这样serviceTask idbusinessTask name业务处理 activiti:delegateExpression${businessProcessor}/这里的businessProcessor是Spring容器里某个Bean的名字引擎会从容器里取出这个Bean来调用。这个Bean需要实现JavaDelegate接口引擎在节点执行时会调用它的execute(DelegateExecution execution)方法。因为Bean本身由Spring管理依赖注入、事务、AOP都能正常参与代码逻辑可以跟容器深度融合。那为什么不能用activiti:expression代替看这个写法serviceTask idbusinessTask name业务处理 activiti:expression${businessProcessor.process(context)}/expression走的是表达式解析直接调用Bean上的某个方法并传入参数。它不需要你的类实现任何引擎接口随便哪个Bean的任意方法都能被调用灵活性更高。但灵活也带来维护问题如果方法返回值会被后续节点使用你得保证它能被正确存储如果业务逻辑复杂写进表达式里会很拧巴一眼看不出这节点到底在做什么。我自己的习惯是核心业务节点用delegateExpression绑定实现了JavaDelegate的专用类简单动作比如调一个HTTP服务、更新一个状态字段就用expression直接调Bean方法。前者结构清晰便于维护后者写起来快。但底线是表达式别超过一两行一旦要写逻辑判断就老老实实建一个Delegate类。1.3 表达式参数传递的隐蔽细节不管用哪种表达式方式参数传递都是容易出问题的地方。引擎支持在表达式里直接引用流程变量比如serviceTask idbusinessTask name业务处理 activiti:expression${businessProcessor.process(execution, applyId, assignee)}/这里的applyId和assignee都是流程变量名引擎会帮忙替换成实际值。这种写法比在Delegate里手动execution.getVariable(applyId)再getVariable省事得多。但代价是如果变量名写错或者变量在当前作用域取不到表达式执行会直接抛异常。流程变量的作用域也要留神有的是任务级别局部变量有的只在某个子流程里可见服务任务节点不一定能取到所有变量。我还有一个偏好复杂对象尽量别通过表达式传。表达式传参一旦涉及复杂对象的序列化和反序列化很容易引出莫名其妙的问题。我在项目里的常规做法是表达式里只传流程实例IDBean内部根据ID去业务表查询当前需要处理的数据。这样既绕开了流程变量的序列化问题也让服务任务的执行逻辑更加可控。2. 服务任务和数据库事务的边界这决定了流程数据的一致性2.1 服务任务节点内部到底发生了什么为什么说事务是服务任务的核心问题先拆解一下服务任务节点自带的执行顺序第一步引擎进入服务任务构造好DelegateExecution执行上下文 第二步调用你绑定的业务代码 第三步业务代码执行完毕流程实例推进到下一个节点 第四步引擎把当前节点信息、历史记录、任务状态等数据写入数据库。如果是同步服务任务这些步骤一般被包含在同一个事务里。也就是说你在业务方法里改了业务表引擎改了流程表这两块数据要么一起提交要么一起回滚。这个特性看起来方便实际项目里却经常埋雷。我印象很深的一个场景某项目流程中有个服务任务代码逻辑是先调用外部接口再做本地业务表更新。外部接口调用成功本地更新也成功了结果流程推进时因为某个字段违反数据库约束整个事务回滚。外部调用已经发出去了没法撤销本地更新也丢掉了流程实例还停在原节点。最后只能靠对账脚本发现两边状态不一致再去人工补数据。这个案例很说明问题事务不是越宽越好。服务任务里如果做了非数据库操作比如外部HTTP请求、发消息、写文件这些副作用一旦和流程引擎的事务绑在一起数据库回滚不了外部状态。这个边界没想清楚后面就得持续为数据一致性买单。2.2 多数据源下的经典坑很多企业应用会把业务数据放一套数据库流程引擎单独放一套库。Spring里同时配置业务事务管理器和流程引擎事务管理器服务任务里业务代码用的肯定还是业务管理器引擎内部推进用的是引擎管理器两套事务各管各的。这样配置的典型现象是业务代码自己的事务提交了但流程引擎的事务因为字符约束等原因回滚两者互相感知不到。或者反过来流程引擎提交了业务代码更新因为脏数据回滚。无论哪种情况最后都会留下不一致的脏状态。还有一个隐蔽问题代码里如果依赖“业务事务先提交引擎事务后提交”的执行顺序来做一些假设一旦顺序被各种配置打乱排查难度会直线上升。我个人的建议是如果业务系统结构不复杂尽量让流程引擎仓库和业务数据源走同一个数据库统一用一个事务管理器管理。运维上如果确实不允许流程表和业务表放同一个库那必须在服务任务编码上做补偿设计。比如引入本地消息表流程引擎的推进走一个事务业务数据的变更走另一个事务通过消息或定时任务保证最终一致。别试图在服务任务里靠代码硬保证分布式事务那只会把复杂度堆到别人没法维护的程度。2.3 手动切割事务的两种实用写法遇到必须和外部系统交互的服务任务我一般会主动避免让外部调用和引擎推进处在同一个事务里。两种比较实用的做法第一种在服务任务代码里不要做长事务操作。把外部调用拆出去放到事务提交后的异步线程里执行。异步线程里的异常没法让主事务回滚所以必须自己落日志并且做好补偿重试设计。第二种在业务代码里用编程式事务比如TransactionTemplate把流程变量更新、业务表写入、外部调用根据实际情况组合成多个独立的事务单元。这样能够精确控制哪个步骤算一组哪个步骤失败了不会影响前面的提交。再进一步还可以从流程设计层面规避问题。把“调用外部系统”独立设计成一个服务任务节点并且给这个节点开启异步执行把这个节点从主流程事务的边界里剥离开。这样外部调用就算慢了、失败了也只是影响它本身所在的异步任务不会让主流程的推进被卡在同一个事务里。具体配置方式我紧接着展开讲。3. 有些服务任务必须配置异步否则高峰期一站全线阻塞3.1 同步节点的瓶颈到底在哪默认情况下服务任务是同步执行的。流程实例走到服务任务节点时当前线程要一直等着你的业务代码执行完毕再继续往下推。如果你的服务任务里调了外部接口外部接口又慢比如耗时1秒那流程节点就卡1秒。单个流程实例卡1秒其实无所谓但高峰期几十上百条流程同时跑到这个节点工作线程池一下就被打满。后面不管是谁办理任务、谁提交流程都得排队等线程。用户感知到的就是点个按钮卡半天页面一直转圈。这就是把慢操作放在同步服务任务节点里最直接的后果。有人觉得那让外部接口变快不就行了。外部系统的性能你真控制不了你能控制的只有自己流程侧的设计。对这类耗时不确定、可能需要重试的调用正确做法就是把这个服务任务节点标成异步。3.2 开启async的正确姿势在BPMN文件里给服务任务节点加上activiti:asynctrue就行serviceTask idexternalTask name调用外部系统 activiti:asynctrue activiti:delegateExpression${externalAdapter}/配置之后主流程走到这个节点时不会硬等业务代码执行完而是把这个节点作为一个异步任务交给独立的Job执行器去处理。如果执行失败还可以按配置重试。主流程线程能马上返回去处理其他请求。不过这里有个前提条件环境里必须把异步执行器真正跑起来。不同版本配置方式有差异但思路都一样要确保AsyncExecutor被激活。我踩过几次坑因为没开执行器异步节点就一直停在那边不往前走。它不是不执行而是根本没有执行器去执行它。3.3 AsyncExecutor参数的调整建议异步执行器启用后有几个参数值得认真对待。不同版本的取名可能有区别但大致匹配这几个作用参数作用实操建议asyncExecutorCorePoolSize异步任务线程池核心线程数建议比业务高峰时的流程并发数略高一点asyncExecutorMaxPoolSize线程池最大线程数不要调太大避免占满线程池导致响应超时asyncExecutorKeepAliveTime空闲线程存活时间保持默认即可除非任务波动特别剧烈重试次数配置控制异步任务失败后的重试次数不设上限的话网络抖动会堆大量积压任务还要注意异步任务重试机制。执行失败默认会重试如果网络抖动一下任务执行器会按照策略不断重新试。假如不设置最大重试次数job表里会堆上一大堆待执行任务最终影响引擎整体的性能。我在线上环境都会把重试次数控制在一个合理范围宁可失败后人工介入也不用无限重试把执行器拖垮。4. 服务任务连不上业务代码一套实测排查链路真实项目里服务任务出问题时的报错信息就那么几个但根因五花八门。这里整理一套排查链路按着顺序走能省不少时间。4.1 BeanNotFound先查名字再查上下文最典型的是Spring找不到对应的Bean。比如delegateExpression写了${businessProcessor}但容器里没有这个名字的Bean。遇到这个报错第一反应别急着改配置按三步查第一步确认Bean确实被Spring扫描到了。检查配置类上的包扫描路径看看业务类所在的包是否真的被覆盖到了。很多项目把流程引擎相关代码单独放在一个模块主应用扫描不到报错就会很奇怪。第二步确认Bean名称拼写完全一致。Spring默认生成Bean名是类名首字母小写比如类名BusinessProcessor默认beanName就是businessProcessor。但如果你在类上按自定义名称注册了Bean表达式里就得写对那个名字。字母大小写差一个报的错都长一样。第三步如果是整合环境还要确认流程引擎能否拿到ApplicationContext。有些版本的引擎通过Spring集成模块来解析表达式必须保证流程引擎是在Spring容器基本准备就绪后才初始化。我见过一个项目某开发者在PostConstruct方法里部署流程定义结果Bean还没完成初始化流程引擎已经开始尝试解析表达式了部署直接失败。这种问题当时调试了很久才发现。4.2 流程变量序列化一个很容易被忽略的陷阱服务任务传参时如果直接把业务对象塞进流程变量而且这个节点还需要读取和修改它序列化坑就来了。引擎在流程实例推进、异步任务存储、历史数据归档等多个环节都会对流程变量做序列化处理。业务对象只要没实现Serializable或者对象图里带着不可序列化的字段比如某个注入的Service依赖就会得到一整页看不懂的序列化异常。我的建议很直接业务对象直接进流程变量的做法能免则免。常用的替代方案是流程变量里只放业务对象的ID服务任务执行时根据ID去业务库查数据。如果实在要暂存数据就单独构造一个只包含简单类型的Map连接对象、函数对象之类一律不放进去。这样能规避掉大量诡异的序列化问题。还有个隐蔽场景服务任务表达式里调用的方法如果返回一个对象引擎可能把这个返回值隐式存储成流程变量。如果这个对象不可序列化报错会来得比较晚很难第一时间定位到根因。经验就是方法返回值尽量保持一致不要随手返回复杂实体对象。4.3 异常与重试哪些异常该重试哪些该直接吞掉服务任务绑定的方法抛异常时流程实例状态会回退到当前节点异常信息记录在任务表中并根据配置判断是否重试。比如数据库连接失败这种临时故障重试是有意义的如果是业务校验不通过这种必现错误重试再多次也没用。我的处理习惯是业务预期会失败的异常在Delegate内部就捕获掉转成业务异常层面能识别的结果避免触发无意义的自动重试。只有临时性故障才允许抛异常让引擎去重试然后把最大重试次数控制在合理范围。网上经常能看到job表里积压几十万条重试任务的案例基本都是重试次数没设上限导致的。5. 多实例节点里的服务任务数据处理要提前设计5.1 collection与elementVariable的正确理解流程引擎的多实例节点在流程图里比较常见比如一个审批需要多个参与方依次或并行处理。多实例节点跟服务任务结合时通常用collection和elementVariable来处理批量数据。假设你要做一个批量外部接口调用节点可以这样设计collectionapplicantIds是流程变量里面有多个IDelementVariablecurrentId表示每个实例当前处理的那一项。那么每个服务任务实例执行时都能从execution.getVariable(currentId)取到当前循环的ID。这个模式天然适合批处理场景。但要注意多实例节点的实例数量取决于节点被创建那一刻collection里的数据快照。流程已经跑起来之后你再往collection里动态添加元素已经生成的实例不会自动增加。所以类似加签、减签这类需求必须在专门的API层面处理而不是靠修改流程变量去实现。很多初学者就是在这里绕了远路。5.2 大数据对象在多实例之间传递的优化多实例每个实例如果都要用同一个比较大的数据对象把整个对象放进collection或者塞进每个实例的局部变量里内存开销和序列化开销都会明显上升。我实测后推荐的做法是大数据对象只保存在一个流程变量里每个实例只是带上一个引用ID。服务任务内部需要时再通过ID去流程变量或者业务库里读取。这么做还有个额外好处可以避免多个并行实例同时修改同一个变量导致的数据覆盖问题。并行实例各自执行时如果都对同一个流程变量赋值最后落库的值往往取决于执行顺序这个顺序你是保证不了的。所以在设计多实例服务任务时一定要明确哪些流程变量是只读的哪些需要每个实例自己维护局部变量。在流程定义阶段就把这个边界定清楚比运行阶段再去处理冲突省事得多。最后说点个人体会。跟服务任务打了这么多交道我慢慢形成了一个习惯流程图里的服务任务节点永远只负责编排具体的业务逻辑全部下沉到独立的Service层。Delegate类也好表达式调用也罢都只是把流程引擎和业务系统粘起来的那层胶水。让这层胶水尽量薄流程定义里的表达式尽量简单多实例数据设计提前做这套思路帮我躲过了很多后来想想都后怕的问题。如果你刚开始做流程引擎集成不妨也试试这个原则。