Activiti 工作流引擎 — 小白讲解 + 面试题精解

Activiti 工作流引擎 — 小白讲解 + 面试题精解

定位:假设你从没听过“工作流”,只会用 if-else + 数据库状态字段处理审批。所有概念从零讲起,配可运行代码 + 面试题。 覆盖:工作流与 BPMN 基础 → Activiti 核心概念与表结构 → Spring Boot 集成实战 → 高级场景(会签 / 驳回 / 加签 / 超时)→ 生产常见问题与解决方案 → 选型对比 → 面试题。 用法:先读讲解理解概念 → 跑代码验证 → 再看面试题自测。 特色:★ 每个难概念都按「一句话定义 → 生活类比 → 流程图 → 致命缺点 → 什么时候用」五步讲;每个场景配可直接粘贴运行的代码。


📖 本文档名词速查(看到不认识的缩写,先查这里)

完整的白话解释、生活类比、面试话术见 00-名词速查手册.md。 下面只列本文档用到的名词,按在文档中出现的顺序排序。

缩写 / 术语 英文全称 一句话说明 出处
工作流 Workflow 把“一件事的审批 / 流转步骤”画成流程图,交给引擎自动推进 第6章
BPM Business Process Management 业务流程管理,一门“怎么把公司流程管好”的学科(是方法论,不是软件) 第6章
BPMN Business Process Model and Notation 画流程图的统一符号标准,让“流程图”能被程序读懂(BPM 的图纸规范) 第6章
工作流引擎 Workflow Engine / Process Engine 读懂流程图、并按图把任务一步步推给对应人的运行时程序 第6章
Activiti Alfresco Activiti Java 里最流行的开源工作流引擎之一,BPMN 2.0 规范的实现 第6章
Flowable Flowable Activiti 原班人马出走后做的分支,Activiti 6 的“精神续作” 第6章
Camunda Camunda BPM 同样是 Activiti 分支,商业化最成功,DMN 决策表是强项 第6章
流程定义 ProcessDefinition 画好的那张“图纸”本身(等于 Java 的 Class) 第6章
流程实例 ProcessInstance 按图纸跑起来的“一次具体审批”(等于 Class new 出来的对象) 第6章
执行流 Execution 流程实例里的“当前指针”,并行分支时会有多个(一个实例可有多个 Execution) 第6章
任务 Task 流程停在某个人面前让他干的活,特指 User Task(人工任务) 第6章
用户任务 User Task 需要人点“同意 / 拒绝”的节点(区别于机器自动执行的 Service Task) 第6章
服务任务 Service Task 不需要人参与、引擎自动调用一段 Java 代码完成的节点 第6章
网关 Gateway 流程图里的“岔路口”,决定往哪条路走 / 要不要并行 第6章
排他网关 Exclusive Gateway(XOR) 多条路里只走一条,按条件判断(像 if / else if) 第6章
并行网关 Parallel Gateway(AND) 多条路同时都走,全部走完才汇合(像开多线程) 第6章
包容网关 Inclusive Gateway(OR) 满足条件的多条路都走(介于排他“只走一条”和并行“全都走”之间) 第6章
流程变量 Process Variable 跟着流程实例走的“数据包”,用来存审批意见、天数、金额等 第6章
会签 Multi-instance(多实例) 一个节点要多个人都审批(可配“全部通过”或“比例通过”才继续) 第6章
加签 Add Sign 审批中途临时“再加一个人来审”(前加签 / 后加签) 第6章
转办 Transfer / Reassign 把自己的任务直接给别人,任务归属人变了 第6章
委派 Delegate 把任务委托给别人办,办完还回到自己手上确认 第6章
驳回 / 退回 Reject / Rollback 审批不通过,流程退回上一个节点(甚至退回到发起人) 第6章
撤回 Withdraw / Cancel 发起人主动把还没审完的流程收回来(和驳回方向相反) 第6章
边界事件 Boundary Event 挂在任务边上的“监听器”,比如超时未处理就自动走另一条路 第6章
定时器事件 Timer Event 时间到了就触发的事件(定时提醒、超时自动通过) 第6章
监听器 Listener 流程走到某个点时自动回调你的代码(ExecutionListener / TaskListener) 第6章
部署 Deployment 把画好的流程图(.bpmn 文件)上传到引擎,引擎解析并存库 第6章
候选组 Candidate Group 任务不指定某个人,而是给一个“角色 / 部门”,组内谁都能领 第6章
签收 Claim 候选组里某人把任务“领走”,变成自己的专属任务 第6章
乐观锁 Optimistic Lock 用版本号防止两人同时改同一条数据(Activiti 表里有 REV_ 字段) 第5章
异步执行器 Async Executor 引擎后台线程池,负责跑定时器、异步任务、重试失败作业 第2章
历史级别 History Level 引擎记录历史的详细程度(none / activity / audit / full),越详细越占空间 第6章
作业 Job 引擎要“稍后执行”的任务(定时器、异步节点、失败重试) 第2章
流程迁移 Process Instance Migration 流程图升级后,把在途的老实例搬到新版本上 第6章

目录


第一章:先搞清楚“工作流”到底是什么(小白必读)

这一章不写一行 Activiti 代码,但它是后面所有内容地基。 很多人学不会 Activiti,是因为一上来就背 API,却没搞清“工作流引擎到底替我干了什么活”。

1.1 从一个请假审批说起:不用工作流,我们是怎么写的

假设公司要做一个请假功能:员工提交请假 → 直属领导审批 → 如果请假 超过 3 天,还要老板审批 → 结束后通知 HR。

大多数人的第一版写法(用数据库状态字段 + if-else 硬编码):

// ❌ 典型写法:状态字段 + if-else 硬编码流程
@Service
public class LeaveService {

    public void submit(LeaveForm form) {
        Leave leave = new Leave();
        leave.setStatus("LEADER_APPROVE");   // 提交后等领导批
        leave.setDays(form.getDays());
        leaveMapper.insert(leave);
    }

    public void approve(Long leaveId, String action, String userId, String comment) {
        Leave leave = leaveMapper.selectById(leaveId);

        if ("LEADER_APPROVE".equals(leave.getStatus()) && "pass".equals(action)) {
            // 领导通过了,看天数决定要不要老板批
            if (leave.getDays() > 3) {
                leave.setStatus("BOSS_APPROVE");
            } else {
                leave.setStatus("FINISHED");
            }
        } else if ("BOSS_APPROVE".equals(leave.getStatus()) && "pass".equals(action)) {
            leave.setStatus("FINISHED");
        } else if ("reject".equals(action)) {
            leave.setStatus("REJECTED");
        }
        leaveMapper.updateById(leave);
    }
}

这段代码能跑,但只要业务一变,就全是坑:

业务变化 硬编码要改什么 痛在哪
请假 >3 天改成 >5 天 改 if (days > 3) 改代码、发版、测试
中间加一级“总监审批” 加一个状态 + 改两处 if-else 状态和跳转逻辑越改越乱
领导可以有 2 个,任一批即可 几乎没法写,得加中间表 会签 / 或签逻辑爆炸
老板 3 天不批就自动通过 自己写定时任务扫表 和业务代码耦合
老板想看“这张单子走到哪了” 只能看到状态字符串 没有流程图可视化
审计问“谁在什么时候批的、说了啥” 得自己建审批记录表 历史追溯要重做
审批人要按组织架构动态算 改代码 无法配置化

核心问题:流程逻辑(“下一步给谁”)和业务逻辑(“请假单的数据”)死死绑在一起。流程一变就要改代码发版。

1.2 用工作流引擎之后,代码变成什么样

同样需求,用工作流引擎:

第一步:画一张流程图(BPMN),声明“谁 → 谁 → 谁”:

提交请假 → [领导审批] →(天数>3?)→ [老板审批] → 结束
                     →(天数<=3)────────────→ 结束

第二步:Java 代码只剩“查我的待办 / 完成我的任务”,不再出现任何 if-else 状态流转:

// ✅ 用工作流引擎:代码里没有"下一步是谁"这种逻辑
@Service
public class LeaveProcessService {

    @Autowired private RuntimeService runtimeService;
    @Autowired private TaskService taskService;

    // 提交:启动一个流程实例,把业务数据当"流程变量"塞进去
    public void submit(LeaveForm form) {
        Map<String, Object> vars = new HashMap<>();
        vars.put("days", form.getDays());          // 天数,网关要用它判断
        vars.put("leader", form.getLeaderId());    // 领导审批人
        vars.put("boss", "boss001");               // 老板审批人
        runtimeService.startProcessInstanceByKey("leaveProcess", String.valueOf(form.getId()), vars);
    }

    // 我的待办:不管流程走到哪一步,永远只查"当前该我干的任务"
    public List<Task> myTasks(String userId) {
        return taskService.createTaskQuery().taskAssignee(userId).list();
    }

    // 审批:完成任务,引擎自动根据流程图决定下一步给谁
    public void approve(String taskId, boolean pass, String comment) {
        Map<String, Object> vars = new HashMap<>();
        vars.put("approved", pass);                // 网关根据这个变量决定走哪条路
        taskService.complete(taskId, vars);
    }
}

对比一下心态变化:

硬编码 if-else 工作流引擎
“下一步给谁”写在哪 Java 代码里 流程图里(改图即可,不用改代码)
加一级审批 改代码 + 发版 改图 + 重新部署
可视化流程图 没有 引擎自带,还能高亮当前节点
审批历史 自己建表 引擎自动记 ACT_HI_* 历史表
会签 / 加签 / 超时 基本实现不了 引擎原生支持
代码量 随流程复杂度爆炸 基本恒定,就那几个 API

1.3 生活类比:工作流引擎 = 公司前台 + 流程手册

一句话不好记,用类比记:

工作流引擎就是公司前台的“收发室 + 行政流程手册”。

  • 流程图(BPMN) = 行政流程手册,写着“报销单先给主管签字,超过 5000 再给总监”。
  • 启动流程实例 = 你填好一张报销单,交给前台。
  • 用户任务(User Task) = 单子出现在某人的“待办收件箱”里。
  • 完成任务(complete) = 主管签完字,把单子放回前台。
  • 网关(Gateway) = 前台看手册:“金额 8000 > 5000,这张要送总监”。
  • 流程变量 = 贴在这叠材料上的便利贴(金额、意见)。
  • 历史表 = 前台的登记簿,谁什么时候签收的,一查就有。

关键好处:改流程 = 改手册,不需要重新培训每个员工(改业务代码)。

1.4 工作流 / BPM / BPMN / 工作流引擎:四个词别搞混

这四个词经常被当成一回事,其实是四个层次。搞清层次,面试就不会说错:

┌─────────────────────────────────────────────────┐
│ BPM  业务流程管理                                  │  ← 方法论/学科:怎么把公司流程管好、优化好
│ (Business Process Management)                   │     是"想法",不是软件
├─────────────────────────────────────────────────┤
│ BPMN 业务流程建模与标记                            │  ← 标准/图纸规范:流程图该怎么画、符号什么意思
│ (Business Process Model and Notation)           │     是"图纸规范",由 OMG 组织制定,现版本 2.0
├─────────────────────────────────────────────────┤
│ 工作流引擎 / 流程引擎                              │  ← 软件/运行时:读懂图纸、按图推进任务的程序
│ (Process Engine)                                │     Activiti / Flowable / Camunda 都是它
├─────────────────────────────────────────────────┤
│ Activiti                                          │  ← 具体产品:上面那层的某一个具体实现
└─────────────────────────────────────────────────┘

一句话记:BPM 是想法 → BPMN 是图纸 → 引擎是施工队 → Activiti 是某一支施工队。

⚠️ 常见口误:很多人说“我用 BPMN 做工作流”——不对。BPMN 是画图标准,你用的是实现了 BPMN 的引擎(Activiti)。

1.5 Activiti 是什么?为什么还有 Flowable 和 Camunda

一句话定义:Activiti 是一个用 Java 写的、实现了 BPMN 2.0 规范的开源工作流引擎,能把画好的流程图部署进去,然后按图驱动业务流转。

但它有一段“分家”的历史,面试常问,必须搞清楚:

2010  Activiti 诞生(Alfresco 公司,创始人 Tom Baeyens,他也是 JBPM 的作者)
        │
2016  Activiti 6 发布后,核心团队与 Alfresco 产生分歧
        │
        ├── Tom Baeyens 等人出走 → 创建 Flowable(Activiti 6 的 fork)
        │                          → 定位:更活跃、更工程化、性能更好
        │
        ├── Camunda 团队(更早从 Activiti 5 fork)→ Camunda BPM
        │                          → 定位:商业化最成功,DMN 决策表强
        │
        └── Alfresco 继续做 Activiti 7
                                   → 定位:转向云原生 / 微服务,拥抱 Spring Boot 2.x
                                   → 但社区活跃度明显不如前两者

现状(2026 年视角):

引擎 活跃度 特点 怎么选
Flowable ★★★★★ 最活跃 Activiti 6 的精神续作,API 与 Activiti 高度相似,性能与文档更好 新项目优先选它
Camunda ★★★★☆ 商业版强,DMN 决策表、Cockpit 运维控制台是亮点 需要决策表 / 商业支持时选
Activiti 7 ★★☆☆☆ 转向云原生,但社区热度下降,更新慢 老系统维护 / 学习用

面试话术:“Activiti 是 JBPM 作者 Tom Baeyens 创立的 BPMN 2.0 引擎。2016 年核心团队出走,fork 出了 Flowable 和 Camunda。所以这三个引擎的 API 和表结构高度相似,学会一个另外两个很快能上手。新项目我更倾向 Flowable,因为社区更活跃、文档更全。”

Activiti 版本怎么选

版本 状态 说明
Activiti 5.x 已停止维护 老项目里常见,表结构 ACT_*,部分设计较旧
Activiti 6.x 维护中(低) 引入 ACT_RU_TASK 等改进,Flowable 就是从这 fork 的
Activiti 7.x 缓慢更新 面向云原生,和 Spring Boot 2.x / Spring Security 深度整合,引入了 SecurityManager 等新概念

实际建议:

  • 学习 / 面试:学 Activiti 6 或 7 都行,核心 API(RepositoryService / RuntimeService / TaskService / HistoryService)三个版本通用。
  • 生产新项目:直接上 Flowable 6/7。
  • 本文代码:以 Activiti 7 + Spring Boot 2.x 为主(最贴近国内招聘 JD 的写法),关键处会标注 Flowable 的差异。

1.6 什么时候该用工作流引擎,什么时候不该用

这是最能体现工程判断力的问题。工作流引擎不是银弹,滥用会让系统变复杂。

✅ 适合用工作流引擎的场景

场景 为什么适合
审批流(请假、报销、采购、合同) 层级多、规则常变、需要留痕
订单状态流转(待付款→待发货→待收货→完成) 状态多且有条件分支,需可视化
工单系统(客服工单、运维工单) 需要指派、转办、升级、超时提醒
风控审核(多轮人工审核 + 规则引擎) 会签、加签、退回需求密集
需要“流程图给老板看”的任何场景 引擎自带流程图 + 高亮追踪

判断口诀:流程会变 + 环节多于 3 步 + 要留痕 + 要可视化 → 用引擎。

❌ 不适合用工作流的场景

场景 为什么不适合
只有 2 步的简单状态(如“启用/禁用”) 一个字段就够,引引擎是杀鸡用牛刀
流程十年不变 硬编码更简单,没必要配置化
超高并发、毫秒级响应的链路(如支付主链路) 引擎要读写几十张表,扛不住高并发(见 6.3 性能优化)
纯机器编排、没有人参与 那是编排引擎(如 Temporal / Cadence / Airflow)的活,不是审批流引擎
团队没人懂 BPMN 学习成本会拖垮项目

⚠️ 诚实提醒:工作流引擎会引入 25+ 张表、一套新概念和运维复杂度。如果你们的审批只有“提交→领导批→结束”三步且三年没改过,用状态字段 + 一张审批记录表就够了,别为了技术而技术。

1.7 本章小结:一张图看懂工作流引擎的定位

                    业务系统(你的 Spring Boot 应用)
                              │
        ┌─────────────────────┼─────────────────────┐
        │                     │                     │
   业务表(你的)         工作流引擎                前端
   leave_order          (Activiti)          流程图展示
   存请假单数据          存流程流转状态          待办列表
        │                     │                     │
        └──── 用 businessKey 关联 ────┘             │
              (两边不互相侵入,靠一个业务ID串起来)    │
                                                    │
                          引擎自带的 25+ 张 ACT_* 表 ─┘

最重要的一句话:★ 业务数据存你自己的业务表,流程状态存引擎的表,两边用 businessKey(就是你的业务主键)关联。 千万不要把请假单的字段全塞进流程变量里——那样查询、统计、报表会全部做不了(详见 6.2)。


第二章:BPMN 2.0 建模(画流程图就是写“代码”)

这一章讲怎么画图。很多人以为 Activiti 难在 API,其实80% 的线上问题出在流程图画错: 网关用错、条件写错、并行不配对……图错了,代码再对也白搭。

2.1 BPMN 只有三类元素(记住这个分类就不会乱)

BPMN 2.0 看着复杂,但所有图形只有三大类:

┌──────────────────────────────────────────────────────────┐
│  ① 事件 Event    ——  圆圈 ○                                │  "发生了什么事"
│     开始事件 ○(细线) / 结束事件 ◉(粗线)                  │  (开始、结束、超时、收到消息)
├──────────────────────────────────────────────────────────┤
│  ② 活动 Activity ——  圆角矩形 ▭                            │  "要干什么活"
│     用户任务 ▭👤 / 服务任务 ▭⚙ / 子流程 ▭⊞                  │  (人审、机器跑、一段子流程)
├──────────────────────────────────────────────────────────┤
│  ③ 网关 Gateway  ——  菱形 ◇                                │  "路怎么分叉/汇合"
│     排他 ◇X / 并行 ◇+ / 包容 ◇O / 事件 ◇△                   │  (if、多线程、条件并行、等事件)
└──────────────────────────────────────────────────────────┘
                        ↓ 用箭头连起来
              ④ 顺序流 Sequence Flow ——  实线箭头 →

一句话记:圆圈是事,方框是活,菱形是岔路,箭头连起来。

2.2 事件:流程的“开始 / 结束 / 被打断”

事件类型 画法 作用 常见用法
开始事件 ○ 单细圈 流程从这开始 一般只有一个;也可以有定时开始(每月 1 号自动生成报表)
结束事件 ◉ 粗圈 流程在这结束 正常结束
错误结束事件 ◉ 里有闪电 抛异常结束 流程出错时终止并抛 BpmnError,可被边界事件捕获
取消结束事件 ◉ 里有叉 取消流程 事务子流程回滚
中间捕获事件 ○ 双圈 流程走到这停下来等 等消息 / 等信号 / 等定时器
边界事件 贴在任务边上的 ○ 任务执行期间如果发生某事,就跳走 超时自动审批(挂在 User Task 上的定时边界事件)

★ 边界事件(Boundary Event)是最有用的一个

场景:领导 3 天不批,就自动通过。

画法:在“领导审批”这个 User Task 的边框上挂一个定时边界事件(圆圈里画个时钟),引一条线到“自动通过”节点。

          ┌──────────────┐
          │  领导审批      │◄─── 挂一个定时边界事件 ○⏰(PT72H)
          │  (User Task)  │
          └──────┬───────┘
                 │ 正常完成
                 ▼
            下一步…
                 ▲
                 │ 72 小时没完成 → 边界事件触发
                 │
            ○⏰ (边界定时事件)

关键属性:

  • timeDuration = PT72H(ISO 8601 时长:72 小时)。P1D = 1 天,PT10M = 10 分钟。
  • cancelActivity = true(默认):边界事件触发后,取消原任务;设 false 则原任务保留(用于“超时发提醒但不取消任务”)。

⚠️ 面试加分点:定时边界事件依赖引擎的异步执行器(Async Executor)。如果你把 asyncExecutorEnabled 关了,定时器永远不触发——这是新手最常见的“边界事件不生效”原因(详见 6.3)。

2.3 任务类型:这个节点“谁来干”

任务类型 BPMN 标签 谁来干 典型用途
用户任务 <userTask> 人 领导审批、财务审核
服务任务 <serviceTask> Java 代码(引擎自动调) 发通知、调用外部接口、写库
脚本任务 <scriptTask> 脚本(Groovy/JS) 简单计算、拼变量
手动任务 <manualTask> 人,但引擎不管(纯标记) 线下活动(如“寄快递”)
接收任务 <receiveTask> 等外部信号 等支付回调、等第三方通知
调用活动 <callActivity> 调另一个流程 公共子流程复用
子流程 <subProcess> 内嵌一段流程 复杂节点内部分组

用户任务的三种“指定审批人”方式(★ 面试高频)

<!-- 方式一:直接指定某个人(写死,不灵活) -->
<userTask id="leaderAudit" name="领导审批" activiti:assignee="zhangsan"/>

<!-- 方式二:用流程变量动态指定(最常用,推荐) -->
<userTask id="leaderAudit" name="领导审批" activiti:assignee="${leader}"/>
<!-- 启动流程时传:vars.put("leader", "zhangsan") -->

<!-- 方式三:候选组(一个角色/部门,谁都能领,需要 claim 签收) -->
<userTask id="financeAudit" name="财务审核"
          activiti:candidateGroups="finance"/>
<!-- 财务组所有人都能在自己的"组待办"里看到,谁先 claim 就归谁 -->

三种方式对比:

方式 待办怎么查 适用场景
assignee(指定人) taskQuery().taskAssignee(userId) 明确知道是谁(如“直属领导”)
candidateUsers(候选多人) taskQuery().taskCandidateUser(userId) 几个人都能批,谁先领谁批(或签)
candidateGroups(候选组) taskQuery().taskCandidateGroup(groupId) 按角色/部门分配(财务组、法务组)

⚠️ 坑:candidateUsers / candidateGroups 的任务必须 claim 签收后才会出现在 taskAssignee 查询里。很多人写完发现“待办列表是空的”,就是因为只查了 taskAssignee 而没查候选。

正确写法(两路都要查):见 4.4 节的 myTasks() 实现。

服务任务的三种实现方式

<!-- 方式一:实现 JavaDelegate 接口(最常用) -->
<serviceTask id="notify" name="发通知"
             activiti:class="com.example.NotifyDelegate"/>

<!-- 方式二:委托给 Spring Bean(推荐,能用 @Autowired) -->
<serviceTask id="notify" name="发通知"
             activiti:delegateExpression="${notifyDelegate}"/>

<!-- 方式三:调用普通 Bean 的方法(表达式) -->
<serviceTask id="notify" name="发通知"
             activiti:expression="${notifyService.send(processInstanceId)}"/>
// 方式一 / 方式二 对应的 Java 类
@Component("notifyDelegate")
public class NotifyDelegate implements JavaDelegate {

    @Autowired  // ★ 用 delegateExpression 时,这里才能注入
    private SmsService smsService;

    @Override
    public void execute(DelegateExecution execution) {
        String bizId = execution.getProcessInstanceBusinessKey();
        String days = (String) execution.getVariable("days");
        smsService.send("您的请假申请(业务ID=" + bizId + ",天数=" + days + ")已通过");
    }
}

⚠️ 大坑:用 activiti:class 时,对象由 Activiti 自己 new 出来的,@Autowired 不会生效(不是 Spring 容器管理的)! 必须用 activiti:delegateExpression="${beanName}" 才能让 Spring 注入生效。这是新手“空指针”排行榜第一名。

2.4 网关:四种菱形,最容易用错(★ 重点)

这是本章最核心的一节。四种网关的区别:

网关 图标 类比 分叉行为 汇聚行为
排他 X ◇ 里 X if / else if 按顺序评估条件,只走第一条满足的 不需要汇聚(多条流入也算走一条)
并行 + ◇ 里 + 开多线程 忽略条件,所有分支同时走 必须等所有分支都到达才继续
包容 O ◇ 里 O if / if / if(多条件可同时成立) 评估条件,所有满足的都走 等所有被激活的分支到达
事件 △ ◇ 里 △ select 等事件 不走条件,等哪个事件先发生 —

2.4.1 排他网关(Exclusive Gateway)— 只走一条

                 ◇X  (排他网关)
                   │
        ┌──────────┼──────────┐
        │          │          │
   ${days>3}  ${days<=3}   默认流
        │          │          │
        ▼          ▼          ▼
   老板审批     直接结束    异常处理
<exclusiveGateway id="gwDays" name="天数判断"/>
<sequenceFlow id="flow1" sourceRef="gwDays" targetRef="bossAudit">
    <conditionExpression xsi:type="tFormalExpression">${days > 3}</conditionExpression>
</sequenceFlow>
<sequenceFlow id="flow2" sourceRef="gwDays" targetRef="endEvent">
    <conditionExpression xsi:type="tFormalExpression">${days <= 3}</conditionExpression>
</sequenceFlow>

规则:

  1. 引擎按 XML 里 <sequenceFlow> 的先后顺序评估条件,走第一个为 true 的。
  2. 如果所有条件都不满足:
    • 有 default 流 → 走默认流
    • 没默认流 → 抛异常 No outgoing sequence flow...

⚠️ 坑 1:条件表达式里用流程变量名,不是 Java 变量名。${days > 3} 里的 days 必须是 runtimeService.setVariable() 设过的。没设过就是 null,表达式求值会报错。

⚠️ 坑 2:> < 在 XML 里要转义吗?不用——因为写在 <![CDATA[ ]]> 里或作为文本节点。但保险起见推荐写 ${days gt 3} 或用 CDATA。

2.4.2 并行网关(Parallel Gateway)— 全部同时走

              ◇+  (并行分叉)
                │
      ┌─────────┼─────────┐
      ▼         ▼         ▼
   财务审核  法务审核   技术审核      ← 三条同时进行,互不等待
      │         │         │
      └─────────┼─────────┘
                ▼
             ◇+  (并行汇聚)          ← 必须三条都完成才继续
                │
                ▼
             下一步
<parallelGateway id="fork" name="三部门并行"/>
<sequenceFlow sourceRef="fork" targetRef="financeAudit"/>   <!-- 不写条件! -->
<sequenceFlow sourceRef="fork" targetRef="legalAudit"/>
<sequenceFlow sourceRef="fork" targetRef="techAudit"/>

<parallelGateway id="join" name="三部门汇聚"/>
<sequenceFlow sourceRef="financeAudit" targetRef="join"/>
<sequenceFlow sourceRef="legalAudit" targetRef="join"/>
<sequenceFlow sourceRef="techAudit" targetRef="join"/>

规则:

  1. 分叉时条件表达式会被直接忽略(写了也没用)。
  2. 汇聚时必须等所有流入分支都到达。三条分支里有一条卡住 → 整个流程卡死。

⚠️ 坑 3(最常见):三条分支中有一条因为条件走不到汇聚网关 → 流程永远卡在那。 排查方法:查 ACT_RU_EXECUTION 表,看哪个 Execution 还活着;或查 ACT_RU_TASK 看谁的任务没完成。

⚠️ 坑 4:并行分支里如果有人“驳回”,另外两条分支已经在跑的任务不会自动撤销,必须你自己写代码删掉(见 5.3 驳回)。

2.4.3 包容网关(Inclusive Gateway)— 满足的都走

              ◇O  (包容分叉)
                │
      ┌─────────┼─────────┐
   ${amount>1万} ${isVip} ${needLegal}
      ▼         ▼         ▼
   总监审批   VIP通道   法务审核      ← 满足条件的都走,可能走 1~3 条
      │         │         │
      └─────────┼─────────┘
                ▼
             ◇O  (包容汇聚)          ← 等所有"被激活"的分支到达

和并行网关的区别:并行是“无脑全开”,包容是“按条件开若干条”。

⚠️ 坑 5:包容汇聚会智能等待——它知道这次实际激活了几条分支,只等这几条。这是它比并行汇聚“聪明”的地方。但如果你在一个分支里用了排他网关导致分支提前结束,包容汇聚仍会正确等待(引擎会传播“分支已结束”信号)。

2.4.4 事件网关(Event-Based Gateway)— 等哪个先来

              ◇△  (事件网关)
                │
      ┌─────────┼─────────┐
      ▼         ▼         ▼
  收到支付消息  超时30分钟  收到取消指令      ← 谁先发生走谁,其余作废

用于“等待外部事件”的场景(如等支付回调 vs 超时关单)。

2.4.5 网关选型速查(★ 背下来)

你的需求 用哪个
二选一 / 多选一(按条件) 排他网关 X
几个人同时审,全部通过才继续 并行网关 + 或 会签(见 5.2)
满足条件的多条都走(数量不定) 包容网关 O
等消息 or 超时,谁先来走谁 事件网关 △
“谁批都行,一个批了就过” 会签 + 一票通过(completionCondition)

2.5 顺序流与条件表达式

顺序流就是箭头。除了普通箭头,还有条件流和默认流:

<!-- 普通顺序流 -->
<sequenceFlow id="f1" sourceRef="start" targetRef="leaderAudit"/>

<!-- 条件顺序流(用在排他/包容网关后面) -->
<sequenceFlow id="f2" sourceRef="gw" targetRef="bossAudit">
    <conditionExpression xsi:type="tFormalExpression">
        <![CDATA[${days > 3}]]>
    </conditionExpression>
</sequenceFlow>

<!-- 默认流(所有条件都不满足时走它) -->
<exclusiveGateway id="gw" default="fDefault"/>
<sequenceFlow id="fDefault" sourceRef="gw" targetRef="errorHandler"/>

支持的表达式写法(Activiti 用 JUEL 引擎,语法类似 JSP EL):

${days > 3}                       // 数值比较
${approved == true}               // 布尔判断(也可简写 ${approved})
${status == 'REJECTED'}           // 字符串比较(单引号)
${amount > 10000 && isVip}        // 逻辑与
${!rejected}                      // 逻辑非
${order.totalAmount > 5000}       // 访问对象的属性(变量必须是可序列化的对象)
${days > 3 ? 'boss' : 'hr'}       // 三元表达式

⚠️ 坑 6:流程变量里存自定义对象(如 Order 对象)时,该对象必须实现 java.io.Serializable。 否则引擎序列化到 ACT_RU_VARIABLE 表时会抛 NotSerializableException。(这也是为什么推荐只存基本类型 + String,见 1.7)

2.6 完整请假流程 BPMN:逐行讲解

下面是一个可以直接用的完整例子(Activiti 7 / Flowable 通用)。放在 src/main/resources/processes/leaveProcess.bpmn20.xml:

<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xmlns:activiti="http://activiti.org/bpmn"
             targetNamespace="http://www.activiti.org/processdef">

  <!-- ① process 的 id 就是后面 startProcessInstanceByKey("leaveProcess") 用的 key -->
  <process id="leaveProcess" name="请假流程" isExecutable="true">

    <!-- ② 开始事件 -->
    <startEvent id="startEvent" name="员工提交"/>
    <sequenceFlow id="flow1" sourceRef="startEvent" targetRef="leaderAudit"/>

    <!-- ③ 领导审批:审批人由流程变量 leader 动态指定 -->
    <userTask id="leaderAudit" name="领导审批"
              activiti:assignee="${leader}"/>

    <!-- ④ 在领导审批上挂一个"72小时超时"的边界事件 -->
    <boundaryEvent id="timeoutBoundary" attachedToRef="leaderAudit"
                   cancelActivity="true">
        <timerEventDefinition>
            <timeDuration>PT72H</timeDuration>
        </timerEventDefinition>
    </boundaryEvent>
    <sequenceFlow id="flowTimeout" sourceRef="timeoutBoundary" targetRef="autoPass"/>

    <!-- ⑤ 超时后自动通过一个 Service Task -->
    <serviceTask id="autoPass" name="超时自动通过"
                 activiti:delegateExpression="${autoPassDelegate}"/>
    <sequenceFlow id="flowAutoPass" sourceRef="autoPass" targetRef="endEvent"/>

    <!-- ⑥ 领导审批完成后,用排他网关按天数分流 -->
    <sequenceFlow id="flow2" sourceRef="leaderAudit" targetRef="gwDays"/>
    <exclusiveGateway id="gwDays" name="天数判断" default="flowToBoss"/>

    <!-- 天数 <= 3 且通过 → 直接结束 -->
    <sequenceFlow id="flowToEnd" sourceRef="gwDays" targetRef="endEvent">
        <conditionExpression xsi:type="tFormalExpression">
            <![CDATA[${approved && days <= 3}]]>
        </conditionExpression>
    </sequenceFlow>

    <!-- 默认流 / 天数 > 3 → 老板审批 -->
    <sequenceFlow id="flowToBoss" sourceRef="gwDays" targetRef="bossAudit"/>
    <userTask id="bossAudit" name="老板审批" activiti:assignee="${boss}"/>

    <!-- ⑦ 老板审批后,按 approved 判断通过还是拒绝 -->
    <sequenceFlow id="flow3" sourceRef="bossAudit" targetRef="gwResult"/>
    <exclusiveGateway id="gwResult" name="结果判断"/>

    <sequenceFlow id="flowPass" sourceRef="gwResult" targetRef="notifyTask">
        <conditionExpression xsi:type="tFormalExpression">
            <![CDATA[${approved}]]>
        </conditionExpression>
    </sequenceFlow>
    <sequenceFlow id="flowReject" sourceRef="gwResult" targetRef="endEvent">
        <conditionExpression xsi:type="tFormalExpression">
            <![CDATA[${!approved}]]>
        </conditionExpression>
    </sequenceFlow>

    <!-- ⑧ 通过后发通知(服务任务,引擎自动执行) -->
    <serviceTask id="notifyTask" name="发送通过通知"
                 activiti:delegateExpression="${notifyDelegate}"/>
    <sequenceFlow id="flow4" sourceRef="notifyTask" targetRef="endEvent"/>

    <!-- ⑨ 结束事件 -->
    <endEvent id="endEvent" name="流程结束"/>

  </process>
</definitions>

逐行要点回顾:

