采购单点击"提交审批"后,页面一直转圈。业务库里它已经不是草稿,却没有记录流程实例编号;流程中心里却查得到一条正在运行的实例。运维如果只看业务库,会以为流程没启动,便让用户再点一次;如果第二次换了请求编号,就可能把同一张采购单送进两个审批流程。另一个极端是流程确实没启动,业务单据却永久停在"审批中",无人能办。
这类故障不是靠延长 HTTP 超时就能根治。业务单据和远程流程服务各自提交事务,网络响应可能在任意时刻丢失。本文用一张虚构采购单 P200 拆解接入流程中心的全过程:哪些条件必须先校验、业务库要先保存什么、流程中心需要提供怎样的幂等契约、超时后怎样恢复原实例,以及上线时如何用两边的数据证明没有半成功。示例是 Java 11 教学程序与 MySQL 8.x 独立表结构,数据均为虚构,不对应任何产品的内部实现。
目录
- 先重现半成功故障
- 草稿创建与提交审批要分开
- 选择同库事务还是跨服务接入
- 本地意图与远端实例怎样绑定
- 超时、拒绝和确认失败怎样恢复
- [Java 教学程序复现两次丢失](#Java 教学程序复现两次丢失)
- [MySQL 约束、投递与双端对账](#MySQL 约束、投递与双端对账)
- 上线验收与参考资料
一、先重现半成功故障
假设甲公司 orgA 的采购单 P200 金额为 800,已经合法保存为 DRAFT。申请人提交审批时使用固定请求键 K200。业务服务完成本地写入后,向流程中心发送启动请求;流程中心创建实例 I1,但是返回 I1 的 HTTP 响应在网络上丢失。此时业务端只能确认"没有收到响应",不能据此推断"远端没有创建实例"。
| 时点 | 业务库 | 流程中心 | 对外应怎样说 |
|---|---|---|---|
| 提交前 | P200=DRAFT |
无实例 | 草稿可编辑 |
| 本地事务提交后 | P200=STARTING,待发送事件 K200 |
暂无或尚未确认 | 已受理,正在启动审批 |
| 第一次远端调用后 | 仍为 STARTING,事件待确认 |
K200 → I1 已创建 |
结果待核对,不能称失败 |
| 按原键恢复后 | P200=IN_APPROVAL,绑定 I1 |
仍是同一个 I1 |
审批已发起,可查看待办 |
这里需要一个对用户可理解的中间态 STARTING。收到"已受理"只代表提交意图已经可靠落库,不代表审批实例已经创建并绑定。页面可以显示"启动中,请稍后刷新",还应提供查看最新状态的接口;不能先返回"审批已发起",等异步任务失败后再偷偷把单据退回草稿。
另设一张金额为 0 的 P201 作为拒绝样本。它在业务校验阶段就被拦住,仍是 DRAFT,不应出现启动事件,更不应触达流程中心。一个成功样本和一个拒绝样本放在一起,才能看清哪些写入属于合法提交,哪些写入必须为零。

图1:超时只证明业务侧没收到结果;用同一个请求键核对原实例,不能换键盲目重启。
二、草稿创建与提交审批要分开
草稿创建只说明业务事实已经保存:采购内容、申请人、金额和附件等由业务模块管理。提交审批是另一次动作,要检查此刻是否满足发起条件。因此流程服务故障不应抹掉一份合法草稿;反过来,草稿创建成功也不代表审批已经发起。
提交前至少有五类检查,且其中会变化的条件需要在提交事务里再核对一次:
| 条件 | 检查位置 | 失败结果 |
|---|---|---|
| 登录者与组织成员身份 | 服务端可信会话,不能用前端传来的 orgId 代替 |
拒绝操作,不改业务数据 |
| 单据归属与操作权限 | 业务表按 org_id + order_id 定位,核对申请人或授权角色 |
不泄露他组织单据 |
| 业务规则 | 金额大于 0、必填项与预算规则满足 | 保留草稿,不写启动事件 |
| 单据状态与修订号 | 锁定单据后确认仍为 DRAFT,表单版本未过期 |
返回冲突,提示刷新 |
| 流程接入配置 | 当前业务类型存在可用流程定义 | 明确报错,不把单据误置为审批中 |
业务校验通过后,本地事务只做两件必须原子完成的事:把单据改为 STARTING,同时插入一条携带 K200 的启动意图 。若任何一步失败,事务回滚,单据仍为 DRAFT,也没有待发送事件。这样即使投递进程宕机,数据库里仍有可恢复的意图。事件内容应是最小引用和版本信息,例如组织、业务类型、业务编号、审批轮次、请求键;不必把完整采购单、附件和敏感字段复制进去。
同一张单据同一审批轮次只能有一个有效启动意图。用户重复提交相同 K200 时,应返回当前 STARTING 或 IN_APPROVAL 状态;换成 K999 再提交,应得到冲突。前端禁用按钮能减少误触,但数据库唯一键才是并发请求最终的保护。若未来允许撤回后重提,应给新轮次明确编号,并保留上一轮次的实例与结果,不宜靠覆盖旧 instance_id 来"复用记录"。

图2:业务规则不满足时仍保留草稿;合法提交只先写入启动意图和"启动中"状态。
三、选择同库事务还是跨服务接入
接入方案首先取决于部署边界,而不是先选一个消息组件。同一应用、同一数据库管理业务单据、实例和首个待办时,可以在一个本地事务中一起提交;第十阶段首篇已经介绍了这种边界。但如果流程中心是独立服务、独立数据库,业务库的 @Transactional 不会让远端 HTTP 调用自动加入同一事务。Spring 声明式事务文档明确说明事务上下文不会跨远程调用传播。
| 部署边界 | 可以保证什么 | 不能假定什么 | 适合的做法 |
|---|---|---|---|
| 同库、同事务 | 单据状态、实例和待办一起提交或回滚 | 外部通知也随数据库原子完成 | 本地事务 + 数据库约束 |
| 业务库与流程库分离 | 各自库内可以原子提交 | 一次 @Transactional 包住两端 |
本地意图 + 可靠投递 + 远端幂等 + 对账 |
| 外部引擎不支持幂等启动 | 本地可保存请求与重试证据 | 超时后安全地无限重试 | 先查证;结果未知时进入人工或自动对账 |
本文采用第二种边界。业务库里的启动意图可用 outbox 表承载:单据状态与待发送事件在同一事务写入,提交后由投递器把事件交给流程中心。AWS 的 Transactional Outbox 模式说明也强调它解决的是本地写入与事件发送的双写窗口,同时指出重复投递仍可能发生,消费端必须幂等。它不承诺"远端只会执行一次";真正防重必须由流程中心的持久化契约承担。
四、本地意图与远端实例怎样绑定
业务侧向流程中心传 orgA + PURCHASE + P200 + round1 + K200。流程中心需把 K200 作为请求幂等键,并对"同组织、同业务单、同轮次"建立唯一约束。第一次收到时创建 I1,再次收到同一请求时返回已有 I1;如果同一请求键被拿去启动别的单据,必须拒绝。只有"接口宣称支持幂等"而没有数据库级约束,在并发请求下仍可能生成两个实例。
流程中心返回实例编号后,业务侧做确认事务 :锁定 P200 和对应启动意图,核对它仍属于 orgA/P200/K200,把 instance_id=I1 与 state=IN_APPROVAL 写到业务单据,同时把启动事件标为已确认。如果这次本地确认也失败,流程中心已创建的 I1 不会回滚;投递器下一次仍以 K200 查询或请求,拿到同一个 I1,再补做确认。
关键是请求键在全过程保持不变。生成新的 UUID 再重试,不是在"重试同一动作",而是向远端提出另一次启动。流程中心如果无法提供可靠的 K200 → I1 查询或幂等重放,就不能把超时任务自动重新启动;应把业务单标成"结果待核对",通过远端业务键、日志和运维证据先确认实例是否存在。这一条件直接决定系统能否安全自动恢复。

图3:第一次远端响应丢失、第二次本地确认丢失,第三次仍按 K200 找回同一个 I1。
五、超时、拒绝和确认失败怎样恢复
把所有异常都交给"失败重试三次"处理,容易制造重复实例。恢复动作应由远端是否可能已经创建实例来决定:
| 失败点 | 远端是否可能有实例 | 应采取的动作 | 禁止的动作 |
|---|---|---|---|
| 业务校验失败 | 不可能;还没发送 | 保留 DRAFT,让用户修正 |
写启动事件或伪造实例 |
| 本地提交事务回滚 | 不可能;事件也回滚 | 提示提交失败,用户可重试 | 把单据留在 STARTING |
| 调用流程中心超时 | 可能已有 | 保持 STARTING,按 K200 查询或幂等重试 |
直接改回草稿、换键再启动 |
| 流程中心明确拒绝且确认未创建 | 不存在 | 记录拒绝原因;进入可修正状态,由业务决定是否重新提交 | 把所有 4xx 都当成可自动重试 |
| 流程已建、业务确认失败 | 已有 | 按原键取回 I1,补写关联和状态 |
在流程中心再建一条 |
| 远端返回与本地关联冲突 | 不确定或有两条 | 停止自动推进,保留原记录并对账 | 覆盖旧 instance_id |
"可修正状态"也需要产品层明确处理。对于被远端明确拒绝且确认无实例 的请求,可以标记 START_FAILED,展示原因并允许修正后生成新轮次或新请求;对于超时未知的请求,不得先标失败再开放重新提交。超时、连接中断和进程崩溃通常都属于结果未知,必须靠查询或幂等重放解决。
投递器的重试需要上限、退避时间和告警。PENDING 长时间不动可能是投递器停了;尝试次数上涨但一直拿不到确认可能是远端故障;RECONCILE 表示关联冲突或缺少远端幂等保证,应交给对账流程。监控指标至少包括 STARTING 存量、最老等待时长、事件失败次数、流程中心同业务键冲突数。只记录一次异常堆栈,无法判断用户单据现在能不能继续操作。

图4:业务校验失败、远端超时、明确拒绝和本地确认失败不能用同一个"重试"按钮处理。
六、Java 教学程序复现两次丢失
下面的 Java 11 程序在内存中模拟业务库、outbox 和流程中心的三个状态面。先让流程中心创建 I1 后丢失响应,再让业务端收到 I1 却丢失本地确认,第三次投递才完成关联。运行 javac Stage10WorkflowStartRecoveryExample.java 和 java Stage10WorkflowStartRecoveryExample,预期输出 TOTAL 17/17 PASS。程序用于验证接口与状态契约 ;Map 不是数据库事务,也不能保证跨进程并发安全,下面的 SQL 约束才是落地时的持久化防线。
java
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
/** Java 11 teaching simulation; maps do not provide durable transactions. */
public final class Stage10WorkflowStartRecoveryExample {
static final class Order {
final String orgId, id;
final int amount;
String state = "DRAFT";
String requestKey, instanceId;
Order(String orgId, String id, int amount) {
this.orgId = orgId; this.id = id; this.amount = amount;
}
}
static final class StartEvent {
final String key, businessKey;
String state = "PENDING";
int attempts;
StartEvent(String key, String businessKey) {
this.key = key; this.businessKey = businessKey;
}
}
static final class Instance {
final String id, requestKey, businessKey;
Instance(String id, String requestKey, String businessKey) {
this.id = id; this.requestKey = requestKey; this.businessKey = businessKey;
}
}
static final class LostResponse extends RuntimeException { }
static final class ProcessCenter {
final Map<String, Instance> byRequest = new HashMap<>();
final Map<String, Instance> byBusiness = new HashMap<>();
int sequence;
Instance start(String requestKey, String businessKey, boolean loseResponse) {
Instance existing = byRequest.get(requestKey);
if (existing != null) {
if (!existing.businessKey.equals(businessKey))
throw new IllegalStateException("REQUEST_KEY_REUSED");
return existing;
}
if (byBusiness.containsKey(businessKey))
throw new IllegalStateException("BUSINESS_ALREADY_STARTED");
Instance created = new Instance("I" + (++sequence), requestKey, businessKey);
byRequest.put(requestKey, created);
byBusiness.put(businessKey, created);
if (loseResponse) throw new LostResponse();
return created;
}
Instance find(String requestKey) { return byRequest.get(requestKey); }
}
final Map<String, Order> orders = new HashMap<>();
final Map<String, StartEvent> outbox = new HashMap<>();
final ProcessCenter process = new ProcessCenter();
static String businessKey(String orgId, String orderId) {
return orgId + ":PURCHASE:" + orderId + ":round1";
}
static String orderKey(String orgId, String orderId) {
return orgId + ":" + orderId;
}
void createDraft(String orgId, String orderId, int amount) {
String key = orderKey(orgId, orderId);
if (orders.putIfAbsent(key, new Order(orgId, orderId, amount)) != null)
throw new IllegalStateException("ORDER_EXISTS");
}
// In a real service, the order update and outbox insert share one local DB transaction.
Order submit(String orgId, String orderId, String requestKey) {
Order order = orders.get(orderKey(orgId, orderId));
if (order == null) throw new IllegalArgumentException("ORDER_NOT_FOUND");
if (Objects.equals(order.requestKey, requestKey)
&& ("STARTING".equals(order.state) || "IN_APPROVAL".equals(order.state)))
return order;
if (!"DRAFT".equals(order.state)) throw new IllegalStateException("ORDER_NOT_DRAFT");
if (order.amount <= 0) throw new IllegalArgumentException("INVALID_AMOUNT");
if (requestKey == null || requestKey.isBlank() || outbox.containsKey(requestKey))
throw new IllegalArgumentException("INVALID_REQUEST_KEY");
order.state = "STARTING";
order.requestKey = requestKey;
outbox.put(requestKey, new StartEvent(requestKey, businessKey(orgId, orderId)));
return order;
}
// Remote start is outside the business DB transaction. A timeout means UNKNOWN.
void dispatch(String orgId, String orderId, String requestKey,
boolean loseResponse, boolean loseAck) {
Order order = orders.get(orderKey(orgId, orderId));
StartEvent event = outbox.get(requestKey);
if (order == null || event == null || !requestKey.equals(order.requestKey)
|| !event.businessKey.equals(businessKey(orgId, orderId)))
throw new IllegalArgumentException("REQUEST_NOT_FOUND");
if ("SENT".equals(event.state)) return;
if (!"PENDING".equals(event.state)) throw new IllegalStateException("NEEDS_RECONCILE");
event.attempts++;
Instance instance;
try {
instance = process.start(requestKey, event.businessKey, loseResponse);
} catch (LostResponse unknown) {
return; // Keep PENDING; retry only because ProcessCenter guarantees idempotent start.
} catch (IllegalStateException conflict) {
event.state = "RECONCILE";
return;
}
if (loseAck) return; // Response arrived, but local acknowledgement did not commit.
if (!("STARTING".equals(order.state) || "IN_APPROVAL".equals(order.state))
|| (order.instanceId != null && !order.instanceId.equals(instance.id))) {
event.state = "RECONCILE";
return;
}
order.instanceId = instance.id;
order.state = "IN_APPROVAL";
event.state = "SENT";
}
private static int passed;
private static void check(String label, boolean ok) {
if (!ok) throw new AssertionError(label);
passed++;
System.out.println("PASS " + label);
}
private static boolean rejected(Runnable action) {
try { action.run(); return false; }
catch (IllegalArgumentException | IllegalStateException expected) { return true; }
}
public static void main(String[] args) {
Stage10WorkflowStartRecoveryExample app = new Stage10WorkflowStartRecoveryExample();
app.createDraft("orgA", "P200", 800);
app.createDraft("orgA", "P201", 0);
check("invalid business input rejected", rejected(() -> app.submit("orgA", "P201", "K201")));
check("invalid input leaves draft", "DRAFT".equals(app.orders.get("orgA:P201").state));
check("invalid input has no outbox", !app.outbox.containsKey("K201"));
Order order = app.submit("orgA", "P200", "K200");
check("submit enters STARTING", "STARTING".equals(order.state));
check("submit creates one outbox event", app.outbox.size() == 1);
check("same submit returns same order", app.submit("orgA", "P200", "K200") == order);
check("different key rejected", rejected(() -> app.submit("orgA", "P200", "K999")));
app.dispatch("orgA", "P200", "K200", true, false);
check("remote instance exists after lost response", app.process.find("K200") != null);
check("timeout leaves STARTING", "STARTING".equals(order.state));
check("timeout leaves event pending", "PENDING".equals(app.outbox.get("K200").state));
app.dispatch("orgA", "P200", "K200", false, true);
check("lost local ack still pending", "PENDING".equals(app.outbox.get("K200").state));
app.dispatch("orgA", "P200", "K200", false, false);
check("recovered instance binding", "I1".equals(order.instanceId));
check("business enters IN_APPROVAL", "IN_APPROVAL".equals(order.state));
check("outbox marked SENT", "SENT".equals(app.outbox.get("K200").state));
check("remote instance created only once", app.process.sequence == 1);
app.dispatch("orgA", "P200", "K200", false, false);
check("duplicate dispatch unchanged", app.process.sequence == 1);
check("other organization cannot use request", rejected(() ->
app.dispatch("orgB", "P200", "K200", false, false)));
System.out.println("TOTAL " + passed + "/" + passed + " PASS");
}
}
在测试结果里,P201 因金额无效保持草稿且没有 outbox;P200 提交后先进入 STARTING;第一次远端调用虽然超时,流程中心已经有 I1;第二次本地确认丢失,事件仍待发送;第三次按 K200 返回同一个 I1,业务单进入 IN_APPROVAL。最终流程中心只创建过 1 个实例。最后还验证不同请求键重提和他组织冒用请求都被拒绝。
这个教学程序特意把远端"创建成功但响应丢失"和本地"拿到结果但确认失败"分成两个注入点。真实系统里,故障可能发生在任意网络或数据库提交边界,故而要让每一步都能从已有记录恢复,不能把"没有抛异常"当作唯一成功证据。
七、MySQL 约束、投递与双端对账
业务库至少保存单据状态、启动请求键和待投递事件。以下 DDL 是独立教学表;流程中心实例表属于另一数据库,列在后面只是为了说明远端必须实现的唯一约束。不要把两段 DDL 误读成一次本地事务可以同时提交两端。
sql
-- 业务数据库
CREATE TABLE demo_purchase (
org_id VARCHAR(32) NOT NULL,
order_id VARCHAR(32) NOT NULL,
amount DECIMAL(12,2) NOT NULL,
state VARCHAR(20) NOT NULL,
start_request_key VARCHAR(64) NULL,
process_instance_id VARCHAR(64) NULL,
revision INT NOT NULL DEFAULT 1,
PRIMARY KEY (org_id, order_id),
UNIQUE KEY uk_start_key (org_id, start_request_key),
CHECK (state IN ('DRAFT','STARTING','IN_APPROVAL','START_FAILED'))
) ENGINE=InnoDB;
CREATE TABLE demo_start_outbox (
org_id VARCHAR(32) NOT NULL,
request_key VARCHAR(64) NOT NULL,
order_id VARCHAR(32) NOT NULL,
approval_round INT NOT NULL,
delivery_state VARCHAR(20) NOT NULL,
attempts INT NOT NULL DEFAULT 0,
next_attempt_at DATETIME(3) NOT NULL,
lease_until DATETIME(3) NULL,
lease_token VARCHAR(64) NULL,
process_instance_id VARCHAR(64) NULL,
PRIMARY KEY (org_id, request_key),
UNIQUE KEY uk_order_round (org_id, order_id, approval_round),
KEY ix_pending (delivery_state, next_attempt_at),
FOREIGN KEY (org_id, order_id) REFERENCES demo_purchase(org_id, order_id),
CHECK (delivery_state IN ('PENDING','SENDING','SENT','RECONCILE','FAILED'))
) ENGINE=InnoDB;
-- 流程中心数据库,由流程中心自己维护
CREATE TABLE demo_process_instance (
org_id VARCHAR(32) NOT NULL,
instance_id VARCHAR(64) NOT NULL,
request_key VARCHAR(64) NOT NULL,
business_type VARCHAR(32) NOT NULL,
business_id VARCHAR(32) NOT NULL,
approval_round INT NOT NULL,
state VARCHAR(20) NOT NULL,
PRIMARY KEY (org_id, instance_id),
UNIQUE KEY uk_remote_request (org_id, request_key),
UNIQUE KEY uk_remote_business_round
(org_id, business_type, business_id, approval_round)
) ENGINE=InnoDB;
提交审批的本地事务先锁 P200,在授权和业务规则核对后写两行。实际服务层必须检查 UPDATE 影响 1 行;如果 INSERT 因唯一键冲突失败,整笔回滚,再读取已有请求键与状态来判断是同请求重试还是新请求冲突。
sql
START TRANSACTION;
SELECT state, amount, revision FROM demo_purchase
WHERE org_id='orgA' AND order_id='P200' FOR UPDATE;
-- 要求 DRAFT、金额 800、提交的 revision 与数据库一致。
UPDATE demo_purchase
SET state='STARTING', start_request_key='K200', revision=revision+1
WHERE org_id='orgA' AND order_id='P200' AND state='DRAFT';
INSERT INTO demo_start_outbox
(org_id,request_key,order_id,approval_round,delivery_state,next_attempt_at)
VALUES ('orgA','K200','P200',1,'PENDING',NOW(3));
COMMIT;
多个投递进程同时工作时,可以在队列式 outbox 表 中用 FOR UPDATE SKIP LOCKED 认领待发记录,写入租约和领取令牌后提交本地事务,再在事务外调用流程中心。MySQL 文档指出 SKIP LOCKED 会跳过已锁行,适合队列式表,不适合一般业务查询。MySQL 8.4 SELECT 文档 以下是认领 K200 的关键 SQL;工作线程必须用查询实际返回的组织和请求键,不应在代码里写死。租约到期的 SENDING 记录也可被重新认领,解决工作线程崩溃后无人重试的问题。
sql
START TRANSACTION;
SELECT org_id, request_key, order_id
FROM demo_start_outbox
WHERE (delivery_state='PENDING' AND next_attempt_at<=NOW(3))
OR (delivery_state='SENDING' AND lease_until<NOW(3))
ORDER BY next_attempt_at LIMIT 1 FOR UPDATE SKIP LOCKED;
-- 假设认领到 orgA / K200 / P200,并生成本次令牌 worker-3。
UPDATE demo_start_outbox
SET delivery_state='SENDING', attempts=attempts+1,
lease_token='worker-3',
lease_until=DATE_ADD(NOW(3),INTERVAL 30 SECOND)
WHERE org_id='orgA' AND request_key='K200';
COMMIT;
-- 在事务外调用流程中心,始终携带原请求键 K200。
远端超时后,如果当前线程仍持有 worker-3,可把事件改回 PENDING 并设置下次重试时间;若租约已被其他线程接管,旧线程的更新必须影响 0 行,不能覆盖新令牌。取得远端实例编号后,本地确认事务锁定单据和事件,核对请求键与 lease_token,再同时写入 I1 和 SENT。这两次本地事务都不能与远端调用合并为一个跨服务事务。若租约在远端调用期间过期而被重新认领,两个线程可能同时调用流程中心,因此远端幂等仍是必要条件。
确认成功的业务库事务需要一起更新 demo_purchase.process_instance_id/state 与 demo_start_outbox.delivery_state/process_instance_id。条件更新应限定 org_id/order_id/request_key 和当前状态;若已是 IN_APPROVAL 且同样绑定 I1,按幂等成功处理;若已有不同实例编号,进入 RECONCILE,不能覆盖。流程中心一侧的 uk_remote_request 与 uk_remote_business_round 分别防请求键复用和同单同轮重复实例;两个唯一键解决的问题不同,缺一项都可能留下漏洞。
验收时不要只在业务库查"已发起"。先看 P200 与 outbox,再去流程中心按 K200 和业务键查实例。第一次响应丢失时,业务库应是 STARTING/PENDING,远端已有 I1;第三次恢复后,业务库应是 IN_APPROVAL/SENT/I1,远端仍只有 I1。P201 的金额校验失败,业务库仍是 DRAFT,两端都没有启动记录。
sql
-- 在业务数据库执行
SELECT p.order_id, p.state, p.start_request_key,
p.process_instance_id, o.delivery_state, o.attempts,
o.process_instance_id AS outbox_instance_id
FROM demo_purchase p
LEFT JOIN demo_start_outbox o
ON o.org_id=p.org_id AND o.order_id=p.order_id
WHERE p.org_id='orgA' AND p.order_id IN ('P200','P201')
ORDER BY p.order_id;
-- 恢复后预期:P200/IN_APPROVAL/K200/I1/SENT;P201/DRAFT,其余启动字段为空。
SELECT p.order_id, p.process_instance_id, o.process_instance_id AS outbox_id
FROM demo_purchase p
JOIN demo_start_outbox o
ON o.org_id=p.org_id AND o.order_id=p.order_id
WHERE p.org_id='orgA' AND p.state='IN_APPROVAL'
AND (o.delivery_state<>'SENT'
OR p.process_instance_id IS NULL
OR o.process_instance_id IS NULL
OR p.process_instance_id<>o.process_instance_id);
-- 预期 0 行;若有结果,业务确认事务或回填逻辑出了问题。
-- 在流程中心数据库执行
SELECT request_key, business_id, approval_round,
COUNT(*) AS instance_count, MIN(instance_id) AS instance_id
FROM demo_process_instance
WHERE org_id='orgA' AND business_type='PURCHASE'
AND business_id='P200'
GROUP BY request_key,business_id,approval_round;
-- 预期只一行:K200 / P200 / 1 / 1 / I1。
这三段查询各自只覆盖所在数据库。跨库对账应按 org_id + request_key + business_type + business_id + approval_round 合并结果,再检查实例编号与状态;不要把某一端暂时查询不到的结果直接当成另一端可以重建实例的依据。若系统在事务提交后不立即可见,还要考虑复制延迟和查询所用的数据源,避免用只读副本的短暂滞后来误判远端不存在。

图5:恢复后 P200、K200 和 I1 三者一致;无效的 P201 只保留草稿。
八、上线验收与参考资料
上线前用固定样本实际注入故障:让 P201 在校验阶段失败,确认没有 outbox;让 P200 第一次远端调用在创建实例后丢响应,确认业务仍处于 STARTING;再让第二次调用拿到 I1 后丢本地确认,检查第三次仍找回 I1。随后重复发送相同请求键、改用另一请求键、跨组织调用,核对两端表里最终只有一个合法实例。单测证明状态规则,接口测试与 SQL 证明持久化约束,网络故障演练证明恢复链路。
监控也要围绕"未完成关联"而非只看请求失败率。建议给 STARTING 超时记录设明确阈值和负责人;对 RECONCILE 记录保留请求键、业务编号、远端查询结果与处理日志。业务员看到的是"启动中/审批中/需处理"的稳定状态,运维看到的是可复查的事件与实例编号。只有这两面能对应,流程接入才算真正完成。
参考资料: