Skip to content

Activiti业务表、流程表与分布式一致性

工作流系统最难的部分通常不是画 BPMN,而是让业务事实、流程运行状态、审批记录、消息和外部系统在各种失败下最终仍能互相解释。

典型事故包括:流程实例已经启动,业务单仍是草稿;Task 已完成,业务状态没有变化;业务表显示已发布,真正的发布接口却失败;消息已经发送,数据库事务随后回滚;用户超时重试后启动了两个流程;管理员直接改 ACT_RU_TASK,导致 Execution、History 和 Job 关系损坏。

核心原则:

Activiti 是流程协调事实源,业务表是领域事实源。能放进同一个本地数据库事务的操作使用本地事务保证原子性;跨数据库、MQ 和外部接口无法只靠 @Transactional 变成一个原子操作,必须使用可持久化意图、幂等执行、有限重试、补偿和周期对账建立最终一致性。

学习目标

完成本页后,你应该能够:

  1. 区分业务事实、流程运行状态、流程历史、审批记录和派生投影。
  2. 解释 businessKey、processInstanceId、processDefinitionId 和业务主键的双向关联。
  3. 设计同库发起流程和完成任务的本地事务。
  4. 证明 Activiti 与业务 Repository 是否真正使用同一个事务管理器。
  5. 解释 Spring @Transactional 自调用、异常吞掉、回滚规则和异步边界为什么会破坏预期。
  6. 列出跨数据库双写的所有主要失败窗口。
  7. 正确选择本地事务、XA/JTA、Outbox、事务消息、Saga/补偿和对账。
  8. 解释 Outbox 能保证什么,以及为什么不能凭空原子化两个独立数据库。
  9. 为提交、认领、完成任务、Listener、MQ 消费和外部调用设计幂等键。
  10. 设计业务状态机、条件更新、唯一约束和并发控制。
  11. 建立流程状态与业务状态对账、自动修复和人工处置 Runbook。
  12. 说明为什么不能直接修改 Activiti 运行时表修流程。

一、先确定每类数据的事实归属

mermaid
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 TaskActiviti Runtime由引擎执行语义维护
流程经过哪些ActivityActiviti History由引擎历史级别和执行轨迹产生
审批意见、requestId、客户端来源业务审批记录需要稳定业务审计和幂等
“是否真正发布到目录”发布业务表/目标系统回执End Event不等于外部发布一定成功
我的待办搜索文档派生投影可重建,不是未经验证的事实源

为什么不能只用Activiti表

Activiti 知道 Task 和 Activity,不知道合同是否合法、库存是否扣减、数据资产是否真正对外发布。引擎历史级别、清理和版本升级也不应决定业务数据生命周期。

为什么不能只用业务状态字段

一个 status=APPROVING 无法表达并行的法务、财务任务、Timer Job、候选组、委派和流程历史。如果全部自己实现,就相当于重新写一个不完整的流程引擎。

二、不要把一个status承载所有生命周期

“审批通过”和“业务发布成功”经常不是同一件事。推荐拆分状态维度:

text
approval_status: DRAFT / SUBMITTING / APPROVING / APPROVED / REJECTED / CANCELED
publish_status:  NOT_STARTED / PENDING / PUBLISHING / PUBLISHED / FAILED

如果只使用一个 status=PUBLISHED,流程审批结束后外部目录发布失败时就无法准确表达:到底审批失败、流程失败,还是发布执行失败。

状态转换必须由受控业务动作完成:

mermaid
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"]

三、推荐业务表结构

下面是教学结构,字段类型、索引和审计要求应按数据库和项目规范调整:

sql
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 唯一约束;不同对象各自需要并发保护。

四、四个关联标识

mermaid
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。应增加流程关联历史表:

sql
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 命令可以使用单库本地事务:

  1. Activiti 表和业务表位于同一个数据库/同一事务资源。
  2. Activiti Spring 集成使用与业务 Repository 相同的 DataSource
  3. SpringProcessEngineConfiguration 使用同一个 PlatformTransactionManager
  4. @Transactional 实际经过 Spring AOP 代理。
  5. 异常没有被吞掉,回滚规则符合预期。
  6. 流程没有在异步 Job 的另一个事务中才完成关键业务动作。