编号 元素 关键点
① <process id> 这个 id 就是 processDefinitionKey,代码里靠它启动流程
② <startEvent> 必须有,且只能有一个(多个开始事件就要指定 message/timer)
③ <userTask assignee="${leader}"> ${} 是 JUEL 表达式,取流程变量
④ <boundaryEvent> attachedToRef 指向要挂的任务;cancelActivity=true 表示触发后取消原任务
⑤ <serviceTask delegateExpression> 用 ${beanName} 才能 @Autowired
⑥ <exclusiveGateway default> 建议总是配默认流,防止条件全不满足时抛异常
⑦ 条件流 ${approved} 是 Boolean,直接用即可
⑧ 服务任务 引擎自动执行,不需要人参与
⑨ <endEvent> 流程结束,实例从 ACT_RU_* 删除,进入 ACT_HI_*

2.7 建模常见坑(血泪清单)

# 坑 现象 解决
1 流程图中文乱码 生成的 PNG 流程图里中文是方块 Linux 服务器缺中文字体;装字体,或在 ProcessEngineConfiguration 里设置 activityFontName / labelFontName 为 宋体/SimSun
2 条件全不满足 No outgoing sequence flow of the exclusive gateway 给排他网关配 default 流
3 并行网关不配对 流程永远不结束,ACT_RU_EXECUTION 里还有记录 检查分叉/汇聚是否成对,分支是否都能到达汇聚
4 activiti:class 注入失败 JavaDelegate 里 @Autowired 的对象是 null 改用 activiti:delegateExpression="${beanName}"
5 流程变量不可序列化 NotSerializableException 变量对象实现 Serializable,或只存 String/基本类型
6 重复部署产生多版本 同一个 key 下流程定义版本 1、2、3… 用 deployment 时按 key 去重;或启动时指定版本(见 3.2)
7 改了图不生效 代码跑的还是老流程 Spring Boot 默认只在表为空时自动部署;改图要手动重新部署或改配置
8 BPMN 文件放错位置 启动时找不到流程 必须放在 resources/processes/ 下且后缀是 .bpmn20.xml 或 .bpmn
9 userTask 没指定审批人 任务出现在“无人认领”状态 至少设 assignee 或 candidateGroups 之一
10 定时器不触发 边界事件到了时间没反应 检查 asyncExecutorEnabled=true(Spring Boot 默认开,但自定义配置容易关掉)

第三章:Activiti 核心概念与表结构

这一章回答两个高频面试问题: ① “Activiti 有哪些核心 Service?” ② “Activiti 有多少张表?分别存什么?” 搞懂表结构,你排查线上问题的能力会超过 80% 的候选人。

3.1 七个核心 Service(记住 4 个就够用)

Activiti 把所有操作封装成 Service。日常开发只用 4 个,另外 3 个了解即可:

Service 干什么 使用频率 典型方法
RepositoryService 管流程定义(图纸):部署、查询、删除 ★★★★★ createDeployment()、createProcessDefinitionQuery()
RuntimeService 管运行中的流程实例:启动、变量 ★★★★★ startProcessInstanceByKey()、setVariable()、deleteProcessInstance()
TaskService 管任务(待办):查询、完成、认领 ★★★★★ createTaskQuery()、complete()、claim()、addComment()
HistoryService 查历史(已结束的流程) ★★★★☆ createHistoricProcessInstanceQuery()、createHistoricTaskInstanceQuery()
IdentityService 管用户/组(Activiti 7 已弱化) ★☆☆☆☆ createUserQuery()、createGroupQuery()
FormService 表单(可选,新版已废弃) ★☆☆☆☆ getStartFormData()
ManagementService 引擎运维:作业、数据库 ★★☆☆☆ createJobQuery()、executeJob()
// Spring Boot 里直接注入即用
@Service
public class ProcessService {
    @Autowired private RepositoryService repositoryService;   // 图纸管理
    @Autowired private RuntimeService    runtimeService;      // 实例运行
    @Autowired private TaskService       taskService;         // 任务待办
    @Autowired private HistoryService    historyService;      // 历史查询
}

💡 记忆口诀:图纸 Repository → 开工 Runtime → 干活 Task → 归档 History。

3.2 四个“实例”名词的区别(★ 面试必问)

这是最容易混淆的一组概念,用 Java 类比一下就懂了:

概念 是什么 Java 类比 生命周期
流程定义 ProcessDefinition 画好的图纸 Class 类 部署后一直存在(可多版本)
流程实例 ProcessInstance 按图纸跑起来的一次审批 new 出来的对象 从启动到结束
执行流 Execution 实例里的“当前指针” 对象的线程/游标 并行分支时有多个
任务 Task 停在某个人面前的活 待办事项 从创建到完成
流程定义(图纸) 1 个
       │  startProcessInstanceByKey()
       ▼
流程实例(对象) N 个           ← 100 个人请假 = 100 个实例,共用 1 个定义
       │
       ├── Execution #1 ──► Task「领导审批」
       │
       └── Execution #2 ──► Task「法务审批」    ← 并行网关分叉时才有多个 Execution

流程定义的版本机制(★ 重要)

同一个 processDefinitionKey 可以部署多次,每次版本号 +1:

// 第一次部署 leaveProcess → version 1
// 改了图再部署     → version 2
// 再改             → version 3

// 启动时如果不指定版本,默认用【最新版本】
runtimeService.startProcessInstanceByKey("leaveProcess", bizKey, vars);

// 也可以指定用某个版本启动(老流程走老图)
ProcessDefinition pd = repositoryService.createProcessDefinitionQuery()
        .processDefinitionKey("leaveProcess")
        .processDefinitionVersion(2)
        .singleResult();
runtimeService.startProcessInstanceById(pd.getId(), bizKey, vars);

这套机制带来的关键特性:

★ 已经在跑的流程实例,永远用旧版本的图跑完。 你部署了 v2,正在跑的 v1 实例不会被“升级”,它会按 v1 走到底。 这是特性而不是 bug——否则改一次流程图,在途的几千个审批单就全乱了。

代价:如果你确实想让在途实例用新图,需要手动做流程迁移(见 6.2)。

3.3 流程变量:跟着流程走的“数据包”

一句话:流程变量就是存在引擎表里的 Map<String, Object>,跟着流程实例走,网关条件、审批人表达式都靠它。

三种作用域(★ 容易搞混)

类型 方法 存到哪 生命周期 用途
全局变量 runtimeService.setVariable(instanceId, k, v) ACT_RU_VARIABLE(EXECUTION_ID_ = 实例ID) 整个流程实例可见 最常用
局部变量 taskService.setVariableLocal(taskId, k, v) ACT_RU_VARIABLE(TASK_ID_ 有值) 只在当前任务可见,任务完成后消失 任务私有数据
瞬时变量 runtimeService.setTransientVariable(...) 不存库,只在内存中 只在当前这一步 大对象、不想入库的数据
// ① 全局变量:整个流程都能读到
runtimeService.setVariable(processInstanceId, "approved", true);
runtimeService.setVariables(processInstanceId, Map.of("days", 5, "reason", "生病"));

// ② 局部变量:只在某个任务内存活,任务完成就没了
taskService.setVariableLocal(taskId, "leaderComment", "同意,注意休息");

// ③ 瞬时变量:不入库,避免大对象拖慢引擎
runtimeService.setTransientVariable(processInstanceId, "bigReport", hugeObject);

// 读取(会自动向上找:先找局部,再找全局)
Object days = runtimeService.getVariable(processInstanceId, "days");

⚠️ 坑:setVariableLocal 在任务完成后数据就没了。如果你想留痕,正确做法是: 用 taskService.addComment(taskId, processInstanceId, "审批意见内容") —— 它会写进 ACT_HI_COMMENT 表永久保留,这才是“审批意见”的正确存法。

变量的序列化与类型

引擎把变量序列化成二进制存进 ACT_RU_VARIABLE 的 BYTEARRAY_VALUE_ID_ 字段(真正的值在 ACT_GE_BYTEARRAY 表)。

Java 类型 引擎 TYPE_ 字段 说明
String string 直接存
Integer / Long / Double integer / long / double 数值类,可做条件比较
Boolean boolean 网关判断最常用
Date date 可用于定时器
自定义对象 serializable 必须实现 Serializable
JSON 字符串 string ★ 推荐:把对象转 JSON 存 String,避免序列化兼容问题

⚠️ 坑(生产事故级):把自定义对象存进流程变量,后来你改了这个类的字段(加减属性)→ 老流程反序列化失败,ClassNotFoundException / InvalidClassException。 推荐做法:★ 流程变量只存 String / 数字 / Boolean,对象一律转 JSON 字符串。这样类怎么改都不会炸。

3.4 表结构全景:25+ 张表,5 个前缀

Activiti 的表全部以 ACT_ 开头,第二个词表示类别:

前缀 含义 存什么 数据量 什么时候清理
ACT_GE_* GEneral 通用 资源文件(流程图 PNG、BPMN XML)、引擎属性 小 随部署删除
ACT_RE_* REpository 仓库 流程定义、部署记录、模型 小 一般不删(删要级联)
ACT_RU_* RUntime 运行时 正在跑的实例、任务、变量、作业 随业务增长 ★ 流程结束自动删除
ACT_HI_* HIstory 历史 已结束的流程、任务、变量、意见 ★ 会爆炸 需定期归档(见 6.3)
ACT_ID_* IDentity 身份 用户、组、成员关系 小 一般不用(接自己的账号体系)

核心表清单(★ 面试常考“说几张表”)

ACT_RE_ 仓库(3 张)

表名 作用
ACT_RE_DEPLOYMENT 一次部署的记录(一个部署可含多个流程定义)
ACT_RE_PROCDEF ★ 流程定义表(KEY_、VERSION_、DEPLOYMENT_ID_、RESOURCE_NAME_)
ACT_RE_MODEL 在线建模的模型(用 Modeler 画的图)

ACT_RU_ 运行时(7 张,★ 流程结束就清空)

表名 作用
ACT_RU_EXECUTION ★ 执行流表(流程实例 + 分支指针,BUSINESS_KEY_ 在这)
ACT_RU_TASK ★ 运行时任务表(待办都在这,ASSIGNEE_、NAME_、CREATE_TIME_)
ACT_RU_VARIABLE 运行时流程变量
ACT_RU_IDENTITYLINK 任务与候选人/候选组的关联(谁可以领这个任务)
ACT_RU_JOB 异步作业(定时器、异步节点)
ACT_RU_TIMER_JOB 定时作业(边界事件、定时开始)
ACT_RU_DEADLETTER_JOB 死信作业(重试多次失败的作业)

ACT_HI_ 历史(8 张,★ 只增不减)

表名 作用
ACT_HI_PROCINST 历史流程实例(开始/结束时间、耗时、发起人)
ACT_HI_TASKINST 历史任务实例(谁在什么时候完成了哪个任务)
ACT_HI_ACTINST 历史活动实例(走过哪些节点,画流程图高亮靠它)
ACT_HI_VARINST 历史变量
ACT_HI_COMMENT ★ 审批意见(addComment 写这里,做审批留痕用)
ACT_HI_IDENTITYLINK 历史候选人关联
ACT_HI_DETAIL 历史详情(变量变更记录,最占空间)
ACT_HI_ATTACHMENT 附件

ACT_GE_ 通用(2 张)

表名 作用
ACT_GE_BYTEARRAY ★ 二进制资源(BPMN XML 原文 + 流程图 PNG + 序列化的变量值)
ACT_GE_PROPERTY 引擎属性(schema.version、next.dbid 等)

ACT_ID_ 身份(7 张,一般用不上) ACT_ID_USER、ACT_ID_GROUP、ACT_ID_MEMBERSHIP、ACT_ID_INFO、ACT_ID_BYTEARRAY、ACT_ID_PRIV、ACT_ID_PRIV_MAPPING

★ 一次完整审批背后的数据流转(面试画图题)

假设张三提交请假(3天),领导审批通过,直接结束。各表变化如下:

【1】启动流程 startProcessInstanceByKey("leaveProcess", "LEAVE_001", vars)
    ├─ ACT_RE_PROCDEF           (不变,图纸早就部署好了)
    ├─ ACT_RU_EXECUTION        +1 行(流程实例,BUSINESS_KEY_ = "LEAVE_001")
    ├─ ACT_RU_TASK             +1 行("领导审批",ASSIGNEE_ = 领导ID)
    ├─ ACT_RU_VARIABLE         +3 行(days=3、leader=xxx、boss=yyy)
    ├─ ACT_HI_PROCINST         +1 行(START_TIME_ 有值,END_TIME_ 为空)
    ├─ ACT_HI_ACTINST          +2 行(走过 startEvent、leaderAudit)
    └─ ACT_HI_TASKINST         +1 行(领导审批,END_TIME_ 为空)

【2】领导完成任务 taskService.complete(taskId, {approved:true})
    ├─ ACT_RU_TASK             -1 行(领导任务删除)
    ├─ ACT_RU_TASK             +1 行(如果天数>3,新增"老板审批"任务)
    ├─ ACT_RU_VARIABLE         更新(approved=true)
    ├─ ACT_HI_TASKINST         更新(领导任务 END_TIME_ 填上)
    └─ ACT_HI_ACTINST          +1 行(走过排他网关 / 下一节点)

【3】流程走到结束事件
    ├─ ACT_RU_EXECUTION        -1 行(★ 运行时数据全部清空)
    ├─ ACT_RU_TASK             -1 行
    ├─ ACT_RU_VARIABLE         -N 行
    ├─ ACT_HI_PROCINST         更新(END_TIME_ 填上,DURATION_ 算出来)
    ├─ ACT_HI_ACTINST          +1 行(endEvent)
    └─ ACT_HI_VARINST          +N 行(变量进历史,如果想留的话)

★ 这张图说明的核心事实:

ACT_RU_* 是临时表(跑完就删),ACT_HI_* 是归档表(永久保留)。 所以“流程跑完查不到数据”是正常的——要去 ACT_HI_* 查。 反过来,ACT_HI_* 只增不减,时间长了会撑爆数据库(见 6.3 归档方案)。

3.5 第三章面试题(★★☆☆☆ 基础)

Q1:Activiti 有哪些核心 Service?各自干什么?

答:4 个常用的。

  • RepositoryService:管流程定义(部署/查询/删除/挂起)
  • RuntimeService:管流程实例(启动/变量/删除)
  • TaskService:管任务(查待办/认领/完成/加审批意见)
  • HistoryService:查历史(已结束的流程/任务/变量)

加分点:“另外 ManagementService 用来查作业(ACT_RU_JOB),排查’定时器不触发’时很有用;IdentityService 在 Activiti 7 里已经弱化,我们一般接自己的账号体系。”

Q2:流程定义和流程实例的区别?

答:流程定义是图纸(Class),流程实例是按图纸跑的一次具体业务(对象)。一个定义可以对应 N 个实例。定义存在 ACT_RE_PROCDEF,实例运行时在 ACT_RU_EXECUTION、结束后进 ACT_HI_PROCINST。

加分点:“定义有版本号,同一个 key 部署多次版本递增。★ 已启动的实例会按旧版本跑完,不会因为新部署而改变——这是保护在途业务的设计。”

Q3:Activiti 的表有多少张?分哪几类?

答:25 张左右,5 类前缀:

  • ACT_RE_:流程定义(仓库)
  • ACT_RU_:运行时(★ 流程结束自动清空)
  • ACT_HI_:历史(★ 只增不减,要归档)
  • ACT_GE_:通用(二进制资源、引擎属性)
  • ACT_ID_:身份(用户/组,一般不用)

加分点:说出关键表的作用:“ACT_RU_TASK 存待办、ACT_HI_COMMENT 存审批意见、ACT_GE_BYTEARRAY 存流程图和序列化的变量值。”

Q4:运行时表(ACT_RU_*)什么时候被清空?

答:流程实例走到结束事件后,引擎会删除该实例在 ACT_RU_EXECUTION、ACT_RU_TASK、ACT_RU_VARIABLE、ACT_RU_IDENTITYLINK 里的所有行,同时把数据写入对应的 ACT_HI_* 历史表。

加分点:“所以线上排查在途流程要查 ACT_RU_*,追溯已完成的要查 ACT_HI_*。这是很多人’流程跑完查不到’的原因。”

Q5:流程变量能存对象吗?有什么风险?

答:能,但对象必须实现 Serializable。风险是:★ 类结构变更会导致老流程反序列化失败。所以生产上建议只存 String / 数字 / Boolean,对象转 JSON 字符串存。


第四章:Spring Boot 集成与代码实战

这一章给你一套能直接粘到项目里跑的代码:从依赖配置 → 部署流程图 → 发起审批 → 查待办 → 审批 → 查历史 → 流程图高亮。

4.1 环境搭建

4.1.1 Maven 依赖

<!-- ===== 方案 A:Activiti 7(国内招聘 JD 常见)===== -->
<dependency>
    <groupId>org.activiti</groupId>
    <artifactId>activiti-spring-boot-starter</artifactId>
    <version>7.1.0.M6</version>
</dependency>
<!-- ★ Activiti 7 强依赖 Spring Security,不加可能启动报错 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

