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章 |
目录
- 第一章:先搞清楚“工作流”到底是什么(小白必读)
- 第二章:BPMN 2.0 建模(画流程图就是写“代码”)
- 第三章:Activiti 核心概念与表结构
- 第四章:Spring Boot 集成与代码实战
- 第五章:高级场景(会签 / 驳回 / 撤回 / 加签 / 超时 / 监听器)
- 第六章:应用场景、生产常见问题与性能优化
- 第七章:选型对比、面试题与小结
第一章:先搞清楚“工作流”到底是什么(小白必读)
这一章不写一行 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>
规则:
- 引擎按 XML 里
<sequenceFlow>的先后顺序评估条件,走第一个为 true 的。 - 如果所有条件都不满足:
- 有
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"/>
规则:
- 分叉时条件表达式会被直接忽略(写了也没用)。
- 汇聚时必须等所有流入分支都到达。三条分支里有一条卡住 → 整个流程卡死。
⚠️ 坑 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}"> 就能显示流程图。
⚠️ 两个坑:
- 中文乱码:上面的三个字体参数传
"宋体"。Linux 服务器如果没装中文字体,画出来是方块 → 需在服务器装字体(fonts-cn之类)或改用图片方式。- 性能:每次请求都重新画图、解析 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关联表)。
方式三:加签 = 转办 + 加一条审批记录(最实用,推荐)
换个思路:不做真正的“流程加签”,而是——
- 把当前任务转办给加签人;
- 加签人批完后,再转办回原审批人;
- 在你的业务表里记一条“加签记录”,用于展示。
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 小时一次(循环提醒) |
⚠️ 定时器不生效的排查清单:
spring.activiti.async-executor-activate是否为true(★ 最常见原因)ACT_RU_TIMER_JOB表里有没有这条定时作业- 服务器时间和数据库时间是否一致
- 多实例部署时,异步执行器是否在多个节点抢同一个作业(需要
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_,风险高)
★ 最佳实践:
- 新增节点要向后兼容:新加的节点尽量加在流程后段,不影响在途实例已走过的路径。
- 灰度发布:先部署 v2,让新发起的走 v2,老的跑完 v1,观察一段时间。
- 迁移前先备份
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,但只在表为空或文件内容变化时重新部署;而且已启动的实例永远用旧版本。
解决方案:
- 确认文件在
resources/processes/下(不是别的目录)。 - 重启后查
ACT_RE_PROCDEF,看VERSION_有没有 +1。 - 如果没变,手动调用
repositoryService.createDeployment()重新部署。 - 已启动的实例不受影响——要测新流程就重新发起一个。
问题 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';
解决方案:
- 短期:给每个并行分支都挂定时边界事件(超时自动通过/自动升级)。
- 长期:并行审批改用会签 + 一票通过(
nrOfCompletedInstances == 1),避免“必须全部完成”的强约束。 - 兜底:做一个运维后台,允许管理员强制完成/转办卡住的任务。
问题 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 时锁冲突(尤其并行汇聚时)。
解决方案:
- 确保引擎和业务表在同一事务、同一库(减少跨库长事务)。
- 缩短事务时间(监听器里不要做耗时操作,见 5.8)。
- 并行网关改会签(减少 Execution 树的复杂度)。
- 实在不行,把引擎表单独放一个库,用独立数据源(但事务一致性要靠 MQ 保证)。
问题 14:SpringBoot 启动报“找不到流程定义”
现象:no processes deployed with key 'leaveProcess'
排查清单:
- 文件是否在
src/main/resources/processes/下? - 文件名后缀是
.bpmn还是.bpmn20.xml?(两种都行,但必须是这两种) <process id="leaveProcess">的id和你代码里的 key 一致吗?isExecutable="true"写了吗?(没写引擎会忽略这个流程)- 编译后
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. 让你设计一个公司的请假审批系统,你怎么设计? ★★★★☆
答(结构化回答):
- 数据分层:请假单存业务表
leave_order(含process_instance_id),流程状态存引擎表,用businessKey = "LEAVE_" + id关联。- 审批人:用
TaskListener(create)查组织架构动态算直属领导,避免写死 userId。- 分支:天数 >3 天走老板审批(排他网关 + 默认流)。
- 超时:挂 72 小时定时边界事件,超时自动通过或催办。
- 留痕:
addComment写审批意见。- 驳回:画驳回线回到“重新填写”节点。
- 事务:
@Transactional保证流程和业务状态一致。- 性能:待办分页查询 + 加索引 + 历史定期归档。 加分点:“业务字段绝不塞进流程变量,否则统计报表做不了。”
Q22. 你们为什么要用工作流引擎?不用行不行? ★★★★☆
答(体现判断力):“要看场景。我们的 OA 审批环节多、规则常变、需要留痕和可视化,用引擎后流程变更改成改图,不用发版,这是核心价值。 但如果只是’提交→审核→完成’三步且常年不变,用状态字段 + 一张审批记录表更简单,引入引擎反而增加 25 张表和运维复杂度。 另外订单、支付这种高并发主链路我们坚决不用工作流,因为它每一步都要读写十几张表,扛不住 QPS。” 加分点:能说出“什么时候不用”比“什么时候用”更显功力。
Q23. 流程走到一半,发现流程图画错了,怎么办? ★★★★☆
- 在途实例:默认用旧图跑完。如果错得很离谱,只能在运维后台强制终止 + 重新发起(要和业务方确认)。
- 改图:修改
.bpmn重新部署(版本 +1),新发起的走新图。- 预防:改图前先备份、灰度发布、新节点尽量加在后段保证向后兼容。 加分点:“最重要的是别急着
deleteDeployment(id, true)级联删除,那会把历史数据一起删了。”
Q24. 三个人会签,其中一人离职了,流程卡住怎么办? ★★★★☆
- 短期:运维后台强制转办(
transferAllTasks)把他的待办转给接替者,或强制完成。- 中期:给会签任务挂定时边界事件,超时自动记为弃权/自动通过。
- 长期:
completionCondition改成“过半数通过”而不是“全部通过”,降低对单点的依赖;审批人用角色/岗位而不是具体 userId。 加分点:“★ 用candidateGroups(岗位)比assignee(具体人)更抗人员变动。”
Q25. 审批通过了,但业务表状态没更新,怎么排查? ★★★★☆
- 看是不是没加
@Transactional(或没加rollbackFor,默认只对 RuntimeException 回滚)。- 看引擎和业务表是不是同一个数据源(跨库本地事务失效)。
- 看业务代码是不是在
complete()之后抛了异常但被吞了(try-catch 没打日志)。- 看是不是监听器里的异步逻辑导致的时序问题。 加分点:“如果引擎库和业务库必须分开,就要上 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 核心认知
五条必须记住的认知
- ★ 业务数据存业务表,流程状态存引擎表,用
businessKey双向关联。绝不把业务字段塞进流程变量。 - ★
ACT_RU_*是临时表(跑完清空),ACT_HI_*是归档表(只增不减)。排查在途查 RU,追溯历史查 HI。 - ★ 已启动的流程实例永远用旧版本图跑完,新部署不影响在途实例——这是保护设计。
- ★ 监听器在引擎事务内同步执行:非核心逻辑要 try-catch 兜底,耗时操作要异步化。
- ★ 不是所有“流转”都适合工作流:高并发主链路(订单、支付)坚决不用。
一页纸速查卡
【核心 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)级联删除
五句话记忆法
- 工作流引擎 = 公司前台 + 流程手册,改流程 = 改手册,不用重新培训员工。
- 图纸(定义)生对象(实例),老对象按老图纸跑完。
- RU 是临时工位,HI 是永久档案。
- 菱形 X 是 if,+ 是多线程,O 是多个 if。
- 变量只放决策数据,业务明细回业务表。
配套文档:
- 名词解释 →
00-名词速查手册.md - Spring 框架基础 →
02-开发框架-讲解与面试题.md - 事务一致性 →
10-数据一致性与缓存同步-实战专题.md - 消息队列 →
04-中间件-讲解与面试题.md
最后更新:2026-09-20