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

本文基于一个真实的互联网医院系统架构演进过程,不涉及任何理论空谈。如果你也面临"A系统调用B系统,B失败了怎么办"的问题,希望这篇文章能给你一些实战参考。

最好的分布式事务,是没有分布式事务------聊聊"绕开它"的六种设计


一、问题背景

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

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

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

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

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

typescript 复制代码
租户端业务落库 → 直接HTTP调用患者端接口 → 失败就抛异常

这个方案在测试环境跑得很欢,但上线后很快暴露了问题:

  • 患者端偶尔网络抖动,导致租户端整个请求失败,医生开方直接报错;
  • 患者端发布重启期间,租户端同步调用全部超时,产生大量单边数据(租户有,患者端没有);
  • 支付回调场景下,租户端已经改了状态,但患者端没收到,患者看到的订单状态一直是"未支付"。

二、为什么不用分布式事务?

分布式事务(Seata、TCC、Saga等)确实是解决跨系统一致性的"标准答案",但我们最终没有选它,原因很直接:

  • 性能开销大:全局锁、二阶段提交、额外的协调服务,每一次跨系统调用都要付出额外的RTT和锁等待成本。对于互联网医院这种高并发场景(尤其是支付回调高峰期),延迟敏感。
  • 运维复杂度高:需要额外部署TC协调器,排查问题时要同时看多个系统的日志和事务状态,定位成本高。
  • 耦合度高:引入分布式事务框架后,两个系统的代码和部署都产生了强耦合,不利于后续独立演进。

我们需要的不是"强一致",而是最终一致------允许短暂的不一致,但必须保证数据最终能对上。


三、最终方案:四道防线

经过多轮讨论和压测,我们最终落地了一套**"本地消息表 + 四道防线"**的方案。下面用图解一步步拆解。

整体架构图

typescript 复制代码
┌─────────────────────────────────────────────────────────────┐
│                        租户端                                │
│                                                             │
│  ┌──────────┐    ┌──────────────┐    ┌──────────────────┐  │
│  │ 业务落库  │───→│ 本地消息表    │───→│ 同步调用患者端    │  │
│  │ (事务内)  │    │ (PENDING)    │    │ (第一道防线)      │  │
│  └──────────┘    └──────────────┘    └────────┬─────────┘  │
│                                               │ 失败       │
│                                    ┌──────────▼─────────┐  │
│                                    │ 异步投递Kafka       │  │
│                                    │ (第二道防线)        │  │
│                                    └────────┬───────────┘  │
│                                               │            │
│                                    ┌──────────▼─────────┐  │
│                                    │ 定时兜底任务        │  │
│                                    │ (第三道防线)        │  │
│                                    │ 指数退避+随机抖动   │  │
│                                    └────────────────────┘  │
└─────────────────────────────────────────────────────────────┘
                          │
                          │  Kafka
                          ▼
┌─────────────────────────────────────────────────────────────┐
│                        患者端                                │
│                                                             │
│  ┌──────────────┐    ┌──────────────┐    ┌──────────────┐  │
│  │ Kafka消费    │───→│ 幂等落库      │───→│ 不回调租户    │  │
│  │ @Retryable   │    │ (Unique Key) │    │               │  │
│  │ Topic步进重试 │    │              │    │               │  │
│  └──────────────┘    └──────────────┘    └──────────────┘  │
│                                                             │
│  ┌──────────────────────────────────────────────────────┐  │
│  │  提供两类接口给租户调用:                               │  │
│  │  1. Kafka代理接口:接收消息并投递到本地Kafka           │  │
│  │  2. 业务状态查询接口:供租户兜底任务主动查询           │  │
│  └──────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────┘
                          │
                          │  T+1 对账任务(第四道防线)
                          ▼
                  ┌───────────────┐
                  │ 比对双方数据库  │
                  │ 修复漏网之鱼    │
                  └───────────────┘

第一道防线:实时同步(快路径)

目标:正常情况下,尽可能快地完成同步,不走任何异步链路。

typescripr 复制代码
租户端                          患者端
  │                               │
  │── 业务落库(事务内) ──────────│
  │                               │
  │── HTTP调用患者端代理接口 ──────│──→ 投递到Kafka
  │                               │
  │←────────── 返回成功 ──────────│
  │                               │
  │ 本地消息表状态 → CONFIRMED     │
  │ (直接结束,不走后续流程)       │

关键点:

  • 同步调用和写本地消息表在同一个数据库事务里完成,保证原子性。
  • 如果同步调用成功,本地消息表状态直接置为 CONFIRMED,后续流程全部跳过。
  • 患者端的代理接口只做"接收参数 → 发送到Kafka → 返回成功",不做复杂业务逻辑,保证轻量和高吞吐。