<!-- ===== 方案 B:Flowable 6/7(新项目推荐,社区更活跃)===== -->
<dependency>
    <groupId>org.flowable</groupId>
    <artifactId>flowable-spring-boot-starter</artifactId>
    <version>6.8.0</version>
</dependency>

<!-- 必备:数据库 + MyBatis(引擎底层用 MyBatis 操作 ACT_* 表) -->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>8.0.33</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>

⚠️ Activiti 7 的 Spring Security 坑(新手第一关) activiti-spring-boot-starter 会自动装配 SecurityAutoConfiguration,它要求 classpath 里有 Spring Security 并提供 UserDetailsService。 如果你的项目不用 Spring Security,启动会报 Bean 缺失错误。两种解法:

// 解法 1:排除安全自动配置(推荐,自己管登录)
@SpringBootApplication(exclude = {
        org.activiti.spring.boot.SecurityAutoConfiguration.class,
        org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration.class
})
public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }
<!-- 解法 2:乖乖加 Spring Security 依赖,并配一个内存用户 -->

Flowable 没有这个问题——这也是新项目推荐 Flowable 的原因之一。

4.1.2 application.yml 配置

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/activiti_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&nullCatalogMeansCurrent=true
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver

  # ===== Activiti 配置 =====
  activiti:
    # 自动建表策略:false(不建) / true(有表就用,缺表就建) / create-drop(启动建、关闭删) / drop-create
    database-schema-update: true
    # ★ 历史级别:none(不记) / activity(只记节点) / audit(记节点+变量,推荐) / full(全记,最占空间)
    history-level: audit
    # 启动时自动部署 resources/processes/ 下的流程图
    check-process-definitions: true
    # ★ 异步执行器:定时器、异步节点、重试都靠它。关了定时器就不触发!
    async-executor-activate: true
    # 流程图字体(解决 Linux 中文乱码)
    activity-font-name: 宋体
    label-font-name: 宋体
    # 是否使用历史表
    db-history-used: true

⚠️ 坑:MySQL 8.x 必须加 nullCatalogMeansCurrent=true,否则引擎建表时可能建到别的库去(因为 DatabaseMetaData 返回了所有库的表)。

4.1.3 目录结构

src/main/resources/
├── processes/                          ← ★ 流程图必须放这里,引擎才会自动部署
│   └── leaveProcess.bpmn20.xml
└── application.yml

⚠️ 坑:只有 resources/processes/ 目录下的 .bpmn / .bpmn20.xml 会被自动部署。 放在别处不会报错,但启动时提示 “No processes found”,然后你一启动流程就报 no processes deployed with key 'xxx'。

4.2 部署流程定义

大多数情况不需要写代码——Spring Boot 启动时会自动部署 processes/ 下的图。

但如果你想动态部署(用户上传流程图、从数据库读 XML),就要用 RepositoryService:

@Service
public class ProcessDeployService {

    @Autowired private RepositoryService repositoryService;

    /** 方式一:从 classpath 部署 */
    public void deployFromClasspath() {
        Deployment deployment = repositoryService.createDeployment()
                .name("请假流程-2026版")
                .addClasspathResource("processes/leaveProcess.bpmn20.xml")
                .deploy();     // ★ 返回 Deployment,含 id 和部署时间
        System.out.println("部署ID=" + deployment.getId());
    }

    /** 方式二:从字符串(XML 内容)部署 —— 适合"在线编辑器保存后部署" */
    public void deployFromString(String xmlContent, String resourceName) {
        repositoryService.createDeployment()
                .name("动态部署")
                .addString(resourceName, xmlContent)
                .deploy();
    }

    /** 方式三:从 ZIP 部署(一次部署多个流程 + 图片) */
    public void deployFromZip(InputStream zipStream) throws IOException {
        repositoryService.createDeployment()
                .name("批量部署")
                .addZipInputStream(new ZipInputStream(zipStream))
                .deploy();
    }

    /** 查询已部署的流程定义(看版本号) */
    public void listDefinitions() {
        List<ProcessDefinition> list = repositoryService.createProcessDefinitionQuery()
                .processDefinitionKey("leaveProcess")
                .orderByProcessDefinitionVersion().desc()
                .list();
        list.forEach(pd -> System.out.printf(
                "id=%s, key=%s, 版本=%d, 名称=%s, 部署ID=%s%n",
                pd.getId(), pd.getKey(), pd.getVersion(), pd.getName(), pd.getDeploymentId()));
    }

    /** ★ 删除部署(级联删除会连历史一起删,慎用!) */
    public void deleteDeployment(String deploymentId) {
        // 只删部署和定义,已启动的实例不受影响
        repositoryService.deleteDeployment(deploymentId);
        // ⚠️ 加 true 会级联删除运行中和历史的实例 —— 生产禁用!
        // repositoryService.deleteDeployment(deploymentId, true);
    }
}

⚠️ 坑:deleteDeployment(id, true) 级联删除会把历史数据也删了,生产环境千万别用。 正确做法:流程定义只“挂起”不删除(见下方)。

/** 挂起 / 激活流程定义:挂起后不能启动新实例,但不影响历史数据 */
public void suspendDefinition(String processDefinitionId) {
    repositoryService.suspendProcessDefinitionById(processDefinitionId);
}
public void activateDefinition(String processDefinitionId) {
    repositoryService.activateProcessDefinitionById(processDefinitionId);
}

4.3 启动流程实例(发起审批)

@Service
public class LeaveProcessService {

    @Autowired private RuntimeService runtimeService;
    @Autowired private TaskService taskService;

    /**
     * 发起请假:启动流程实例
     * @param leaveId 业务主键(请假单ID)
     */
    public String startLeaveProcess(Long leaveId, LeaveForm form) {
        // ① businessKey:把流程实例和你的业务表关联起来的"绳子"
        String businessKey = "LEAVE_" + leaveId;

        // ② 流程变量:网关条件、审批人表达式都靠它
        Map<String, Object> variables = new HashMap<>();
        variables.put("days", form.getDays());            // 天数(网关判断用)
        variables.put("reason", form.getReason());        // 事由
        variables.put("leader", form.getLeaderId());      // 领导审批人(${leader})
        variables.put("boss", "boss001");                 // 老板审批人(${boss})
        variables.put("applicant", form.getApplicantId());// 发起人(撤回时要用)

        // ③ 启动(用 key 启动 = 用最新版本)
        ProcessInstance instance = runtimeService
                .startProcessInstanceByKey("leaveProcess", businessKey, variables);

        // ④ ★ 把流程实例ID 回写到业务表(后面靠它反查流程状态)
        // leaveMapper.updateProcessInstanceId(leaveId, instance.getId());

        return instance.getId();
    }
}

★ 关键设计(面试加分):

  • businessKey = 你的业务主键(如 "LEAVE_10086"),存进 ACT_RU_EXECUTION.BUSINESS_KEY_。
  • 双向关联:流程表有 BUSINESS_KEY_ → 能找到业务单;业务表存 processInstanceId → 能找到流程。
  • 这样“业务数据”和“流程状态”既分离又能互查,是最推荐的集成姿势(详见 6.4)。

4.4 查询我的待办(★ 最容易写错)

这段代码是 90% 的人会写错的地方——因为只查了 taskAssignee,漏了候选组任务:

/**
 * ❌ 错误写法:只查 assignee,会漏掉"候选组"里还没认领的任务
 */
public List<TaskVO> myTasksWrong(String userId) {
    return taskService.createTaskQuery().taskAssignee(userId).list();  // 漏了候选任务!
}

/**
 * ✅ 正确写法:assignee(我的专属任务)+ candidateUser(我能领的任务)两路都查
 */
public List<TaskVO> myTasks(String userId, List<String> myGroups) {
    // ① 直接分配给我的
    List<Task> assigned = taskService.createTaskQuery()
            .taskAssignee(userId)
            .orderByTaskCreateTime().desc()
            .list();

    // ② 我是候选人 / 我的组是候选组的(还没被认领的)
    List<Task> candidate = taskService.createTaskQuery()
            .taskCandidateOrAssigned(userId)                 // ★ 关键:候选 或 已分配
            // .taskCandidateGroupIn(myGroups)               // 也可以按组查
            .orderByTaskCreateTime().desc()
            .list();

    // ③ 合并去重(同一个任务可能两条路都命中)
    Map<String, Task> map = new LinkedHashMap<>();
    Stream.concat(assigned.stream(), candidate.stream())
          .forEach(t -> map.putIfAbsent(t.getId(), t));

    // ④ 转成 VO(带上业务数据)
    return map.values().stream().map(t -> {
        TaskVO vo = new TaskVO();
        vo.setTaskId(t.getId());
        vo.setTaskName(t.getName());
        vo.setCreateTime(t.getCreateTime());

        // ★ 通过 businessKey 反查业务单数据(请假天数、事由…)
        ProcessInstance pi = runtimeService.createProcessInstanceQuery()
                .processInstanceId(t.getProcessInstanceId()).singleResult();
        if (pi != null) {
            String bizKey = pi.getBusinessKey();          // "LEAVE_10086"
            Long leaveId = Long.valueOf(bizKey.replace("LEAVE_", ""));
            vo.setBizKey(bizKey);
            vo.setLeave(leaveMapper.selectById(leaveId));  // 你的业务表
            vo.setDays((Integer) runtimeService.getVariable(t.getProcessInstanceId(), "days"));
        }
        return vo;
    }).collect(Collectors.toList());
}

常用查询条件速查:

查询方法 含义
.taskAssignee(userId) 直接指派给某人的任务
.taskCandidateUser(userId) 某人是候选人的任务(未认领)
.taskCandidateGroupIn(groups) 候选组在这些组里的任务
.taskCandidateOrAssigned(userId) ★ 候选 或 已指派(最常用)
.processInstanceBusinessKey(bizKey) 按业务主键查
.processDefinitionKey(key) 只查某个流程的任务
.taskUnassigned() 未认领的
.orderByTaskCreateTime().desc() 按创建时间倒序
.listPage(first, max) 分页(★ 一定要分页,待办可能几万条)

4.5 完成任务(审批)+ 写审批意见

/**
 * 审批:完成任务 + 记录意见
 */
@Transactional(rollbackFor = Exception.class)
public void completeTask(String taskId, String userId, Boolean approved, String comment) {

    Task task = taskService.createTaskQuery().taskId(taskId).singleResult();
    if (task == null) {
        throw new RuntimeException("任务不存在或已被处理,taskId=" + taskId);
    }

    // ① ★ 写审批意见(存进 ACT_HI_COMMENT,永久保留,这才是"留痕"的正确做法)
    //    不要用 setVariable("comment", xxx) —— 那存的是变量,任务完成就没了
    if (StringUtils.hasText(comment)) {
        taskService.addComment(taskId, task.getProcessInstanceId(),
                               approved ? "同意:" + comment : "驳回:" + comment);
    }

    // ② 传流程变量:网关靠 ${approved} 决定走"通过"还是"拒绝"分支
    Map<String, Object> variables = new HashMap<>();
    variables.put("approved", approved);
    variables.put("approver", userId);
    variables.put("approveTime", new Date());

    // ③ 如果是候选任务,先认领(claim)再完成,否则部分版本会报错
    if (task.getAssignee() == null) {
        taskService.claim(taskId, userId);
    }

    // ④ 完成任务 —— 引擎自动根据流程图推进到下一节点
    taskService.complete(taskId, variables);

    // ⑤ ★ 同步更新业务表状态(流程与业务的一致性由你保证,见 6.2)
    // leaveMapper.updateStatus(...);
}

认领 / 转办 / 委派(代码)

// 【认领 claim】候选任务 → 变成我的专属任务
taskService.claim(taskId, userId);
// 反向操作:把任务退回候选池(取消认领)
taskService.unclaim(taskId);

// 【转办】直接换人(任务归属永久变更,不回来)
taskService.setAssignee(taskId, "newUserId");

// 【委派 delegate】我委托别人办,他办完后任务回到我这(状态 DELEGATION_=PENDING)
taskService.delegateTask(taskId, "otherUserId");
// 被委托人完成后,任务回到委派人手里,需要他再 resolveTask
taskService.resolveTask(taskId);

// 【设置候选人】
taskService.addCandidateUser(taskId, "user1");
taskService.addCandidateGroup(taskId, "finance");

转办 vs 委派 的区别(面试常问):

  • 转办:任务换主人,原审批人不再参与。setAssignee()。
  • 委派:任务临时借给别人办,办完回到我手上确认。delegateTask() + resolveTask()。

4.6 查询流程状态与历史

/** ① 查流程实例当前状态(在途) */
public ProcessStatusDTO getStatus(String processInstanceId) {
    ProcessInstance pi = runtimeService.createProcessInstanceQuery()
            .processInstanceId(processInstanceId).singleResult();

    ProcessStatusDTO dto = new ProcessStatusDTO();
    if (pi != null) {
        dto.setRunning(true);
        dto.setBusinessKey(pi.getBusinessKey());
        // 当前停在哪个节点
        List<Task> tasks = taskService.createTaskQuery()
                .processInstanceId(processInstanceId).list();
        dto.setCurrentNodes(tasks.stream().map(Task::getName).collect(Collectors.toList()));
    } else {
        // 不在运行时表 → 已结束,去历史表查
        dto.setRunning(false);
        HistoricProcessInstance hpi = historyService.createHistoricProcessInstanceQuery()
                .processInstanceId(processInstanceId).singleResult();
        if (hpi != null) {
            dto.setEndTime(hpi.getEndTime());
            dto.setDuration(hpi.getDurationInMillis());
        }
    }
    return dto;
}

/** ② 查审批流水(谁在什么时候批了什么)—— 做审批记录展示用 */
public List<CommentVO> getApprovalHistory(String processInstanceId) {
    // ACT_HI_COMMENT:审批意见
    List<Comment> comments = taskService.getProcessInstanceComments(processInstanceId);

    // ACT_HI_TASKINST:任务历史(含审批人、开始结束时间)
    List<HistoricTaskInstance> tasks = historyService.createHistoricTaskInstanceQuery()
            .processInstanceId(processInstanceId)
            .orderByHistoricTaskInstanceStartTime().asc()
            .list();

    return tasks.stream().map(t -> {
        CommentVO vo = new CommentVO();
        vo.setTaskName(t.getName());
        vo.setAssignee(t.getAssignee());
        vo.setStartTime(t.getStartTime());
        vo.setEndTime(t.getEndTime());
        // 匹配该任务上写的意见
        vo.setComment(comments.stream()
                .filter(c -> t.getId().equals(c.getTaskId()))
                .map(Comment::getFullMessage)
                .findFirst().orElse(""));
        return vo;
    }).collect(Collectors.toList());
}

/** ③ 查我发起的流程(我的申请列表) */
public List<HistoricProcessInstance> myStartedProcesses(String userId) {
    return historyService.createHistoricProcessInstanceQuery()
            .startedBy(userId)
            .orderByProcessInstanceStartTime().desc()
            .listPage(0, 20);
}

/** ④ 查已办任务(我审批过的) */
public List<HistoricTaskInstance> myDoneTasks(String userId) {
    return historyService.createHistoricTaskInstanceQuery()
            .taskAssignee(userId)
            .finished()                    // ★ 只看已完成的
            .orderByHistoricTaskInstanceEndTime().desc()
            .listPage(0, 20);
}

4.7 完整可运行示例(Controller 层)

@RestController
@RequestMapping("/api/leave")
public class LeaveController {

    @Autowired private LeaveProcessService processService;
    @Autowired private LeaveMapper leaveMapper;

    /** 1. 提交请假 */
    @PostMapping("/submit")
    public Result<String> submit(@RequestBody LeaveForm form) {
        // ① 先写业务表(拿到主键)
        Leave leave = new Leave();
        leave.setDays(form.getDays());
        leave.setReason(form.getReason());
        leave.setApplicantId(form.getApplicantId());
        leave.setStatus("APPROVING");
        leaveMapper.insert(leave);

        // ② 启动流程(用业务主键做 businessKey)
        String instanceId = processService.startLeaveProcess(leave.getId(), form);

        // ③ 回写流程实例ID
        leave.setProcessInstanceId(instanceId);
        leaveMapper.updateById(leave);

        return Result.ok(instanceId);
    }

    /** 2. 我的待办 */
    @GetMapping("/tasks")
    public Result<List<TaskVO>> myTasks(@RequestParam String userId,
                                        @RequestParam(required = false) List<String> groups) {
        return Result.ok(processService.myTasks(userId, groups));
    }

    /** 3. 审批 */
    @PostMapping("/approve")
    public Result<Void> approve(@RequestBody ApproveRequest req) {
        processService.completeTask(req.getTaskId(), req.getUserId(),
                                    req.getApproved(), req.getComment());
        return Result.ok();
    }

    /** 4. 审批流水 */
    @GetMapping("/history/{instanceId}")
    public Result<List<CommentVO>> history(@PathVariable String instanceId) {
        return Result.ok(processService.getApprovalHistory(instanceId));
    }

    /** 5. 流程图(高亮当前节点) */
    @GetMapping(value = "/diagram/{instanceId}", produces = "image/png")
    public void diagram(@PathVariable String instanceId, HttpServletResponse resp) throws IOException {
        processService.writeProcessDiagram(instanceId, resp.getOutputStream());
    }
}

4.8 流程图高亮追踪(老板最爱的功能)

把“流程走到哪一步”画成一张图,当前节点标红、走过的节点标绿:

@Service
public class ProcessDiagramService {

    @Autowired private RepositoryService repositoryService;
    @Autowired private RuntimeService runtimeService;
    @Autowired private HistoryService historyService;
    @Autowired private ProcessEngine processEngine;

    /**
     * 生成流程图 PNG:高亮当前节点(红框)+ 已走过节点(绿框)
     */
    public void writeProcessDiagram(String processInstanceId, OutputStream out) throws IOException {
        // ① 拿到流程定义
        HistoricProcessInstance hpi = historyService.createHistoricProcessInstanceQuery()
                .processInstanceId(processInstanceId).singleResult();
        String processDefinitionId = hpi.getProcessDefinitionId();

        // ② 拿到 BPMN 模型(用于画图)
        BpmnModel bpmnModel = repositoryService.getBpmnModel(processDefinitionId);

        // ③ 计算要高亮的节点
        List<String> highLightedActivities = new ArrayList<>();   // 红框:当前节点
        List<String> highLightedFlows = new ArrayList<>();        // 走过的连线

        ProcessInstance pi = runtimeService.createProcessInstanceQuery()
                .processInstanceId(processInstanceId).singleResult();

        if (pi != null) {
            // 在途:当前活动 = ACT_RU_EXECUTION 里的 ACT_ID_
            highLightedActivities = runtimeService.getActiveActivityIds(processInstanceId);
        }
        // 已走过(绿框):从历史活动实例里取
        List<HistoricActivityInstance> finished = historyService.createHistoricActivityInstanceQuery()
                .processInstanceId(processInstanceId)
                .finished()
                .list();
        List<String> finishedIds = finished.stream()
                .map(HistoricActivityInstance::getActivityId).collect(Collectors.toList());

        // ④ 画图(Activiti/Flowable 自带的工具类)
        ProcessDiagramGenerator generator = processEngine.getProcessEngineConfiguration()
                .getProcessDiagramGenerator();

        InputStream is = generator.generateDiagram(
                bpmnModel,
                "png",
                highLightedActivities,     // 当前节点(红)
                finishedIds,               // 走过节点(绿)
                "宋体", "宋体", "宋体",     // 活动字体 / 标签字体 / 注解字体
                null,
                1.0,
                false);

        // ⑤ 输出
        IOUtils.copy(is, out);
        is.close();
    }
}

前端直接 <img src="/api/leave/diagram/{instanceId}"> 就能显示流程图。

⚠️ 两个坑:

  1. 中文乱码:上面的三个字体参数传 "宋体"。Linux 服务器如果没装中文字体,画出来是方块 → 需在服务器装字体(fonts-cn 之类)或改用图片方式。
  2. 性能:每次请求都重新画图、解析 BPMN,开销不小。生产上建议缓存生成的 PNG(按 processDefinitionId + 当前节点集合 做 key)。

第五章:高级场景(会签 / 驳回 / 撤回 / 加签 / 超时 / 监听器)

这一章是国内 OA / 审批系统真正会遇到的需求,也是面试拉开差距的地方。 ★ 先打个预防针:驳回和加签,Activiti 原生支持很弱——这是它最大的短板之一,本章会讲清楚可行的实现方案和各自的代价。

5.1 动态指定审批人(4 种方式)

方式 怎么做 灵活度 适用场景
① BPMN 里写死 activiti:assignee="zhangsan" ☆ 仅 Demo
② 流程变量 activiti:assignee="${leader}" ★★★ 启动时知道审批人
③ 任务监听器 TaskListener 里 setAssignee() ★★★★ 审批人要查数据库/组织架构才能确定
④ 候选人/组 + claim candidateGroups="finance" ★★★★ 按角色分配,谁先领谁批

方式③ 最灵活:用监听器动态算审批人

<userTask id="leaderAudit" name="领导审批">
    <extensionElements>
        <activiti:taskListener event="create"
                               delegateExpression="${leaderAssignListener}"/>
    </extensionElements>
</userTask>
/**
 * 任务创建时,动态计算审批人(可以查组织架构、查上级、查规则表)
 */
@Component("leaderAssignListener")
public class LeaderAssignListener implements TaskListener {

    @Autowired private OrgService orgService;   // 你的组织架构服务

    @Override
    public void notify(DelegateTask delegateTask) {
        // ① 从流程变量拿到申请人
        String applicant = (String) delegateTask.getVariable("applicant");

        // ② 查组织架构算他的直属领导(可以很复杂:代理、兼职、多级…)
        String leader = orgService.findDirectLeader(applicant);
        if (leader == null) {
            leader = orgService.findDeptHead(applicant);   // 兜底:部门负责人
        }

        // ③ 设置审批人
        delegateTask.setAssignee(leader);

        // ④ 也可以同时加候选组(让领导的秘书也能代批)
        delegateTask.addCandidateGroup("secretary");
    }
}

⚠️ 坑:监听器里调用的服务如果抛异常,会导致整个流程事务回滚(见 5.8)。 所以查组织架构这种外部调用,一定要 try-catch 兜底,别让流程因为组织架构接口抖动就启动失败。

5.2 会签(多实例 Multi-instance)★ 面试高频

一句话:一个节点要多个人都审批,这就是会签(也叫“多实例任务”)。

生活类比:公司采购申请,需要财务、法务、技术三个部门都签字才能通过——这就是会签。

5.2.1 BPMN 写法

<userTask id="multiAudit" name="三部门会签" activiti:assignee="${assignee}">
    <multiInstanceLoopCharacteristics isSequential="false"
                                      activiti:collection="${auditors}"
                                      activiti:elementVariable="assignee">
        <!-- ★ 完成条件:全部完成才继续(默认行为,可不写) -->
        <completionCondition>${nrOfCompletedInstances == nrOfInstances}</completionCondition>
    </multiInstanceLoopCharacteristics>
</userTask>

三个关键属性:

属性 含义
isSequential false = 并行会签(三个人同时收到待办);true = 串行会签(一个批完才轮到下一个)
activiti:collection 审批人集合变量(如 ["finance","legal","tech"])
activiti:elementVariable 循环变量名,每个实例把自己的审批人放进去(配合 assignee="${assignee}")

引擎自动提供的三个内置变量(★ 面试必背):

变量 含义
nrOfInstances 总实例数(如 3)
nrOfCompletedInstances 已完成的实例数
nrOfActiveInstances 还没完成的实例数

5.2.2 四种会签规则(用 completionCondition 控制)

<!-- ① 全部通过才继续(默认,最严格) -->
<completionCondition>${nrOfCompletedInstances == nrOfInstances}</completionCondition>

<!-- ② 一票通过:任何一人同意就过 -->
<completionCondition>${nrOfCompletedInstances == 1}</completionCondition>

<!-- ③ 过半数通过 -->
<completionCondition>${nrOfCompletedInstances / nrOfInstances >= 0.5}</completionCondition>

<!-- ④ 比例通过:80% 同意(★ 注意整数除法陷阱,要转 double) -->
<completionCondition>${nrOfCompletedInstances * 1.0 / nrOfInstances >= 0.8}</completionCondition>

⚠️ 坑(整数除法):nrOfCompletedInstances / nrOfInstances 在 JUEL 里如果都是整数,1/3 = 0! 必须写成 nrOfCompletedInstances * 1.0 / nrOfInstances 转浮点。这个 bug 很隐蔽。

5.2.3 一票否决(★ 国内最常用)

需求:三个人会签,任何一人驳回,整个流程立刻结束/驳回。

completionCondition 只能控制“什么时候算完成”,管不了“驳回”。所以要配合一个流程变量 + 排他网关:

<userTask id="multiAudit" name="三部门会签" activiti:assignee="${assignee}">
    <multiInstanceLoopCharacteristics isSequential="false"
                                      activiti:collection="${auditors}"
                                      activiti:elementVariable="assignee">
        <!-- 有人否决就立刻结束会签(不管还有几个没批) -->
        <completionCondition>${nrOfCompletedInstances == nrOfInstances || rejected == true}</completionCondition>
    </multiInstanceLoopCharacteristics>
    <extensionElements>
        <!-- 每个子任务完成时,如果 approved=false,就把 rejected 置为 true -->
        <activiti:taskListener event="complete" delegateExpression="${vetoListener}"/>
    </extensionElements>
</userTask>

<!-- 会签后,用排他网关判断结果 -->
<exclusiveGateway id="gwVeto"/>
<sequenceFlow sourceRef="gwVeto" targetRef="endReject">
    <conditionExpression>${rejected == true}</conditionExpression>
</sequenceFlow>
<sequenceFlow sourceRef="gwVeto" targetRef="nextStep">
    <conditionExpression>${rejected != true}</conditionExpression>
</sequenceFlow>
@Component("vetoListener")
public class VetoListener implements TaskListener {
    @Override
    public void notify(DelegateTask delegateTask) {
        Boolean approved = (Boolean) delegateTask.getVariable("approved");
        if (Boolean.FALSE.equals(approved)) {
            // ★ 一票否决:置标志位,让 completionCondition 立刻满足,跳出会签
            delegateTask.setVariable("rejected", true);
        }
    }
}

⚠️ 坑:一票否决触发后,其他两个人手上的待办任务不会自动消失! 引擎只是结束了会签节点,但那两个未完成的 ACT_RU_TASK 还躺在那。 必须自己清理:

// 会签结束后,删掉该实例下所有未完成的会签任务
taskService.createTaskQuery()
        .processInstanceId(processInstanceId)
        .taskDefinitionKey("multiAudit")
        .list()
        .forEach(t -> taskService.deleteTask(t.getId(), "一票否决,清理未完成任务"));

5.2.4 Java 代码:启动会签

public void startMultiAudit(String bizKey) {
    Map<String, Object> vars = new HashMap<>();
    // 审批人集合(可以从数据库查、可以从组织架构算)
    vars.put("auditors", Arrays.asList("finance001", "legal001", "tech001"));
    vars.put("rejected", false);          // ★ 否决标志位,一定要初始化
    runtimeService.startProcessInstanceByKey("purchaseProcess", bizKey, vars);
}

/** 查某个会签节点当前的进度 */
public String multiAuditProgress(String processInstanceId) {
    int total = (int) runtimeService.getVariable(processInstanceId, "nrOfInstances");
    int done  = (int) runtimeService.getVariable(processInstanceId, "nrOfCompletedInstances");
    return String.format("会签进度:%d/%d", done, total);
}

5.3 驳回 / 退回(★ Activiti 的短板,重点看)

需求:领导审批不通过,退回到发起人重新填写;或者退回到上一个节点。

⚠️ 先说实话:Activiti 官方没有提供“自由跳转到任意节点”的 API。 (Flowable 有 ChangeActivityStateBuilder,Camunda 有 ProcessInstanceModification,这两个是它们的优势。) 所以国内项目里驳回通常用下面三种方案,各有代价:

方案一:画“驳回连线”(★ 推荐,最稳)

在图里显式画一条从审批节点回到发起节点的线,用排他网关 + 变量控制:

[提交申请] ──► [领导审批] ──(approved)──► [结束]
                   │
                   └──(rejected)──► [重新填写] ──► [领导审批]
                                       ▲
                                   驳回后回到这
<exclusiveGateway id="gwAudit"/>
<sequenceFlow sourceRef="gwAudit" targetRef="endEvent">
    <conditionExpression>${approved}</conditionExpression>
</sequenceFlow>
<sequenceFlow sourceRef="gwAudit" targetRef="resubmitTask">
    <conditionExpression>${!approved}</conditionExpression>
</sequenceFlow>
<userTask id="resubmitTask" name="重新填写" activiti:assignee="${applicant}"/>
<sequenceFlow sourceRef="resubmitTask" targetRef="leaderAudit"/>
优点 缺点
✅ 纯 BPMN 标准,稳定、可追踪 ❌ 节点一多,“驳回线”会画成蜘蛛网
✅ 流程图直观,老板看得懂 ❌ 每加一个可驳回节点,就要多画一条线
✅ 不需要 hack 数据库 ❌ 驳回路径写死,不能“动态选退回哪个节点”

方案二:自定义 Command 强制跳转(灵活但有风险)

思路:直接改 ACT_RU_EXECUTION 的 ACT_ID_,让引擎认为“当前节点变了”。

/**
 * ⚠️ 这是 hack 方案:直接改执行流的当前节点。
 * 风险:绕过了引擎的状态机,可能产生脏数据(历史轨迹不连续、Execution 树不一致)。
 * Flowable 用户请直接用 runtimeService.createChangeActivityStateBuilder()。
 */
public void jumpToNode(String processInstanceId, String targetActivityId) {
    // 用 managementService 执行一个自定义命令,拿到 CommandContext 后改 ExecutionEntity
    managementService.executeCommand(commandContext -> {
        ExecutionEntity execution = commandContext.getExecutionEntityManager()
                .findById(processInstanceId);
        execution.setCurrentActivityId(targetActivityId);
        commandContext.getExecutionEntityManager().update(execution);
        return null;
    });
}
优点 缺点
✅ 任意节点自由跳转,不用画蜘蛛网 ❌ 非官方 API,引擎版本升级可能失效
✅ 图干净 ❌ 可能产生脏数据(历史 ACT_HI_ACTINST 不连续)
❌ 并行分支场景下容易搞坏 Execution 树

方案三:驳回 = 撤销重提(简单粗暴但常用)

/**
 * 驳回即"作废当前流程,让发起人重填后重新启动一个新实例"
 * 优点:逻辑最简单,不留脏数据;缺点:流程实例ID变了,审批流水被切断
 */
@Transactional(rollbackFor = Exception.class)
public void rejectAndRestart(String processInstanceId, String reason) {
    // ① 记录驳回原因
    taskService.addComment(null, processInstanceId, "驳回:" + reason);
    // ② 删除流程实例(第二个参数是删除原因,会写进历史)
    runtimeService.deleteProcessInstance(processInstanceId, "审批驳回,作废重提");
    // ③ 改业务表状态,让发起人可以重新编辑并提交
    leaveMapper.updateStatus(bizId, "DRAFT");
}

★ 三种方案怎么选(面试话术)

“驳回方案要看业务复杂度。简单流程(就 2~3 个节点)直接画驳回线,最稳,纯标准实现。 复杂流程(十几级审批、要动态退回到任意节点)我们会做一个通用的跳转能力——如果是 Flowable 就用官方的 ChangeActivityStateBuilder;如果是 Activiti,就要谨慎评估,因为官方没提供,要么自己实现 Command(有版本兼容风险),要么退化成’作废重提’。 我们项目最终选的是画驳回线 + 少量关键节点做作废重提的组合,兼顾稳定性和灵活性。”

5.4 撤回(发起人主动收回)

需求:领导还没批,发起人后悔了,想把单子收回来。

推荐实现:同 5.3 方案三——直接删除流程实例。

/**
 * 撤回:发起人收回未完成的审批
 */
@Transactional(rollbackFor = Exception.class)
public void withdraw(String processInstanceId, String userId) {
    // ① 校验:必须是发起人本人
    HistoricProcessInstance hpi = historyService.createHistoricProcessInstanceQuery()
            .processInstanceId(processInstanceId).singleResult();
    if (!userId.equals(hpi.getStartUserId())) {
        throw new RuntimeException("只有发起人能撤回");
    }

    // ② 校验:已经结束的流程不能撤回
    if (hpi.getEndTime() != null) {
        throw new RuntimeException("流程已结束,无法撤回");
    }

    // ③ 删除实例(会写入历史,标记删除原因)
    runtimeService.deleteProcessInstance(processInstanceId, "发起人撤回");

    // ④ 改业务表状态
    leaveMapper.updateStatus(bizId, "WITHDRAWN");
}

⚠️ 坑:deleteProcessInstance 会删除 ACT_RU_* 的所有运行时数据,历史表会记一条“已删除”的记录。 如果用户撤回后想重新提交,建议走“作废 + 新建实例”,而不是“恢复”(恢复很麻烦)。

5.5 转办 / 委派(已在 4.5 讲过,这里补全对比)

// 【转办】换主人,一去不回
taskService.setAssignee(taskId, newUserId);

// 【委派】临时借出,办完回来
taskService.delegateTask(taskId, otherUserId);      // 任务状态 → PENDING
taskService.resolveTask(taskId);                    // 被委托人办完后,回到委派人确认
转办 委派
任务归属 永久变成别人 名义上还是我的(owner 是我,assignee 是他)
对方办完后 流程直接继续 回到我手上,我再 resolveTask 确认
查待办 我查不到了 我还能看到(状态 PENDING)
典型场景 我离职/休假,交接给同事 我让助理先过一遍,我来拍板

5.6 加签(★ Activiti 原生不支持,需要自己实现)

需求:审批过程中,领导觉得“这事得让法务也看看”,临时加一个人进来审。

  • 前加签:在当前任务之前加一个人,他先审,审完再到我这。
  • 后加签:我审完,再让另一个人审,然后流程继续。

⚠️ Activiti 没有原生加签 API。国内主流实现方式有三种:

方式一:用多实例会签“预留”加签位(设计阶段)

把节点设计成会签(collection 变量),加签时往集合里加人:

/**
 * 会签节点"加签":往审批人集合里加一个人(仅在该节点还没全部完成时有效)
 */
public void addSignToMultiInstance(String processInstanceId, String newUserId) {
    @SuppressWarnings("unchecked")
    List<String> auditors = (List<String>) runtimeService
            .getVariable(processInstanceId, "auditors");

    if (auditors == null) auditors = new ArrayList<>();
    auditors.add(newUserId);

    // ⚠️ Activiti 里,动态改 collection 不会自动创建新实例!
    // 需要手动创建一个子实例任务:
    runtimeService.setVariable(processInstanceId, "auditors", auditors);
}

⚠️ 重要坑:Activiti 的 multiInstance 在创建时就按当时的 collection 生成了 N 个实例,之后改 collection 不会自动补建。 所以这个方式只在会签还没开始时有效。已经跑起来的会签要加人,得用方式二/三。

方式二:创建独立任务 + 主流程等待(最通用)

/**
 * 后加签:在当前任务完成后、流程继续前,插入一个临时任务
 */
@Transactional(rollbackFor = Exception.class)
public void addSignAfter(String currentTaskId, String newUserId, String reason) {
    Task current = taskService.createTaskQuery().taskId(currentTaskId).singleResult();
    String processInstanceId = current.getProcessInstanceId();
    String executionId = current.getExecutionId();

    // ① 创建一个"独立任务"挂在同一个 execution 上
    Task newTask = taskService.newTask();
    newTask.setName("加签审批(" + reason + ")");
    newTask.setAssignee(newUserId);
    taskService.saveTask(newTask);

    // ② 关键:把它关联到当前执行流(否则流程不知道要等它)
    //    需要设置 executionId —— Activiti 里通过 IdentityLink / 自定义字段关联
    taskService.addComment(newTask.getId(), processInstanceId, "由 " + current.getName() + " 加签");

    // ③ 挂起当前执行流,等加签任务完成
    runtimeService.suspendProcessInstanceById(processInstanceId);

    // ④ 加签人完成这个任务后,恢复流程
    //    (在加签完成的回调里:taskService.complete(newTaskId); runtimeService.activateProcessInstanceById(pi);)
}

⚠️ 这个方案逻辑较重,且“独立任务”和流程实例的关系要靠你自己维护(建议在你的业务表里建一张 process_addsign 关联表)。

方式三:加签 = 转办 + 加一条审批记录(最实用,推荐)

换个思路:不做真正的“流程加签”,而是——

  1. 把当前任务转办给加签人;
  2. 加签人批完后,再转办回原审批人;
  3. 在你的业务表里记一条“加签记录”,用于展示。
public void addSignSimple(String taskId, String addSignUserId, String originalUserId, String reason) {
    // ① 记录加签(业务表,用于前端展示"经 XX 加签")
    addSignMapper.insert(new AddSign(taskId, addSignUserId, originalUserId, reason, new Date()));

    // ② 转办给加签人
    taskService.setAssignee(taskId, addSignUserId);
    taskService.addComment(taskId, null, "加签给 " + addSignUserId + ",原因:" + reason);
    // ③ 加签人完成后,由监听器或回调转办回原审批人
}

★ 面试话术:“加签 Activiti 原生不支持。我们项目权衡后选了转办 + 加签记录表的方案——流程层面不改动(避免脏数据),业务层面完整记录加签轨迹。代价是流程图上看不到加签节点,好处是零风险、实现简单。”

5.7 超时自动处理(定时器 + 边界事件)

已在 2.2 讲过边界事件,这里补三种超时玩法:

玩法 实现 场景
超时自动通过 定时边界事件 → 走到“自动通过”分支 领导忘了批,别卡住业务
超时提醒 定时边界事件(cancelActivity=false)→ 走“发提醒”分支,原任务保留 催一下,但别替他做决定
超时自动驳回 定时边界事件 → 走到“驳回”分支 长期未处理自动作废
<!-- 超时提醒(不取消原任务):cancelActivity=false 是关键 -->
<boundaryEvent id="remindBoundary" attachedToRef="leaderAudit" cancelActivity="false">
    <timerEventDefinition>
        <timeDuration>PT24H</timeDuration>     <!-- 24 小时后提醒 -->
    </timerEventDefinition>
</boundaryEvent>
<sequenceFlow sourceRef="remindBoundary" targetRef="remindTask"/>
<serviceTask id="remindTask" name="发催办短信"
             activiti:delegateExpression="${remindDelegate}"/>

⚠️ cancelActivity 的区别(面试常问):

  • true(默认):超时后取消原任务,流程走边界事件的分支。
  • false:超时后原任务还在,只是额外走一次提醒分支(可以配合循环做“每 24 小时催一次”)。

ISO 8601 时长速查:

写法 含义
PT10M 10 分钟
PT2H 2 小时
P1D 1 天
P3D 3 天
PT72H 72 小时
R3/PT24H 重复 3 次,每 24 小时一次(循环提醒)

⚠️ 定时器不生效的排查清单:

  1. spring.activiti.async-executor-activate 是否为 true(★ 最常见原因)
  2. ACT_RU_TIMER_JOB 表里有没有这条定时作业
  3. 服务器时间和数据库时间是否一致
  4. 多实例部署时,异步执行器是否在多个节点抢同一个作业(需要 JobExecutor 的锁机制,通常没问题)

5.8 监听器与事务陷阱(★ 生产事故高发区)

两种监听器

监听器 挂在哪 事件 用途
ExecutionListener 流程节点/连线/流程级 start、end、take(连线) 节点进入/离开时做业务动作
TaskListener 用户任务 create、assignment、complete、delete 动态设审批人、记录日志、发通知
<userTask id="leaderAudit" name="领导审批" activiti:assignee="${leader}">
    <extensionElements>
        <!-- 任务创建时:动态算审批人 -->
        <activiti:taskListener event="create" delegateExpression="${leaderAssignListener}"/>
        <!-- 任务完成时:记录日志 / 发通知 -->
        <activiti:taskListener event="complete" delegateExpression="${auditLogListener}"/>
    </extensionElements>
</userTask>
@Component("auditLogListener")
public class AuditLogListener implements TaskListener {
    @Override
    public void notify(DelegateTask delegateTask) {
        String eventName = delegateTask.getEventName();   // "complete"
        String assignee = delegateTask.getAssignee();
        String taskName = delegateTask.getName();
        // 写你自己的审计日志表
        auditLogMapper.insert(new AuditLog(taskName, assignee, eventName, new Date()));
    }
}

★ 事务陷阱(必须知道)

核心事实:★ 监听器是在引擎的同一个事务里同步执行的。

taskService.complete(taskId, vars)
   │
   ├─ 开启事务 ─────────────────────────────┐
   │   ① 当前任务从 ACT_RU_TASK 删除          │
   │   ② 执行 ExecutionListener / TaskListener │  ← 你的代码在这
   │   ③ 计算下一节点,创建 ACT_RU_TASK        │
   │   ④ 写 ACT_HI_* 历史                    │
   └─ 提交事务 ─────────────────────────────┘

由此产生三个必须遵守的规则:

① 监听器里抛异常 → 整个流程回滚

// ❌ 错误:通知失败导致审批失败,用户点"同意"却回滚了
public void notify(DelegateTask delegateTask) {
    smsService.send("审批完成");   // 短信接口挂了 → 抛异常 → 整个 complete() 回滚
}

// ✅ 正确:非核心动作必须 try-catch 兜底
public void notify(DelegateTask delegateTask) {
    try {
        smsService.send("审批完成");
    } catch (Exception e) {
        log.error("发通知失败,不影响流程推进", e);
        // 可以写一张"失败重试表",让定时任务补偿
    }
}

② 监听器里不要做耗时操作

监听器在事务内同步执行,如果里面调了个 3 秒的外部接口,数据库事务会被持有 3 秒,并发一高就锁等待。

// ✅ 正确:耗时操作异步化(发个 MQ 消息,让消费者去做)
public void notify(DelegateTask delegateTask) {
    mqProducer.send("audit-notify", buildMessage(delegateTask));   // 毫秒级返回
}

③ 监听器里不要调用 taskService.complete() 去完成别的任务

会造成重入问题(引擎正在推进流程,你又让它推进一次),可能出现死锁或状态错乱。 需要“自动完成某任务”时,用异步作业或MQ 消费后再完成。

5.8.1 需要“流程提交后”再执行的动作怎么办?

场景:审批通过后要调外部系统发货,但必须等流程事务提交成功再做(否则流程回滚了,货却发了)。

方案:事务同步器(TransactionSynchronization)

@Component("shipListener")
public class ShipListener implements TaskListener {

    @Autowired private ShippingService shippingService;

    @Override
    public void notify(DelegateTask delegateTask) {
        String bizKey = delegateTask.getProcessInstanceBusinessKey();

        // 注册一个"事务提交后"的回调
        if (TransactionSynchronizationManager.isSynchronizationActive()) {
            TransactionSynchronizationManager.registerSynchronization(
                new TransactionSynchronization() {
                    @Override
                    public void afterCommit() {
                        // ★ 只有流程事务真正提交成功,才会执行这里
                        shippingService.ship(bizKey);
                    }
                });
        }
    }
}

★ 面试加分点:“监听器在引擎事务内同步执行,所以耗时操作和外部调用要异步化,非核心逻辑要 try-catch 兜底;如果必须’流程提交后再做’,用 Spring 的 TransactionSynchronization.afterCommit()。”


第六章:应用场景、生产常见问题与性能优化

这一章回答三个问题:这东西能用在哪?线上会出什么问题?怎么让它跑得快?

6.1 五个典型应用场景

场景一:OA 审批(最经典)

业务:请假 / 报销 / 加班 / 出差 / 采购申请。

流程特征:层级审批 + 金额条件分支 + 需要留痕。

提交 → 直属领导审批 →(金额>5000)→ 总监审批 →(金额>5万)→ 总经理审批 → 财务打款 → 结束
                   →(金额<=5000)──────────────────────────────────────────────┘

设计要点:

要点 做法
审批人怎么定 监听器查组织架构(LeaderAssignListener)
金额分支 排他网关 + ${amount > 5000}
加签 / 转办 见 5.5 / 5.6
超时 定时边界事件,72 小时自动通过或催办
留痕 addComment 写 ACT_HI_COMMENT

场景二:订单状态流转

业务:待付款 → 待发货 → 待收货 → 已完成,中间还有退款、取消。

流程特征:状态多、分支多、和支付/库存系统交互多。

下单 → 待付款 →(30分钟未付)→ 自动关单
             →(已付款)→ 待发货 → 待收货 →(7天自动确认)→ 完成
                                    └→ 申请退款 → 客服审核 → 退款

⚠️ 重要提醒:★ 订单主链路不建议用工作流引擎! 原因:订单是高并发场景(大促时每秒上万单),而工作流引擎每推进一次要读写十几张表,根本扛不住。 正确做法:订单状态用普通状态字段 + MQ 驱动,工作流只用在低频的长流程上(如退款审核、售后工单)。

面试说这句会加分:“不是所有’流转’都适合上工作流,要考虑 QPS。”


场景三:工单系统(客服 / 运维)

业务:用户提工单 → 一线客服处理 → 升级二线 → 转研发 → 解决 → 回访。

流程特征:动态指派、升级、超时 SLA。

创建工单 → 一线处理 →(24h未解决)→ 自动升级二线 →(48h未解决)→ 升级主管
                   →(需研发)→ 研发处理 → 回到一线确认 → 关闭

设计要点:

需求 实现
自动升级 定时边界事件(cancelActivity=true)+ 转办
SLA 计时 引擎自带 ACT_HI_TASKINST.DURATION_,或自己算
转派 setAssignee 转办
挂起/恢复 suspendProcessInstanceById

场景四:风控审核

业务:贷款申请 → 反欺诈机审 → 人工初审 → 人工复审 → 终审 → 放款。

流程特征:机审 + 人审混合,多轮,会签密集。

提交 → [服务任务:调规则引擎] →(高风险)→ 人工复审团会签(3人) → 终审 → 放款
                            →(低风险)→ 自动通过 → 放款

设计要点:

  • 机审节点用 <serviceTask> 自动执行。
  • 人工复审用多实例会签(3 人,过半数通过)。
  • 高风险用一票否决(见 5.2.4)。

场景五:合同 / 用章审批

业务:法务 + 财务 + 业务负责人三方会签,且任何一环驳回即终止。

流程特征:并行会签 + 一票否决 + 需要附件。

需求 实现
三方并行 并行网关 或 多实例会签
一票否决 completionCondition + rejected 变量(5.2.4)
附件 taskService.createAttachment() 存 ACT_HI_ATTACHMENT
用章记录 服务任务写你的业务表

场景速查表

场景 是否适合工作流 关键设计
OA 审批 ✅ 非常适合 监听器动态定人 + 驳回线
工单 ✅ 适合 超时升级 + 转办
风控审核 ✅ 适合 会签 + 一票否决
合同用章 ✅ 适合 并行会签 + 附件
订单主链路 ❌ 不适合(高并发) 状态字段 + MQ
支付主链路 ❌ 不适合(毫秒级) 普通代码
数据同步/ETL ❌ 不适合 编排引擎(Airflow/Temporal)

6.2 生产常见问题与解决方案(★ 重点,15 个坑)

问题 1:并发完成同一个任务 → OptimisticLockingException

现象:两个领导同时点了“同意”,其中一个报错:

org.activiti.engine.ActivitiOptimisticLockingException:
Task[id=xxx] was updated by another transaction concurrently

原因:Activiti 的表有 REV_ 版本字段,用乐观锁防止并发修改。两人同时改同一条 ACT_RU_TASK,后提交的发现 REV_ 变了,就抛异常。

解决方案:

// 方案一:前端防重(按钮置灰 + 幂等 token),最简单
// 方案二:捕获异常,给用户友好提示 + 重试
public void completeWithRetry(String taskId, Map<String, Object> vars) {
    int maxRetry = 3;
    for (int i = 0; i < maxRetry; i++) {
        try {
            taskService.complete(taskId, vars);
            return;
        } catch (ActivitiOptimisticLockingException e) {
            log.warn("任务并发冲突,第 {} 次重试, taskId={}", i + 1, taskId);
            if (i == maxRetry - 1) {
                throw new RuntimeException("该任务已被其他操作处理,请刷新后重试");
            }
        }
    }
}
// 方案三:加分布式锁(Redisson),按 taskId 加锁,串行化同一任务的完成操作

问题 2:ACT_HI_* 历史表爆炸,数据库撑爆

现象:上线半年后 ACT_HI_VARINST 几千万行,查询变慢,备份要几小时。

原因:历史表只增不减,尤其 ACT_HI_DETAIL(变量变更明细)和 ACT_HI_VARINST 增长最快。

解决方案:

# ① 降低历史级别:full → audit(audit 不记变量明细)
spring:
  activiti:
    history-level: audit
级别 记录内容 数据量
none 不记任何历史(性能最好,但没法追溯) 无
activity 只记流程实例和活动节点 小
audit(推荐) 活动 + 任务 + 表单属性(不记变量明细) 中
full 全部,包括每个变量的每次变更(最容易爆) 大
-- ② 定期归档:把 3 个月前的历史数据搬到归档库,然后从主库删除
-- 注意顺序:先删子表,再删主表
DELETE FROM ACT_HI_DETAIL      WHERE PROC_INST_ID_ IN (SELECT ID_ FROM ACT_HI_PROCINST WHERE END_TIME_ < '2026-06-01');
DELETE FROM ACT_HI_VARINST     WHERE PROC_INST_ID_ IN (SELECT ID_ FROM ACT_HI_PROCINST WHERE END_TIME_ < '2026-06-01');
DELETE FROM ACT_HI_TASKINST    WHERE PROC_INST_ID_ IN (SELECT ID_ FROM ACT_HI_PROCINST WHERE END_TIME_ < '2026-06-01');
DELETE FROM ACT_HI_ACTINST     WHERE PROC_INST_ID_ IN (SELECT ID_ FROM ACT_HI_PROCINST WHERE END_TIME_ < '2026-06-01');
DELETE FROM ACT_HI_COMMENT     WHERE PROC_INST_ID_ IN (SELECT ID_ FROM ACT_HI_PROCINST WHERE END_TIME_ < '2026-06-01');
DELETE FROM ACT_HI_PROCINST    WHERE END_TIME_ < '2026-06-01';

-- ③ 加索引(默认索引不够,按你的查询条件补)
CREATE INDEX idx_hi_procinst_bizkey ON ACT_HI_PROCINST(BUSINESS_KEY_);
CREATE INDEX idx_hi_taskinst_assign ON ACT_HI_TASKINST(ASSIGNEE_, END_TIME_);
CREATE INDEX idx_ru_task_assignee   ON ACT_RU_TASK(ASSIGNEE_);

⚠️ 注意:归档脚本要在业务低峰期跑,且分批删除(LIMIT 5000 循环),否则一次删几百万行会锁表。


问题 3:流程定义升级后,在途实例怎么办?

