跨服务工单的一致性:补偿、防线与不漂移的契约

把告警系统和 BPM 工单打通,听起来是”调一个发起流程的接口”就能做完的事。真正做起来才发现,难点从来不在发起,而在失败的那一瞬间:远程流程已经发起成功、本地事务却回滚了,那条没有告警指向的孤儿工单谁来收?本文记录这次改造里三个相互咬合的设计——契约、补偿、防线——以及一个”什么都没发生”的典型 bug。

工单一致性:契约、补偿、防线
图 1:远程成功、本地失败的不一致窗口,靠事务同步器在回滚时反向补偿来关闭。

两条线,一份契约

告警转工单有两个入口:页面上手工选定流程发起(手工线),以及告警事件命中规则后自动发单(自动线)。本次最核心的契约变更,是流程绑定统一改用 processExtId(流程扩展表的稳定 id),由 BPM 侧按它解析出当前的流程定义 id;旧的 procDefId 字段弃用。

为什么要改?procDefId 是 Flowable 流程定义的主键,流程每次重新部署都会生成新版本、新 id。调用方不该关心这个会漂移的值——它只需表达”我要用哪个流程”;而”该流程当前对应哪个定义版本、是否已发布、业务类型是否匹配”是 BPM 侧的知识,就该留在 BPM 侧。旧契约把易变的技术 id 泄漏给业务方,两边各存一份,版本一升级就漂移。

顺着这个逻辑,组装参数的工具方法里刻意不设 procDefId

1
2
3
4
public static FlowBo buildFlowBo(AlarmingRecordDTO record, String processExtId, String userId) {
// 不设置 proDefId —— BPM start() 会按 processExtId 重新解析并校验,
// 调用方传了也会被覆盖
}

Review 时很容易觉得”顺手把 procDefId 一起传过去更保险”——恰恰有害:传一个过期值过去,要么被静默覆盖(误导后来者),要么被校验拒绝(制造故障)。契约里不出现这个字段,才是最清晰的表达。

降级有路:先落库、后发单、失败不抛

自动线的时序设计遵循一个原则——告警是事实,工单只是处置手段

  1. 告警先落库。处置链路故障不能丢事实;
  2. 规则没绑流程,直接 return——这是正常分支,不是错误;
  3. BPM 发起失败,只记 error 日志、不抛异常:告警保留、工单字段留空、事后可手工补转;
  4. 发起成功才回写工单号,并注册补偿。

单向依赖、降级有路:BPM 挂了,告警采集的核心功能不受影响,自动工单退化为手工补转,业务闭环不断。

防孤儿:先注册补偿,再回写

整个改造里顺序最讲究的一段代码:

1
2
3
4
register(businessId);   // 拿到 businessId 后,立即注册事务同步器
writeBack(record); // 之后再做本地回写
// 若 writeBack 抛异常导致本地事务回滚:
// 补偿回调仍会调 BPM 删掉刚发起的流程实例——不留孤儿工单

Spring 的事务同步器(TransactionSynchronization)允许在事务完成前后挂回调——afterCompletion 里能拿到事务最终是提交还是回滚。如果顺序反过来(先回写再注册),回写抛异常时补偿还没挂上,本地回滚了、远程流程却已发起——审批人的待办里会多出一条永远处理不完的任务。跨服务”远程成功 + 本地失败”的不一致窗口,就是靠这个回调在回滚时反向关掉。

防线:越权能力收口在网关

补偿要删的是”刚发起的流程”。这个能力若暴露给外部,等于一个删任意工单的接口。所以补偿接口三层配套:Feign 客户端供服务间调用、BPM 侧实现真正的流程实例回滚清理、网关新增一个内部接口拒绝过滤器,直接对外部请求 deny 这条路径。

服务间 Feign 调用不走网关,所以网关 deny 不影响补偿链路;而所有外部请求必经网关这个唯一入口。在网关拒一条路径,就是这条内部接口的全部外暴露面。 内部接口的安全靠网关收口,而不是指望调用方自觉——后来所有新增的服务间接口都沿用了这个模式。

本次最有记录价值的 bug:静默的守卫

按新契约联调时遇到一个诡异现象:规则绑定成功、告警正常落库,但自动工单不发起,也没有任何错误日志。功能”没发生”,日志一片干净。

根因是迁移遗留的一段判空:

1
2
3
if (StringUtils.isEmpty(dto.getFlowId())) {
return; // 旧字段恒空 → 静默跳过,无日志无异常
}

契约改成 flowExtId 后,旧的 flowId 恒为 null,这个守卫 100% 命中、每次都在发单前悄悄 return。定位的关键转折是意识到:**”无日志”本身就是线索**——它排除了所有异常路径,把问题从”哪里出错了”变成”哪里提前退出了”。再用二分法锁死:临时补回旧字段能通、去掉就不通,嫌疑收敛到这三行。

修复是删掉这三行,而不是”顺手把 flowId 也校验上”——后续代码根本不消费这个字段,判空一个后文不用的字段没有意义。

写守卫要么带日志(哪怕 debug 级),要么质疑它的存在。一个迁移后必然恒真的判空就是死代码,而死代码不但不报错,还会把活路径一起带死。

验证

本地全链路 E2E:手工线四表(告警记录、流程业务表、待办表、Flowable 运行时任务表)一致;自动线刻意验证”flow_id 为 NULL”的场景——正是修复前会静默跳过的场景;停掉 BPM 服务验证降级语义:告警照常落库、错误日志在、接口不丢数据,重启后手工补转闭环。跨服务改造的信心不来自代码看起来对,而来自失败路径被逐一演过一遍

参考资料