第二道防线:Kafka异步削峰(非阻塞重试)

目标:同步调用失败时,通过Kafka异步重试,且不影响其他业务。

typescript 复制代码
租户端                          患者端
  │                               │
  │── 同步调用失败 ───────────────│
  │                               │
  │ 本地消息表状态 → PENDING       │
  │                               │
  │── 异步投递到Kafka ────────────│──→ 消息进入主Topic
  │                               │
  │ 本地消息表状态 → SENT          │
  │                               │
  │                          ┌────▼────┐
  │                          │Kafka消费 │
  │                          │失败!    │
  │                          └────┬────┘
  │                               │
  │                          ┌────▼────────────────┐
  │                          │自动进入重试Topic     │
  │                          │retry-1: 1分钟后消费  │
  │                          │retry-2: 5分钟后消费  │
  │                          │retry-3: 30分钟后消费 │
  │                          └────┬────────────────┘
  │                               │
  │                          ┌────▼────┐
  │                          │仍失败?  │──→ 进入死信Topic(DLT)
  │                          └─────────┘      触发告警,人工介入

Spring Kafka 配置示例:

java 复制代码
@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")
public void listenPrescriptionSync(PrescriptionMessage message) {
    patientService.syncPrescription(message);
}

为什么用非阻塞重试?

如果用传统的同步重试(消费线程里直接 try-catch + Thread.sleep),一条消息失败会阻塞整个分区的后续消费。而非阻塞重试会把失败消息转发到独立的重试Topic,主Topic继续畅通,其他业务不受影响。

每个业务独立配置:支付回调和处方同步可以使用不同的Topic,各自配置不同的重试间隔和次数,互不干扰。


第三道防线:租户兜底任务(指数退避 + 随机抖动)

目标:应对Kafka投递失败、患者端长时间宕机等极端情况,同时防止重试流量把刚恢复的下游打挂。

这是整个方案里最容易被忽视但最关键的一环。

本地消息表结构
sql 复制代码
CREATE TABLE local_message (
    id              BIGINT PRIMARY KEY AUTO_INCREMENT,
    biz_type        VARCHAR(32) NOT NULL COMMENT '业务类型:PAY/PRESCRIPTION',
    biz_id          VARCHAR(64) NOT NULL COMMENT '业务主键:订单号/处方号',
    payload         TEXT NOT NULL COMMENT '消息体JSON',
    status          VARCHAR(16) NOT NULL COMMENT 'PENDING/SENT/CONFIRMED/DEAD',
    retry_count     INT DEFAULT 0 COMMENT '重试次数',
    next_retry_time DATETIME COMMENT '下次允许重试的时间',
    create_time     DATETIME DEFAULT CURRENT_TIMESTAMP,
    update_time     DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_status_retry (status, next_retry_time)
);
兜底任务扫描逻辑
java 复制代码
@Scheduled(fixedDelay = 60_000) // 每分钟执行一次
public void retrySentMessages() {
    // 只扫描状态为SENT且到达重试时间的记录
    List<LocalMessage> messages = messageMapper.selectRetryable(
        "SENT", LocalDateTime.now()
    );
    
    for (LocalMessage msg : messages) {
        try {
            // 主动调用患者端查询接口,确认是否已落库
            boolean synced = patientApiClient.querySyncStatus(
                msg.getBizType(), msg.getBizId()
            );
            
            if (synced) {
                // 患者端已处理,标记为已同步
                messageMapper.updateStatus(msg.getId(), "CONFIRMED");
            } else {
                // 患者端未处理,重新投递
                patientApiClient.sendToKafka(msg.getBizType(), msg.getPayload());
                // 指数退避:计算下次重试时间
                long nextDelay = calculateBackoff(msg.getRetryCount());
                messageMapper.updateRetryInfo(
                    msg.getId(), msg.getRetryCount() + 1, 
                    LocalDateTime.now().plusSeconds(nextDelay)
                );
            }
        } catch (Exception e) {
            // 调用患者端接口失败(如下游宕机),同样走指数退避
            long nextDelay = 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)
                );
            }
        }
    }
}

