首页/新闻资讯/正文详情

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

发布时间:2026/11/6 18:23:53 来源:云帆数科 栏目:资讯中心
跨系统数据同步:为什么我放弃了分布式事务,选择“本地消息表+四道防线”
本文基于一个真实的互联网医院系统架构演进过程不涉及任何理论空谈。如果你也面临“A系统调用B系统B失败了怎么办”的问题希望这篇文章能给你一些实战参考。最好的分布式事务是没有分布式事务——聊聊“绕开它“的六种设计一、问题背景我们的互联网医院系统有两套独立部署的服务租户端医院管理后台负责订单、处方、病历等业务。患者端患者使用的C端应用独立数据库、独立部署环境。两个系统之间需要频繁同步数据典型场景有两个支付回调支付厂商先回调租户端租户端更新订单状态后需要同步到患者端。医生开方医生在租户端开诊断、病历、处方租户端落库后需要异步同步到患者端。最开始的实现非常简单粗暴租户端业务落库 → 直接HTTP调用患者端接口 → 失败就抛异常这个方案在测试环境跑得很欢但上线后很快暴露了问题患者端偶尔网络抖动导致租户端整个请求失败医生开方直接报错患者端发布重启期间租户端同步调用全部超时产生大量单边数据租户有患者端没有支付回调场景下租户端已经改了状态但患者端没收到患者看到的订单状态一直是未支付。二、为什么不用分布式事务分布式事务Seata、TCC、Saga等确实是解决跨系统一致性的标准答案但我们最终没有选它原因很直接性能开销大全局锁、二阶段提交、额外的协调服务每一次跨系统调用都要付出额外的RTT和锁等待成本。对于互联网医院这种高并发场景尤其是支付回调高峰期延迟敏感。运维复杂度高需要额外部署TC协调器排查问题时要同时看多个系统的日志和事务状态定位成本高。耦合度高引入分布式事务框架后两个系统的代码和部署都产生了强耦合不利于后续独立演进。我们需要的不是强一致而是最终一致——允许短暂的不一致但必须保证数据最终能对上。三、最终方案四道防线经过多轮讨论和压测我们最终落地了一套**“本地消息表 四道防线”**的方案。下面用图解一步步拆解。整体架构图┌─────────────────────────────────────────────────────────────┐ │ 租户端 │ │ │ │ ┌──────────┐ ┌──────────────┐ ┌──────────────────┐ │ │ │ 业务落库 │───→│ 本地消息表 │───→│ 同步调用患者端 │ │ ││(事务内)││(PENDING)││(第一道防线)│ │ │ └──────────┘ └──────────────┘ └────────┬─────────┘ │ │ │ 失败 │ │ ┌──────────▼─────────┐ │ │ │ 异步投递Kafka │ │ ││(第二道防线)│ │ │ └────────┬───────────┘ │ │ │ │ │ ┌──────────▼─────────┐ │ │ │ 定时兜底任务 │ │ ││(第三道防线)│ │ │ │ 指数退避随机抖动 │ │ │ └────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ │ │ Kafka ▼ ┌─────────────────────────────────────────────────────────────┐ │ 患者端 │ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ Kafka消费 │───→│ 幂等落库 │───→│ 不回调租户 │ │ │ │Retryable││(Unique Key)│ │ │ │ │ │ Topic步进重试 │ │ │ │ │ │ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ 提供两类接口给租户调用 │ │ │ │1.Kafka代理接口接收消息并投递到本地Kafka │ │ │ │2.业务状态查询接口供租户兜底任务主动查询 │ │ │ └──────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ │ │T1对账任务第四道防线 ▼ ┌───────────────┐ │ 比对双方数据库 │ │ 修复漏网之鱼 │ └───────────────┘第一道防线实时同步快路径目标正常情况下尽可能快地完成同步不走任何异步链路。租户端 患者端 │ │ │── 业务落库事务内 ──────────│ │ │ │── 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(attempts4,backoffBackoff(delay1000*60,// 首次延迟1分钟multiplier5,// 指数因子maxDelay1000*60*30// 最大延迟30分钟),autoCreateTopicstrue,dltStrategyDltStrategy.FAIL_ON_ERROR)KafkaListener(topicsprescription_sync_topic,groupIdpatient-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)NOTNULLCOMMENTPENDING/SENT/CONFIRMED/DEAD,retry_countINTDEFAULT0COMMENT重试次数,next_retry_timeDATETIMECOMMENT下次允许重试的时间,create_timeDATETIMEDEFAULTCURRENT_TIMESTAMP,update_timeDATETIMEDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMP,INDEXidx_status_retry(status,next_retry_time));兜底任务扫描逻辑Scheduled(fixedDelay60_000)// 每分钟执行一次publicvoidretrySentMessages(){// 只扫描状态为SENT且到达重试时间的记录ListLocalMessagemessagesmessageMapper.selectRetryable(SENT,LocalDateTime.now());for(LocalMessagemsg:messages){try{// 主动调用患者端查询接口确认是否已落库booleansyncedpatientApiClient.querySyncStatus(msg.getBizType(),msg.getBizId());if(synced){// 患者端已处理标记为已同步messageMapper.updateStatus(msg.getId(),CONFIRMED);}else{// 患者端未处理重新投递patientApiClient.sendToKafka(msg.getBizType(),msg.getPayload());// 指数退避计算下次重试时间longnextDelaycalculateBackoff(msg.getRetryCount());messageMapper.updateRetryInfo(msg.getId(),msg.getRetryCount()1,LocalDateTime.now().plusSeconds(nextDelay));}}catch(Exceptione){// 调用患者端接口失败如下游宕机同样走指数退避longnextDelaycalculateBackoff(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){longbaseDelay60;// 基础间隔60秒longmultiplier5;// 指数因子longdelaybaseDelay*(long)Math.pow(multiplier,retryCount);delayMath.min(delay,7200);// 最大不超过2小时// 叠加0~50%的随机抖动防止重试风暴longjitterThreadLocalRandom.current().nextLong(0,delay/2);returndelayjitter;}指数退避效果演示重试次数计算间隔叠加抖动后说明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次调用恢复后压力平滑。第四道防线T1对账终极兜底目标修复所有前三道防线都漏掉的极端情况并输出差异报表用于问题追溯。每天凌晨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隔离独立配置数据兜底—T1对账适用场景金融核心交易日志/通知订单/处方/病历同步六、写在最后这套方案不是一开始就设计出来的而是在线上出了几次单边数据事故后一次次迭代出来的。核心思想其实就一句话不要指望任何单一机制100%可靠用多层防线把概率问题变成确定性问题。第一道防线实时同步解决99%的正常情况租户业务落库后同步调用患者端代理接口。成功则直接结束。第二道防线Kafka 异步削峰解决网络抖动等瞬时故障同步失败写入本地消息表。异步投递到 Kafka。患者端消费失败时利用 RetryableTopic 进行非阻塞的步进式重试1m - 5m - 30m不影响其他业务。第三道防线租户兜底任务解决下游宕机等系统级故障针对 Kafka 投递失败或长时间未确认的消息租户定时任务扫描本地消息表采用指数退避策略调用患者端接口防止下游宕机时被上游的重试流量雪崩打挂。第四道防T1 对账线兜住所有漏网之鱼每日凌晨跑批比对双方数据库修复所有极端情况下的漏网之鱼。如果你也在做类似的跨系统数据同步希望这篇文章能帮你少走一些弯路。欢迎在评论区交流你的实践方案。

相关推荐

黄精不是姜:一味“补阴”,一味“散寒”,别再搞混了
黄精不是姜:一味“补阴”,一味“散寒”,别再搞混了

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 4:00:30

【第二篇 · 番外 01】
【第二篇 · 番外 01】

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 4:00:30

当下突想-随记
当下突想-随记

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 4:00:30

临夏电工证培训价格怎么算?电子证书怎么拿?
临夏电工证培训价格怎么算?电子证书怎么拿?

临夏电工证培训价格怎么算?电子证书怎么拿? 你是不是也遇到过这种情况:不知道去哪报名怕被中介坑,好不容易找到一家培训机构,结果培训费贵得离谱,考试又没通过?尤其是现在很多单位都要求持证上岗, 电子证书… · 2026/11/6 18:23:05

济阳区哪里能办电工证通过率揭秘:外省证能转吗?手把手教你办理
济阳区哪里能办电工证通过率揭秘:外省证能转吗?手把手教你办理

济阳区哪里能办电工证通过率揭秘:外省证能转吗?手把手教你办理 之前考的证在外省能不能转过来?这个问题我天天被问,说白了,很多人手里有证但不知道怎么用,更别提怎么把外省的证“搬”到济阳来了。今天咱们就来个实打实的解答, 济阳区哪里能办电工证… · 2026/11/6 18:23:05

汝阳在哪里考电工证 良心建议:学历不高怕报不上名?看看这些干货
汝阳在哪里考电工证 良心建议:学历不高怕报不上名?看看这些干货

汝阳在哪里考电工证 良心建议:学历不高怕报不上名?看看这些干货 学历不高怕报不上名?别慌!我从业这么多年,见过太多人因为学历问题放弃考电工证,其实只要你符合条件, 汝阳在哪里考电工证… · 2026/11/6 18:23:05

电工证实操怕挂科?真实经历告诉你怎么稳过数据中心电工证考试
电工证实操怕挂科?真实经历告诉你怎么稳过数据中心电工证考试

电工证实操怕挂科?真实经历告诉你怎么稳过数据中心电工证考试 实操考试心里没底怕挂科?我懂你,我自己也经历过。那年在河南省驻马店市,跟着师傅做公路工程配电项目,第一次去考数据中心电工证,连仪表盘都看不明白,结果一紧张,连最基础的绝缘电阻测试都… · 2026/11/6 18:23:05

西安建筑电工证报考别再走弯路,听我讲个真实经历
西安建筑电工证报考别再走弯路,听我讲个真实经历

西安建筑电工证报考别再走弯路,听我讲个真实经历 你是不是也这样,手里拿着一堆建筑电工证报名信息,却不知道该信哪家?怕被中介坑,怕钱花了证办不下来,这事儿我真经历过,现在就跟你唠唠西安建筑电工证报考的那些事儿。… · 2026/11/6 18:23:05

佛电工证怎么考不被坑?良心建议给你划重点
佛电工证怎么考不被坑?良心建议给你划重点

佛电工证怎么考不被坑?良心建议给你划重点 你是不是也这样?看到“佛电工证”几个字,心里直打鼓,不知道该去哪报名,怕被中介骗钱、怕考不过、怕证书不靠谱,甚至担心以后找工作用不上。我懂你的担心,今天我就用 良心建议… · 2026/11/6 18:22:02

双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 10:27:59

STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 10:28:03

DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/6 10:29:23

三菱PLC数据采集到数据库的时序设计:Python+MC协议实战
三菱PLC数据采集到数据库的时序设计:Python+MC协议实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/6 1:02:11

ASIC主模式RTL设计:状态机与三态驱动的流片级实现
ASIC主模式RTL设计:状态机与三态驱动的流片级实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/6 1:02:11

STM32嵌入式C++开发:GDB调试与Renode仿真实战指南
STM32嵌入式C++开发:GDB调试与Renode仿真实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/6 1:02:11

双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 10:27:59

STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 10:28:03

DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/6 10:29:23

了解更多?预约专属演示

我们的顾问将为您一对一讲解产品与方案

企业微信二维码