在微服务改造和国产化适配这两股浪潮的交叉点上,“若依微服务里配 MySQL + DM 双数据源”是一个绕不开的经典需求。很多团队在信创环境下拿到一套若依Cloud,第一步不是加业务代码,而是琢磨怎么在不破坏原有权限体系的前提下,让业务模块能同时读写关系型数据。
先说清楚这套方案是干嘛的。它解决的问题非常具体:默认的若依微服务框架只配置了一个数据源,要么全库MySQL,要么全库达梦。但实际交付中经常碰到两种情况——旧系统数据留在MySQL里,新模块必须落到达梦上;或者一套代码要交付给两个不同的最终用户,一个要求MySQL,另一个指定了达梦。如果直接在代码里硬写两套连接,改起来痛苦,维护成本更高。用抽象数据源加自定义注解的路子,可以做到业务代码无感知,请求到达Mapper时自动切换到底层数据库。适合正在做国产化迁移的团队、接手若依二开的开发者,以及想搞明白多数据源路由原理的读者。
这篇文章我会从整体设计思路开始,拆解为什么选注解加AOP方案而不是简单配两个数据源,然后给到核心的代码实现、配置项解析,再附上我实际踩过的坑和排查思路。所有代码基于常见的若依Cloud版本,理论上适配大多数分支。
1. 整体设计与思路拆解
1.1 为什么不能简单配两个数据源
如果只是“能连上两种数据库”,Spring Boot自带的配置确实够用——把两个DataSource定义出来,注入到不同Mapper里各用各的。但放到若依微服务这种框架里,这种思路有几个很现实的问题。
首先是事务问题。若依很多Service是带@Transactional的,如果底层连着两个独立数据源,一个业务方法里同时操作MySQL和达梦,Spring默认的事务管理器只会管其中一个。数据不一致了排查起来极痛苦,表面看是配置问题,实际是事务边界失控。
其次是代码侵入性。多数据源的硬编码意味着所有Mapper、Service都要指定“我连的是哪个库”,一两个模块还好,几十个模块就变成维护灾难。团队成员稍不留意,新写的Mapper没有指定数据源,默认走主库,排查起来要命。
还有一层是环境迁移成本。交付时如果用户要求换数据库,硬编码的方案等于要把所有Mapper的注解全部改一遍。用路由方案的话,只需要改配置文件和SQL方言,代码层基本不用动。
所以我更倾向于“路由式多数据源”——框架只注册一个主数据源入口,通过AOP切面和自定义注解动态决定当前线程走哪个数据源。核心思想不复杂:每个方法执行前,根据注解里的@DS("dm")这样的标记,把一个数据源标识塞进ThreadLocal;方法执行完再把这个标识清理掉。底层数据库连接池是多个,但业务代码看到的永远是一个DataSource接口,连接是在getConnection()时才被路由到真正的库里。这跟城市里设公交总站很类似,线路很多,但乘客在同一个站点上车,闸机自动把人分流到对应线路。
1.2 方案选型:自定义注解 + AOP 的优势
市面上现成的动态数据源组件不少,但放到若依里集成,还是自己封装更可控。
拿内部架构来看,若依本身有很成熟的SpringUtils、SecurityUtils这些工具类,包装一下不费劲。而且开源动态数据源组件功能很重,像分库分表、读写分离这些在项目里根本用不上,白白增加复杂度。自己实现也就是一个注解类、一个切面类、一个动态数据源类,总共三个核心文件。
选择自定义方案还有几层考量:
一是和若依框架的契合度。若依的Mapper层通常是单参数或实体参数,自定义注解在方法级别拦截,基本不会跟MyBatis的代理逻辑冲突。切面只需要拦截Service层的公开方法,简单又干净。
二是权限体系不会被破坏。若依的SysUser权限、Token校验都在网关和SecurityFilter里处理,数据源切换只发生在Service层调用链中,上层逻辑完全无感知。
三是在排错上,自己写的代码理得清调用关系。出了问题能直接定位是切面没触发、注解没生效,还是数据库连接本身有问题。用开源组件,万一兼容性翻车,光看组件源码就够折腾半天。
1.3 整体结构预览
整套方案最终落地是这几个层次:
- 配置层:
application.yml里定义master和dm两个数据源连接信息。 - 路由层:
DynamicDataSource继承AbstractRoutingDataSource,重写determineCurrentLookupKey()方法,从ThreadLocal拿数据源标识。 - 切面层:
DataSourceAspect拦截带@DataSource注解的方法,执行前设置标识,执行后清除。 - 使用层:业务代码在Service方法或类级别加上
@DataSource("dm")注解,声明这个业务走达梦。
这个结构和“配置中心 + 路由表 + 交通指挥员”的模型完全对应,核心就是数据源标识的传递和动态解析。
2. 核心细节解析与实操要点
2.1 自定义注解的设计细节
先看注解定义:
import java.lang.annotation.*; @Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface DataSource { String value() default "master"; }注解本身很简单,但有两个细节容易出问题。
第一是@Target为什么要同时声明METHOD和TYPE。这涉及到Spring AOP的继承机制。如果某个Service接口没有注解,但实现类上有,Spring对实现类做代理时是能识别到的。反过来,如果注解标在接口方法上,部分代理模式会失效。为了系统严谨,我建议用法上统一,Service实现类上加注解,并且容忍类级别注解的配置。TYPE的声明会让“切面直接检查类上注解”变为可能,安全性更高。
第二是value的默认值。设为master意味着“不注明就是主库”,这很符合框架习惯——绝大多数查询走主库,只有明确要求的模块才切到达梦。
提示:注解名用
@DataSource比较直观,但注意别和某些组件自带的同名注解弄混。实际集成中如果项目里塞了多个动态数据源依赖,可能会冲突。出问题时用全限定名导入就能区分。
2.2 动态数据源路由核心
动态数据源的核心类在Spring里已经现成了,就是AbstractRoutingDataSource。看名字也知道这个类设计出来就是干这个事的。
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; public class DynamicDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { return DataSourceContextHolder.getDataSource(); } }关键在于DataSourceContextHolder,它的本质就是一个ThreadLocal包装:
public class DataSourceContextHolder { private static final ThreadLocal<String> CONTEXT_HOLDER = new ThreadLocal<>(); public static void setDataSource(String dataSourceType) { CONTEXT_HOLDER.set(dataSourceType); } public static String getDataSource() { return CONTEXT_HOLDER.get(); } public static void clearDataSource() { CONTEXT_HOLDER.remove(); } }为什么用ThreadLocal?因为数据源路由要保证“这个请求走到这个分支,就一路用这个数据源”,但不同请求之间绝对不能串。ThreadLocal天然把变量隔离在各自的线程里,互相不干扰。
实际配置DynamicDataSource时,AbstractRoutingDataSource里有几个关键方法:
setDefaultTargetDataSource():默认数据源setTargetDataSources():所有可用数据源的Map映射afterPropertiesSet():初始化时把所有DataSource解析成连接
在@Configuration配置类里组装:
@Configuration public class DataSourceConfig { @Bean @ConfigurationProperties("spring.datasource.druid.master") public DataSource masterDataSource() { return DruidDataSourceBuilder.create().build(); } @Bean @ConfigurationProperties("spring.datasource.druid.dm") public DataSource dmDataSource() { return DruidDataSourceBuilder.create().build(); } @Bean @Primary public DynamicDataSource dataSource() { DynamicDataSource dynamicDataSource = new DynamicDataSource(); Map<Object, Object> targetDataSources = new HashMap<>(); targetDataSources.put("master", masterDataSource()); targetDataSources.put("dm", dmDataSource()); dynamicDataSource.setTargetDataSources(targetDataSources); dynamicDataSource.setDefaultTargetDataSource(masterDataSource()); return dynamicDataSource; } }这里有个非常关键的细节:多个DataSourceBean 存在时,必须用@Primary标注路由数据源。否则Spring注入时会发现有两个类型匹配的Bean,直接报NoUniqueBeanDefinitionException。而且这个@Primary加在路由数据源上之后,所有依赖DataSource的地方(包括事务管理器、MyBatis的SqlSessionFactory)拿到的都是路由数据源,后续才能实现真正的动态切换。
2.3 AOP切面的执行时机与顺序
切面类负责拦截注解标记的方法:
@Aspect @Component public class DataSourceAspect { @Pointcut("@annotation(com.example.annotation.DataSource) || @within(com.example.annotation.DataSource)") public void dataSourcePointCut() {} @Before("dataSourcePointCut()") public void before(JoinPoint point) { MethodSignature signature = (MethodSignature) point.getSignature(); Method method = signature.getMethod(); DataSource dataSource = method.getAnnotation(DataSource.class); if (dataSource == null) { Class<?> targetClass = point.getTarget().getClass(); dataSource = targetClass.getAnnotation(DataSource.class); } if (dataSource != null) { DataSourceContextHolder.setDataSource(dataSource.value()); } } @After("dataSourcePointCut()") public void after() { DataSourceContextHolder.clearDataSource(); } }切点表达式里我用了@annotation和@within两个条件,这是为了让方法级和类级注解都能被拦截。如果只写@annotation,类头上的注解不会触发切面,这是很多人在实际应用中遇到的坑。
切面@Before和@After的时机也讲究。切面必须在@Transactional事务管理器开始之前设置数据源标识,否则事务管理器已经通过路由数据源拿到了连接,再切换就晚了。好在Spring中@Before的执行发生在事务通知之前,前提是切面本身没有额外设置低优先级。如果业务里还挂了其他切面,建议给DataSourceAspect设置@Order(1)这类低数值高优先级。
2.4 为什么不用MyBatis原生多数据库支持
有些同学会问,MyBatis不是本身支持多数据库ID吗?直接配置databaseIdProvider不也能区分MySQL和达梦?
这个思路只解决“同一套Mapper针对不同数据库写不同SQL”的问题,比如一个查询,MySQL用LIMIT,达梦用LIMIT或ROWNUM。但它解决不了“一个Service里两个不同业务模块连不同库”的问题。DatabaseId是在全局层面选SQL方言,而数据源路由是在连接层面选数据库。两者解决的问题不同,且可以并存——我做完数据源切换,照样可以用databaseIdProvider区分方言。
达梦有一个点要注意:它高度兼容Oracle语法,但跟MySQL的差异也不能忽视。自增主键的写法、分页语法、某些字符串函数都有差异。如果要做代码同时支持两种库,建议在Mapper里针对不同databaseId写两套SQL,或者把复杂的SQL抽出来做方言适配。这个后面实操部分会演示。
3. 实操过程与核心环节实现
3.1 修改 application.yml 配置
实际项目中,若依微服务的配置通常放在Nacos里统一管理。新建模块时把application-druid.yml这样的公共配置拉出来看,按下面方式扩展数据源部分:
spring: datasource: druid: master: url: jdbc:mysql://localhost:3306/ry_cloud?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root123 driver-class-name: com.mysql.cj.jdbc.Driver dm: url: jdbc:dm://localhost:5236/DMSERVER?compatibleMode=mysql username: SYSDBA password: SYSDBA001 driver-class-name: dm.jdbc.driver.DmDriver initial-size: 5 min-idle: 5 max-active: 20配置里有几个细节值得展开。
驱动类名必须确认。MySQL 8及以上用com.mysql.cj.jdbc.Driver,5.x系列用com.mysql.jdbc.Driver。达梦8的驱动类是dm.jdbc.driver.DmDriver。如果写错,启动时直接报ClassNotFoundException,这个倒好排查,一眼就能看出来。
达梦的连接URL格式。默认端口是5236,DMSERVER是默认服务名,实际可能要按安装实例改。注意我写了?compatibleMode=mysql,这是让达梦在语法兼容层面尽量往MySQL靠。达梦官方提供了多种兼容模式,如果项目里大量用了MySQL特有的函数,这个参数能省很多改SQL的功夫。但还是要提醒,兼容模式不是万能的,复杂的查询建议还是单独拿到达梦客户端上执行一遍验证。
Druid监控配置。若依默认用Druid连接池,多个数据源时Druid的监控统计也能配多个。比如:
spring: datasource: druid: filter: stat: enabled: true web-stat-filter: enabled: true如果生产环境要看每个数据源的活跃连接、慢查询数,把多个数据源的DruidDataSource注册到Druid监控页时需要利用StatFilter和WallFilter手动注册。默认情况下,监控页只会显示一个聚合值,具体单独数据源的状态可以靠Druid提供的DataSourceStatManager查看。这里先不展开,想深入的话直接看Druid官方文档关于多数据源监控的部分。
3.2 创建达梦数据库表结构
连接信息配好之后,数据库侧的准备工作不能忽略。达梦一般有两种操作方式:用达梦自带的“达梦管理工具”图形化操作,或者用disql命令行执行脚本。
实际项目里,我见过太多人把精力全放在代码上,结果库表都没准备好。讲个真实感受,某次联调环境上,开发说“连接没问题啊,能ping通”,结果一执行查询就报“表或视图不存在”。因为他在MySQL里建了表,但达梦库里还是空的。所以建表脚本一定要提前在达梦控制台上执行一遍。
达梦建表时兼容MySQL写法还是有一些细节要注意:
-- 如果原来MySQL表里有这种自增主键定义 -- 在达梦中需要换成使用序列 CREATE SEQUENCE seq_ry_user START WITH 1 INCREMENT BY 1; CREATE TABLE "SYSDBA"."RY_USER" ( "USER_ID" INT NOT NULL, "USER_NAME" VARCHAR(50), "STATUS" CHAR(1) DEFAULT '0', PRIMARY KEY ("USER_ID") ); INSERT INTO "SYSDBA"."RY_USER"("USER_ID","USER_NAME") VALUES (seq_ry_user.NEXTVAL, '测试用户');AUTO_INCREMENT在达梦里可以被部分兼容,但最稳定的做法还是显式创建序列。还有字段注释,MySQL里是COMMENT '名称',达梦里必须用:
COMMENT ON COLUMN "SYSDBA"."RY_USER"."USER_NAME" IS '用户名称';如果原来库很大,建议直接用工具迁移,而不是手动跑脚本。达梦自带迁移工具可以把MySQL的表结构和数据一键迁过来,同时会把AUTO_INCREMENT和COMMENT做适配,能省很多事。
3.3 在Service层切换数据源
配置和表都就绪后,使用层就很简洁了。假设现在有个需求:用户同步模块走达梦,其余模块走MySQL。Service里这样写:
@Service public class SysUserSyncServiceImpl implements SysUserSyncService { @Resource private DmUserMapper dmUserMapper; @Override @DataSource("dm") public List<DmUser> selectDmUserList(DmUser dmUser) { return dmUserMapper.selectDmUserList(dmUser); } }这个写法有两个细节要说清。
一是@DataSource标注在实现类方法上,而不是接口方法上。我们切面切的是实现类,标注在接口上在某些情况下会失效,尤其当CGLIB代理时。统一注解写在实现类上,能避免很多诡异行为。
二是方法执行完后,切面会清理ThreadLocal。如果不清理,线程池里的线程复用后会把数据源标识带到下一个任务,出现“A请求切到了达梦,B请求也跟着去了达梦”的串数据现象。这个可以说是多数据源开发中最容易踩的坑之一。加了@After清理之后,每次请求都是干净状态。
3.4 封装Dm的Mapper支持
既然有了独立数据源,Mapper自然也要能在这个数据源上工作。直接在若依的Mapper层写是可行的,但要建独立的Mapper接口以及对应的XML文件。
简单示例,Mapper接口:
public interface DmUserMapper { List<DmUser> selectDmUserList(DmUser dmUser); int insertDmUser(DmUser dmUser); }XML里是达梦对应的SQL:
<select id="selectDmUserList" parameterType="DmUser" resultType="DmUser"> SELECT user_id, user_name, status FROM RY_USER <where> <if test="userId != null"> AND user_id = #{userId} </if> <if test="userName != null and userName != ''"> AND user_name LIKE CONCAT('%', #{userName}, '%') </if> </where> </select>注意一个常见的分页问题。若依自带的分页插件是PageHelper,它默认会按方言自动识别数据库。多数据源情况下,PageHelper识别数据库类型是靠拿到的连接来判断的,理论上能正常工作。但要注意PageHelper的dialect参数不要硬编码成mysql,否则查达梦数据源时生成的分页SQL不兼容。让插件自动识别即可。
至于DmUser实体类,字段最好和达梦表保持严格一致。如果两个库都有同一个表,可以共用一个实体类,但别在实体里加奇怪的联合字段。多数据源场景下,实体类越干净,Mapper的匹配越省心。
4. 常见问题与排查技巧实录
4.1 Spring事务导致切换失效
现象:在Service方法上同时写@Transactional和@DataSource("dm"),发现切面设置了数据源,但查询报错“连接指向的还是主库”。
原因:Spring事务管理器在切面之前就已经获取了数据库连接。更准确地说,@Transactional的事务通知优先级默认高于切面,它在进入业务方法之前就决定了连接归属。事务一旦开启,连接上下文已经绑定到当前线程,后面再怎么切都晚了。
解决思路:
- 非事务类操作,避免在标记了
@DataSource("dm")的方法上加重型事务注解。 - 如果事务必须存在,把数据源的初始化放在事务管理器创建连接之前,比如在配置里就把事务管理器绑定到路由数据源上。
- 另一种做法是在同一个事务内始终使用同一种数据源,不要在一个标记了
@Transactional的大方法内部去切换多个数据源,拆分成多个独立Service方法,各自指定数据源。
变通技巧:如果确实要在一个完整事务里同时跨MySQL和达梦,简单的@Transactional已无法覆盖,这是全局事务的问题。轻量级方案是用消息表加最终一致性代替强事务;如果非要强一致,需要引入分布式事务框架(比如Seata),但这属于另一个层面的话题,至少在若依框架里不建议自己硬写。
4.2 异步线程丢失数据源标识
现象:在Controller里调用了带@Async的Service方法,方法上标了@DataSource("dm"),结果异步执行时查了主库。
原因:@Async会把任务提交到线程池执行,新线程里没有原来那个ThreadLocal的数据。数据源标识只存在于提交任务的线程,异步线程拿不到。
解决思路:
- 尽量在异步方法内部再次标注
@DataSource("dm"),让异步线程自己设置数据源。 - 或者在异步抛出之前,把数据源值作为参数传进去,在异步方法内部手动设置。
- 严格来说,这不是框架的问题,而是
ThreadLocal的天然特性。理解这一点后,排查起来就快了。
4.3 切面拦截不到Service方法
现象:注解标了,切面也定义了,但DataSourceContextHolder里始终是空。
排查思路:
先确认代理是否生效。Spring AOP默认用JDK动态代理,若依的Service大多是接口加实现的结构,如果注解标在实现类方法上,理论上CGLIB或JDK代理都能拦截。但如果Service方法被final修饰,CGLIB无法增强。另外检查切面类是否被Spring扫描到,很多情况是@Aspect注解忘了配@Component,导致切面根本没注册。
再检查切点签名。@annotation(com.example.annotation.DataSource)这种写法要求注解的@Retention是RUNTIME,否则运行时反射拿不到。这个坑虽然基础,但确实有人踩。
最后切面里做的方法判断。如果方法上有注解,就直接用方法注解;如果方法上没有,再看类上是否有。顺序错了也可能拿到不同结果。
4.4 达梦连接被Druid回收或连接泄漏
现象:长时间运行后,偶尔报“无可用连接”,或者连接池指标异常。
原因:数据源切换场景下,连接是按需向Druid池申请的。如果某个方法标记了@DataSource("dm"),但执行过程中抛了异常,@After清理了ThreadLocal,但连接本身如果没有归还,池子就会慢慢耗尽。正常情况下MyBatis的SqlSession会在finally块里把连接关掉归还,但如果事务拦截器异常或者连接被借用后没正常返回,就容易泄漏。
解决思路:
DruidDataSource配置里设置max-active和min-idle,同时开启testWhileIdle和time-between-eviction-runs-millis,让空闲连接能被正常回收。- 关注
@After和@AfterReturning/@AfterThrowing的区别。如果数据源清理放在@AfterReturning,异常分支就不会执行清理,导致数据源状态残留。建议用@After保证清理必定执行。 - 在代码里显式配置连接回收周期:
spring: datasource: druid: time-between-eviction-runs-millis: 60000 min-evictable-idle-time-millis: 300000 validation-query: SELECT 1 test-while-idle: true4.5 MySQL和达梦SQL方言差异速查表
| 功能 | MySQL | 达梦(兼容MySQL模式) | 建议 |
|---|---|---|---|
| 自增主键 | AUTO_INCREMENT | 序列(SEQUENCE) | 通过工具迁移时自动处理,手工建表建序列 |
| 分页 | LIMIT m, n | 兼容MySQL模式下可用LIMIT,或用ROWNUM | 用PageHelper自动识别方言 |
| 字符串拼接 | CONCAT() 函数 | 兼容模式支持, | 避免在复杂查询中混合拼接 |
| 日期函数 | NOW(), DATE_FORMAT() | NOW()可用,DATE_FORMAT可能需调整 | 尽量用标准函数 |
| 布尔类型 | TINYINT(1) | TINYINT | 兼容性较好 |
| 全文索引 | 原生支持 | 第三方组件或全文索引能力不同 | 评估业务依赖程度 |
达梦有一个特点,差不多所有信创项目里都会遇到:虽然兼容模式帮你挡了一部分SQL差异,但索引设计、存储过程编写、字段类型长度这些还是需要在达梦环境里亲自验证。尤其存储过程,如果用了MySQL的DELIMITER自定义分隔符,达梦里根本不认这套。
4.6 配置项参考清单
| 配置项 | 推荐值 | 说明 |
|---|---|---|
initial-size | 5 | 初始连接数 |
max-active | 20 | 最大活跃连接数 |
min-idle | 5 | 最小空闲连接数 |
validation-query | SELECT 1 | 连接可用性检查 |
test-while-idle | true | 空闲连接定期检查 |
time-between-eviction-runs-millis | 60000 | 驱逐线程运行间隔 |
max-wait | 10000 | 获取连接最大等待毫秒数 |
这些值不是死的,需要根据并发压力调优。核心思路是“探测要频繁,等待要短,空闲要及时回收”。
5. 踩坑总结与扩展建议
5.1 若依本身封装带来的“隐藏坑”
若依微服务的@DataSource注解其实在某些官方版本里是存在的,但覆盖不完全。我们项目组最初直接用框架自带的注解,发现部分场景无效。翻源码才发现,若依自带的DataSourceAspect只做了方法级拦截,类级注解没覆盖。因此如果你在类上标注了,行为就变得不可预期。这种情况建议不用框架自带的切面,直接引入自己写的这套,能保证行为一致。
还有一个点是若依的@EnableAutoConfiguration配置比较多,如果DataSourceConfig写的位置不当,可能被其他自动配置覆盖。建议把DynamicDataSource的配置类放到模块的config包下,并确保它能被组件扫描扫到。
5.2 后续可以扩展的功能
这套方案做好之后,很多相关的增强能力都能自然扩展:
- 主从分离:在路由Map里加一个
slave数据源,切面加一个“只读方法走从库”的规则,不需要额外引入架构变化。 - 多数据库支持:路由Map里加更多Key,比如
oracle、postgresql,每个数据源一个Key,业务层按需选择。 - 数据源健康检查:定时任务里轮询每个数据源,连接失败能告警。Druid自带的监控已经能提供大部分指标,直接对接告警平台就行。
- 灰度切换:通过配置中心动态改
targetDataSources的Map,实现不停机切换数据库,不过这个对代码结构要求高一些。
5.3 最终一点关于配置管理的建议
多数据源配置千万不要硬编码在本地application.yml里。如果用了若依Cloud,配置统一放在Nacos的对应配置文件中管理,并且注意区分环境:dev、test、prod环境的数据源地址、账号密码都不一样,建议单独建配置文件,避免联调时误连生产库。
我个人的习惯是数据源配置单独抽一个datasource.yml文件,仅配置数据源相关的所有参数,模块共享时引入即可。改数据库地址、切换账号密码,完全不用碰到业务代码和主配置文件。这套做法在多个项目里用下来,团队配合效率高很多,上线时出问题的概率也小很多。
回到最初的需求上,很多人以为“多数据源就是配两个连接串”,搞完才发现真正麻烦的是事务、异步、方言这些连带问题。希望这篇操作笔记能帮各位少走弯路。按这个思路配好之后,代码层面完全无感知,业务开发该怎么写就怎么写,底层数据库的切换交给AOP去处理,这本身就是最顺手的玩法。