// 指数退避 + 随机抖动
private long calculateBackoff(int retryCount) {
    long baseDelay = 60;          // 基础间隔60秒
    long multiplier = 5;          // 指数因子
    long delay = baseDelay * (long) Math.pow(multiplier, retryCount);
    delay = Math.min(delay, 7200); // 最大不超过2小时
    // 叠加0~50%的随机抖动,防止重试风暴
    long jitter = ThreadLocalRandom.current().nextLong(0, delay / 2);
    return delay + 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对账(终极兜底)

目标:修复所有前三道防线都漏掉的极端情况,并输出差异报表用于问题追溯。

typescript 复制代码
每天凌晨2:00执行:

1. 从租户端数据库导出当天所有订单/处方ID
2. 调用患者端批量查询接口,比对双方数据
3. 发现不一致的记录:
   - 租户有、患者端没有 → 触发补偿同步
   - 状态不一致 → 以租户端为准修正患者端
4. 输出对账差异报表,发送到运维群

对账任务不需要实时性,但必须全覆盖、可追溯。它是整个一致性体系的"安全网"。


四、关键设计要点总结

1. 幂等是底线

  • 患者端Kafka代理接口 :通过 biz_id 防重,已投递过的消息直接返回成功。
  • 患者端Kafka消费 :通过数据库唯一索引(Unique Key)或状态机更新(UPDATE ... WHERE status = 'INIT')做最终幂等。
  • 租户兜底任务 :查询接口本身是只读的,天然幂等;重发接口同样通过 biz_id 防重。

2. 状态机清晰

typescript 复制代码
PENDING → SENT → CONFIRMED
               FAILED → DEAD(告警/人工)

每个状态转换都有明确的触发条件,不会出现状态混乱。

3. 患者端不反向调用租户/回调租户

因为两套环境物理隔离、MQ组件不同,可以有以下俩种方案:

(1)患者端消费成功后不回调租户 。租户通过主动查询 的方式确认同步状态,彻底解耦。

(2)患者端消费成功后"回调租户",租户接到请求更新状态

4. 随机抖动不能省

没有抖动的指数退避(步进式重试),在大量消息同时到达同一重试时间点时,依然会造成局部流量峰值。加上0~50%的随机抖动后,重试请求会被均匀打散。


五、方案对比

维度 分布式事务 纯异步MQ 本方案(四道防线)
一致性 强一致 最终一致 最终一致
性能 低 高 高
运维复杂度 高 低 中
下游宕机保护 指数退避+抖动
业务隔离 --- 需手动隔离 Topic隔离+独立配置
数据兜底 --- T+1对账
适用场景 金融核心交易 日志/通知 订单/处方/病历同步

六、写在最后

这套方案不是一开始就设计出来的,而是在线上出了几次单边数据事故后,一次次迭代出来的。

核心思想其实就一句话:不要指望任何单一机制100%可靠,用多层防线把概率问题变成确定性问题。

  • 第一道防线(实时同步)解决99%的正常情况:租户业务落库后,同步调用患者端代理接口。成功则直接结束。
  • 第二道防线(Kafka 异步削峰)解决网络抖动等瞬时故障:同步失败,写入本地消息表。异步投递到 Kafka。患者端消费失败时,利用 @RetryableTopic 进行非阻塞的步进式重试(1m -> 5m -> 30m),不影响其他业务。
  • 第三道防线(租户兜底任务)解决下游宕机等系统级故障:针对 Kafka 投递失败或长时间未确认的消息,租户定时任务扫描本地消息表,采用指数退避策略调用患者端接口,防止下游宕机时被上游的重试流量雪崩打挂。
  • 第四道防(T+1 对账)线兜住所有漏网之鱼:每日凌晨跑批,比对双方数据库,修复所有极端情况下的漏网之鱼。

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

相关推荐
一枕槐安4874 天前
大模型限流、重试、超时处理:API 调用健壮性方案
linux·服务器·php·大模型api·指数退避·限流控制·超时预算
蒸鱼Yuzheng13 天前
自动化长任务治理:租约、心跳、孤儿进程与安全回收
自动化测试·python·任务调度·可靠性·分布式系统
凤山老林15 天前
构建多通道通知中心:模板化设计、异步削峰与可靠重试实践
spring boot·异步削峰·模板设计
一切皆是因缘际会1 个月前
掌控信息论:同源星际通信基础理论 上
人工智能·ai·系统架构·分布式系统·信息论·星际通信·物理计算机
蒸鱼Yuzheng1 个月前
自动化 Socket 协议设计:消息序列、心跳、幂等重试与背压
自动化测试·python·websocket·socket·分布式系统
ShineWinsu1 个月前
对于Redis:Redis特性以及应用场景的解析
linux·c++·redis·缓存·高并发·分布式系统·key-value
deepseek232 个月前
MCP 2026-07-28 Tasks扩展深度解析:从无状态协议到长时运行任务的架构演进
ai agent·分布式系统·协议设计·mcp·无状态协议·tasks扩展
ylj_dev2 个月前
从 0 构建 AI Workload Platform(八):受限执行环境与 Kubernetes
docker·容器·kubernetes·分布式系统·ai工作流·容器安全·ai infra
TunerT_TQ3 个月前
Valhalla 静态工程审阅 #007|AgentENV 源码证据驱动评测【大厂开源基础设施特辑】
rust·开源·go·github·sandbox·分布式系统·ai基础设施