1. 从一个真实踩坑场景说起为什么我又回头啃Activiti的事件模型刚接触工作流引擎那会儿我对Activiti的理解停留在“画个流程图部署上去然后调API往下推”这个层面。直到有一次做一个审批链路的项目需求里有一条流程发起后如果三天内没人处理自动升级通知流程被强制终止时要触发一组资源回收动作。我当时的第一反应是在业务代码里写定时任务轮询结果被带我的老哥一句话点醒——“你为什么不直接用流程引擎自己的事件机制”那一刻我才意识到自己一直在把Activiti当成一个“流程图执行器”在用而忽略了它本质上是一套事件驱动的状态机。启动事件、结束事件、各种任务节点它们不只是图上的图形每一个背后都对应着明确的执行语义和可扩展的监听入口。把这些搞明白之后很多原本需要外部轮询、硬编码判断的逻辑都能收敛到流程定义本身里代码干净了不止一个档次。这篇内容就是把我重新梳理Activiti事件与任务体系的过程记录下来。核心围绕三块启动事件、结束事件、任务节点。我会讲清楚它们各自的类型、执行时机、配置方式以及在实际项目里怎么用、容易踩什么坑。适合已经能跑通一个Hello World流程、但想真正把流程控制权握在自己手里的开发者。如果你还在“流程能跑就行”的阶段这篇可能会让你对工作流引擎的认知往上提一层。2. 启动事件流程的入口远不止一个圆圈2.1 启动事件的本质与执行时机很多人以为启动事件就是流程图上那个带绿色三角的圆圈画上去就完事了。实际上启动事件是流程实例被创建时的第一个执行语义单元。当你调用runtimeService.startProcessInstanceByKey()或者startProcessInstanceById()时引擎做的第一件事就是定位流程定义的启动事件然后从这里开始推进执行。这里有个容易被忽略的点启动事件本身不产生任何“待办”或“任务”它只是一个触发点。但它的类型决定了流程如何被触发、能启动几次、启动时携带什么语义。Activiti支持的启动事件类型主要有空启动事件、定时启动事件、消息启动事件、信号启动事件、错误启动事件仅用于子流程。每一种对应不同的业务场景选错了要么功能实现不了要么引入不必要的复杂度。我见过有团队为了做“每月1号自动发起月度汇报流程”在外部写了个定时任务去调API启动流程。这当然能跑通但完全没必要——用定时启动事件把CRON表达式配在流程定义里引擎自己就会在指定时间创建实例。少一个外部调度器就少一个故障点。2.2 空启动事件最常用也最容易被低估空启动事件就是那个没有任何图标的圆圈它表示流程只能通过API手动启动。这是绝大多数业务审批流的起点——用户点“发起申请”后端调API创建实例。配置上没什么花哨的XML里就是一个startEvent idstart name开始/。但这里有个实操细节值得说启动事件的id命名。我习惯用startEvent或者业务语义化的名字比如submitStart。为什么强调这个因为当你在后面写执行监听器或者做流程追溯时启动事件的id会出现在历史表中一个清晰的id能让你排查问题时少翻半天文档。还有一个坑空启动事件默认只允许通过startProcessInstanceByKey这类API启动。如果你在流程里同时配了定时启动事件那这个流程就有两个入口。这时候要注意不同启动事件触发创建的流程实例走的路径可能完全不同。我建议在流程设计阶段就明确这个流程到底有几个入口每个入口的后续路径是否一致如果不一致考虑拆成两个流程定义别硬塞在一个图里。2.3 定时启动事件让流程自己“醒过来”定时启动事件在XML里长这样startEvent idtimerStart name定时启动 timerEventDefinition timeCycle0 0 9 * * ?/timeCycle /timerEventDefinition /startEventtimeCycle用的是CRON表达式上面这个表示每天上午9点启动一次。除了timeCycle还有timeDate指定某个时间点启动一次和timeDuration从部署后延迟多久启动一次。这里有个非常关键的注意事项定时启动事件的调度是基于流程部署时间还是基于引擎启动时间实测下来Activiti的定时任务是由JobExecutor统一调度的timeCycle的周期计算起点是流程定义部署的时间。这意味着如果你在下午3点部署了一个“每天9点启动”的流程它不会等到明天9点才第一次启动而是会根据CRON表达式的语义在下一个匹配的时间点触发。但如果你用的是timeDuration比如PT5M那它就是从部署完成那一刻开始算5分钟后启动第一次。踩过的坑有一次做演示环境部署了一个每分钟启动一次的定时流程结果忘了关一晚上创建了几千个实例把测试库塞满了。所以定时启动事件一定要配合业务上的“是否需要重复启动”来判断如果只是演示记得及时挂起流程定义或者删除部署。2.4 消息与信号启动事件解耦触发源消息启动事件和信号启动事件经常被混淆我用一个类比来解释消息是点对点的快递信号是广播。消息启动事件需要指定一个消息名启动时通过runtimeService.startProcessInstanceByMessage(消息名)来触发。它要求流程定义中必须有一个唯一匹配的消息启动事件不能有歧义。适合“收到某个特定业务事件后启动流程”的场景比如“收到支付成功通知后启动发货流程”。信号启动事件则是全局广播通过runtimeService.signalEventReceived(信号名)触发。所有订阅了该信号的流程定义都会被启动。这个特性很强大但也很危险——如果你在多个流程里用了同一个信号名一次广播会同时启动多个流程实例。我一般只在明确需要“一对多触发”的场景下用信号比如“系统维护通知”需要同时触发多个巡检流程。配置示例!-- 消息启动事件 -- startEvent idmsgStart name消息启动 messageEventDefinition messageRefpaymentSuccessMsg/ /startEvent !-- 信号启动事件 -- startEvent idsignalStart name信号启动 signalEventDefinition signalRefmaintenanceSignal/ /startEvent注意消息和信号的定义需要在流程定义的definitions根元素下提前声明否则部署时会报校验错误。这个错误信息有时候不太直观新手容易卡在这里。3. 结束事件流程收尾的多种姿势3.1 结束事件的执行语义结束事件是流程实例的终点。当执行流到达结束事件时引擎会做几件事结束当前执行分支、判断是否所有分支都已结束、如果是则销毁流程实例、写入历史记录。听起来简单但结束事件的类型不同收尾行为差异很大。Activiti支持的结束事件类型包括空结束事件、终止结束事件、错误结束事件、取消结束事件、补偿结束事件。日常业务里用得最多的是前两种后三种属于“高级武器”用对了能解决大问题用错了会让人怀疑人生。3.2 空结束事件与终止结束事件的本质区别空结束事件就是普通圆圈执行流到达它就自然结束。如果流程有多个并行分支空结束事件只结束当前分支其他分支继续执行直到所有分支都到达各自的结束事件流程实例才真正结束。终止结束事件则完全不同——它是一个“核弹级”的结束。执行流到达终止结束事件时整个流程实例立即终止所有还在活动的分支、任务、定时器全部被销毁。XML里这样写endEvent idterminateEnd name强制终止 terminateEventDefinition/ /endEvent我做过一个采购审批流程正常路径是“部门经理审批→财务审批→结束”。但业务上有个需求如果金额超过某个阈值需要“一票否决”任何一级审批人拒绝后整个流程立即终止不需要再走后续节点。这种场景用终止结束事件就非常合适——在拒绝的分支上直接连一个终止结束事件流程瞬间结束干净利落。实操心得终止结束事件会跳过所有正在等待的任务这意味着那些任务的审批人不会再看到待办。如果业务上需要“通知所有待办人流程已终止”得在执行监听器里自己写逻辑引擎不会自动帮你做这件事。3.3 错误结束事件子流程的异常出口错误结束事件只能用在子流程内部。它的作用是抛出一个业务错误这个错误会被最近的父级子流程捕获然后走对应的错误边界事件。如果一直没有被捕获最终会抛到最外层流程实例以异常状态结束。这个机制适合处理“子流程内部出现业务规则不满足”的情况。比如一个“合同审核子流程”里如果发现合同金额与预算不符可以抛出一个BUDGET_MISMATCH错误父流程捕获后走“退回修改”的分支。这样就把异常处理逻辑从业务代码里抽离到了流程定义中。配置示例endEvent iderrorEnd name预算不符 errorEventDefinition errorRefBUDGET_MISMATCH/ /endEvent3.4 结束事件与历史数据的关系这里有一个很多人关心的问题流程结束后历史表里会留下什么答案是取决于你的历史级别配置。Activiti的历史级别有none、activity、audit、full四档。默认是audit会记录流程实例、活动实例、任务实例的基本信息但不记录变量更新细节。如果你在流程结束后需要做数据分析比如统计每个节点的平均停留时间那至少要开到audit级别。如果需要追溯变量的每一次变更那得开到full。但full级别对性能有影响生产环境要权衡。我个人的经验是核心业务流程开audit非核心的、量大的流程开activity甚至none。别一刀切全开full数据库膨胀速度会让你怀疑人生。4. 任务节点流程真正干活的地方4.1 用户任务最核心也最复杂用户任务是Activiti里使用频率最高的节点它代表“需要人来处理”的环节。XML配置看起来简单userTask iddeptApprove name部门经理审批 activiti:assignee${deptManager}/但实际项目里用户任务的复杂度往往不在节点本身而在分配策略和表单集成上。分配策略有几种直接指定assignee固定人、用表达式动态指定${deptManager}、用candidateUsers指定候选人列表、用candidateGroups指定候选组。我一般推荐用candidateGroups配合业务角色而不是直接绑死到具体人。原因很简单人员会变动角色相对稳定。今天张三当部门经理明天可能换成李四如果流程里写死了张三人员变动时就得改流程定义重新部署这显然不合理。候选组的方式是流程里写activiti:candidateGroupsROLE_DEPT_MANAGER然后在业务代码里维护“谁拥有这个角色”的映射。查询待办时用taskService.createTaskQuery().taskCandidateGroup(ROLE_DEPT_MANAGER)这样人员变动只需要改业务侧的映射关系流程定义不用动。注意候选任务和指派任务是两种不同的查询方式。候选任务需要先“认领”claim才会变成指派任务。如果你用taskCandidateGroup查出来的任务直接调complete会报错必须先claim。这个坑我见过至少三个同事踩过。4.2 服务任务把业务逻辑嵌入流程服务任务代表自动执行的逻辑不需要人参与。Activiti支持几种实现方式Java类、表达式、委托表达式、Web Service调用。最常用的是Java委托类serviceTask idsendNotify name发送通知 activiti:classcom.example.SendNotifyDelegate/对应的Java类需要实现JavaDelegate接口public class SendNotifyDelegate implements JavaDelegate { Override public void execute(DelegateExecution execution) { String applicant (String) execution.getVariable(applicant); // 发送通知逻辑 } }这里有个设计原则服务任务里只放“与流程强相关”的逻辑比如更新流程变量、调用流程相关的业务服务。不要把大段业务计算塞进委托类里否则流程定义和业务代码会耦合得太紧后期维护很痛苦。我习惯的做法是委托类只做参数组装和调用真正的业务逻辑放在独立的Service里委托类通过Spring注入来调用。4.3 脚本任务与业务规则任务脚本任务允许在流程里直接写脚本Groovy、JavaScript等适合做简单的变量计算或条件判断。比如scriptTask idcalcScore scriptFormatgroovy script def base execution.getVariable(baseScore) execution.setVariable(finalScore, base * 1.2) /script /scriptTask但脚本任务有个明显的缺点脚本内容存在数据库里调试困难版本管理也不方便。我一般只在原型验证或者非常简单的场景下用正式项目里更倾向于用服务任务。业务规则任务则是把规则引擎如Drools集成进来适合规则频繁变动的场景。不过引入规则引擎本身就有学习成本如果规则不复杂用排他网关加条件表达式就够了没必要为了“看起来高级”而引入额外依赖。4.4 手动任务与接收任务手动任务表示“需要人工完成但引擎不跟踪”的环节。它不会产生待办任务执行流到达后直接通过。适合记录“某个线下动作已完成”这种语义。接收任务则是“等待外部消息”的节点执行流到达后会停在那里直到收到指定的信号或消息才继续。这个在“等待第三方回调”的场景里很有用比如“等待支付网关回调”可以用接收任务来实现而不是在业务代码里轮询。5. 事件与任务的组合实战一个审批流的完整拆解5.1 需求描述与流程设计假设有一个“费用报销审批”流程需求如下员工提交报销单后启动流程如果金额小于500直接由系统自动审批通过如果金额大于等于500走部门经理审批部门经理审批通过后如果金额大于5000还需要财务总监审批任何一级审批拒绝流程立即终止流程结束时无论通过还是拒绝都要记录结束时间和最终状态这个流程用到了空启动事件、排他网关、用户任务、服务任务、终止结束事件、空结束事件。5.2 关键节点配置与参数说明启动事件用空启动事件API手动触发。第一个网关判断金额exclusiveGateway idamountCheck name金额判断/分支条件用表达式sequenceFlow idflow1 sourceRefamountCheck targetRefautoApprove conditionExpression xsi:typetFormalExpression ${amount 500} /conditionExpression /sequenceFlow自动审批用服务任务部门经理审批用用户任务并配置候选组。拒绝分支统一连到终止结束事件。这里有个参数计算的细节金额比较用还是取决于业务规则。我一般会在需求评审时明确边界值比如“500以下”到底含不含500。这种细节不确认清楚上线后就是扯皮。5.3 执行监听器记录流程轨迹为了记录流程结束时间我在结束事件上挂了一个执行监听器public class ProcessEndListener implements ExecutionListener { Override public void notify(DelegateExecution execution) { execution.setVariable(endTime, new Date()); execution.setVariable(finalStatus, execution.getVariable(approved) ? PASSED : REJECTED); } }XML里这样引用endEvent idend name结束 extensionElements activiti:executionListener eventend classcom.example.ProcessEndListener/ /extensionElements /endEvent实操心得执行监听器的event属性有start和end两个值。对于结束事件用end才能在流程实例销毁前拿到执行上下文。用start的话有些变量可能已经被清理了。6. 常见问题与排查技巧实录6.1 启动事件相关的高频问题问题一定时启动事件不触发。排查顺序先确认JobExecutor是否启用activiti.cfg.xml里的jobExecutorActivate配置再确认流程定义是否处于激活状态挂起的流程不会触发定时器最后检查CRON表达式是否正确。我遇到过有人把CRON写成0 0 9 * * *六位最后一位是秒结果每天9点0分0秒触发一次但业务期望的是9点整差了一秒导致测试时没观察到。问题二消息启动事件报“找不到消息定义”。检查message元素是否在definitions下声明且messageRef与startEvent里的引用一致。另外消息名是全局唯一的不同流程定义里不能有同名消息。6.2 结束事件相关的典型异常异常一流程结束后还有待办任务。这通常是因为用了空结束事件而不是终止结束事件。空结束事件只结束当前分支其他分支的任务还在。如果业务上要求“一处结束处处结束”必须用终止结束事件。异常二错误结束事件抛出的错误没有被捕获。检查错误边界事件是否配置在正确的子流程边界上以及errorRef是否匹配。错误码是大小写敏感的BUDGET_MISMATCH和budget_mismatch会被当成两个不同的错误。6.3 任务节点的排查速查表问题现象可能原因排查方法查询不到待办任务候选人组配置错误检查candidateGroups值与查询条件是否一致任务无法完成任务未认领先调claim再调complete服务任务不执行委托类路径错误检查activiti:class全限定名确认类在classpath下任务分配给了错误的人表达式变量未设置在启动流程时确认相关变量已传入并行任务只完成了一个网关配置问题检查并行网关的汇聚逻辑是否正确6.4 独家避坑技巧第一个技巧流程定义部署后先别急着写业务代码用单元测试把每个分支都跑一遍。Activiti提供了Deployment注解和ProcessEngineRule写测试并不麻烦。我见过太多团队流程图画完直接上业务结果某个分支的条件表达式写错了上线后才发现。第二个技巧给流程定义加版本注释。Activiti部署时会自动生成版本号但版本号本身不携带语义。我习惯在流程定义的documentation标签里写清楚这个版本改了什么比如“v3增加财务总监审批节点”。这样回滚或者排查历史数据时能快速定位到对应的业务变更。第三个技巧慎用全局事件监听器。Activiti支持在引擎级别注册全局监听器能监听所有流程的所有事件。这个功能很强大但也很容易造成性能问题——每个事件都触发监听器如果监听器里有数据库操作高并发下会成为瓶颈。我一般只在需要做统一审计日志时才用全局监听且监听器里只做异步写入。7. 我个人的一些使用体会把启动事件、结束事件、任务节点这三块吃透之后我最大的感受是工作流引擎的价值不在于“能画流程图”而在于“能把业务规则从代码里抽出来用声明式的方式表达”。以前写审批逻辑满屏的if-else和状态判断现在把这些逻辑收敛到流程定义里代码只负责“启动流程”和“处理任务”职责清晰了很多。当然这并不意味着所有逻辑都要往流程里塞。我的原则是流程定义负责“什么时候做什么”业务代码负责“具体怎么做”。比如“金额大于5000需要财务总监审批”这是流程逻辑放流程定义里“财务总监审批时看到的报表怎么生成”这是业务逻辑放代码里。边界划清楚了两边都好维护。还有一个体会是不要害怕重新部署流程定义。Activiti的版本机制允许同一个key有多个版本新启动的实例走新版本老实例继续走老版本。所以流程调整时直接部署新版本就行不需要停服务或者做数据迁移。这个特性让流程的迭代变得很轻量我现在的习惯是流程有改动就重新部署版本号自然递增历史数据也能对应到正确的版本。最后分享一个排查问题的小习惯遇到流程行为不符合预期时先去ACT_HI_ACTINST表里查这个实例的活动轨迹看看执行流到底走了哪条路径。很多时候问题不是出在引擎而是出在条件表达式的计算结果上。把轨迹查清楚比盯着流程图猜要高效得多。