mermaid
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 与业务持久层一致。多数据源项目应显式指定:

java
@Transactional(transactionManager = "workflowTransactionManager")
public void submit(...) {
    // 业务表和Activiti表必须都受这个事务资源管理
}

方法名称只是示例,不能为了看起来明确而指向只管理 Activiti 数据源的事务管理器。真正要看 Bean 引用关系。

6.2 开启事务调试日志

在测试环境观察:

  • 哪个事务管理器创建事务。
  • 业务 SQL 与 ACT 表 SQL 是否加入相同事务。
  • 使用的连接是否属于相同 DataSource。
  • 异常后是否统一执行 rollback。

不要在生产长期开启包含参数的 SQL Debug,可能泄露敏感数据并产生大量日志。

6.3 故障注入验证

java
@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 同类自调用绕过代理

java
public void outer(Long id) {
    this.submitInTransaction(id); // 常规代理模式下可能绕过事务拦截
}

@Transactional
public void submitInTransaction(Long id) {
    // ...
}

将事务方法放到独立 Spring Bean,或让外部调用经过代理。不要通过注入自己等技巧掩盖设计问题。

7.2 捕获异常后不抛出

java
@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 的默认规则不同。需要根据项目规范:

java
@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 幂等命令表

sql
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

java
@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:

sql
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 行更新都当数据库故障。

十、同库完成任务的一致性

mermaid
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:

java
List<Task> nextTasks = taskService.createTaskQuery()
        .processInstanceId(processInstanceId)
        .active()
        .list();

业务表的 currentTaskSummary 是派生展示,不应通过 singleResult() 假设只有一个任务。

流程结束后业务一定通过吗

不一定。流程可能从拒绝 End、取消 End、异常终止 End 结束。最终业务结果应来自明确流程变量、结束事件语义或业务动作,而不是仅判断 Runtime ProcessInstance 已不存在。

十一、审批完成与真正发布要分开

审批通过后调用外部目录服务发布资产。如果在 taskService.complete 的数据库事务中同步调用:

text
目录发布成功 → 本地事务回滚:外部已发布,本地仍审批中
目录请求超时 → 外部实际成功:本地无法判断是否重试
本地持锁等待目录服务:接口变慢、锁竞争扩大

推荐同事务写 Outbox:

mermaid
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 解决的是“数据库状态与待发送事件”在同一个数据库事务中的原子记录问题。

sql
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)
);

同一事务:

java
@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发布器怎样保证并发安全

mermaid
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 当作全数据库通用方案。

另一种方式是短事务抢占租约:

text
PENDING → PROCESSING(owner, leaseUntil) → PUBLISHED/RETRY/FAILED

远程调用不要一直持有数据库行锁。应先抢占、提交,再调用,超时后由 lease 恢复;这意味着同一事件可能重复发送,消费者必须幂等。

十四、为什么Outbox通常是至少一次而不是恰好一次

失败窗口:

text
消息发送成功

Publisher在更新Outbox为PUBLISHED之前崩溃

恢复后再次发送同一eventId

无法仅靠发送端知道消息是否已经被目标系统处理。因此工程上使用:

text
至少一次投递 + 稳定eventId + 消费端Inbox/唯一约束 + 幂等业务更新
= 业务效果上的“有效一次”

十五、消费者Inbox与幂等

sql
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)
);

消费者事务:

java
@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

java
transactionSynchronizationManager.registerSynchronization(...afterCommit...);

afterCommit 可以避免事务回滚时发消息,但仍有窗口:数据库已经提交,应用在执行回调前崩溃,事件永久丢失。它适合非关键缓存清理、进程内通知,不适合作为关键业务事件唯一可靠来源。

Outbox 将“需要发送”持久化,因此进程崩溃后仍可恢复。

