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


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

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

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

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

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

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


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

2.1 框架做了什么

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

1
2
3
4
5
@DS("master")    // 走主库
public void insertOrder() { ... }

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

框架帮你做的事情:

  • 通过 AOP 拦截 @DS 注解,自动切换数据源
  • 支持多数据源的事务管理
  • 配合 Druid 连接池

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

2.2 框架没做的事

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

  • SQL Server 里的表是别人建的,字段命名毫无规律
  • Firebird 的表连文档都没有,全靠猜
  • 三套库的数据类型不完全对应

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


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

3.1 数据源配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 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 引入驱动依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!-- 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 第一个坑:切换数据源后事务不回滚

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// ❌ 错误写法:切换数据源后,事务管理会乱
@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 正确做法:显式指定事务管理器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// ✅ 正确写法
@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());
}
}
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 同步失败记录写入补偿表
@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 字段类型不对齐

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

数据类型 MySQL SQL Server Firebird 处理方式
日期时间 datetime datetime TIMESTAMP 统一转 LocalDateTime
布尔 tinyint(1) bit SMALLINT 用 Integer 接收,代码层判断
长文本 longtext nvarchar(max) BLOB SUB_TYPE TEXT 统一用 String 接收
金额 decimal(18,2) money NUMERIC(18,2) BigDecimal 统一处理

5.2 一个生产事故

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

1
2
3
4
5
6
7
// ❌ 错误做法
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 最容易翻车的是字符编码

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

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

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

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

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


七、增量同步策略

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

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

1
2
3
4
-- 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 或行哈希),只插入/更新变更记录
1
2
3
4
5
6
7
8
// 本地比对方案
@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 Exception Firebird 数据库版本和 JDBC 驱动不对 先确认 Firebird 版本,再选 Jaybird 版本

九、总结

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

  1. 别幻想分布式事务:跨库场景下,逐条同步 + 补偿机制是工程上最务实的方案
  2. 提前确认对方数据库的版本和字符集:尤其是 Firebird 这种小众数据库,接上去再发现不对,debug 成本极高
  3. 建一层 DTO 映射:不要把异构库的字段直接暴露给业务代码,中间加一层转换,出问题只改一处
  4. 增量同步是必须的:全量同步撑不过第一个月,设计时就考虑好增量标记字段

下篇预告:《从单体到模块化:若依多模块 Maven 项目架构实战》