Activiti任务生命周期、待办已办与并发审批
用户在审批中心看到的是“我的待办、待认领、已办理、转办、委派、通过、拒绝”,但引擎内部真正维护的是 Task、Execution、Identity Link、Variable 和 History。taskService.complete(taskId) 不是删除一行待办,而是在一个引擎命令中结束当前任务、推进 Execution、计算网关、创建下一任务、更新历史并提交事务。
生产审批接口也绝不能只调用 complete。它还必须处理服务端权限、业务状态、审批意见、幂等请求、并发完成、变量类型、历史查询和跨系统一致性。否则会出现越权审批、同一任务被处理两次、流程已走但业务状态没变、任务完成后已办查不到等问题。
学习目标
完成本页后,你应该能够:
- 区分 Task、Execution、ProcessInstance 和业务审批记录。
- 解释 User Task 从创建、候选、认领、分配、委派到完成的生命周期。
- 区分 assignee、candidate user、candidate group、owner 和 identity link。
- 正确查询个人待办、候选待办、已办和完整轨迹。
- 解释 claim 为什么需要并发控制,以及两个人同时认领/完成会怎样。
- 区分认领、取消认领、转办、委派、解决委派和完成任务。
- 正确设计全局变量、Task Local变量和历史变量。
- 写出带权限、幂等、审批记录和事务的完整 Java Demo。
- 处理大待办列表的分页、索引、冗余查询和数据权限。
- 排查任务查不到、重复审批、任务完成回滚、已办缺失和待办错人。
一、先区分四类数据
flowchart TD
A["ProcessInstance:一张业务单的一次流程运行"] --> B["Execution:引擎当前执行路径"]
B --> C["Task:某个User Task产生的人工工作项"]
D["业务审批记录:谁对哪张单做了什么"] --> A
E["业务表:合同、报销单、资产申请"] --> A| 对象 | 主要回答 | 完成Task后怎样 |
|---|---|---|
| ProcessInstance | 这次流程是否仍运行、使用哪个定义版本 | 可能继续运行,也可能结束 |
| Execution | 当前流程Token在哪些节点/作用域 | 推进、分裂、汇聚或删除 |
| Runtime Task | 现在谁需要办理什么 | 从运行时任务表移除 |
| Historic Task | 过去谁何时办理过什么 | 按历史级别和清理策略保留 |
| 业务审批记录 | 审批意见、操作者、请求ID、业务结论 | 由业务系统长期保留和审计 |
Task 不是业务单。一个合同流程可以先后产生主管、法务、财务多个 Task;并行时还可以同时存在多个 Task。
二、User Task从哪里产生
BPMN:
<userTask id="legalReview"
name="法务审核"
activiti:candidateGroups="LEGAL_REVIEWER"/>Execution 进入该节点时,引擎通常会:
flowchart TD
A["Execution进入User Task"] --> B["创建运行时Task实体"]
B --> C["写taskDefinitionKey、名称、流程实例和Execution关系"]
C --> D["解析assignee、candidate user/group"]
D --> E["写Assignee或Identity Link"]
E --> F["执行Task Create Listener"]
F --> G["刷新运行时Task和历史记录"]
G --> H["提交事务后待办可见"]如果 Task Listener 抛异常,创建 Task 的整个引擎命令可能回滚,用户就查不到任务。不能只查 ACT_RU_TASK 后认定“引擎没创建”,还要查看触发该节点的请求/Job 异常。
三、Task常见字段怎样理解
| 字段 | 含义 | 常见误区 |
|---|---|---|
id | 当前Task实例唯一ID | 不是Activity ID,也不能跨实例复用 |
taskDefinitionKey | BPMN User Task节点ID | 不是Task表主键 |
name | 面向用户的任务名称 | 名称可变,不适合做稳定逻辑判断 |
processInstanceId | 所属流程实例 | 还需businessKey关联业务单 |
executionId | 所属执行路径 | 并行时不同Task可能有不同executionId |
assignee | 当前明确办理人 | null不等于任何人都能办理 |
owner | 委派等场景中的任务所有者 | 不等于当前assignee |
createTime | 任务创建时间 | 不等于流程启动时间 |
dueDate | 业务到期时间元数据 | 引擎不会天然替你实现所有SLA动作 |
priority | 优先级元数据 | 不会自动实现复杂抢单调度 |
delegationState | 委派状态 | PENDING时通常需resolve而非直接当普通Task处理 |
四、assignee和candidate到底是什么
4.1 assignee
明确处理人。任务已分配给用户 u1001 后,其他候选人通常不应直接完成。
<userTask id="managerReview"
name="主管审批"
activiti:assignee="${managerUserId}"/>4.2 candidate user
候选用户可以看到并认领任务:
<userTask id="expertReview"
name="专家审核"
activiti:candidateUsers="u1001,u1002"/>硬编码用户只适合演示。生产人员变化应来自组织服务或业务变量。
4.3 candidate group
候选组表示某角色/组中的用户可认领:
<userTask id="securityReview"
name="安全审核"
activiti:candidateGroups="SECURITY_REVIEWER"/>Activiti 自带 Identity 数据与企业 IAM/组织系统未必是同一套。taskCandidateUser(userId) 是否能正确解析用户所属组,取决于 IdentityService 集成方式。很多商业系统会自己从组织服务获取 groupIds,再组合查询或维护任务投影表。
4.4 identity link
Task 与候选用户、候选组、参与者等关系通常通过 Identity Link 表达。它比 ASSIGNEE_ 单字段更丰富。
flowchart TD
A["Task"] --> B["Assignee:当前办理人"]
A --> C["Candidate User:候选用户"]
A --> D["Candidate Group:候选组"]
A --> E["Owner:委派前所有者"]
A --> F["Participant等Identity Link"]五、任务生命周期
flowchart TD
A["User Task创建"] --> B{"是否直接指定assignee"}
B -->|"是"| C["Assigned:个人待办"]
B -->|"否"| D["Unclaimed:候选池任务"]
D --> E["claim认领"]
E --> C
C --> F{"处理动作"}
F -->|"取消认领"| D
F -->|"转办"| G["更换assignee"]
F -->|"委派"| H["Delegation PENDING"]
H --> I["受托人resolve"]
I --> C
C --> J["complete完成"]
G --> J
J --> K["Runtime Task移除,Execution继续"]
K --> L["Historic Task保留已办轨迹"]不是每个任务都会经过所有状态。直接 assignee 的任务无需 claim;没有委派也不会出现 owner/delegationState。
六、查询我的待办不能只写一条模糊API
“我的待办”可能包含:
- 已经明确分配给我的任务。
- 我作为候选用户可以认领的任务。
- 我所属候选组可以认领的任务。
- 委派给我等待处理的任务。
- 代理关系下需要展示的任务。
产品要先定义列表范围,不能直接把某个 Query 方法名当业务规则。
6.1 查询已分配给我的任务
List<Task> assigned = taskService.createTaskQuery()
.taskAssignee(userId)
.active()
.orderByTaskCreateTime()
.desc()
.listPage(offset, pageSize);6.2 查询我可认领的候选任务
如果引擎 IdentityService 已正确接入用户组:
List<Task> candidate = taskService.createTaskQuery()
.taskCandidateUser(userId)
.active()
.orderByTaskCreateTime()
.desc()
.listPage(offset, pageSize);如果组关系来自外部 IAM,需要明确查询实现。有些版本支持传 groupIds 或 OR 查询能力,有些项目会维护独立待办索引。不要先把全量任务查进内存再用 Java 过滤。
6.3 候选或已分配组合查询
部分 Activiti 版本提供:
taskService.createTaskQuery()
.taskCandidateOrAssigned(userId)
.active()
.listPage(offset, pageSize);必须对当前版本做集成测试,确认候选组解析、委派状态、租户和分页总数符合业务定义。
6.4 为什么还要关联业务数据
Task 中通常没有合同金额、申请部门、数据等级等完整展示字段。简单列表可以批量取得 processInstanceId/businessKey 后查询业务表;复杂高并发审批中心可建设业务待办投影表或搜索索引。
禁止 N+1:
错误:查20个Task,再循环20次查流程实例、20次查业务表
正确:分页查Task → 批量查实例/businessKey → 批量查业务单 → 内存按ID组装七、分页和总数为什么可能慢
TaskQuery query = taskService.createTaskQuery()
.taskAssignee(userId)
.active();
long total = query.count();
List<Task> page = query
.orderByTaskCreateTime()
.desc()
.listPage(offset, pageSize);风险:
- 候选组查询需要关联 Identity Link 和组关系。
- 加多个变量条件可能 Join 变量表并扩大结果集。
- 深分页 offset 越大,数据库扫描和丢弃越多。
count和列表是两条 SQL,中间可能有任务变化,结果不是严格快照。- 每次请求都查完整历史变量会增加 IO。
生产优化:
- 查看当前 Activiti 版本真实 SQL 和执行计划。
- 按租户、assignee/candidate、创建时间、流程 key 设计查询路径。
- 页大小设上限,避免前端请求十万条。
- 高数据量使用游标/锚点分页或审批投影表。
- 对投影表使用 Outbox/CDC 和对账,不假设双写永不失败。
- 不随意给引擎表加索引,先评估写放大、升级脚本和官方 schema。
八、claim认领全过程
taskService.claim(taskId, userId);语义:将尚未分配的候选任务设置给某个 assignee。完整业务流程应是:
flowchart TD
A["用户请求认领taskId"] --> B["服务端读取Task"]
B --> C["校验Task仍活动且assignee为空"]
C --> D["校验用户是候选人或属于候选组"]
D --> E["调用claim"]
E --> F["引擎更新assignee和Revision"]
F --> G["事务提交"]
G --> H["任务从候选池变为个人待办"]8.1 两个人同时认领会怎样
两个请求都可能先读到 assignee=null,但数据库更新时必须只有一个成功。引擎通常通过 Revision/乐观锁和“已被认领”校验保证竞争结果。
flowchart TD
A["用户A和用户B同时认领"] --> B["两边都读取到候选Task"]
B --> C["A先提交assignee=A"]
C --> D["B更新时发现Task已认领或Revision变化"]
D --> E["B得到冲突/已认领结果"]业务接口应该把失败转换为明确的 TASK_ALREADY_CLAIMED,并返回最新 assignee 的脱敏信息,而不是统一返回 500。
8.2 claim不能替代权限校验
不能相信前端“只有候选人看得到按钮”。服务端要验证候选 Identity Link 和企业数据权限。不同 Activiti 版本对非候选用户直接 claim 的校验行为可能不同,业务安全不能依赖未验证的默认实现。
九、取消认领
常见做法:
taskService.setAssignee(taskId, null);这会把个人任务放回候选池,但业务上应限制:
- 当前操作者是 assignee 或管理员。
- 任务尚未开始不可逆外部操作。
- 原候选用户/组关系仍存在。
- 记录取消认领原因和审计。
如果任务一开始就只有动态 assignee、没有候选关系,清空后可能成为无人可见任务。
十、转办和委派不是一回事
10.1 转办
转办通常表示把当前处理责任永久交给另一人:
taskService.setAssignee(taskId, targetUserId);原办理人不再需要收回任务。业务要记录 from、to、原因和授权来源。
10.2 委派
委派表示原办理人临时交给受托人处理,受托人完成工作后任务通常返回 owner,由原办理人最终确认。
taskService.delegateTask(taskId, delegateUserId);常见内部变化:
- 原 assignee 保存为 owner。
- 新 assignee 变成受托人。
- delegationState 变为
PENDING。
受托人不是直接完成整个 BPMN Task,而是解决委派:
taskService.resolveTask(taskId, variables);任务返回 owner 后,owner 再调用 complete 推进流程。精确状态变化以当前 Activiti 版本测试为准。
10.3 对比
| 动作 | 当前assignee | owner | 是否推进Execution |
|---|---|---|---|
| claim | 设置为认领人 | 通常不变 | 否 |
| unclaim | 清空 | 通常不变 | 否 |
| transfer/setAssignee | 换成目标人 | 通常不自动保留原人 | 否 |
| delegateTask | 受托人 | 保存原处理责任人 | 否 |
| resolveTask | 通常回到owner | 保留 | 否 |
| complete | Task结束 | 不再是运行时Task | 是 |
十一、complete内部到底发生什么
taskService.complete(taskId, variables);不是一条 DELETE FROM ACT_RU_TASK。典型过程:
flowchart TD
A["根据taskId加载Task"] --> B["校验Task状态和Revision"]
B --> C["合并本次流程变量"]
C --> D["触发Task完成Listener"]
D --> E["记录历史Task结束信息"]
E --> F["移除运行时Task和Identity Link"]
F --> G["唤醒关联Execution继续"]
G --> H["经过Sequence Flow和Gateway"]
H --> I["创建下一Task、Job或结束实例"]
I --> J["刷新变量、Execution和History"]
J --> K["提交数据库事务"]任意阶段抛异常,整个命令通常回滚。因此“调用 complete 后仍能查到原 Task”不一定是引擎没执行,可能是后续网关或 Listener 失败导致事务正确回滚。
十二、两个人同时完成同一任务
即使前端按钮禁用,两个浏览器、重试网关或重复消息仍可能并发提交。
flowchart TD
A["请求A和请求B携带同一taskId"] --> B["两边查询时Task都存在"]
B --> C["A先完成并提交"]
C --> D["B完成时Task不存在或Revision冲突"]
D --> E["B转换为幂等成功或明确已处理"]业务选择:
- 同一个 requestId 重试:返回第一次审批结果,视为幂等成功。
- 不同用户竞争处理:后提交者返回“任务已由他人处理”。
- 同一用户不同决定:不能静默成功,要返回已处理结果和原决定。
不能在捕获异常后再次无条件调用 complete。
十三、变量作用域
13.1 流程/Execution变量
taskService.setVariable(taskId, "approved", Boolean.TRUE);通常进入关联 Execution 的可继承变量作用域,后续网关可读取。
13.2 Task Local变量
taskService.setVariableLocal(taskId, "draftComment", "待补充材料");局部变量属于当前 Task,任务完成后后续 Activity 未必能读取。适合临时 UI 状态的程度也要考虑历史配置和敏感数据,不应保存真正草稿附件。
13.3 完成时传变量
Map<String, Object> variables = new HashMap<>();
variables.put("approved", Boolean.TRUE);
variables.put("approvalLevel", "DIRECTOR");
taskService.complete(taskId, variables);推荐在一次引擎命令中提交网关需要的变量,避免先 setVariable 成功、随后 complete 失败却留下半成品变量。若二者在同一 Spring 事务也可能一起回滚,但单次 API 更清晰。
13.4 类型契约
variables.put("approved", Boolean.TRUE); // Boolean
variables.put("amount", new BigDecimal("99.90")); // BigDecimal
variables.put("result", "APPROVED"); // 稳定枚举字符串不要把所有值都转 String,也不要放 JPA Entity、InputStream、密码、附件或大 JSON。Java 序列化对象会产生类版本和反序列化安全风险。
十四、审批意见应该存在哪里
Activiti 可提供 Task Comment/Attachment 等能力,但商业系统通常仍需要独立审批记录表,用于:
- 稳定业务查询和报表。
- 保存幂等 requestId、操作者身份快照、操作类型和来源。
- 权限和脱敏控制。
- 长期审计、归档和法规保留。
- 不依赖引擎历史级别和清理策略。
示例:
CREATE TABLE approval_record (
id BIGINT PRIMARY KEY,
request_id VARCHAR(64) NOT NULL,
business_type VARCHAR(64) NOT NULL,
business_id VARCHAR(64) NOT NULL,
process_instance_id VARCHAR(128) NOT NULL,
process_definition_id VARCHAR(128) NOT NULL,
task_id VARCHAR(128) NOT NULL,
task_definition_key VARCHAR(128) NOT NULL,
operator_id VARCHAR(64) NOT NULL,
action VARCHAR(32) NOT NULL,
comment_text VARCHAR(1000),
created_at TIMESTAMP NOT NULL,
UNIQUE (request_id),
UNIQUE (task_id, action)
);UNIQUE(task_id, action) 只是示例。一个 Task 是否允许多次保存草稿、评论和最终动作,需要按业务模型设计。
十五、完整审批接口设计
Controller 只接收、校验和转换响应:
@RestController
@RequestMapping("/api/approval-tasks")
public class ApprovalTaskController {
private final ApprovalTaskApplicationService applicationService;
public ApprovalTaskController(
ApprovalTaskApplicationService applicationService) {
this.applicationService = applicationService;
}
@PostMapping("/{taskId}/decisions")
public ApprovalResultResponse decide(
@PathVariable String taskId,
@Valid @RequestBody ApprovalDecisionRequest request,
Authentication authentication) {
ApprovalResult result = applicationService.decide(
taskId,
authentication.getName(),
request.requestId(),
request.decision(),
request.comment());
return ApprovalResultResponse.from(result);
}
}请求对象:
public record ApprovalDecisionRequest(
@NotBlank String requestId,
@NotNull ApprovalDecision decision,
@Size(max = 1000) String comment) {
}
public enum ApprovalDecision {
APPROVE,
REJECT
}如果项目使用 JDK 8,不能使用 record,应改为普通 DTO 类。文档明确版本差异,避免把语言语法错误归因于 Activiti。
十六、完整Service Demo
@Service
public class ApprovalTaskApplicationService {
private final TaskService taskService;
private final RuntimeService runtimeService;
private final ApprovalRecordRepository approvalRecordRepository;
private final BusinessDocumentRepository documentRepository;
private final ApprovalPermissionService permissionService;
public ApprovalTaskApplicationService(
TaskService taskService,
RuntimeService runtimeService,
ApprovalRecordRepository approvalRecordRepository,
BusinessDocumentRepository documentRepository,
ApprovalPermissionService permissionService) {
this.taskService = taskService;
this.runtimeService = runtimeService;
this.approvalRecordRepository = approvalRecordRepository;
this.documentRepository = documentRepository;
this.permissionService = permissionService;
}
@Transactional
public ApprovalResult decide(
String taskId,
String operatorId,
String requestId,
ApprovalDecision decision,
String comment) {
ApprovalRecord existing = approvalRecordRepository
.findByRequestId(requestId)
.orElse(null);
if (existing != null) {
return ApprovalResult.from(existing);
}
Task task = taskService.createTaskQuery()
.taskId(taskId)
.active()
.singleResult();
if (task == null) {
ApprovalRecord completed = approvalRecordRepository
.findFinalActionByTaskId(taskId)
.orElseThrow(() ->
new TaskAlreadyProcessedException(taskId));
return ApprovalResult.from(completed);
}
if (task.getDelegationState() == DelegationState.PENDING) {
throw new IllegalStateException("委派任务需要先解决委派,不能直接最终完成");
}
ProcessInstance instance = runtimeService
.createProcessInstanceQuery()
.processInstanceId(task.getProcessInstanceId())
.singleResult();
if (instance == null) {
throw new IllegalStateException("任务存在但流程实例不存在");
}
String businessKey = instance.getBusinessKey();
BusinessDocument document = documentRepository
.findByIdForUpdate(businessKey)
.orElseThrow(() -> new IllegalStateException("业务单据不存在"));
permissionService.assertCanComplete(
operatorId,
task,
document);
boolean approved = decision == ApprovalDecision.APPROVE;
Map<String, Object> variables = new HashMap<>();
variables.put("approved", approved);
variables.put("lastApproverId", operatorId);
taskService.complete(taskId, variables);
ApprovalRecord record = ApprovalRecord.finalDecision(
requestId,
document.getBusinessType(),
document.getId(),
instance.getProcessInstanceId(),
instance.getProcessDefinitionId(),
task.getId(),
task.getTaskDefinitionKey(),
operatorId,
decision,
sanitizeComment(comment));
approvalRecordRepository.save(record);
document.applyDecision(task.getTaskDefinitionKey(), decision);
return ApprovalResult.from(record);
}
private String sanitizeComment(String comment) {
// 这里只表示需要长度、字符和敏感信息策略;不能只依赖前端过滤
return comment;
}
}16.1 为什么先查requestId
客户端超时重试时,相同 requestId 应返回第一次结果,避免重复 complete 和重复外部动作。
16.2 为什么Task不存在还要查审批记录
Task 完成后从运行时表移除。“查不到”可能是正常幂等重试,也可能是错误 taskId。审批记录可以区分两者。
16.3 为什么锁业务单
防止同一业务单的并行任务或其他业务接口同时修改状态。并行审批场景不能简单让每个 Task 都把业务状态改成最终 APPROVED,应由明确汇聚节点或状态聚合逻辑决定。
16.4 为什么先complete还是先保存记录需要讨论
在同一数据库和同一 Spring 事务中,二者通常一起提交/回滚。示例先 complete 后保存最终记录,若保存失败整个事务应回滚,Task 恢复。若数据库不同或事务未正确接入,则顺序无法提供原子性,需要 Outbox、补偿和对账。
十七、权限校验的完整层次
flowchart TD
A["用户已登录"] --> B["接口级权限:能否访问审批API"]
B --> C["任务级权限:assignee/candidate/owner"]
C --> D["数据权限:能否处理该业务单"]
D --> E["状态权限:当前节点和业务状态是否允许动作"]
E --> F["动作权限:通过、拒绝、转办、撤回是否允许"]
F --> G["并发校验:Task是否仍活动"]只校验 taskAssignee(userId) 不够:管理员代理、候选组、委派和业务部门权限都可能影响结果。也不能只查候选组,因为已被别人认领的任务不应再被候选人完成。
十八、已办为什么必须查历史
运行时 Task 完成后通常从 ACT_RU_TASK 移除,历史信息进入类似 ACT_HI_TASKINST 的表。因此:
待办 = Runtime Task
已办 = Historic Task + 业务审批记录示例:
List<HistoricTaskInstance> handled = historyService
.createHistoricTaskInstanceQuery()
.taskAssignee(userId)
.finished()
.orderByHistoricTaskInstanceEndTime()
.desc()
.listPage(offset, pageSize);注意:
- 候选人没有认领/办理的任务不应算他的已办。
- 委派中受托人
resolve后,最终 assignee/owner 历史语义要按版本验证。 - 自动跳过/删除的任务和正常完成任务要区分 deleteReason。
- 历史级别过低可能缺少变量或 Activity 细节。
- 历史清理后不能只依赖引擎表满足长期审计。
十九、怎样查完整审批轨迹
通常需要组合:
- HistoricProcessInstance:流程起止、definitionId、businessKey。
- HistoricActivityInstance:经过哪些 Activity。
- HistoricTaskInstance:人工任务的 assignee、起止时间和删除原因。
- HistoricVariableInstance/Detail:变量历史,受历史级别影响。
- 业务审批记录:决定、意见、requestId、操作人快照。
flowchart TD
A["businessKey查询业务单"] --> B["processInstanceId"]
B --> C["HistoricProcessInstance"]
B --> D["HistoricActivityInstance时间线"]
B --> E["HistoricTaskInstance人工轨迹"]
B --> F["业务审批记录和意见"]
C --> G["按开始时间和节点语义组装展示"]
D --> G
E --> G
F --> G并行 Activity 的时间区间会重叠,不能简单按开始时间推断严格先后。应展示分支和节点身份。
二十、Comment和AuthenticatedUser
部分 Activiti 版本使用 IdentityService 的 authenticated user 记录 Comment 操作者:
try {
identityService.setAuthenticatedUserId(operatorId);
taskService.addComment(taskId, processInstanceId, comment);
} finally {
identityService.setAuthenticatedUserId(null);
}为什么必须 finally 清理:相关实现可能使用 ThreadLocal。Web 线程池会复用线程,不清理可能让后续请求错误继承上一个用户。
即使使用引擎 Comment,仍建议保留业务审批记录,并对意见长度、XSS 展示、敏感词和数据权限做处理。
二十一、Task Listener生命周期边界
常见事件名称随版本略有差异,包括 create、assignment、complete、delete 等。
适合:
- 创建任务后生成技术审计或 Outbox 事件。
- 记录 assignee 变化。
- 计算小型、稳定候选关系。
不适合:
- 把完整审批业务隐藏在 Listener。
- 同步发送不可幂等短信/邮件。
- 在 complete Listener 再次调用 complete 当前 Task。
- 吞异常让流程继续但业务状态未更新。
Listener 失败往往会让创建/完成 Task 的事务回滚。排查“任务突然消失/没生成”必须查 Listener 日志和最内层异常。
二十二、并行任务怎样更新业务状态
法务和财务并行时:
法务完成 ≠ 整个合同审批完成
财务完成 ≠ 整个合同审批完成
二者汇聚后的业务动作才可能标记联合审核完成错误做法:每个 Task 完成都直接把合同改为 APPROVED。
正确思路:
- 每个任务写独立审批记录。
- 业务状态保持
JOINT_REVIEWING。 - 汇聚后由明确 Service Task/业务聚合操作更新为下一状态。
- 任一拒绝如何取消其他 Task 和回滚业务资源要提前建模。
二十三、跨数据库和消息一致性
若 Activiti 表和业务表在同一 DataSource、同一事务管理器中,complete 和业务更新可以参与本地事务。
如果跨数据库、调用外部系统或发 MQ:
flowchart TD
A["本地事务完成Task并更新业务表"] --> B["同事务写Outbox事件"]
B --> C["提交成功"]
C --> D["异步发布器发送消息"]
D --> E["消费者按eventId幂等处理"]
E --> F["对账和失败重试"]不要在事务中先发消息再提交数据库,否则消息消费者可能看到尚未提交或最终回滚的状态。详细见:业务表与流程一致性。
二十四、任务投影表什么时候需要
直接查询 Activiti Runtime 表适合规模不大、查询维度简单的系统。出现以下需求时可考虑独立投影:
- 跨多流程、多业务类型统一搜索。
- 按业务金额、部门、风险等级复杂排序筛选。
- 数百万历史已办和复杂统计。
- 外部组织权限和代理关系频繁变化。
- 需要 Elasticsearch 全文检索。
投影表示例字段:
taskId, processInstanceId, businessKey, taskDefinitionKey,
assignee, candidateRole, tenantId, businessType,
title, applicantId, departmentId, priority,
createdAt, dueAt, status, sourceVersion投影是派生数据,不是流程事实唯一来源。必须设计事件重放、幂等 upsert、延迟监控和周期对账。
二十五、故障一:用户查不到待办
flowchart TD
A["确认userId、tenantId和查询条件"] --> B["按taskId/processInstanceId直接查Task"]
B --> C{"Task是否存在"}
C -->|"否"| D["查Execution、Job、Listener异常和历史"]
C -->|"是"| E["检查assignee、candidate Identity Link"]
E --> F["检查用户组解析和组织缓存"]
F --> G["检查是否已被别人claim或delegate"]
G --> H["检查业务数据权限和投影延迟"]常见原因:
- Task 尚未创建,上一个异步 Job 失败。
- Task 已完成,只能从历史查询。
- 已被其他人认领。
- candidate group 存在,但外部 IAM 组关系未接入引擎查询。
- tenantId/流程 key/时间条件错误。
- 待办投影消费延迟或丢事件。
- Listener 创建 Task 时异常导致事务回滚。
二十六、故障二:无权限用户完成了任务
检查:
- 接口是否只按 taskId 调
complete。 - 是否信任前端传 operatorId。
- claim/complete 前是否校验 assignee/candidate和数据权限。
- 管理员代理是否有审计和范围限制。
- Task 创建时动态 assignee 是否被恶意变量影响。
- 发布模型是否允许任意表达式调用权限 Bean。
修复不是只隐藏按钮,而是给 Service 增加服务器端授权和回归测试,并审计历史受影响任务。
二十七、故障三:任务完成接口报错,刷新后仍在
先看最内层异常:
- 网关变量不存在/类型错误。
- 下一 Task Listener 创建失败。
- Service Task Delegate 超时或抛异常。
- 乐观锁冲突。
- 业务审批记录唯一约束冲突。
- 业务表更新失败。
如果都在同一事务,原 Task 仍在通常是正确回滚。不要手工删 Task,要修复根因后用同一幂等请求安全重试。
二十八、故障四:同一任务出现两条审批记录
检查:
- 是否有 requestId 唯一约束。
- 两个请求是否使用不同 requestId 并同时通过前置查询。
- 审批记录是否在
complete失败后单独提交。 - 消费者是否至少一次投递但没有幂等。
- Listener 和业务 Service 是否各写了一次记录。
数据库唯一约束是并发下最后防线,不能只依赖“先查询不存在再插入”。
二十九、故障五:已办查不到或办理人不对
检查:
- 是否误查 Runtime Task。
- History Level 是否记录 Task/Identity/变量。
- 任务是否 claim 后才完成,assignee 是否真正设置。
- 委派场景展示受托人、owner还是最终完成人的业务定义。
- 历史是否已清理/归档。
- 业务审批记录是否因跨事务失败缺失。
- 查询是否错误限制 definitionVersion 或 tenantId。
三十、故障六:待办列表越来越慢
证据顺序:
- 捕获当前 Activiti 版本生成的真实 SQL。
- 使用数据库 EXPLAIN 查看扫描行数、Join 顺序和排序临时表。
- 统计 Task、Identity Link、Variable、History 表规模和数据倾斜。
- 检查候选组数量、IN 列表、变量过滤和深分页。
- 检查 N+1 业务查询和序列化时间。
- 决定索引、查询改写、归档或投影,而不是先盲目加缓存。
缓存待办有强一致性风险:Task 已被完成但缓存仍展示时,完成接口仍必须以引擎当前状态为准。
三十一、生产监控
建议监控:
| 指标 | 维度/说明 |
|---|---|
| 活跃Task数量 | processKey、definitionVersion、taskDefinitionKey、tenant |
| Task创建/完成速率 | 发现流程停滞 |
| Task停留时长P50/P95/P99 | 发现SLA风险 |
| claim冲突数 | 发现候选池竞争或重复点击 |
| complete失败分类 | 权限、已处理、乐观锁、表达式、Delegate、数据库 |
| 最老Task年龄 | 发现僵尸待办 |
| 委派PENDING数量和年龄 | 发现委派未解决 |
| 待办投影延迟/对账差异 | 发现派生数据不一致 |
不要用 taskId、processInstanceId、businessKey、userId 作为指标 Label。高基数身份放结构化日志和 Trace。
三十二、日志应记录什么
推荐字段:
requestId
operatorId(按安全策略脱敏)
taskId
taskDefinitionKey
processInstanceId
processDefinitionId
businessType
businessId(必要时脱敏)
action
result
errorCode
durationMs不要记录密码、Token、完整身份证、病历详情、完整流程变量和未脱敏审批意见。
三十三、常见误区
| 误区 | 准确结论 | 后果 |
|---|---|---|
| Task就是业务单 | 一张业务单可产生多个Task | 状态和查询模型混乱 |
| assignee为空谁都能办 | 可能是候选池任务,需要Identity Link和权限 | 越权审批 |
| claim只是一条普通更新 | 存在多人并发竞争和Revision控制 | 把正常冲突当500 |
| 转办等于委派 | 委派有owner和resolve语义 | 任务永久交错或无法完成 |
| complete只是删除任务 | 它推进Execution并可能创建网关/Task/Job | 不理解后续异常为何回滚 |
| Task查不到就是错误 | 完成后Runtime Task正常消失 | 重试被误判为系统故障 |
| 已办继续查ACT_RU_TASK | 已办来自History和业务记录 | 流程结束后记录消失 |
| 前端隐藏按钮就是权限 | 必须服务端校验任务和数据权限 | 可伪造请求越权 |
| 先查再插就能防重复 | 并发下两个请求可同时查不到 | 重复审批记录 |
| 流程变量都用String | 网关类型转换不可靠 | 分支走错/异常 |
| 给任务表随便加索引 | 会影响写入和升级 | 新慢SQL或升级失败 |
三十四、面试标准回答
Activiti任务生命周期是什么
Execution进入User Task后创建运行时Task和候选Identity Link。任务可以直接分配给assignee,也可以进入候选池后被claim;转办会更换assignee,委派会保存owner并进入PENDING,受托人resolve后通常返回owner。complete会结束运行时Task、推进Execution、计算网关并创建下一任务或结束实例,已办从历史Task和业务审批记录查询。
assignee和candidate有什么区别
assignee是当前明确办理人;candidate user/group只是可认领候选关系。候选任务被某人claim后通常变成他的个人待办,其他候选人不应再完成。候选关系也不能替代业务数据权限,服务端必须校验登录用户、任务关系、业务范围和当前状态。
两个人同时认领或完成任务会怎样
两个请求可能都先读到相同Task,但引擎通过任务状态、Revision和乐观锁使最终只有一个状态变更成功。业务层要把冲突区分为同requestId幂等重试和不同用户竞争;数据库审批记录还要有requestId/task动作唯一约束,不能只靠前置查询。
转办和委派有什么区别
转办通常永久把assignee改为另一个人,原处理人不再收回;委派是临时让受托人处理,引擎保存owner并将delegationState设为PENDING,受托人resolve后任务通常回到owner,再由owner最终complete。具体状态要以项目Activiti版本测试。
为什么complete报错后任务还在
complete不仅删除Task,还会写变量、执行Listener、推进Execution、计算网关、创建下一任务和写历史,这些通常属于同一数据库事务。后续表达式、Delegate、业务更新或乐观锁失败会让事务回滚,所以原Task仍在是正确结果。应保留异常证据、修复根因并用幂等请求重试,不能直接删表。
待办和已办怎样查询
待办查运行时Task,区分taskAssignee、candidate user/group和委派;已办查HistoricTaskInstance并结合业务审批记录。列表展示需要通过processInstanceId/businessKey批量关联业务表,避免N+1。高数据量复杂筛选可以建设派生投影,但必须有幂等、延迟监控和对账。
三十五、学习实验与验收
- 创建候选组Task,查询 Identity Link,使用两个用户并发 claim,记录一个成功一个冲突。
- 取消认领后验证任务回到候选池。
- 分别实现转办和委派,观察 assignee、owner、delegationState。
- 受托人调用 resolve,再由 owner complete,验证历史操作者语义。
- 两个线程同时 complete 同一taskId,验证乐观锁和业务幂等记录。
- 故意让后续网关表达式失败,证明原Task和业务更新回滚。
- 设置全局和Task Local变量,完成后验证后续节点可见性。
- 查询运行时待办和历史已办,说明数据为什么迁移。
- 分页查询100万级模拟数据,分析真实SQL和执行计划。
- 建设简化任务投影,模拟消息重复和丢失并通过对账修复。
验收时必须能回答:
- Task、Execution、ProcessInstance和业务审批记录是什么关系?
- candidate group怎样解析到企业用户?
- claim并发为什么只有一个成功?
- 转办、委派、resolve和complete分别改变什么?
- complete为什么可能因下一节点错误而整体回滚?
- 相同requestId重试和不同用户竞争怎样返回?
- Task Local变量为什么不能可靠供后续网关读取?
- Runtime待办和Historic已办为什么来自不同表?
- 大待办列表为什么慢,从哪里取得真实SQL证据?
- 投影表为什么必须对账而不能成为未经验证的事实源?