现象:流程图改了并重新部署(v2),但还有 200 个在途实例跑在 v1 上。

默认行为:★ 在途实例继续用 v1 跑完(这是引擎的保护设计)。

如果想让在途实例也用新图(流程迁移):

// Flowable 有官方 API:
runtimeService.createProcessInstanceMigrationBuilder()
        .migrateToProcessDefinition(newDefinitionId)
        .processInstanceIds(instanceIds)
        .migrate();

// Activiti 7 没有官方迁移 API。可选方案:
//   ① 让在途实例自然跑完(推荐,最安全)
//   ② 作废在途实例,用新版本重启(有业务风险,要和业务方确认)
//   ③ 自己写迁移逻辑(改 ACT_RU_EXECUTION 的 PROC_DEF_ID_,风险高)

★ 最佳实践:

  1. 新增节点要向后兼容:新加的节点尽量加在流程后段,不影响在途实例已走过的路径。
  2. 灰度发布:先部署 v2,让新发起的走 v2,老的跑完 v1,观察一段时间。
  3. 迁移前先备份 ACT_RU_* 和 ACT_HI_*。

问题 4:流程变量存了大对象,引擎变慢

现象:往流程变量里塞了一个 2MB 的报表对象,之后每推进一步都变慢。

原因:变量存在 ACT_GE_BYTEARRAY,每次推进流程引擎都要反序列化/序列化相关变量。

解决方案:

// ❌ 错误:存大对象
runtimeService.setVariable(pi, "report", hugeReportObject);

// ✅ 正确 1:只存 ID,数据放业务表
runtimeService.setVariable(pi, "reportId", report.getId());

// ✅ 正确 2:用瞬时变量(不入库,只在当前这一步有效)
runtimeService.setTransientVariable(pi, "report", hugeReportObject);

// ✅ 正确 3:对象转 JSON 存 String(避免 Serializable 兼容问题)
runtimeService.setVariable(pi, "reportJson", JSON.toJSONString(report));

★ 原则:流程变量只放“流程决策需要的数据”(天数、金额、是否通过),业务明细一律放业务表。


问题 5:流程图中文变成方块(乱码)

现象:生成的流程图 PNG 里,中文全是 □□□。

原因:服务器(尤其 Linux)没装中文字体,Java AWT 画图时找不到字体。

解决方案:

# ① 服务器装中文字体(CentOS 示例)
yum install -y fontconfig
# 把 Windows 的 simsun.ttc / msyh.ttc 拷到 /usr/share/fonts/chinese/
fc-cache -fv
# ② 配置里指定字体
spring:
  activiti:
    activity-font-name: 宋体
    label-font-name: 宋体
    annotation-font-name: 宋体

问题 6:改了流程图但没生效

现象:改了 .bpmn 文件,重启应用,流程还是走老路径。

原因:Spring Boot 默认 check-process-definitions=true,但只在表为空或文件内容变化时重新部署;而且已启动的实例永远用旧版本。

解决方案:

  1. 确认文件在 resources/processes/ 下(不是别的目录)。
  2. 重启后查 ACT_RE_PROCDEF,看 VERSION_ 有没有 +1。
  3. 如果没变,手动调用 repositoryService.createDeployment() 重新部署。
  4. 已启动的实例不受影响——要测新流程就重新发起一个。

问题 7:流程推进成功,但业务表没更新(数据不一致)

现象:审批通过了,但订单状态还是“待审核”。

原因:流程操作和业务操作不在同一个事务,或者业务代码抛异常但流程没回滚(/反之)。

解决方案:★ 放在同一个 @Transactional 里。

@Transactional(rollbackFor = Exception.class)   // ★ 必须加 rollbackFor
public void approve(String taskId, boolean passed, Long bizId) {
    // ① 推进流程
    Map<String, Object> vars = Map.of("approved", passed);
    taskService.complete(taskId, vars);

    // ② 更新业务表(和 ① 同一个事务,要么都成功要么都回滚)
    orderMapper.updateStatus(bizId, passed ? "APPROVED" : "REJECTED");

    // ③ 如果 ② 抛异常,① 也会回滚
}

⚠️ 关键:数据源必须是同一个(引擎和你业务表在同一个库),否则本地事务管不了跨库。 如果引擎和业务库分开,就要用消息队列 + 最终一致性(见 10-数据一致性与缓存同步-实战专题.md)。


问题 8:审批人离职 / 组织架构调整

现象:任务指派给了一个已经离职的员工,待办永远没人处理。

解决方案:

/**
 * 批量转办:某人离职,把他所有待办转给接替者
 */
@Transactional(rollbackFor = Exception.class)
public void transferAllTasks(String fromUser, String toUser) {
    List<Task> tasks = taskService.createTaskQuery()
            .taskAssignee(fromUser)
            .list();
    for (Task task : tasks) {
        taskService.setAssignee(task.getId(), toUser);
        taskService.addComment(task.getId(), task.getProcessInstanceId(),
                               "原审批人 " + fromUser + " 离职,任务转办给 " + toUser);
    }
    log.info("已将 {} 的 {} 个待办转交给 {}", fromUser, tasks.size(), toUser);
}

★ 更好的设计:审批人不要写死 userId,用角色/岗位(candidateGroups),这样人员变动时只改组织架构,不动流程数据。


问题 9:待办列表查询越来越慢

现象:ACT_RU_TASK 几万行后,查待办要 2 秒。

解决方案:

-- ① 加索引(默认可能没有)
CREATE INDEX idx_ru_task_assignee ON ACT_RU_TASK(ASSIGNEE_);
CREATE INDEX idx_ru_task_pi       ON ACT_RU_TASK(PROC_INST_ID_);
CREATE INDEX idx_ru_exec_bizkey   ON ACT_RU_EXECUTION(BUSINESS_KEY_);
// ② 一定要分页,别 list() 全量
taskService.createTaskQuery().taskCandidateOrAssigned(userId)
        .orderByTaskCreateTime().desc()
        .listPage(pageNo * pageSize, pageSize);   // ★ 分页

// ③ 别在循环里查业务表(N+1 问题)—— 批量查出来再 map
List<Long> bizIds = tasks.stream().map(t -> parseBizId(t)).collect(Collectors.toList());
Map<Long, Leave> leaveMap = leaveMapper.selectBatchIds(bizIds);   // 一次查完

问题 10:并行分支里有一条卡住,整个流程死锁

现象:三个部门并行审批,法务那个人离职了没人批,流程永远不结束。

排查:

-- 看还有哪些执行流活着
SELECT PROC_INST_ID_, ACT_ID_, IS_ACTIVE_ FROM ACT_RU_EXECUTION WHERE PROC_INST_ID_ = 'xxx';
-- 看谁的任务没完成
SELECT ID_, NAME_, ASSIGNEE_, CREATE_TIME_ FROM ACT_RU_TASK WHERE PROC_INST_ID_ = 'xxx';

解决方案:

  1. 短期:给每个并行分支都挂定时边界事件(超时自动通过/自动升级)。
  2. 长期:并行审批改用会签 + 一票通过(nrOfCompletedInstances == 1),避免“必须全部完成”的强约束。
  3. 兜底:做一个运维后台,允许管理员强制完成/转办卡住的任务。

问题 11:多实例会签,有人一直不批

现象:3 人会签,其中 1 人休假,会签卡在第 2/3。

解决方案:

<!-- 改成"过半数通过",2/3 即可 -->
<completionCondition>${nrOfCompletedInstances * 1.0 / nrOfInstances >= 0.5}</completionCondition>
// 加上超时:给会签任务挂边界事件,超时自动记为"弃权"
// 或者做运维功能:管理员强制完成某人的会签任务

问题 12:ACT_RU_JOB 表堆积,异步执行器忙不过来

现象:大量定时器/异步作业积压,ACT_RU_JOB 几万行,流程推进延迟。

原因:异步执行器线程池太小,或作业执行失败反复重试。

解决方案:

spring:
  activiti:
    async-executor-activate: true
    # 调整异步执行器参数(Flowable 配置项名略有不同)
    async-executor-core-pool-size: 10
    async-executor-max-pool-size: 50
// 查失败作业
List<Job> deadLetters = managementService.createDeadLetterJobQuery().list();
// 手动重试或删除
managementService.moveJobToDeadLetterJob(jobId);   // 移到死信
managementService.executeJob(jobId);               // 立即执行
managementService.deleteJob(jobId);                // 删除

问题 13:数据库死锁(并发更新 ACT_RU_EXECUTION)

现象:高并发下偶发死锁日志:Deadlock found when trying to get lock。

原因:多个实例同时推进,引擎更新 ACT_RU_EXECUTION 时锁冲突(尤其并行汇聚时)。

解决方案:

  1. 确保引擎和业务表在同一事务、同一库(减少跨库长事务)。
  2. 缩短事务时间(监听器里不要做耗时操作,见 5.8)。
  3. 并行网关改会签(减少 Execution 树的复杂度)。
  4. 实在不行,把引擎表单独放一个库,用独立数据源(但事务一致性要靠 MQ 保证)。

问题 14:SpringBoot 启动报“找不到流程定义”

现象:no processes deployed with key 'leaveProcess'

排查清单:

  1. 文件是否在 src/main/resources/processes/ 下?
  2. 文件名后缀是 .bpmn 还是 .bpmn20.xml?(两种都行,但必须是这两种)
  3. <process id="leaveProcess"> 的 id 和你代码里的 key 一致吗?
  4. isExecutable="true" 写了吗?(没写引擎会忽略这个流程)
  5. 编译后 target/classes/processes/ 下有文件吗?

问题 15:误删部署导致数据丢失

现象:调用了 deleteDeployment(id, true) 级联删除,历史数据也没了。

教训:

// ⚠️ 生产禁用
repositoryService.deleteDeployment(deploymentId, true);   // 级联删除,历史也没了

// ✅ 正确:只删部署记录,不动实例和历史
repositoryService.deleteDeployment(deploymentId);

// ✅ 更推荐:根本不删,只"挂起"
repositoryService.suspendProcessDefinitionById(processDefinitionId);

6.3 性能优化清单

优化项 做法 效果
历史级别 full → audit ★★★★★ 历史表数据量减半以上
加索引 给 ASSIGNEE_、BUSINESS_KEY_、PROC_INST_ID_ 加索引 ★★★★★ 查询提速 10 倍+
变量瘦身 只存 ID 和决策数据,大对象放业务表或用瞬时变量 ★★★★☆
分页查询 所有 list() 改 listPage() ★★★★☆
异步执行器 开启并调大线程池 ★★★☆☆
流程图缓存 生成的 PNG 按 定义ID+节点集合 缓存 ★★★☆☆
历史归档 定期把老数据搬到归档库 ★★★★★ 根治表膨胀
避免 N+1 批量查业务表,别在循环里查 ★★★★☆
监听器异步化 耗时操作发 MQ,不在事务内做 ★★★★☆
引擎独立数据源 引擎表单独放库(牺牲本地事务) ★★☆☆☆ 慎用

历史归档脚本(生产可用,分批删除)

/**
 * 定期归档:把 N 天前已结束的流程历史搬到归档表,再从主表删除
 * 建议:用 xxl-job / @Scheduled 在低峰期跑,每批 5000 条
 */
@Component
public class HistoryArchiveJob {

    @Autowired private HistoryService historyService;
    @Autowired private JdbcTemplate jdbcTemplate;

    private static final int BATCH_SIZE = 5000;
    private static final int RETAIN_DAYS = 180;   // 保留 180 天

    @Scheduled(cron = "0 0 3 * * ?")   // 每天凌晨 3 点
    public void archive() {
        LocalDateTime deadline = LocalDateTime.now().minusDays(RETAIN_DAYS);

        int total = 0;
        while (true) {
            // ① 查出一批要归档的实例ID
            List<String> ids = jdbcTemplate.queryForList(
                    "SELECT ID_ FROM ACT_HI_PROCINST " +
                    "WHERE END_TIME_ IS NOT NULL AND END_TIME_ < ? LIMIT ?",
                    String.class, Timestamp.valueOf(deadline), BATCH_SIZE);

            if (ids.isEmpty()) break;

            // ② 备份到归档库(自己实现,比如写归档表或导出文件)
            archiveMapper.batchInsert(ids);

            // ③ 按子表 → 主表顺序删除
            String inClause = String.join(",", ids.stream()
                    .map(id -> "'" + id + "'").collect(Collectors.toList()));
            jdbcTemplate.execute("DELETE FROM ACT_HI_DETAIL   WHERE PROC_INST_ID_ IN (" + inClause + ")");
            jdbcTemplate.execute("DELETE FROM ACT_HI_VARINST  WHERE PROC_INST_ID_ IN (" + inClause + ")");
            jdbcTemplate.execute("DELETE FROM ACT_HI_TASKINST WHERE PROC_INST_ID_ IN (" + inClause + ")");
            jdbcTemplate.execute("DELETE FROM ACT_HI_ACTINST  WHERE PROC_INST_ID_ IN (" + inClause + ")");
            jdbcTemplate.execute("DELETE FROM ACT_HI_COMMENT  WHERE PROC_INST_ID_ IN (" + inClause + ")");
            jdbcTemplate.execute("DELETE FROM ACT_HI_PROCINST WHERE ID_ IN (" + inClause + ")");

            total += ids.size();
            log.info("已归档 {} 条历史流程", total);
        }
    }

    @Autowired private ArchiveMapper archiveMapper;
}

⚠️ 归档前务必确认:审批意见(ACT_HI_COMMENT)如果业务上要长期可查,先备份再删,或者归档到独立的查询库。

6.4 与业务系统集成的正确姿势(★ 架构设计)

这是最能体现设计能力的一节。核心原则:

★★ 业务数据存业务表,流程状态存引擎表,两边用 businessKey 关联。绝不把业务字段全塞进流程变量。

✅ 推荐架构

┌──────────────────────────────────────────────────────────┐
│                    你的业务表                              │
│  leave_order (请假单)                                      │
│  ├─ id                 (业务主键)                          │
│  ├─ days, reason...    (业务字段)                          │
│  ├─ status             (业务状态,冗余一份便于查询)          │
│  └─ process_instance_id ★ (流程实例ID,回写)                │
└───────────────────┬──────────────────────────────────────┘
                    │  businessKey = "LEAVE_" + id
                    │  (双向关联)
┌───────────────────┴──────────────────────────────────────┐
│              Activiti 引擎表(ACT_*)                      │
│  ACT_RU_EXECUTION.BUSINESS_KEY_ = "LEAVE_10086"           │
│  ACT_RU_TASK      (当前待办是谁)                          │
│  ACT_HI_COMMENT   (审批意见留痕)                          │
└──────────────────────────────────────────────────────────┘

为什么这么设计:

好处 说明
业务查询不受影响 “查我这个月请了几次假” → 直接查业务表,不用碰引擎
报表统计能做 业务表是结构化字段,能做 SQL 聚合;流程变量是 KV,很难统计
解耦 哪天换工作流引擎(Activiti→Flowable),业务表不用动
性能 业务表可以随便加索引优化,引擎表结构改不了

❌ 反面教材

// ❌ 把业务数据全塞进流程变量
Map<String, Object> vars = new HashMap<>();
vars.put("leaveOrder", leaveOrder);      // 整个对象
vars.put("applicantName", "张三");
vars.put("department", "技术部");
vars.put("days", 3);
vars.put("reason", "生病");

// 后果:
// 1. "查技术部这个月请了几天假" → 没法查(变量是 KV,且存在 BYTEARRAY)
// 2. 类结构一改,老流程反序列化失败
// 3. 每次推进流程都要序列化/反序列化整个对象 → 慢

状态双写的处理

业务表通常会冗余一个 status 字段(便于查询),这就涉及双写一致性:

@Transactional(rollbackFor = Exception.class)   // ★ 同一事务,同库
public void approve(String taskId, boolean passed, Long leaveId) {
    taskService.complete(taskId, Map.of("approved", passed));   // ① 推进流程
    leaveMapper.updateStatus(leaveId, passed ? "APPROVED" : "REJECTED");  // ② 更新业务状态
}

如果引擎和业务库分开了(多数据源),本地事务失效,要改用:

  • MQ 最终一致性(流程完成后发消息,消费者更新业务表)
  • 定时对账(比对流程状态和业务状态,不一致就告警/补偿)

📖 详细的分布式事务与一致性方案,见 10-数据一致性与缓存同步-实战专题.md。


第七章:选型对比、面试题与小结

7.1 Activiti vs Flowable vs Camunda 怎么选

这三个是“同源三兄弟”,API 和表结构相似度 90%,学会一个另外两个很快上手。

