本文基于一个真实的互联网医院系统架构演进过程,不涉及任何理论空谈。如果你也面临“A系统调用B系统,B失败了怎么办”的问题,希望这篇文章能给你一些实战参考。
最好的分布式事务,是没有分布式事务——聊聊“绕开它“的六种设计
一、问题背景
我们的互联网医院系统有两套独立部署的服务:
- 租户端:医院管理后台,负责订单、处方、病历等业务。
- 患者端:患者使用的C端应用,独立数据库、独立部署环境。
两个系统之间需要频繁同步数据,典型场景有两个:
- 支付回调:支付厂商先回调租户端,租户端更新订单状态后,需要同步到患者端。
- 医生开方:医生在租户端开诊断、病历、处方,租户端落库后,需要异步同步到患者端。
最开始的实现非常简单粗暴:
租户端业务落库 → 直接HTTP调用患者端接口 → 失败就抛异常这个方案在测试环境跑得很欢,但上线后很快暴露了问题:
- 患者端偶尔网络抖动,导致租户端整个请求失败,医生开方直接报错;
- 患者端发布重启期间,租户端同步调用全部超时,产生大量单边数据(租户有,患者端没有);
- 支付回调场景下,租户端已经改了状态,但患者端没收到,患者看到的订单状态一直是"未支付"。
二、为什么不用分布式事务?
分布式事务(Seata、TCC、Saga等)确实是解决跨系统一致性的"标准答案",但我们最终没有选它,原因很直接:
- 性能开销大:全局锁、二阶段提交、额外的协调服务,每一次跨系统调用都要付出额外的RTT和锁等待成本。对于互联网医院这种高并发场景(尤其是支付回调高峰期),延迟敏感。
- 运维复杂度高:需要额外部署TC协调器,排查问题时要同时看多个系统的日志和事务状态,定位成本高。
- 耦合度高:引入分布式事务框架后,两个系统的代码和部署都产生了强耦合,不利于后续独立演进。
我们需要的不是"强一致",而是最终一致——允许短暂的不一致,但必须保证数据最终能对上。
三、最终方案:四道防线
经过多轮讨论和压测,我们最终落地了一套**“本地消息表 + 四道防线”**的方案。下面用图解一步步拆解。
整体架构图
┌─────────────────────────────────────────────────────────────┐ │ 租户端 │ │ │ │ ┌──────────┐ ┌──────────────┐ ┌──────────────────┐ │ │ │ 业务落库 │───→│ 本地消息表 │───→│ 同步调用患者端 │ │ ││(事务内)││(PENDING)││(第一道防线)│ │ │ └──────────┘ └──────────────┘ └────────┬─────────┘ │ │ │ 失败 │ │ ┌──────────▼─────────┐ │ │ │ 异步投递Kafka │ │ ││(第二道防线)│ │ │ └────────┬───────────┘ │ │ │ │ │ ┌──────────▼─────────┐ │ │ │ 定时兜底任务 │ │ ││(第三道防线)│ │ │ │ 指数退避+随机抖动 │ │ │ └────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ │ │ Kafka ▼ ┌─────────────────────────────────────────────────────────────┐ │ 患者端 │ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ Kafka消费 │───→│ 幂等落库 │───→│ 不回调租户 │ │ │ │@Retryable││(Unique Key)│ │ │ │ │ │ Topic步进重试 │ │ │ │ │ │ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ 提供两类接口给租户调用: │ │ │ │1.Kafka代理接口:接收消息并投递到本地Kafka │ │ │ │2.业务状态查询接口:供租户兜底任务主动查询 │ │ │ └──────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ │ │T+1对账任务(第四道防线) ▼ ┌───────────────┐ │ 比对双方数据库 │ │ 修复漏网之鱼 │ └───────────────┘第一道防线:实时同步(快路径)
目标:正常情况下,尽可能快地完成同步,不走任何异步链路。
租户端 患者端 │ │ │── 业务落库(事务内) ──────────│ │ │ │── HTTP调用患者端代理接口 ──────│──→ 投递到Kafka │ │ │←────────── 返回成功 ──────────│ │ │ │ 本地消息表状态 → CONFIRMED │ │ (直接结束,不走后续流程) │关键点:
- 同步调用和写本地消息表在同一个数据库事务里完成,保证原子性。
- 如果同步调用成功,本地消息表状态直接置为
CONFIRMED,后续流程全部跳过。 - 患者端的代理接口只做"接收参数 → 发送到Kafka → 返回成功",不做复杂业务逻辑,保证轻量和高吞吐。
第二道防线:Kafka异步削峰(非阻塞重试)
目标:同步调用失败时,通过Kafka异步重试,且不影响其他业务。
租户端 患者端 │ │ │── 同步调用失败 ───────────────│ │ │ │ 本地消息表状态 →PENDING│ │ │ │── 异步投递到Kafka ────────────│──→ 消息进入主Topic │ │ │ 本地消息表状态 →SENT│ │ │ │ ┌────▼────┐ │ │Kafka消费 │ │ │失败! │ │ └────┬────┘ │ │ │ ┌────▼────────────────┐ │ │自动进入重试Topic │ │ │retry-1:1分钟后消费 │ │ │retry-2:5分钟后消费 │ │ │retry-3:30分钟后消费 │ │ └────┬────────────────┘ │ │ │ ┌────▼────┐ │ │仍失败? │──→进入死信Topic(DLT)│ └─────────┘ 触发告警,人工介入Spring Kafka 配置示例:
@RetryableTopic(attempts="4",backoff=@Backoff(delay=1000*60,// 首次延迟1分钟multiplier=5,// 指数因子maxDelay=1000*60*30// 最大延迟30分钟),autoCreateTopics="true",dltStrategy=DltStrategy.FAIL_ON_ERROR)@KafkaListener(topics="prescription_sync_topic",groupId="patient-prescription-group")publicvoidlistenPrescriptionSync(PrescriptionMessagemessage){patientService.syncPrescription(message);}为什么用非阻塞重试?
如果用传统的同步重试(消费线程里直接try-catch + Thread.sleep),一条消息失败会阻塞整个分区的后续消费。而非阻塞重试会把失败消息转发到独立的重试Topic,主Topic继续畅通,其他业务不受影响。
每个业务独立配置:支付回调和处方同步可以使用不同的Topic,各自配置不同的重试间隔和次数,互不干扰。
第三道防线:租户兜底任务(指数退避 + 随机抖动)
目标:应对Kafka投递失败、患者端长时间宕机等极端情况,同时防止重试流量把刚恢复的下游打挂。
这是整个方案里最容易被忽视但最关键的一环。
本地消息表结构
CREATETABLElocal_message(idBIGINTPRIMARYKEYAUTO_INCREMENT,biz_typeVARCHAR(32)NOTNULLCOMMENT'业务类型:PAY/PRESCRIPTION',biz_idVARCHAR(64)NOTNULLCOMMENT'业务主键:订单号/处方号',payloadTEXTNOTNULLCOMMENT'消息体JSON',statusVARCHAR(16)NOTNULLCOMMENT'PENDING/SENT/CONFIRMED/DEAD',retry_countINTDEFAULT0COMMENT'重试次数',next_retry_timeDATETIMECOMMENT'下次允许重试的时间',create_timeDATETIMEDEFAULTCURRENT_TIMESTAMP,update_timeDATETIMEDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMP,INDEXidx_status_retry(status,next_retry_time));兜底任务扫描逻辑
@Scheduled(fixedDelay=60_000)// 每分钟执行一次publicvoidretrySentMessages(){// 只扫描状态为SENT且到达重试时间的记录List<LocalMessage>messages=messageMapper.selectRetryable("SENT",LocalDateTime.now());for(LocalMessagemsg:messages){try{// 主动调用患者端查询接口,确认是否已落库booleansynced=patientApiClient.querySyncStatus(msg.getBizType(),msg.getBizId());if(synced){// 患者端已处理,标记为已同步messageMapper.updateStatus(msg.getId(),"CONFIRMED");}else{// 患者端未处理,重新投递patientApiClient.sendToKafka(msg.getBizType(),msg.getPayload());// 指数退避:计算下次重试时间longnextDelay=calculateBackoff(msg.getRetryCount());messageMapper.updateRetryInfo(msg.getId(),msg.getRetryCount()+1,LocalDateTime.now().plusSeconds(nextDelay));}}catch(Exceptione){// 调用患者端接口失败(如下游宕机),同样走指数退避longnextDelay=calculateBackoff(msg.getRetryCount());if(msg.getRetryCount()>=MAX_RETRY){messageMapper.updateStatus(msg.getId(),"DEAD");alertService.send("消息重试超限: "+msg.getBizId());}else{messageMapper.updateRetryInfo(msg.getId(),msg.getRetryCount()+1,LocalDateTime.now().plusSeconds(nextDelay));}}}}// 指数退避 + 随机抖动privatelongcalculateBackoff(intretryCount){longbaseDelay=60;// 基础间隔60秒longmultiplier=5;// 指数因子longdelay=baseDelay*(long)Math.pow(multiplier,retryCount);delay=Math.min(delay,7200);// 最大不超过2小时// 叠加0~50%的随机抖动,防止重试风暴longjitter=ThreadLocalRandom.current().nextLong(0,delay/2);returndelay+jitter;}指数退避效果演示
| 重试次数 | 计算间隔 | 叠加抖动后 | 说明 |
|---|---|---|---|
| 0 → 1 | 60秒 | 60~90秒 | 快速重试 |
| 1 → 2 | 300秒 | 5~7.5分钟 | 稍微等待 |
| 2 → 3 | 1500秒 | 25~37.5分钟 | 应对半小时级宕机 |
| 3 → 4 | 7200秒 | 2~3小时 | 长时间降级 |
| ≥ 4 | — | — | 标记DEAD,告警转人工 |
假设患者端宕机2小时:
- 固定1分钟重试:产生120次无效调用,恢复瞬间流量雪崩。
- 指数退避+抖动:2小时内只产生3~4次调用,恢复后压力平滑。
第四道防线:T+1对账(终极兜底)
目标:修复所有前三道防线都漏掉的极端情况,并输出差异报表用于问题追溯。
每天凌晨2:00执行:1.从租户端数据库导出当天所有订单/处方ID2.调用患者端批量查询接口,比对双方数据3.发现不一致的记录:-租户有、患者端没有 → 触发补偿同步-状态不一致 → 以租户端为准修正患者端4.输出对账差异报表,发送到运维群对账任务不需要实时性,但必须全覆盖、可追溯。它是整个一致性体系的"安全网"。
四、关键设计要点总结
1. 幂等是底线
- 患者端Kafka代理接口:通过
biz_id防重,已投递过的消息直接返回成功。 - 患者端Kafka消费:通过数据库唯一索引(Unique Key)或状态机更新(
UPDATE ... WHERE status = 'INIT')做最终幂等。 - 租户兜底任务:查询接口本身是只读的,天然幂等;重发接口同样通过
biz_id防重。
2. 状态机清晰
PENDING→SENT→CONFIRMEDFAILED→DEAD(告警/人工)每个状态转换都有明确的触发条件,不会出现状态混乱。
3. 患者端不反向调用租户/回调租户
因为两套环境物理隔离、MQ组件不同,可以有以下俩种方案:
(1)患者端消费成功后不回调租户。租户通过主动查询的方式确认同步状态,彻底解耦。
(2)患者端消费成功后“回调租户”,租户接到请求更新状态
4. 随机抖动不能省
没有抖动的指数退避(步进式重试),在大量消息同时到达同一重试时间点时,依然会造成局部流量峰值。加上0~50%的随机抖动后,重试请求会被均匀打散。
五、方案对比
| 维度 | 分布式事务 | 纯异步MQ | 本方案(四道防线) |
|---|---|---|---|
| 一致性 | 强一致 | 最终一致 | 最终一致 |
| 性能 | 低 | 高 | 高 |
| 运维复杂度 | 高 | 低 | 中 |
| 下游宕机保护 | 指数退避+抖动 | ||
| 业务隔离 | — | 需手动隔离 | Topic隔离+独立配置 |
| 数据兜底 | — | T+1对账 | |
| 适用场景 | 金融核心交易 | 日志/通知 | 订单/处方/病历同步 |
六、写在最后
这套方案不是一开始就设计出来的,而是在线上出了几次单边数据事故后,一次次迭代出来的。
核心思想其实就一句话:不要指望任何单一机制100%可靠,用多层防线把概率问题变成确定性问题。
- 第一道防线(实时同步)解决99%的正常情况:租户业务落库后,同步调用患者端代理接口。成功则直接结束。
- 第二道防线(Kafka 异步削峰)解决网络抖动等瞬时故障:同步失败,写入本地消息表。异步投递到 Kafka。患者端消费失败时,利用 @RetryableTopic 进行非阻塞的步进式重试(1m -> 5m -> 30m),不影响其他业务。
- 第三道防线(租户兜底任务)解决下游宕机等系统级故障:针对 Kafka 投递失败或长时间未确认的消息,租户定时任务扫描本地消息表,采用指数退避策略调用患者端接口,防止下游宕机时被上游的重试流量雪崩打挂。
- 第四道防(T+1 对账)线兜住所有漏网之鱼:每日凌晨跑批,比对双方数据库,修复所有极端情况下的漏网之鱼。
如果你也在做类似的跨系统数据同步,希望这篇文章能帮你少走一些弯路。欢迎在评论区交流你的实践方案。