资讯详情

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

📅 2026/11/5 4:00:11 | 华诺云谱 👁 阅读
跨系统数据同步:为什么我放弃了分布式事务,选择“本地消息表+四道防线”
本文基于一个真实的互联网医院系统架构演进过程不涉及任何理论空谈。如果你也面临“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 对账线兜住所有漏网之鱼每日凌晨跑批比对双方数据库修复所有极端情况下的漏网之鱼。如果你也在做类似的跨系统数据同步希望这篇文章能帮你少走一些弯路。欢迎在评论区交流你的实践方案。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