Activiti业务表、流程表与分布式一致性
工作流系统最难的部分通常不是画 BPMN,而是让业务事实、流程运行状态、审批记录、消息和外部系统在各种失败下最终仍能互相解释。
典型事故包括:流程实例已经启动,业务单仍是草稿;Task 已完成,业务状态没有变化;业务表显示已发布,真正的发布接口却失败;消息已经发送,数据库事务随后回滚;用户超时重试后启动了两个流程;管理员直接改 ACT_RU_TASK,导致 Execution、History 和 Job 关系损坏。
核心原则:
Activiti 是流程协调事实源,业务表是领域事实源。能放进同一个本地数据库事务的操作使用本地事务保证原子性;跨数据库、MQ 和外部接口无法只靠
@Transactional变成一个原子操作,必须使用可持久化意图、幂等执行、有限重试、补偿和周期对账建立最终一致性。
学习目标
完成本页后,你应该能够:
- 区分业务事实、流程运行状态、流程历史、审批记录和派生投影。
- 解释 businessKey、processInstanceId、processDefinitionId 和业务主键的双向关联。
- 设计同库发起流程和完成任务的本地事务。
- 证明 Activiti 与业务 Repository 是否真正使用同一个事务管理器。
- 解释 Spring
@Transactional自调用、异常吞掉、回滚规则和异步边界为什么会破坏预期。 - 列出跨数据库双写的所有主要失败窗口。
- 正确选择本地事务、XA/JTA、Outbox、事务消息、Saga/补偿和对账。
- 解释 Outbox 能保证什么,以及为什么不能凭空原子化两个独立数据库。
- 为提交、认领、完成任务、Listener、MQ 消费和外部调用设计幂等键。
- 设计业务状态机、条件更新、唯一约束和并发控制。
- 建立流程状态与业务状态对账、自动修复和人工处置 Runbook。
- 说明为什么不能直接修改 Activiti 运行时表修流程。
一、先确定每类数据的事实归属
flowchart TD
A["业务表:合同、报销、资产等领域事实"] --> F["关联与对账层"]
B["Activiti Runtime:Execution、Task、Variable、Job"] --> F
C["Activiti History:实例、Activity、Task历史"] --> F
D["业务审批记录:动作、意见、幂等请求、操作者快照"] --> F
E["Outbox/Inbox:待发布和已消费事件"] --> F
F --> G["待办投影、搜索索引、报表和外部系统"]| 数据 | 权威来源 | 原因 |
|---|---|---|
| 合同金额、资产内容、申请人 | 业务表 | 属于领域事实和权限边界 |
| 当前Execution和User Task | Activiti Runtime | 由引擎执行语义维护 |
| 流程经过哪些Activity | Activiti History | 由引擎历史级别和执行轨迹产生 |
| 审批意见、requestId、客户端来源 | 业务审批记录 | 需要稳定业务审计和幂等 |
| “是否真正发布到目录” | 发布业务表/目标系统回执 | End Event不等于外部发布一定成功 |
| 我的待办搜索文档 | 派生投影 | 可重建,不是未经验证的事实源 |
为什么不能只用Activiti表
Activiti 知道 Task 和 Activity,不知道合同是否合法、库存是否扣减、数据资产是否真正对外发布。引擎历史级别、清理和版本升级也不应决定业务数据生命周期。
为什么不能只用业务状态字段
一个 status=APPROVING 无法表达并行的法务、财务任务、Timer Job、候选组、委派和流程历史。如果全部自己实现,就相当于重新写一个不完整的流程引擎。
二、不要把一个status承载所有生命周期
“审批通过”和“业务发布成功”经常不是同一件事。推荐拆分状态维度:
approval_status: DRAFT / SUBMITTING / APPROVING / APPROVED / REJECTED / CANCELED
publish_status: NOT_STARTED / PENDING / PUBLISHING / PUBLISHED / FAILED如果只使用一个 status=PUBLISHED,流程审批结束后外部目录发布失败时就无法准确表达:到底审批失败、流程失败,还是发布执行失败。
状态转换必须由受控业务动作完成:
flowchart TD
A["DRAFT"] -->|"提交请求"| B["SUBMITTING"]
B -->|"流程实例建立"| C["APPROVING"]
C -->|"审批通过"| D["APPROVED"]
C -->|"审批拒绝"| E["REJECTED"]
C -->|"允许撤回"| F["CANCELED"]
D -->|"创建发布意图"| G["PENDING"]
G -->|"执行发布"| H["PUBLISHING"]
H -->|"目标系统确认"| I["PUBLISHED"]
H -->|"超过重试或永久错误"| J["FAILED"]三、推荐业务表结构
下面是教学结构,字段类型、索引和审计要求应按数据库和项目规范调整:
CREATE TABLE asset_publish_apply (
id BIGINT PRIMARY KEY,
asset_id BIGINT NOT NULL,
applicant_id VARCHAR(64) NOT NULL,
approval_status VARCHAR(32) NOT NULL,
publish_status VARCHAR(32) NOT NULL,
process_instance_id VARCHAR(128),
process_definition_id VARCHAR(128),
process_definition_version INT,
current_task_summary VARCHAR(500),
row_version BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE (process_instance_id)
);3.1 为什么保存processDefinitionId
同一个 key 可能有多个版本。线上排查时仅有 processInstanceId 可以再查 definitionId,但业务表冗余 definitionId/version 能更快定位,并为历史归档后保留版本证据。
3.2 currentTaskSummary为什么只是派生字段
并行流程可能同时有多个 Task,单个 current_node_name 会丢信息。该字段只能用于列表展示缓存,权威当前任务仍来自 Activiti Runtime 或可靠待办投影。
3.3 rowVersion做什么
用于业务表乐观并发控制。它不替代 Activiti Task 自己的 Revision,也不替代 requestId 唯一约束;不同对象各自需要并发保护。
四、四个关联标识
flowchart TD
A["业务主键:applyId等于1001"] --> B["businessKey:流程反查业务"]
C["processInstanceId:一次运行实例"] --> D["业务表回写实例关系"]
E["processDefinitionId:本次使用的具体版本"] --> D
F["requestId:一次用户/系统命令的幂等身份"] --> G["审批记录或命令表唯一约束"]| 标识 | 解决的问题 | 能否互相替代 |
|---|---|---|
| 业务主键 | 领域对象身份 | 不能替代流程实例ID |
| businessKey | 从流程实例找到业务单 | 通常取业务主键或稳定组合键 |
| processInstanceId | 从业务单精确查某次流程运行 | 一个业务可能有重开/重提多次流程,要明确模型 |
| processDefinitionId | 确认该实例执行哪版模型 | 不能只看process key |
| requestId | 识别同一次提交/审批重试 | 不能使用随机新ID处理每次重试 |
如果业务允许驳回后重新发起一个新流程实例,不能只在业务表保留最后一个 processInstanceId。应增加流程关联历史表:
CREATE TABLE business_process_binding (
id BIGINT PRIMARY KEY,
business_type VARCHAR(64) NOT NULL,
business_id VARCHAR(64) NOT NULL,
process_type VARCHAR(64) NOT NULL,
process_instance_id VARCHAR(128) NOT NULL,
process_definition_id VARCHAR(128) NOT NULL,
lifecycle_no INT NOT NULL,
binding_status VARCHAR(32) NOT NULL,
created_at TIMESTAMP NOT NULL,
ended_at TIMESTAMP,
UNIQUE (process_instance_id),
UNIQUE (business_type, business_id, process_type, lifecycle_no)
);五、先判断能不能使用同一个本地事务
满足以下条件时,业务表更新和 Activiti 命令可以使用单库本地事务:
- Activiti 表和业务表位于同一个数据库/同一事务资源。
- Activiti Spring 集成使用与业务 Repository 相同的
DataSource。 SpringProcessEngineConfiguration使用同一个PlatformTransactionManager。@Transactional实际经过 Spring AOP 代理。- 异常没有被吞掉,回滚规则符合预期。
- 流程没有在异步 Job 的另一个事务中才完成关键业务动作。
flowchart TD
A["Spring事务拦截器开启数据库事务"] --> B["业务Repository更新业务表"]
B --> C["RuntimeService/TaskService执行引擎Command"]
C --> D["Activiti使用同一连接/事务写ACT表"]
D --> E["业务审批记录和Outbox写入"]
E --> F{"方法是否正常结束"}
F -->|"是"| G["统一Commit"]
F -->|"异常触发回滚"| H["统一Rollback"]“代码上写了 @Transactional”不是证据。必须通过配置、事务日志和故障注入证明两边确实同提交、同回滚。
六、怎样证明使用的是同一事务管理器
6.1 检查配置
确认 ProcessEngine 配置中的 DataSource 和 TransactionManager 与业务持久层一致。多数据源项目应显式指定:
@Transactional(transactionManager = "workflowTransactionManager")
public void submit(...) {
// 业务表和Activiti表必须都受这个事务资源管理
}方法名称只是示例,不能为了看起来明确而指向只管理 Activiti 数据源的事务管理器。真正要看 Bean 引用关系。
6.2 开启事务调试日志
在测试环境观察:
- 哪个事务管理器创建事务。
- 业务 SQL 与 ACT 表 SQL 是否加入相同事务。
- 使用的连接是否属于相同 DataSource。
- 异常后是否统一执行 rollback。
不要在生产长期开启包含参数的 SQL Debug,可能泄露敏感数据并产生大量日志。
6.3 故障注入验证
@Transactional
public void transactionProbe(Long applyId) {
AssetPublishApply apply = applyRepository.findByIdForUpdate(applyId)
.orElseThrow();
ProcessInstance instance = runtimeService.startProcessInstanceByKey(
"asset_publish",
applyId.toString());
apply.markApproving(instance.getId());
applyRepository.save(apply);
throw new IllegalStateException("故障注入:验证业务表和ACT表一起回滚");
}测试后验证:
- 业务状态没有变为 APPROVING。
- 根据 businessKey 查不到新的运行实例。
- 没有遗留第一 Task、Variable 和 History 半成品。
测试必须在隔离环境执行,并使用唯一 businessKey,避免把旧数据误判为未回滚。
七、Spring事务最常见的失效方式
7.1 同类自调用绕过代理
public void outer(Long id) {
this.submitInTransaction(id); // 常规代理模式下可能绕过事务拦截
}
@Transactional
public void submitInTransaction(Long id) {
// ...
}将事务方法放到独立 Spring Bean,或让外部调用经过代理。不要通过注入自己等技巧掩盖设计问题。
7.2 捕获异常后不抛出
@Transactional
public void submit(Long id) {
try {
runtimeService.startProcessInstanceByKey("asset_publish", id.toString());
} catch (Exception ex) {
log.error("启动失败", ex);
// 错误:方法正常返回,前面的业务更新可能提交
}
}如果业务要求整体失败,应抛出异常或显式标记 rollback-only,并让接口返回失败。
7.3 Checked Exception默认不一定回滚
Spring 默认通常对 RuntimeException/Error 回滚,Checked Exception 的默认规则不同。需要根据项目规范:
@Transactional(rollbackFor = Exception.class)
public void submit(...) throws Exception {
// ...
}不要机械给所有方法加 rollbackFor=Throwable.class;要明确哪些异常是业务可接受结果,哪些必须回滚。
7.4 事务方法不是public或对象不是Spring Bean
常规代理模式下,private 方法、新手动 new 出来的对象不会获得预期事务代理。
7.5 使用了错误的事务管理器
多 DataSource 中,业务 Repository 在 businessTransactionManager,Activiti 在 workflowTransactionManager,一个普通本地事务不能自动覆盖两个独立连接。
7.6 异步方法是新线程、新事务
@Async、MQ Consumer 和 Activiti Job Executor 不继承原请求线程的本地事务。外层提交成功不等于异步操作也成功。
7.7 在事务内调用慢外部接口
数据库锁长期占用,远程超时后结果不确定:调用方认为失败,但外部系统可能已成功。优先本地保存意图,提交后异步调用。
八、同库提交流程的完整实现
8.1 幂等命令表
CREATE TABLE workflow_command (
request_id VARCHAR(64) PRIMARY KEY,
command_type VARCHAR(32) NOT NULL,
business_type VARCHAR(64) NOT NULL,
business_id VARCHAR(64) NOT NULL,
command_status VARCHAR(32) NOT NULL,
process_instance_id VARCHAR(128),
result_code VARCHAR(64),
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);8.2 Java Demo
@Service
public class AssetPublishWorkflowService {
private final AssetPublishApplyRepository applyRepository;
private final WorkflowCommandRepository commandRepository;
private final BusinessProcessBindingRepository bindingRepository;
private final RuntimeService runtimeService;
public AssetPublishWorkflowService(
AssetPublishApplyRepository applyRepository,
WorkflowCommandRepository commandRepository,
BusinessProcessBindingRepository bindingRepository,
RuntimeService runtimeService) {
this.applyRepository = applyRepository;
this.commandRepository = commandRepository;
this.bindingRepository = bindingRepository;
this.runtimeService = runtimeService;
}
@Transactional
public SubmitResult submit(
Long applyId,
String operatorId,
String requestId) {
WorkflowCommand existing = commandRepository
.findById(requestId)
.orElse(null);
if (existing != null) {
return SubmitResult.from(existing);
}
AssetPublishApply apply = applyRepository
.findByIdForUpdate(applyId)
.orElseThrow(() -> new IllegalArgumentException("申请不存在"));
if (!apply.canSubmit(operatorId)) {
throw new IllegalStateException("申请状态或权限不允许提交");
}
WorkflowCommand command = WorkflowCommand.processing(
requestId,
"SUBMIT",
"ASSET_PUBLISH",
applyId.toString());
commandRepository.save(command);
Map<String, Object> variables = new HashMap<>();
variables.put("applicantId", operatorId);
variables.put("sensitive", apply.isSensitive());
variables.put("riskLevel", apply.getRiskLevel().name());
variables.put("ruleVersion", apply.getRuleVersion());
ProcessInstance instance = runtimeService.startProcessInstanceByKey(
"asset_publish",
applyId.toString(),
variables);
apply.markApproving(
instance.getProcessInstanceId(),
instance.getProcessDefinitionId());
bindingRepository.save(BusinessProcessBinding.active(
"ASSET_PUBLISH",
applyId.toString(),
"APPROVAL",
instance.getProcessInstanceId(),
instance.getProcessDefinitionId(),
apply.nextLifecycleNo()));
command.markSucceeded(instance.getProcessInstanceId());
return SubmitResult.from(command);
}
}8.3 并发提交怎样被挡住
需要多层保护:
- requestId 主键:相同客户端重试返回相同结果。
- 业务行锁或状态条件更新:不同 requestId 不能同时从 DRAFT 提交。
- Binding 唯一约束:同一业务生命周期只绑定一个实例。
- businessKey 对账:发现意外重复实例。
只做 if (status == DRAFT) 的内存判断不够,两个事务都可能在更新前读到 DRAFT。
九、业务状态条件更新
不使用悲观锁时,可以使用 CAS:
UPDATE asset_publish_apply
SET approval_status = 'SUBMITTING',
row_version = row_version + 1,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
AND approval_status = 'DRAFT'
AND row_version = ?;只有更新行数为 1 的请求获得提交权。更新行数为 0 可能表示:
- 已被其他请求提交。
- 业务状态不允许。
- rowVersion 已变化。
必须重新读取并返回准确业务结果,不能把所有 0 行更新都当数据库故障。
十、同库完成任务的一致性
flowchart TD
A["接收taskId、requestId和审批决定"] --> B["按requestId查幂等记录"]
B --> C["加载Task和流程实例"]
C --> D["锁定业务单并校验状态/权限"]
D --> E["调用taskService.complete并传变量"]
E --> F["写审批记录"]
F --> G["更新业务状态或Outbox意图"]
G --> H["同一事务统一提交"]完整任务服务见:任务生命周期、待办已办与并发审批。
为什么不能假设只有一个nextTask
并行网关和多实例会产生多个 Task:
List<Task> nextTasks = taskService.createTaskQuery()
.processInstanceId(processInstanceId)
.active()
.list();业务表的 currentTaskSummary 是派生展示,不应通过 singleResult() 假设只有一个任务。
流程结束后业务一定通过吗
不一定。流程可能从拒绝 End、取消 End、异常终止 End 结束。最终业务结果应来自明确流程变量、结束事件语义或业务动作,而不是仅判断 Runtime ProcessInstance 已不存在。
十一、审批完成与真正发布要分开
审批通过后调用外部目录服务发布资产。如果在 taskService.complete 的数据库事务中同步调用:
目录发布成功 → 本地事务回滚:外部已发布,本地仍审批中
目录请求超时 → 外部实际成功:本地无法判断是否重试
本地持锁等待目录服务:接口变慢、锁竞争扩大推荐同事务写 Outbox:
flowchart TD
A["完成最终审批Task"] --> B["业务approvalStatus改为APPROVED"]
B --> C["publishStatus改为PENDING"]
C --> D["同事务写ASSET_PUBLISH_REQUESTED Outbox"]
D --> E["提交本地事务"]
E --> F["异步发布器调用目录服务"]
F --> G["按eventId幂等"]
G --> H["回写PUBLISHED或FAILED"]十二、Outbox原理
Outbox 解决的是“数据库状态与待发送事件”在同一个数据库事务中的原子记录问题。
CREATE TABLE outbox_event (
event_id VARCHAR(64) PRIMARY KEY,
aggregate_type VARCHAR(64) NOT NULL,
aggregate_id VARCHAR(64) NOT NULL,
event_type VARCHAR(128) NOT NULL,
payload_json TEXT NOT NULL,
event_status VARCHAR(32) NOT NULL,
retry_count INT NOT NULL DEFAULT 0,
next_retry_at TIMESTAMP,
created_at TIMESTAMP NOT NULL,
published_at TIMESTAMP,
last_error_code VARCHAR(128)
);同一事务:
@Transactional
public void markApprovedAndCreatePublishEvent(
AssetPublishApply apply,
String processInstanceId) {
apply.markApprovedAndPendingPublish();
OutboxEvent event = OutboxEvent.pending(
UUID.randomUUID().toString(),
"ASSET_PUBLISH_APPLY",
apply.getId().toString(),
"ASSET_PUBLISH_REQUESTED",
buildSafePayload(apply, processInstanceId));
outboxEventRepository.save(event);
}如果事务回滚,业务状态和 Outbox 一起回滚;如果提交,两者一起存在。发布器即使宕机,恢复后仍可扫描未发送事件。
十三、Outbox发布器怎样保证并发安全
flowchart TD
A["多个Publisher实例扫描PENDING事件"] --> B["数据库锁/租约竞争批次"]
B --> C["一个实例获得eventId处理权"]
C --> D["发送MQ或调用目标系统"]
D --> E{"得到结果"}
E -->|"成功"| F["标记PUBLISHED"]
E -->|"可重试失败"| G["retryCount加一并设置nextRetryAt"]
E -->|"永久失败"| H["标记FAILED并告警"]数据库支持时可使用 FOR UPDATE SKIP LOCKED,但 MySQL、PostgreSQL、Oracle、SQL Server 的语法和锁行为不同,不能复制一条 SQL 当作全数据库通用方案。
另一种方式是短事务抢占租约:
PENDING → PROCESSING(owner, leaseUntil) → PUBLISHED/RETRY/FAILED远程调用不要一直持有数据库行锁。应先抢占、提交,再调用,超时后由 lease 恢复;这意味着同一事件可能重复发送,消费者必须幂等。
十四、为什么Outbox通常是至少一次而不是恰好一次
失败窗口:
消息发送成功
↓
Publisher在更新Outbox为PUBLISHED之前崩溃
↓
恢复后再次发送同一eventId无法仅靠发送端知道消息是否已经被目标系统处理。因此工程上使用:
至少一次投递 + 稳定eventId + 消费端Inbox/唯一约束 + 幂等业务更新
= 业务效果上的“有效一次”十五、消费者Inbox与幂等
CREATE TABLE inbox_message (
consumer_name VARCHAR(128) NOT NULL,
event_id VARCHAR(64) NOT NULL,
processed_at TIMESTAMP NOT NULL,
result_code VARCHAR(64),
PRIMARY KEY (consumer_name, event_id)
);消费者事务:
@Transactional
public void consume(AssetPublishRequested event) {
boolean first = inboxRepository.tryInsert(
"asset-directory-publisher",
event.eventId());
if (!first) {
return;
}
assetDirectoryRepository.publishIdempotently(
event.assetId(),
event.eventId());
}tryInsert 必须依靠数据库主键/唯一约束处理并发,不是先 select 再 insert。
如果目标是外部 HTTP 系统,调用时传 Idempotency-Key: eventId,并要求对方持久化幂等结果;如果对方不支持,只能通过业务唯一键、查询确认和补偿降低风险。
十六、afterCommit回调为什么不能替代Outbox
transactionSynchronizationManager.registerSynchronization(...afterCommit...);afterCommit 可以避免事务回滚时发消息,但仍有窗口:数据库已经提交,应用在执行回调前崩溃,事件永久丢失。它适合非关键缓存清理、进程内通知,不适合作为关键业务事件唯一可靠来源。
Outbox 将“需要发送”持久化,因此进程崩溃后仍可恢复。
十七、Outbox不能解决什么
Outbox 只有与要保护的状态写在同一个本地事务资源中,才能提供原子记录。
错误理解:
业务数据库写业务状态
Activiti独立数据库完成Task
业务数据库再写Outbox这三个操作仍跨两个数据库。业务库 Outbox 不能证明 Activiti 命令已经提交;Activiti 库中的 Outbox 也不能证明业务库更新成功。
跨库时必须重新设计流程,或者使用真正协调两个资源的分布式事务。
十八、Activiti库和业务库分开时有哪些失败窗口
假设先启动流程,再更新业务库:
flowchart TD
A["Activiti库启动流程成功"] --> B["应用在更新业务库前崩溃"]
B --> C["存在孤儿流程实例,业务单仍是DRAFT"]假设先更新业务库,再启动流程:
flowchart TD
A["业务库状态改为APPROVING"] --> B["Activiti启动失败或超时"]
B --> C["业务显示审批中,但没有流程实例"]即使两个调用都返回成功,第二个数据库提交时仍可能失败。简单调整顺序不能消除双写问题,只是选择哪种不一致更容易修复。
十九、跨库方案一:持久化流程命令并异步执行
业务库先把状态改为 SUBMITTING,同事务写 START_PROCESS 命令/Outbox:
flowchart TD
A["业务库本地事务"] --> B["DRAFT改为SUBMITTING"]
B --> C["写START_PROCESS命令"]
C --> D["提交"]
D --> E["流程启动Worker读取命令"]
E --> F["按businessKey幂等查/启动Activiti实例"]
F --> G["回写processInstanceId和APPROVING"]
G --> H["对账任务验证两边关系"]Worker怎样防重复启动
- 命令有稳定 commandId/requestId。
- 启动前按 businessKey + process type 查询已有实例。
- 启动后持久化 binding。
- Worker 重试遇到已有实例时回收其 ID,而不是再次启动。
- 对历史已结束实例和允许重提场景增加 lifecycleNo,不能仅按 businessKey 粗暴去重。
仍然存在的窗口
流程实例启动成功,但 Worker 在回写业务库前崩溃。恢复后必须根据 businessKey 查到已有实例并完成回写,不能重新启动。
二十、跨库完成Task的命令模式
用户审批时,业务库先持久化审批命令 PENDING,Worker 再完成 Activiti Task:
flowchart TD
A["用户提交审批requestId"] --> B["业务库校验并写PENDING命令"]
B --> C["立即返回PROCESSING或等待短轮询"]
C --> D["Worker读取命令并查Task"]
D --> E["校验Task仍可由该用户处理"]
E --> F["调用taskService.complete"]
F --> G["回写审批记录和命令SUCCEEDED"]
G --> H["对账补偿中间崩溃窗口"]如果 Task 完成后回写业务库前崩溃,重试时 Runtime Task 已不存在。Worker 应查 Historic Task 和业务幂等记录,确认是否是自己完成,再把命令收敛为 SUCCEEDED;不能直接判失败或重新完成下一 Task。
这种模式牺牲即时强一致响应,换取可恢复性。接口可以返回 202 Accepted 和 commandId,前端查询最终结果。
二十一、跨库方案二:XA/JTA两阶段提交
如果两个数据库都提供 XA 资源,可以由 JTA 协调两阶段提交:
flowchart TD
A["全局事务开始"] --> B["业务库执行更新"]
B --> C["Activiti库执行引擎命令"]
C --> D["Prepare阶段询问两个资源能否提交"]
D --> E{"是否全部Prepared"}
E -->|"是"| F["Commit两个资源"]
E -->|"否"| G["Rollback两个资源"]优点:对应用呈现较强原子性。
代价和边界:
- 数据库、驱动、连接池和框架必须正确支持 XA。
- Prepare 后资源可能持锁,协调器故障会出现 In-doubt 事务。
- 运维恢复、监控和故障演练复杂。
- 不能自动覆盖普通 HTTP、非 XA MQ 和外部系统。
- Activiti 版本与 JTA 集成必须做真实兼容测试。
审批系统通常不必为了所有流程强行引入 XA。若确实需要强原子且团队能运维,可以评估;否则优先可恢复命令和对账。
二十二、事务消息能否解决Activiti跨库一致性
事务消息通常解决“本地数据库事务与消息发送”的一致性,不自动把业务库和 Activiti 库合并为一个事务。
合理链路:
业务库提交SUBMITTING + 事务消息/Outbox
→ 消费者幂等启动Activiti
→ 结果事件回写业务库
→ 对账修复仍要处理:
- 消息重复。
- 消费成功但回执丢失。
- 流程启动成功但消费者崩溃。
- businessKey 去重。
- 消息乱序。
- 死信和人工处置。
二十三、Saga与补偿什么时候适用
跨多个系统的长流程无法长期持有数据库事务,可以把每一步设计成“本地提交 + 可补偿动作”:
flowchart TD
A["审批通过"] --> B["冻结预算"]
B --> C["创建合同编号"]
C --> D["推送外部归档"]
D --> E["完成"]
D -->|"失败"| F["撤销合同编号或标记作废"]
F --> G["解冻预算"]补偿不是把时间倒流:
- 已发送短信无法“撤回”,只能发送更正。
- 已被外部读取的数据可能无法彻底消除影响。
- 退款不等于原支付事务回滚。
- 每个补偿动作也可能失败,需要重试、告警和人工介入。
TCC 更适合能明确 Try/Confirm/Cancel 资源预留的短业务资源,不适合把数天人工审批锁在 Try 阶段。
二十四、分布式锁为什么不是一致性方案
Redis 锁可以降低并发提交概率,但不能原子提交两个数据库:
拿到锁
→ Activiti库提交成功
→ 业务库提交失败
→ 释放锁不一致仍然存在。锁还可能过期、主从切换、业务执行超时。真正需要的是持久化状态机、幂等、事务边界和对账,锁只能作为并发控制的一部分。
二十五、幂等必须覆盖哪些入口
| 入口 | 推荐幂等键 | 持久化位置 |
|---|---|---|
| 提交流程 | 客户端requestId + 业务生命周期 | workflow_command唯一约束 |
| 完成任务 | requestId;另加taskId最终动作约束 | approval_record/command |
| Listener/Delegate | processInstanceId + activityId + 业务动作版本 | operation_log唯一约束 |
| Outbox事件 | eventId | outbox_event主键 |
| MQ消费 | consumerName + eventId | inbox_message主键 |
| 外部HTTP调用 | Idempotency-Key | 下游系统幂等表/业务唯一键 |
| 补偿动作 | originalOperationId + compensateType | compensation_record |
“查不到再执行”不是并发安全幂等,必须让数据库唯一约束或目标系统原子条件更新成为最后防线。
二十六、Listener和Delegate的一致性边界
同步 Listener/Delegate 通常处于当前 Activiti 命令事务。如果它写同库业务表,可以参与本地事务;如果调用远程服务,则远程副作用不能随本地回滚。
推荐:
- 同步阶段只做本地、短时、确定、可回滚操作。
- 外部调用写 Outbox/命令后异步执行。
- Delegate 使用稳定 operationId 幂等。
- 异常分类为可重试、永久失败和业务拒绝。
- 不吞异常制造“流程继续、业务没做”的假成功。
异步 Service Task 由 Job Executor 在新事务中执行。前一个 User Task 完成提交,只代表 Job 已持久化,不代表外部动作已成功。
二十七、撤回、终止和删除的状态一致性
撤回
要校验发起人、当前 Task 是否已被处理、并行分支、外部副作用和业务状态。撤回成功后要记录业务动作和流程处置方式。
管理员终止
需要记录:操作者、原因、原节点、活动 Task/Job、业务状态、未完成外部动作和补偿计划。
删除流程实例
RuntimeService 删除实例通常会结束/删除运行状态并写 deleteReason,具体历史行为依版本和配置。它不是业务取消的完整实现;业务表、审批记录、Outbox 和外部系统仍需同步处置。
禁止直接删ACT_RU_TASK
Task 与 Execution、Identity Link、Variable、History、Job 有关联。删除一行 Task 不会让 Execution 正确越过 User Task,反而会产生“流程运行但无任务”的损坏状态。
二十八、对账为什么不是补丁而是正式能力
任何跨库/跨系统最终一致方案都应假设重试仍可能耗尽、代码可能有 bug、人工可能误操作。因此必须周期对账。
flowchart TD
A["扫描近期和长期未完成业务单"] --> B["根据binding查processInstanceId"]
B --> C["查询Runtime和History"]
C --> D["查询Task、Job、Outbox、Inbox和目标系统"]
D --> E["套用一致性规则分类差异"]
E --> F1["安全自动修复"]
E --> F2["重试命令或事件"]
E --> F3["创建人工工单"]
F1 --> G["记录修复前后证据"]
F2 --> G
F3 --> G二十九、建议对账规则
| 业务状态 | 流程证据 | 是否一致 | 处理方向 |
|---|---|---|---|
| DRAFT | 无流程实例 | 一致 | 无动作 |
| DRAFT | 有活动实例 | 不一致 | 查重复启动/回写失败,禁止盲删实例 |
| SUBMITTING | 无实例且命令可重试 | 暂时一致 | Worker重试,超过SLA告警 |
| SUBMITTING | 已有实例 | 可修复差异 | 回写binding和APPROVING |
| APPROVING | 有活动实例/Task/等待Job | 一致 | 监控停留时长 |
| APPROVING | 仅有已结束历史 | 不一致 | 根据结束结果回写业务状态 |
| APPROVED | 流程按通过路径结束 | 一致 | 检查publish状态 |
| APPROVED | 流程仍活动 | 可能不一致 | 查是否提前更新业务状态 |
| PUBLISHED | 目标系统无发布记录 | 严重不一致 | 核对幂等事件,重试或人工处置 |
| CANCELED | 仍有活动Task/Job | 不一致 | 检查取消命令和补偿 |
规则必须考虑流程等待消息、异步 Job 和并行 Task,不能用“没有 Task 就是流程结束”的错误判断。
三十、自动修复的安全边界
适合自动修复:
- 实例已存在、业务表缺 processInstanceId,但 businessKey 唯一且证据确定。
- Outbox 仍为 RETRY 且错误可重试。
- 投影表缺失,可从权威源重建。
- 历史显示明确通过结束,业务状态仍 APPROVING,且没有冲突业务动作。
不适合自动修复:
- 存在两个活动流程实例,无法判断哪个合法。
- 并行 Execution 损坏或 ACT 表被人工改过。
- 外部资金/合同/医疗发布副作用结果不确定。
- 业务表与流程历史给出相互冲突的人工决定。
- 需要任意节点跳转或伪造历史。
不确定时生成工单并冻结后续动作,不能为了“对账清零”自动覆盖事实。
三十一、人工修复平台应该记录什么
- incidentId、业务ID、processInstanceId、definitionId。
- 检测规则和原始差异。
- Runtime、History、Task、Job、业务表和外部系统证据快照。
- 修复方案、审批人和执行人分离。
- 使用的公开 API/补偿命令。
- 修复前后状态与校验结果。
- 是否需要客户通知、数据更正和复盘。
高风险修复使用双人复核,不允许管理员在数据库客户端直接执行无记录 UPDATE。
三十二、场景一:流程启动成功,业务表仍是DRAFT
证据采集
- 用业务 ID 作为 businessKey 查询 Runtime 和 History。
- 查 processDefinitionId、startTime、starter 和第一 Task。
- 查提交 requestId/command 状态。
- 查业务事务日志、异常和数据库提交记录。
- 查是否发生超时重试并生成多个实例。
修复决策
- 唯一活动实例且业务请求合法:受控回写 binding 和 APPROVING。
- 多个活动实例:暂停业务操作,按审批轨迹和副作用确定保留对象,其他实例走受审计终止。
- 业务请求本就非法:不能仅因为流程存在就改为 APPROVING,需要取消/补偿并复盘权限漏洞。
三十三、场景二:业务显示APPROVING,但没有流程
检查 Runtime 和 History,区分:
- 尚未启动,命令仍 PENDING/RETRY。
- 启动失败达到重试上限。
- 流程已经结束但业务未回写。
- processInstanceId 写错或连接了错误环境。
- 流程实例被管理员删除。
不要直接把状态改回 DRAFT。可能存在已发出的审批通知或外部副作用,应先根据 commandId/businessKey 确认事实。
三十四、场景三:Task完成,业务状态没变
取证顺序:
flowchart TD
A["查Runtime Task是否仍存在"] --> B["查Historic Task结束时间和deleteReason"]
B --> C["查下一Task/Execution/流程结束结果"]
C --> D["查审批记录requestId"]
D --> E["查业务事务是否回滚/使用错误事务管理器"]
E --> F["查Listener、异步Job和回写事件"]可能原因:
complete与业务更新跨两个事务,只有前者提交。- 业务状态更新条件 0 行,但代码没检查更新行数。
- Task 完成后进入异步 Job,最终业务动作尚未执行。
- Listener/消息回写失败并进入重试。
- 流程从拒绝路径结束,代码错误只用
pass参数推断最终结果。 - 并行中一个 Task 完成,不应该更新最终状态。
三十五、场景四:业务已PUBLISHED,目标系统没有数据
检查:
- 是否把“审批通过”误当“发布成功”。
- Outbox 是否创建、状态是什么。
- Publisher 是否拿到租约、发送次数和错误码。
- 目标系统是否按 eventId 幂等处理。
- 消息是否在死信队列。
- 目标系统成功但回执丢失,能否按业务键查询确认。
如果目标结果不确定,优先查询目标系统,不要无条件重复创建。
三十六、场景五:同一业务有两个流程实例
根因:
- 两个不同 requestId 并发通过 DRAFT 校验。
- 只在应用内加锁,多实例部署后失效。
- Worker 启动成功后回写失败,重试又启动一次。
- businessKey 去重没有 lifecycle 维度或根本未查询。
- 用户双击/网关重试。
治理:
- requestId 主键。
- 业务状态 CAS/行锁。
- binding 唯一约束。
- Worker 启动前后按 businessKey 对账。
- 重复实例告警。
不要自动保留“最新实例”并删除旧实例,旧实例可能已经产生合法审批和外部副作用。
三十七、场景六:Outbox一直堆积
查看 Lag 分布,而不是只看总数:
- 按 eventType、partition/key、目标系统、错误码分组。
- 最老 PENDING/PROCESSING 年龄。
- retryCount 分布。
- Publisher吞吐与新增速率。
- 是否少量毒消息阻塞整个批次。
- lease 是否因进程崩溃未恢复。
- 目标限流、超时和连接池。
扩容 Publisher 短暂有效后又堆积,常见原因是入口速率持续高于稳定处理能力、目标系统限流、热点业务键串行、数据库扫描/锁争用、毒消息重试占满线程或下游响应变慢。扩容只提高某一层容量,不会修复最窄瓶颈。
三十八、数据修复为什么不能直接改ACT表
一次 Task/Execution 状态可能涉及:
ACT_RU_TASKACT_RU_EXECUTIONACT_RU_IDENTITYLINKACT_RU_VARIABLEACT_RU_JOB及版本相关Job表ACT_HI_TASKINSTACT_HI_ACTINSTACT_HI_VARINST- 引擎进程内 Deployment/Entity Cache
只修改其中一张表不会执行引擎生命周期、Listener、历史和乐观锁逻辑。优先使用公开 API、补偿流程或受控迁移工具。
如果最终只能数据库修复,应:停相关实例流量、完整备份、在同版本副本库演练、由引擎专家评审、记录每条 SQL、修复所有关联、清理/重启缓存、执行一致性验证,并保留审计。它属于最后手段,不是普通 Runbook 第一项。
三十九、监控和告警
| 指标 | 用途 |
|---|---|
| SUBMITTING最老年龄和数量 | 发现跨库启动命令卡住 |
| APPROVING但无Runtime实例数量 | 发现业务/流程差异 |
| 有实例但无binding数量 | 发现回写失败 |
| 重复businessKey活动实例数 | 发现幂等失效 |
| Outbox PENDING/RETRY最老年龄 | 发现事件发布积压 |
| Inbox重复率 | 评估上游重复投递和崩溃窗口 |
| 命令成功/失败/重试/永久失败 | 发现流程Worker故障 |
| 对账差异按规则分类 | 衡量一致性健康度 |
| 自动修复成功率和人工积压 | 评估恢复能力 |
| 补偿失败与最老年龄 | 防止长期半完成业务 |
业务 ID、processInstanceId、eventId 等高基数值放日志和 Trace,不放 Prometheus Label。
四十、事务与一致性测试矩阵
| 故障注入点 | 预期结果 |
|---|---|
| 启动流程后抛RuntimeException | 同库时业务表和ACT表都回滚 |
| 业务状态更新后抛Checked Exception | 按rollback规则验证是否回滚 |
| Outbox写入前失败 | 业务状态和Outbox一起回滚 |
| 数据库提交后Publisher启动前宕机 | Outbox恢复后仍能发送 |
| MQ发送成功、标记PUBLISHED前宕机 | 重复发送,消费者Inbox去重 |
| 流程启动成功、binding回写前跨库Worker宕机 | 重试按businessKey找回实例,不重复启动 |
| complete成功、业务命令回写前Worker宕机 | 根据History和requestId收敛命令 |
| 两个请求同时提交同一业务 | 只有一个状态CAS/binding成功 |
| 两个用户同时完成同一Task | 只有一个引擎命令成功,审批记录不重复 |
| 目标系统超时但实际成功 | 按幂等键查询确认,不盲目重复创建 |
没有故障注入的“一致性设计”只是推测。
四十一、常见误区
| 误区 | 正确结论 | 后果 |
|---|---|---|
| 加了@Transactional就一定同事务 | 必须同事务资源、同管理器并经过代理 | 一边提交一边回滚 |
| 调整双写顺序可消除跨库问题 | 只能改变失败形态,不能消除窗口 | 孤儿流程或虚假审批中 |
| End Event等于业务已发布 | 流程结束与外部副作用是不同事实 | 页面显示成功但目标无数据 |
| Outbox能原子覆盖任意数据库 | 只保护同一本地事务中的状态和事件 | 两库仍不一致 |
| MQ exactly-once解决全部重复 | 业务消费和外部调用仍需幂等 | 重复发布/扣款 |
| 分布式锁等于分布式事务 | 锁不原子提交两个资源 | 释放锁后不一致仍在 |
| 重试一定能恢复 | 永久错误和毒消息会无限重试 | 队列堆积、下游雪崩 |
| 补偿就是回滚 | 外部副作用通常只能执行新反向动作 | 审计和业务事实被误解 |
| 一个currentNode字段够用 | 并行/多实例可有多个Task | 展示和权限错误 |
| 直接改ACT表最快 | 会破坏Execution、History、Job和缓存关联 | 流程进一步损坏 |
| 对账是事故后临时脚本 | 最终一致系统必须把对账作为正式能力 | 小差异长期积累成大事故 |
四十二、面试标准回答
业务表和Activiti表怎样关联
业务表保存业务事实、业务状态和processInstanceId;启动流程时把稳定业务主键作为businessKey,并记录具体processDefinitionId。Activiti Runtime保存Execution、Task、Variable和Job,History保存执行轨迹,业务审批记录保存requestId、决定和操作者快照。流程引擎负责协调过程,业务表负责领域事实,双方通过binding和周期对账建立可追踪关系。
怎样保证启动流程和更新业务表一致
如果两者在同一数据库、使用同一DataSource和PlatformTransactionManager,就放在一个Spring本地事务中,并通过故障注入证明一起提交/回滚,同时用requestId、业务状态CAS和binding唯一约束防重复。如果是两个数据库,普通@Transactional无效,应选择XA/JTA,或把提交变成持久化命令,由Worker按businessKey幂等启动,再通过结果回写和对账最终一致。
Outbox解决什么问题
Outbox把业务状态变更和“需要发送的事件”写在同一个本地事务里,提交后由Publisher至少一次发送;发送成功但标记前崩溃会重复,因此消费者要用eventId和Inbox/唯一约束幂等。Outbox不能自动原子覆盖两个独立数据库,也不能让不支持幂等的外部副作用变成恰好一次。
Task完成但业务状态没变怎样排查
先确认Runtime Task、Historic Task、下一Execution和流程结束路径,再查审批requestId和业务更新结果。重点核对complete和业务更新是否同事务、是否选错TransactionManager、状态条件更新是否0行、是否处于并行中间节点、是否进入异步Job,以及Listener/Outbox回写是否失败。不能只判断Task不存在就手工改业务状态。
为什么需要对账
跨库和跨系统存在进程崩溃、消息重复、回执丢失、重试耗尽和人工误操作,单靠重试无法证明最终一致。对账要按businessKey/processInstanceId关联Runtime、History、业务表、Outbox、Inbox和目标系统,分类差异;证据确定的自动修复,不确定或涉及外部副作用的进入人工工单,并记录修复前后审计。
为什么不能直接修改ACT表
Task、Execution、Identity Link、Variable、Job、History和引擎缓存共同构成运行状态,直接改一张表会绕过生命周期、Listener、乐观锁和历史维护,容易制造无Task的Execution或错误汇聚。应优先使用公开API、补偿和迁移工具;数据库修复只能作为停流、备份、演练和专家评审后的最后手段。
四十三、学习实验与验收
- 将业务表和 Activiti 表放在同一测试库,启动流程后抛异常,验证全部回滚。
- 模拟同类自调用和错误事务管理器,观察为什么
@Transactional看起来存在却不生效。 - 两个线程用不同 requestId 并发提交同一 DRAFT 单据,验证 CAS/行锁和唯一约束。
- 最终审批同事务写 Outbox,验证回滚时两者一起消失。
- 模拟 Publisher 发送成功后崩溃,证明消费者 Inbox 能去重。
- 将 Activiti 与业务表分到两个测试数据库,复现两种双写顺序的失败窗口。
- 实现 START_PROCESS 命令 Worker,在启动成功回写前崩溃,重启后按 businessKey 找回实例。
- 实现 COMPLETE_TASK 命令,在 Task 完成后回写前崩溃,使用 History 收敛状态。
- 构造 APPROVING 但流程已结束、DRAFT 但有活动实例、PUBLISHED 但目标无记录三种对账差异。
- 为自动修复和人工修复分别设计证据、审批和审计。
验收时必须能回答:
- 为什么流程状态和业务状态不能只保留一个?
- 怎样证明 Activiti 与业务 Repository 真在同一事务?
@Transactional有哪些常见失效方式?- 两个数据库为什么无法通过调整执行顺序得到原子性?
- Outbox、Inbox、commandId、requestId 和 businessKey 分别做什么?
- 发送成功、标记失败时为什么会重复,怎样得到业务有效一次?
- XA/JTA、异步命令、事务消息和Saga各适合什么边界?
- 为什么分布式锁不能替代一致性方案?
- 对账规则怎样区分暂时不一致、可自动修复和必须人工介入?
- 为什么不能直接删除
ACT_RU_TASK修复卡住流程?
