ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

跨系统数据同步:为什么我放弃了分布式事务,选择“本地消息表+四道防线”

跨系统数据同步:为什么我放弃了分布式事务,选择“本地消息表+四道防线”

本文基于一个真实的互联网医院系统架构演进过程,不涉及任何理论空谈。如果你也面临“A系统调用B系统,B失败了怎么办”的问题,希望这篇文章能给你一些实战参考。
最好的分布式事务,是没有分布式事务——聊聊“绕开它“的六种设计


一、问题背景

我们的互联网医院系统有两套独立部署的服务:

  • 租户端:医院管理后台,负责订单、处方、病历等业务。
  • 患者端:患者使用的C端应用,独立数据库、独立部署环境。

两个系统之间需要频繁同步数据,典型场景有两个:

  1. 支付回调:支付厂商先回调租户端,租户端更新订单状态后,需要同步到患者端。
  2. 医生开方:医生在租户端开诊断、病历、处方,租户端落库后,需要异步同步到患者端。

最开始的实现非常简单粗暴:

租户端业务落库 → 直接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 → 160秒60~90秒快速重试
1 → 2300秒5~7.5分钟稍微等待
2 → 31500秒25~37.5分钟应对半小时级宕机
3 → 47200秒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 对账)线兜住所有漏网之鱼:每日凌晨跑批,比对双方数据库,修复所有极端情况下的漏网之鱼。

如果你也在做类似的跨系统数据同步,希望这篇文章能帮你少走一些弯路。欢迎在评论区交流你的实践方案。

返回列表