一套系统连三种数据库,不是炫技,是制造业数字化的真实刚需。这篇文章复盘我在若依框架上踩过的多数据源深坑——从事务不回滚到动态切换翻车,每一个坑都花了至少一天。


一、为什么一个系统要连三种数据库?

先交代背景。我们的 MES 系统要跑在印刷包装厂的环境里,这些工厂的 IT 现状通常是这样的:

数据库用途为什么不能动
MySQL 8我们自己的 MES 主库新建的,可控
SQL Server客户的旧 ERP 系统用了 8 年,不敢动
Firebird老旧的印刷行业软件用得更久,更不敢动

老板的要求很简单:新系统不能要求客户换掉旧系统。数据必须在三套库之间流转——工单从 ERP 同步过来、生产数据写进 MES、部分统计还要回写到 Firebird 给老软件读。

这就叫异构数据库数据同步,听起来高大上,做起来全是坑。

注:本文基于若依框架(RuoYi Spring Boot 3 版本)的多数据源方案进行实战讲解。方案思路可复用到任何 Spring Boot 项目。


二、若依框架的多数据源原理

2.1 框架做了什么

若依框架内置了基于 dynamic-datasource 的多数据源支持。核心思路是注解切换

@DS("master")    // 走主库
public void insertOrder() { ... }

@DS("erp")       // 走客户的 SQL Server
public List<Order> syncOrder() { ... }

框架帮你做的事情:

  • 通过 AOP 拦截 @DS 注解,自动切换数据源

  • 支持多数据源的事务管理

  • 配合 Druid 连接池

看起来很简单对吧?接下来就是实际场景中爆出来的坑。

2.2 框架没做的事

若依的多数据源方案有个前提假设:所有库的表结构是你可控的。但实际情况是:

  • SQL Server 里的表是别人建的,字段命名毫无规律

  • Firebird 的表连文档都没有,全靠猜

  • 三套库的数据类型不完全对应

这些才是真正的难度所在。


三、第一步:配置多数据源

3.1 数据源配置

# application-druid.yml
spring:
  datasource:
    druid:
      # 主数据源 - MES 系统
      master:
        url: jdbc:mysql://localhost:3306/your_mes_db?useUnicode=true&characterEncoding=utf8
        username: your_username
        password: ${MYSQL_PWD}
        driver-class-name: com.mysql.cj.jdbc.Driver

      # 从数据源 - 客户 ERP(SQL Server)
      erp:
        url: jdbc:sqlserver://HOST:1433;DatabaseName=LEGACY_ERP
        username: your_username
        password: ${SQLSERVER_PWD}
        driver-class-name: com.microsoft.sqlserver.jdbc.SQLServerDriver

      # 从数据源 - 老旧印刷软件(Firebird)
      legacy:
        url: jdbc:firebirdsql://HOST:3050/PATH/TO/LEGACY.FDB?encoding=GB2312
        username: your_username
        password: ${FIREBIRD_PWD}
        driver-class-name: org.firebirdsql.jdbc.FBDriver

3.2 引入驱动依赖

<!-- pom.xml -->
<!-- MySQL 驱动(若依已自带) -->
<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
</dependency>

<!-- SQL Server 驱动 -->
<dependency>
    <groupId>com.microsoft.sqlserver</groupId>
    <artifactId>mssql-jdbc</artifactId>
    <version>12.6.1.jre11</version>
</dependency>

<!-- Firebird 驱动 -->
<dependency>
    <groupId>org.firebirdsql.jdbc</groupId>
    <artifactId>jaybird</artifactId>
    <version>5.0.4.java11</version>
</dependency>

⚠️ 坑 #1:Firebird 驱动版本混乱

Jaybird 4.x 需要 Java 11+,但有些老版本 Firebird 数据库(2.x)只能用 Jaybird 3.x 连接。你的厂里的 Firebird 是哪个版本?先问清楚,否则连上去就是 GDS Exception


四、真正的大坑:多数据源事务管理

4.1 第一个坑:切换数据源后事务不回滚

这是最常见也最致命的问题。看这段代码:

// ❌ 错误写法:切换数据源后,事务管理会乱
@Service
public class OrderSyncService {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @DS("master")
    @Transactional
    public void syncOrderFromErp(Long orderId) {
        // 第一步:从 SQL Server 读取工单数据
        String sql = "SELECT * FROM TB_ORDER WHERE ID = ?";
        Map<String, Object> order = jdbcTemplate.queryForMap(sql, orderId);

        // 第二步:写入 MySQL(此时数据源已经切回 master)
        jdbcTemplate.update(
            "INSERT INTO mes_order (...) VALUES (...)", ...
        );

        // 第三步:如果这里抛异常——
        throw new RuntimeException("模拟异常");
        // MySQL 的 INSERT 已经提交了!不会回滚!
    }
}

原因:Spring 的 @Transactional 默认绑定的是 masterTransactionManager。当你在 erp 数据源上执行查询后,erp 的事务已经独立提交,和 master 事务不在同一个事务管理器下。

4.2 正确做法:显式指定事务管理器

// ✅ 正确写法
@Service
public class OrderSyncService {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    /**
     * 从 ERP(SQL Server)同步单条工单到 MES(MySQL)
     * 注意:两个数据源各自管理事务,跨库无法强一致
     */
    @DS("erp")
    public Map<String, Object> readOrderFromErp(Long orderId) {
        // 读操作 — 不需要事务,只管读
        return jdbcTemplate.queryForMap(
            "SELECT * FROM TB_ORDER WHERE ID = ?", orderId
        );
    }

    @DS("master")
    @Transactional(transactionManager = "masterTransactionManager")
    public void saveOrderToMes(Map<String, Object> orderData) {
        // 写操作 — 走 master 事务管理器
        jdbcTemplate.update(
            "INSERT INTO mes_order (...) VALUES (...)", ...
        );
    }

    // 协调方法:逐条同步,加补偿逻辑
    public SyncResult syncOneOrder(Long orderId) {
        try {
            Map<String, Object> data = readOrderFromErp(orderId);
            saveOrderToMes(data);
            return SyncResult.success(orderId);
        } catch (Exception e) {
            log.error("工单 {} 同步失败", orderId, e);
            return SyncResult.fail(orderId, e.getMessage());
        }
    }
}

核心原则:跨数据库的强一致性基本做不到(除非上分布式事务,性能极差)。工程上的做法是 逐条同步 + 异常补偿

// 同步失败记录写入补偿表
@DS("master")
public void recordSyncFailure(Long orderId, String errorMsg) {
    jdbcTemplate.update(
        "INSERT INTO sync_failure_log (order_id, error_msg, retry_count, created_at) " +
        "VALUES (?, ?, 0, NOW())", orderId, errorMsg
    );
}

// 定时任务重试失败的同步
@Scheduled(cron = "0 */5 * * * ?")  // 每 5 分钟
public void retryFailedSync() {
    List<SyncFailure> failures = getPendingRetries();
    for (SyncFailure f : failures) {
        syncOneOrder(f.getOrderId());
    }
}

五、第二个大坑:异构数据库的数据类型映射

5.1 字段类型不对齐

三个数据库的数据类型各有各的脾气:

数据类型MySQLSQL ServerFirebird处理方式
日期时间datetimedatetimeTIMESTAMP统一转 LocalDateTime
布尔tinyint(1)bitSMALLINT用 Integer 接收,代码层判断
长文本longtextnvarchar(max)BLOB SUB_TYPE TEXT统一用 String 接收
金额decimal(18,2)moneyNUMERIC(18,2)BigDecimal 统一处理

5.2 一个生产事故

SQL Server 的 money 类型映射到 Java 时,默认会变成 Double,导致精度丢失。一个客户的工单金额 12345.67 同步后变成了 12345.669999...

// ❌ 错误做法
Map<String, Object> row = jdbcTemplate.queryForMap(sql);
Double amount = (Double) row.get("TOTAL_AMOUNT");  // 精度丢失!

// ✅ 正确做法:强制指定返回类型
BigDecimal amount = (BigDecimal) row.get("TOTAL_AMOUNT");
// 或者在 MyBatis 的 ResultMap 中显式指定 jdbcType=DECIMAL

六、第三个坑:Firebird 的编码问题

Firebird 是一个小众数据库,但印刷行业有大量基于它的老旧软件。连接 Firebird 最容易翻车的是字符编码

// ❌ 连接字符串不指定编码,中文全部乱码
"jdbc:firebirdsql://HOST:3050/PATH/TO/LEGACY.FDB"

// ✅ 必须指定 encoding
"jdbc:firebirdsql://HOST:3050/PATH/TO/LEGACY.FDB?encoding=GB2312"
// 或者 encoding=UTF8(看对方建库时的字符集)

但问题来了:你不知道对方的 Firebird 是什么字符集建的。如果有源码或者备份文件,用 Firebird 的 gstat 工具查:

gstat -h YOUR_DB.GDB | grep "Character set"

如果拿不到,就用 GB2312 和 UTF8 各试一次,看哪次中文不出现"锟斤拷"。


七、增量同步策略

全量同步不现实,ERP 里的工单表可能几十万行。增量同步的关键是找到可靠的变更标识

7.1 理想情况:有更新时间戳

-- SQL Server 侧:查询最近更新的工单
SELECT * FROM TB_ORDER
WHERE UPDATE_TIME > ?
ORDER BY UPDATE_TIME ASC

7.2 现实情况:没有时间戳

印刷厂的旧 ERP,大概率没有 UPDATE_TIME 字段。此时方案是:

  • 有自增 ID:用 MAX(id) 做增量标记,只同步 ID 大于上次最大 ID 的记录

  • 日期字段:用 CREATE_DATE 做分区查询,每天定时同步当天的数据

  • 什么都没有:全量查询 + 本地比对(MD5 或行哈希),只插入/更新变更记录

// 本地比对方案
@DS("erp")
public List<String> getRemoteOrderHashes() {
    return jdbcTemplate.queryForList(
        "SELECT CONCAT(ID, '|', ORDER_NO, '|', STATUS) AS ROW_HASH FROM TB_ORDER",
        String.class
    );
}

个人建议:如果对方允许,帮他们在 ERP 表上加一个 UPDATE_TIME 字段 + 触发器自动更新。这比任何取巧方案都靠谱。


八、踩坑汇总

现象原因解决
多数据源事务不回滚切换数据源后异常不生效TransactionManager,AOP 切面顺序问题显式指定 transactionManager,读写分离
跨库强一致性分布式事务不支持不同数据库物理隔离逐条同步 + 失败日志 + 定时补偿
SQL Server money 类型精度丢失JDBC 默认映射为 Double强制用 BigDecimal 接收
Firebird 编码中文乱码默认字符集不匹配URL 指定 encoding 参数,先用 gstat 查
异构表字段映射字段名、类型都不对应三套库各自为政建一个 DTO 层做字段映射转换
驱动版本不兼容连接报 GDS ExceptionFirebird 数据库版本和 JDBC 驱动不对先确认 Firebird 版本,再选 Jaybird 版本

九、总结

多数据源这件事,配通只需要半小时,跑稳需要半个月。核心经验:

  1. 别幻想分布式事务:跨库场景下,逐条同步 + 补偿机制是工程上最务实的方案

  2. 提前确认对方数据库的版本和字符集:尤其是 Firebird 这种小众数据库,接上去再发现不对,debug 成本极高

  3. 建一层 DTO 映射:不要把异构库的字段直接暴露给业务代码,中间加一层转换,出问题只改一处

  4. 增量同步是必须的:全量同步撑不过第一个月,设计时就考虑好增量标记字段


如果你也在独立开发产品,或者对制造业数字化感兴趣,欢迎关注这个公众号。我会持续分享从代码到产品的全过程——包括成功的经验,也包括踩过的坑。

一个人的产品之路,不孤单。👇

原创作者 MqCode(全栈开发者,印刷包装行业 MES+CRM 系统独立开发),欢迎自由转发。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