维度 Activiti 7 Flowable 6/7 Camunda 7/8
出身 Alfresco 官方原版 Activiti 6 fork(原班人马) Activiti 5 fork
社区活跃度 ★★☆☆☆ 下降 ★★★★★ 最活跃 ★★★★☆ 很活跃
更新频率 慢(7.1.0.M6 后趋缓) 快 快
Spring Boot 整合 有 starter,但强依赖 Spring Security starter 干净,开箱即用 starter 完善
自由跳转(驳回) ❌ 无官方 API ✅ ChangeActivityStateBuilder ✅ ProcessInstanceModification
决策表 DMN ❌ ✅ 有 ✅ 最强(Camunda 的王牌)
运维控制台 一般 Flowable Modeler/IDM ✅ Cockpit 最强(可干预实例)
CMMN(案例管理) ❌ ✅ ✅
云原生 / K8s 7.x 主打云原生 有 Flowable 7 支持 Camunda 8(Zeebe)原生云原生
学习资料(中文) ★★★★★ 最多(但不新) ★★★☆☆ ★★★☆☆
License Apache 2.0 Apache 2.0 Apache 2.0(部分企业版收费)

选型建议(直接给结论)

你的情况 选哪个
新项目,国内,团队熟悉中文资料 Flowable ★ 推荐
需要复杂的决策表 / 规则引擎配合 Camunda
需要强大的运维控制台(运营能自己干预流程) Camunda(Cockpit 很强)
老系统维护,已经在用 Activiti 继续用,或评估迁 Flowable(迁移成本低,API 几乎一致)
学习 / 面试 学 Activiti 概念 + 知道三者的区别就够了
高吞吐、云原生、要水平扩展 Camunda 8(Zeebe) 或 编排引擎(Temporal)

★ 面试话术(关于迁移):“Activiti、Flowable、Camunda 同源,表结构都是 ACT_*、Service API 名字都一样,所以互相迁移成本很低。我们项目从 Activiti 迁到 Flowable,主要改动就是依赖坐标和少量包名,业务代码几乎没动。”

7.2 面试题(共 30 题)

7.2.1 基础题(1~10,★★☆☆☆)

Q1. 什么是工作流引擎?解决了什么问题? ★★☆☆☆

工作流引擎是“读懂流程图、按图推进任务”的运行时程序。它把流程逻辑(下一步给谁)从业务代码里抽出来,变成可配置的流程图。 解决的问题:流程变更不用改代码发版、有可视化流程图、自动留痕、原生支持会签/加签/超时。 加分点:“核心是关注点分离——业务数据归业务表,流转规则归流程图。”

Q2. BPM、BPMN、工作流引擎的区别? ★★☆☆☆

BPM 是方法论(怎么管流程);BPMN 是图纸标准(流程图怎么画);引擎 是软件(按图纸施工);Activiti 是具体的引擎产品。 加分点:用类比“想法 → 图纸 → 施工队 → 某支施工队”。

Q3. Activiti 有哪些核心 Service? ★★☆☆☆

RepositoryService(流程定义)、RuntimeService(流程实例)、TaskService(任务)、HistoryService(历史)。 另外 IdentityService、FormService、ManagementService。

Q4. 流程定义和流程实例的区别?版本机制是什么? ★★★☆☆

定义是图纸(Class),实例是具体一次业务(对象)。同一 key 部署多次版本递增。 ★ 已启动的实例按旧版本跑完,新部署不影响在途实例。

Q5. Activiti 有多少张表?分哪几类? ★★☆☆☆

25 张左右,5 类:ACT_RE_(定义)、ACT_RU_(运行时,★跑完清空)、ACT_HI_(历史,★只增不减)、ACT_GE_(通用/二进制)、ACT_ID_(身份)。

Q6. 任务(Task)和执行流(Execution)的区别? ★★★☆☆

Execution 是流程实例的“指针”,一个实例至少有一个;并行分支时会有多个。 Task 是停在某个人面前的待办,特指 User Task。 加分点:“一个 Execution 上最多挂一个当前 Task;并行网关分叉后,N 个分支 = N 个 Execution = N 个 Task。”

Q7. 怎么指定任务的审批人? ★★☆☆☆

三种:① activiti:assignee 指定人;② activiti:candidateUsers / candidateGroups 候选(需 claim 认领);③ TaskListener 的 create 事件里动态 setAssignee()。 加分点:“生产上最灵活的是监听器方式,可以查组织架构动态算审批人。”

Q8. 四种网关的区别? ★★★★☆

  • 排他 X:只走第一条满足条件的(if/else if),建议配默认流
  • 并行 +:忽略条件,所有分支同时走;汇聚要等所有分支
  • 包容 O:满足条件的多条都走;汇聚等被激活的分支
  • 事件 △:等哪个事件先发生走哪个 加分点:“并行网关条件会被忽略;包容汇聚会’智能等待’只等被激活的分支。”

Q9. 流程变量怎么存?有什么坑? ★★★☆☆

runtimeService.setVariable() 全局;taskService.setVariableLocal() 局部(任务完成后消失);setTransientVariable() 瞬时(不入库)。 坑:对象必须 Serializable,且类结构变更会导致老流程反序列化失败 → 建议只存 String/数字/Boolean。

Q10. 审批意见应该存在哪? ★★★☆☆

taskService.addComment(taskId, processInstanceId, "意见") → 存进 ACT_HI_COMMENT,永久保留。 加分点:“不要用 setVariable("comment", ...),那是流程变量,任务完成就没了,而且历史里查不到。”


7.2.2 进阶题(11~20,★★★☆☆ ~ ★★★★☆)

Q11. 会签怎么实现?一票否决呢? ★★★★☆

用 <multiInstanceLoopCharacteristics>:isSequential 控制串行/并行,collection 是审批人集合,elementVariable 是循环变量。 一票否决:加 rejected 变量 + TaskListener(complete) 里检测 approved==false 就置 rejected=true,再让 completionCondition 包含 || rejected == true。 加分点:“★ 一票否决触发后,其他人未完成的待办不会自动消失,要手动 deleteTask 清理。”

Q12. nrOfInstances / nrOfCompletedInstances 是什么? ★★★☆☆

多实例的内置变量:总实例数、已完成实例数、活跃实例数(nrOfActiveInstances)。用于 completionCondition。 加分点:“写比例判断时注意整数除法陷阱,要 * 1.0 转浮点。”

Q13. 驳回(退回上一节点)怎么实现? ★★★★☆

★ Activiti 没有官方自由跳转 API。三种方案: ① 画驳回线(推荐):显式画一条回到上一节点的顺序流,用排他网关控制 ② 自定义 Command 改 ACT_RU_EXECUTION.ACT_ID_(hack,有版本风险) ③ 作废重提:deleteProcessInstance 后让发起人重新提交 加分点:“Flowable 有 ChangeActivityStateBuilder、Camunda 有 ProcessInstanceModification,这是它们相对 Activiti 的优势。”

Q14. 加签怎么做? ★★★★☆

Activiti 原生不支持。常见做法:① 转办 + 业务表记加签记录(最实用、零风险);② 多实例集合加人(只在会签未开始时有效);③ 创建独立任务 + 挂起主流程(复杂)。 加分点:说出“动态改 collection 不会自动补建实例”这个坑。

Q15. 监听器在事务里吗?有什么风险? ★★★★☆

★ 是,在引擎同一个事务内同步执行。风险: ① 抛异常会导致整个流程回滚(非核心逻辑要 try-catch) ② 耗时操作会长时间持有数据库事务(要异步化,发 MQ) ③ 不能在监听器里调 complete() 完成其他任务(重入问题) 加分点:“需要’事务提交后再执行’用 Spring 的 TransactionSynchronization.afterCommit()。”

Q16. 定时器为什么没触发? ★★★☆☆

排查:① async-executor-activate 是否为 true(最常见);② ACT_RU_TIMER_JOB 里有没有这条作业;③ 服务器时间对不对;④ 有没有被移到死信作业 ACT_RU_DEADLETTER_JOB。

Q17. activiti:class 和 activiti:delegateExpression 的区别? ★★★★☆

class 由引擎自己 new,@Autowired 不生效(空指针)。 delegateExpression = "${beanName}" 从 Spring 容器取 Bean,注入正常。生产必用后者。

Q18. OptimisticLockingException 是什么?怎么解决? ★★★★☆

引擎表有 REV_ 版本字段,乐观锁防止并发修改。两人同时完成同一任务,后提交的报错。 解决:前端按钮防重 + 重试机制 + 按 taskId 加分布式锁。

Q19. 历史表太大怎么办? ★★★★☆

① 历史级别 full → audit(不记变量明细);② 定期归档(把老数据搬走再删,注意子表→主表顺序、分批删除);③ 加索引;④ 归档前备份 ACT_HI_COMMENT。

Q20. 流程定义升级后,在途实例怎么办? ★★★★☆

默认继续用旧版本跑完(保护设计)。要迁移:Flowable 有 createProcessInstanceMigrationBuilder();Activiti 7 没有官方 API,建议让老实例自然跑完 + 灰度。 加分点:“所以改流程图要向后兼容,新节点尽量加在后段。”


7.2.3 场景题(21~26,★★★★☆ ~ ★★★★★)

Q21. 让你设计一个公司的请假审批系统,你怎么设计? ★★★★☆

答(结构化回答):

  1. 数据分层:请假单存业务表 leave_order(含 process_instance_id),流程状态存引擎表,用 businessKey = "LEAVE_" + id 关联。
  2. 审批人:用 TaskListener(create) 查组织架构动态算直属领导,避免写死 userId。
  3. 分支:天数 >3 天走老板审批(排他网关 + 默认流)。
  4. 超时:挂 72 小时定时边界事件,超时自动通过或催办。
  5. 留痕:addComment 写审批意见。
  6. 驳回:画驳回线回到“重新填写”节点。
  7. 事务:@Transactional 保证流程和业务状态一致。
  8. 性能:待办分页查询 + 加索引 + 历史定期归档。 加分点:“业务字段绝不塞进流程变量,否则统计报表做不了。”

Q22. 你们为什么要用工作流引擎?不用行不行? ★★★★☆

答(体现判断力):“要看场景。我们的 OA 审批环节多、规则常变、需要留痕和可视化,用引擎后流程变更改成改图,不用发版,这是核心价值。 但如果只是’提交→审核→完成’三步且常年不变,用状态字段 + 一张审批记录表更简单,引入引擎反而增加 25 张表和运维复杂度。 另外订单、支付这种高并发主链路我们坚决不用工作流,因为它每一步都要读写十几张表,扛不住 QPS。” 加分点:能说出“什么时候不用”比“什么时候用”更显功力。

Q23. 流程走到一半,发现流程图画错了,怎么办? ★★★★☆

  1. 在途实例:默认用旧图跑完。如果错得很离谱,只能在运维后台强制终止 + 重新发起(要和业务方确认)。
  2. 改图:修改 .bpmn 重新部署(版本 +1),新发起的走新图。
  3. 预防:改图前先备份、灰度发布、新节点尽量加在后段保证向后兼容。 加分点:“最重要的是别急着 deleteDeployment(id, true) 级联删除,那会把历史数据一起删了。”

Q24. 三个人会签,其中一人离职了,流程卡住怎么办? ★★★★☆

  1. 短期:运维后台强制转办(transferAllTasks)把他的待办转给接替者,或强制完成。
  2. 中期:给会签任务挂定时边界事件,超时自动记为弃权/自动通过。
  3. 长期:completionCondition 改成“过半数通过”而不是“全部通过”,降低对单点的依赖;审批人用角色/岗位而不是具体 userId。 加分点:“★ 用 candidateGroups(岗位)比 assignee(具体人)更抗人员变动。”

Q25. 审批通过了,但业务表状态没更新,怎么排查? ★★★★☆

  1. 看是不是没加 @Transactional(或没加 rollbackFor,默认只对 RuntimeException 回滚)。
  2. 看引擎和业务表是不是同一个数据源(跨库本地事务失效)。
  3. 看业务代码是不是在 complete() 之后抛了异常但被吞了(try-catch 没打日志)。
  4. 看是不是监听器里的异步逻辑导致的时序问题。 加分点:“如果引擎库和业务库必须分开,就要上 MQ 最终一致性 + 定时对账。”

Q26. 如何让老板看到“流程走到哪一步了”? ★★★☆☆

用 ProcessDiagramGenerator.generateDiagram() 生成 PNG:高亮当前节点(红框,来自 runtimeService.getActiveActivityIds())+ 已走过节点(绿框,来自 ACT_HI_ACTINST)。 加分点:“两个坑——① Linux 中文乱码要装字体/指定宋体;② 每次画图开销大,要按 定义ID+节点集合 缓存。”


7.2.4 追问链(27~30,连环深挖)

Q27. 追问链:工作流引擎的持久化

“流程状态存在哪?” → ACT_RU_* 表 → “流程结束后呢?” → 运行时删除,进 ACT_HI_* → “那历史表会一直增长吗?” → 是,要归档 → “归档时直接 DELETE 行吗?” → 不行,要先备份、再按子表→主表顺序、分批删,否则锁表且数据丢失 → “如果老板要看三年前的审批记录呢?” → 归档到独立的历史查询库,业务查询走归档库

Q28. 追问链:事务一致性

“审批时流程和业务状态怎么保持一致?” → 同一个 @Transactional → “如果引擎和业务库分开了呢?” → 本地事务失效,上 MQ 最终一致性 → “消息丢了怎么办?” → 本地消息表 + 定时对账 → “对账发现不一致怎么补偿?” → 以流程状态为准,回写业务表(或人工介入) 📖 详见 10-数据一致性与缓存同步-实战专题.md

Q29. 追问链:性能

“待办查询慢怎么优化?” → 加索引(ASSIGNEE_、PROC_INST_ID_)+ 分页 → “引擎整体变慢呢?” → 历史级别降级、变量瘦身、监听器异步化 → “能不能用 Redis 缓存待办?” → 可以,但要注意缓存与 ACT_RU_TASK 的一致性问题(审批后要删缓存) → “那高并发场景呢?” → ★ 高并发主链路别用工作流,用状态字段 + MQ

Q30. 追问链:选型

“为什么选 Activiti?” → 说明项目背景(老系统/团队熟悉) → “知道 Flowable 吗?” → 同源,社区更活跃 → “两者怎么迁移?” → API 和表结构 90% 一致,改依赖坐标 + 少量包名 → “新项目你选哪个?” → Flowable(中文资料、社区、驳回 API 都有优势) → “什么时候选 Camunda?” → 需要 DMN 决策表或 Cockpit 运维控制台时


7.3 第七章小结:Activiti 核心认知

五条必须记住的认知

  1. ★ 业务数据存业务表,流程状态存引擎表,用 businessKey 双向关联。绝不把业务字段塞进流程变量。
  2. ★ ACT_RU_* 是临时表(跑完清空),ACT_HI_* 是归档表(只增不减)。排查在途查 RU,追溯历史查 HI。
  3. ★ 已启动的流程实例永远用旧版本图跑完,新部署不影响在途实例——这是保护设计。
  4. ★ 监听器在引擎事务内同步执行:非核心逻辑要 try-catch 兜底,耗时操作要异步化。
  5. ★ 不是所有“流转”都适合工作流:高并发主链路(订单、支付)坚决不用。

一页纸速查卡

【核心 API】
  RepositoryService  部署/查/删流程定义     ← 图纸
  RuntimeService     启动/变量/删除实例     ← 开工
  TaskService        查待办/认领/完成/意见  ← 干活
  HistoryService     查历史                ← 归档

【表前缀】
  ACT_RE_ 定义   ACT_RU_ 运行时(跑完清)   ACT_HI_ 历史(只增)
  ACT_GE_ 资源   ACT_ID_ 身份(一般不用)

【网关】
  X 排他=只走一条   + 并行=全走且等全部   O 包容=满足的都走   △ 事件=等谁先来

【会签】
  multiInstanceLoopCharacteristics
  nrOfInstances / nrOfCompletedInstances / nrOfActiveInstances
  一票否决 = rejected 变量 + completionCondition + 手动清理未完成任务

【驳回】Activiti 无官方 API → 画驳回线 / 自定义 Command / 作废重提
【加签】Activiti 无原生支持 → 转办 + 加签记录表(推荐)
【超时】定时边界事件 + async-executor-activate=true
【留痕】addComment → ACT_HI_COMMENT
【事务】@Transactional(rollbackFor=Exception.class) 同库同事务

上线前检查清单

  • 历史级别设为 audit(不是 full)
  • async-executor-activate = true(否则定时器不生效)
  • 排他网关都配了默认流(防止条件全不满足抛异常)
  • 服务任务用 delegateExpression 而不是 class(否则 @Autowired 失效)
  • 流程变量只存 String/数字/Boolean,对象转 JSON
  • 给 ASSIGNEE_、BUSINESS_KEY_、PROC_INST_ID_ 加了索引
  • 待办查询都用 listPage() 分页
  • 审批意见用 addComment(不是 setVariable)
  • 监听器里非核心逻辑 try-catch、耗时操作异步化
  • 流程图字体配置为宋体(防 Linux 乱码)
  • 有历史归档任务在跑
  • 禁止 deleteDeployment(id, true) 级联删除

五句话记忆法

  1. 工作流引擎 = 公司前台 + 流程手册,改流程 = 改手册,不用重新培训员工。
  2. 图纸(定义)生对象(实例),老对象按老图纸跑完。
  3. RU 是临时工位,HI 是永久档案。
  4. 菱形 X 是 if,+ 是多线程,O 是多个 if。
  5. 变量只放决策数据,业务明细回业务表。

配套文档:

最后更新:2026-09-20