十七、Outbox不能解决什么

Outbox 只有与要保护的状态写在同一个本地事务资源中,才能提供原子记录。

错误理解:

text
业务数据库写业务状态
Activiti独立数据库完成Task
业务数据库再写Outbox

这三个操作仍跨两个数据库。业务库 Outbox 不能证明 Activiti 命令已经提交;Activiti 库中的 Outbox 也不能证明业务库更新成功。

跨库时必须重新设计流程,或者使用真正协调两个资源的分布式事务。

十八、Activiti库和业务库分开时有哪些失败窗口

假设先启动流程,再更新业务库:

mermaid
flowchart TD
    A["Activiti库启动流程成功"] --> B["应用在更新业务库前崩溃"]
    B --> C["存在孤儿流程实例,业务单仍是DRAFT"]

假设先更新业务库,再启动流程:

mermaid
flowchart TD
    A["业务库状态改为APPROVING"] --> B["Activiti启动失败或超时"]
    B --> C["业务显示审批中,但没有流程实例"]

即使两个调用都返回成功,第二个数据库提交时仍可能失败。简单调整顺序不能消除双写问题,只是选择哪种不一致更容易修复。

十九、跨库方案一:持久化流程命令并异步执行

业务库先把状态改为 SUBMITTING,同事务写 START_PROCESS 命令/Outbox:

mermaid
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怎样防重复启动

  1. 命令有稳定 commandId/requestId。
  2. 启动前按 businessKey + process type 查询已有实例。
  3. 启动后持久化 binding。
  4. Worker 重试遇到已有实例时回收其 ID,而不是再次启动。
  5. 对历史已结束实例和允许重提场景增加 lifecycleNo,不能仅按 businessKey 粗暴去重。

仍然存在的窗口

流程实例启动成功,但 Worker 在回写业务库前崩溃。恢复后必须根据 businessKey 查到已有实例并完成回写,不能重新启动。

二十、跨库完成Task的命令模式

用户审批时,业务库先持久化审批命令 PENDING,Worker 再完成 Activiti Task:

mermaid
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 协调两阶段提交:

mermaid
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 库合并为一个事务。

合理链路:

text
业务库提交SUBMITTING + 事务消息/Outbox
→ 消费者幂等启动Activiti
→ 结果事件回写业务库
→ 对账修复

仍要处理:

  • 消息重复。
  • 消费成功但回执丢失。
  • 流程启动成功但消费者崩溃。
  • businessKey 去重。
  • 消息乱序。
  • 死信和人工处置。

二十三、Saga与补偿什么时候适用

跨多个系统的长流程无法长期持有数据库事务,可以把每一步设计成“本地提交 + 可补偿动作”:

mermaid
flowchart TD
    A["审批通过"] --> B["冻结预算"]
    B --> C["创建合同编号"]
    C --> D["推送外部归档"]
    D --> E["完成"]
    D -->|"失败"| F["撤销合同编号或标记作废"]
    F --> G["解冻预算"]

补偿不是把时间倒流:

  • 已发送短信无法“撤回”,只能发送更正。
  • 已被外部读取的数据可能无法彻底消除影响。
  • 退款不等于原支付事务回滚。
  • 每个补偿动作也可能失败,需要重试、告警和人工介入。

TCC 更适合能明确 Try/Confirm/Cancel 资源预留的短业务资源,不适合把数天人工审批锁在 Try 阶段。

二十四、分布式锁为什么不是一致性方案

Redis 锁可以降低并发提交概率,但不能原子提交两个数据库:

text
拿到锁
→ Activiti库提交成功
→ 业务库提交失败
→ 释放锁

不一致仍然存在。锁还可能过期、主从切换、业务执行超时。真正需要的是持久化状态机、幂等、事务边界和对账,锁只能作为并发控制的一部分。

二十五、幂等必须覆盖哪些入口

入口推荐幂等键持久化位置
提交流程客户端requestId + 业务生命周期workflow_command唯一约束
完成任务requestId;另加taskId最终动作约束approval_record/command
Listener/DelegateprocessInstanceId + activityId + 业务动作版本operation_log唯一约束
Outbox事件eventIdoutbox_event主键
MQ消费consumerName + eventIdinbox_message主键
外部HTTP调用Idempotency-Key下游系统幂等表/业务唯一键
补偿动作originalOperationId + compensateTypecompensation_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、人工可能误操作。因此必须周期对账。

mermaid
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

证据采集

  1. 用业务 ID 作为 businessKey 查询 Runtime 和 History。
  2. 查 processDefinitionId、startTime、starter 和第一 Task。
  3. 查提交 requestId/command 状态。
  4. 查业务事务日志、异常和数据库提交记录。
  5. 查是否发生超时重试并生成多个实例。

修复决策

  • 唯一活动实例且业务请求合法:受控回写 binding 和 APPROVING。
  • 多个活动实例:暂停业务操作,按审批轨迹和副作用确定保留对象,其他实例走受审计终止。
  • 业务请求本就非法:不能仅因为流程存在就改为 APPROVING,需要取消/补偿并复盘权限漏洞。

三十三、场景二:业务显示APPROVING,但没有流程

检查 Runtime 和 History,区分:

  • 尚未启动,命令仍 PENDING/RETRY。
  • 启动失败达到重试上限。
  • 流程已经结束但业务未回写。
  • processInstanceId 写错或连接了错误环境。
  • 流程实例被管理员删除。

不要直接把状态改回 DRAFT。可能存在已发出的审批通知或外部副作用,应先根据 commandId/businessKey 确认事实。

三十四、场景三:Task完成,业务状态没变

取证顺序:

mermaid
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_TASK
  • ACT_RU_EXECUTION
  • ACT_RU_IDENTITYLINK
  • ACT_RU_VARIABLE
  • ACT_RU_JOB及版本相关Job表
  • ACT_HI_TASKINST
  • ACT_HI_ACTINST
  • ACT_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、补偿和迁移工具;数据库修复只能作为停流、备份、演练和专家评审后的最后手段。

四十三、学习实验与验收

  1. 将业务表和 Activiti 表放在同一测试库,启动流程后抛异常,验证全部回滚。
  2. 模拟同类自调用和错误事务管理器,观察为什么 @Transactional 看起来存在却不生效。
  3. 两个线程用不同 requestId 并发提交同一 DRAFT 单据,验证 CAS/行锁和唯一约束。
  4. 最终审批同事务写 Outbox,验证回滚时两者一起消失。
  5. 模拟 Publisher 发送成功后崩溃,证明消费者 Inbox 能去重。
  6. 将 Activiti 与业务表分到两个测试数据库,复现两种双写顺序的失败窗口。
  7. 实现 START_PROCESS 命令 Worker,在启动成功回写前崩溃,重启后按 businessKey 找回实例。
  8. 实现 COMPLETE_TASK 命令,在 Task 完成后回写前崩溃,使用 History 收敛状态。
  9. 构造 APPROVING 但流程已结束、DRAFT 但有活动实例、PUBLISHED 但目标无记录三种对账差异。
  10. 为自动修复和人工修复分别设计证据、审批和审计。

验收时必须能回答:

  • 为什么流程状态和业务状态不能只保留一个?
  • 怎样证明 Activiti 与业务 Repository 真在同一事务?
  • @Transactional 有哪些常见失效方式?
  • 两个数据库为什么无法通过调整执行顺序得到原子性?
  • Outbox、Inbox、commandId、requestId 和 businessKey 分别做什么?
  • 发送成功、标记失败时为什么会重复,怎样得到业务有效一次?
  • XA/JTA、异步命令、事务消息和Saga各适合什么边界?
  • 为什么分布式锁不能替代一致性方案?
  • 对账规则怎样区分暂时不一致、可自动修复和必须人工介入?
  • 为什么不能直接删除 ACT_RU_TASK 修复卡住流程?

关联知识点