1. 先把"两个库"这件事想清楚,再动手改配置
Spring Boot 多数据源配置这件事,我前后做过四五次,每次的翻车点都不一样。最近一次是接手一个已经跑了六年的业务系统:主库在 SQL Server 上,历史数据和报表逻辑全在里面;新上的数据采集模块要求用 PostgreSQL,因为要存 JSONB 和做复杂聚合。两边都得连,还得在同一个 Spring Boot 服务里共存,业务代码不能写成两套。这篇文章就把 Spring Boot 集成 SQL Server 与 PostgreSQL 双数据源的完整过程拆开讲——从为什么这么设计、连接参数怎么填、动态路由怎么实现、事务边界怎么划,到我自己踩过的那些坑。
如果你正在面对"老库不能动、新库必须上"的局面,或者单纯想搞清楚AbstractRoutingDataSource到底该怎么用、@Transactional为什么在多数据源下会失效,这篇内容应该能省你不少时间。需要说明的是,文中涉及的表名、库名、IP 都是占位示例,参数值请按你自己的环境替换。
1.1 什么情况下多数据源是真需求,什么情况下是自找麻烦
先说判断标准。多数据源不是"看起来高级"就上,它带来的复杂度是实打实的:连接池要维护两套、事务管理器要配两个、ORM 框架的会话工厂要建两个、监控指标要分两路看。所以我一般用三个问题来筛:
第一个问题,数据能不能迁。如果两个库的数据量都不大、业务耦合不深,老老实实做一次数据迁移,统一到一个库上,长期维护成本会低一个数量级。我见过太多团队为了"省迁移的两周"结果在多数据源上耗了半年。
第二个问题,写入是否交叉。如果只是"读老库、写新库",那本质上是一个只读数据源加一个读写数据源,复杂度和真正的双写完全不是一个档次。反过来,如果一个业务操作要同时写两张表且分属两个库,那就要认真考虑分布式事务或者补偿机制了。
第三个问题,团队能不能接受"配置地狱"。多数据源意味着启动类上要排除自动配置、@Primary要精确标注、@Transactional要指定管理器名,任何一个新人漏写一处,表现出来的可能就是"数据莫名写到了另一个库"。这种问题排查起来极其痛苦。
在我这个项目里,三个问题的答案分别是:不能迁(历史数据 800G,业务方不接受停机窗口)、有交叉(采集任务要把结果回写主库的统计表)、能接受(团队就四个人,约定写清楚就行)。所以我选择了多数据源,但不是全功能的多数据源,而是主库走默认路径、新库走注解显式切换的收敛方案。
1.2 三种实现路线的取舍:路由式、双套 ORM、还是拆服务
确定要做之后,落地路线有三条,我在选型阶段把每条都摸了一遍。
路线一:动态数据源路由。核心是继承AbstractRoutingDataSource,用一个ThreadLocal存当前数据源标识,每次取连接时根据标识从 Map 里挑一个真实数据源返回。优点是业务代码几乎无侵入,一个注解就能切库;缺点是对事务不友好,事务开启后再切换是无效的,必须保证切换发生在事务之前。
路线二:双套 ORM 完全隔离。每个数据源配一套DataSource+SqlSessionFactory+MapperScan+TransactionManager,Mapper 分包存放,互相不干扰。优点是边界极其清晰,永远不会串库;缺点是代码重复度高,两套 Mapper 里如果有相似逻辑,维护起来很烦。
路线三:拆成两个服务。从架构上最干净,但需要引入服务间调用、网络延迟、数据一致性处理,对一个只有四个人的团队来说属于过度设计。
我最终选的是路线一为主、路线二兜底的混合方案:常规业务查询用注解路由,跨库批量写入这类高风险操作单独配一套独立的SqlSessionFactory,走专用通道。这样既保留了开发效率,又给关键链路上了保险。这个决定后面救了我一次——批量回写主库的那段代码,如果走路由方案,在异步线程里切库会丢上下文,独立通道就没这个隐患。
2. 依赖、连接串与配置组织:魔鬼全在细节里
动手之前,先把依赖和连接参数搞对。这两块看着简单,实际上 SQL Server 和 PostgreSQL 的驱动在默认行为上差异很大,很多"连不上""乱码""超时"的问题根子都在这里。
2.1 驱动版本与连接池的版本对齐
依赖上没什么玄学,但版本要卡准。SQL Server 的 JDBC 驱动从 9.x 开始分成 jre8 和 jre11 两个分支,选错会在启动时报UnsupportedClassVersionError。PostgreSQL 驱动相对省心,但要注意 42.x 系列在 SSL 默认行为上有过变化。
<dependency> <groupId>com.microsoft.sqlserver</groupId> <artifactId>mssql-jdbc</artifactId> <version>12.6.1.jre11</version> </dependency> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <version>42.7.3</version> </dependency> <dependency> <groupId>com.zaxxer</groupId> <artifactId>HikariCP</artifactId> </dependency>连接池我统一用 HikariCP,理由很实际:Spring Boot 默认就是它,两套数据源共用一种池实现,参数含义一致,排查问题时不用在两种池的术语之间来回翻译。Druid 的监控面板确实好看,但多数据源场景下要配两个StatViewServlet,还得处理监控本身的线程安全,收益不大。
提示:如果项目里用了
spring-boot-starter-data-jpa,它会自带一个 HikariCP 版本,显式声明依赖时不要覆盖成差异过大的版本,否则可能出现NoSuchMethodError。
2.2 两个库的连接串,坑位完全不一样
SQL Server 的连接串是最容易出问题的地方。从 10.x 驱动开始,encrypt默认为true,也就是说即使你连的是内网一台没配证书的实例,驱动也会尝试加密握手,然后抛The driver could not establish a secure connection to SQL Server by using Secure Sockets Layer (SSL) encryption。解决办法有两个:生产环境老老实实配证书;测试和内网环境显式加trustServerCertificate=true。
jdbc:sqlserver://10.0.0.11:1433;databaseName=erp_main;encrypt=true;trustServerCertificate=true;loginTimeout=5;applicationName=erp-servicePostgreSQL 那边的问题主要在 schema 和时区。默认 schema 是public,如果业务表建在别的 schema 下,不显式指定就会报"表不存在",而且报错信息往往只告诉你表名,不告诉你去哪找。
jdbc:postgresql://10.0.0.12:5432/collect_db?currentSchema=collect&reWriteBatchedInserts=true&ApplicationName=erp-servicereWriteBatchedInserts=true这个参数值得单独说。PostgreSQL 驱动在开启后会把多条INSERT重写成一条多值插入,批量写入性能提升非常明显,我实测 5000 条一批从 3.2 秒降到 0.6 秒左右。SQL Server 那边对应的是useBulkCopyForBatchInsert=true,原理是走 TDS 协议的 Bulk Copy,效果同样显著,但它对字段类型有要求,遇到datetimeoffset之类的类型可能回退到普通模式。
2.3 配置文件的组织方式,别用默认前缀
这里有个非常经典的坑:自定义前缀的数据源不能用url,必须用jdbc-url。
原因在于spring.datasource.url是DataSourceProperties的属性,Spring Boot 会自动把它转换成 Hikari 需要的jdbcUrl。但当你用@ConfigurationProperties("spring.datasource.primary")直接绑定到HikariDataSource时,绑定的是 Hikari 自己的 setter,而 Hikari 的字段名是jdbcUrl,写url会绑定失败且不报错,最后表现是"连接串为 null"。
spring: datasource: primary: jdbc-url: jdbc:sqlserver://10.0.0.11:1433;databaseName=erp_main;encrypt=true;trustServerCertificate=true username: app_user password: ${DB_PWD} driver-class-name: com.microsoft.sqlserver.jdbc.SQLServerDriver hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 3000 idle-timeout: 600000 max-lifetime: 1700000 connection-test-query: SELECT 1 collect: jdbc-url: jdbc:postgresql://10.0.0.12:5432/collect_db?currentSchema=collect&reWriteBatchedInserts=true username: collect_user password: ${DB_PWD_COLLECT} driver-class-name: org.postgresql.Driver hikari: maximum-pool-size: 12 minimum-idle: 3 connection-timeout: 3000 max-lifetime: 1500000 connection-test-query: SELECT 1max-lifetime要设得比数据库或中间层的空闲断连时间短一截。SQL Server 侧我按 1700 秒设(约 28 分钟),PostgreSQL 侧 1500 秒,都是留了余量之后的保守值。这个参数不设的后果是:连接被对端悄悄掐掉,池子还以为它活着,取出来一用就报Connection reset,而且往往是低峰期偶发,最难查。
连接池大小我做了一次粗算。主库的核心接口 QPS 峰值约 180,单次事务平均持有连接 25 毫秒,按利特尔法则并发占用约为180 × 0.025 ≈ 4.5,考虑到慢查询和批量的长事务,取 20 是比较稳的。新库那边 QPS 只有 30 左右,但单次任务耗时长(批量插入 2 到 5 秒),并发连接需求反而更高,所以给了 12。这两个数字加起来要小于数据库实例的max_connections减去留给运维和其他应用的余量,这一点在给 DBA 报备时一定要算清楚。
3. 动态数据源的完整实现与事务顺序
这一节是整篇文章的核心。多数据源能不能用得稳,八成取决于路由和事务的配合是否正确。
3.1 数据源 Bean 的构建与主数据源的确定
用DataSourceBuilder创建两个HikariDataSource,其中一个标@Primary。这个注解不能省,也不能标错——Spring 在注入DataSource类型时如果发现多个候选又没有主候选,会直接启动失败。
@Configuration public class DataSourceConfig { @Bean("primaryDataSource") @Primary @ConfigurationProperties(prefix = "spring.datasource.primary") public DataSource primaryDataSource() { return DataSourceBuilder.create().type(HikariDataSource.class).build(); } @Bean("collectDataSource") @ConfigurationProperties(prefix = "spring.datasource.collect") public DataSource collectDataSource() { return DataSourceBuilder.create().type(HikariDataSource.class).build(); } }如果项目里有DataSourceAutoConfiguration在跑,它会尝试按spring.datasource.*去创建一个默认数据源,跟我们定义的重名或冲突。稳妥的做法是在启动类上排除掉:
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})排除之后,MyBatis 或 JPA 的自动配置也会跟着失效,需要手动配置SqlSessionFactory。这部分在第四节展开。
3.2 用 AbstractRoutingDataSource 实现运行时切换
AbstractRoutingDataSource的思路很朴素:它本身是个DataSource,内部维护一个Map<Object, DataSource>和一个默认数据源,每次getConnection()时调用determineCurrentLookupKey()拿到 key,从 Map 里取出真实数据源再委托过去。
public class DynamicDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { return DataSourceContextHolder.get(); } } public final class DataSourceContextHolder { private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>(); public static void set(String key) { CONTEXT.set(key); } public static String get() { return CONTEXT.get(); } public static void clear() { CONTEXT.remove(); } }装配的时候把两个真实数据源塞进 Map,默认指向主库:
@Bean("dynamicDataSource") @Primary public DataSource dynamicDataSource( @Qualifier("primaryDataSource") DataSource primary, @Qualifier("collectDataSource") DataSource collect) { Map<Object, Object> targets = new HashMap<>(4); targets.put("primary", primary); targets.put("collect", collect); DynamicDataSource ds = new DynamicDataSource(); ds.setTargetDataSources(targets); ds.setDefaultTargetDataSource(primary); return ds; }有个细节容易被忽略:AbstractRoutingDataSource在afterPropertiesSet()里才把targetDataSources解析成resolvedDataSources,如果你在 Bean 初始化之后再往 Map 里塞东西是不生效的。另外它的getConnection()是在每次调用时才做路由判断,这一点对理解后面的时序问题很关键。
注意:
DataSourceContextHolder用完必须clear()。我见过因为线程池复用导致的"幽灵切库"——某个请求切到新库,处理完没清上下文,线程被还回池子,下一个请求拿到同一个线程,莫名其妙去查了新库,报"表不存在"。这种问题在压测时几乎复现不出来,只有线上偶发。
3.3 注解加切面,把切库变成一行代码
裸用DataSourceContextHolder.set()到处写太丑,封装一个注解:
@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface DS { String value() default "primary"; }切面里最关键的是执行顺序。Spring 的@Transactional默认切面顺序是Ordered.LOWEST_PRECEDENCE,如果我们的切面顺序排在它后面,就会出现"事务已经开启并绑定了连接,然后才切库"的情况,切换完全无效。
@Aspect @Component @Order(Ordered.HIGHEST_PRECEDENCE) public class DataSourceAspect { @Around("@annotation(ds)") public Object around(ProceedingJoinPoint pjp, DS ds) throws Throwable { String origin = DataSourceContextHolder.get(); try { DataSourceContextHolder.set(ds.value()); return pjp.proceed(); } finally { if (origin == null) { DataSourceContextHolder.clear(); } else { DataSourceContextHolder.set(origin); } } } }这里的finally里做的是"恢复现场"而不是简单清理,为了支持嵌套调用——A 方法切到新库,内部调用 B 方法又切回主库,B 执行完应该回到新库而不是主库。这个细节很多示例代码都没写,一旦业务里出现多层调用就会出问题。
3.4 让切换在事务内也生效:LazyConnectionDataSourceProxy
上面那套方案有个硬限制:只能在事务外切库。因为@Transactional一开,Spring 就从数据源取一个连接绑到当前线程,后面的切库只是改了 ThreadLocal,连接不会换。
绕过的办法是LazyConnectionDataSourceProxy。它包装动态数据源,事务开始时并不真正取连接,只返回一个代理,等到第一次真正执行 SQL 时才去getConnection()。这样即使事务先开、切库后发生,最终拿到的仍然是正确的连接。
@Bean("lazyDataSource") @Primary public DataSource lazyDataSource(@Qualifier("dynamicDataSource") DataSource dynamic) { return new LazyConnectionDataSourceProxy(dynamic); }代价是事务的隔离级别和只读属性在第一次取连接时才能生效,某些依赖元数据提前判断的场景(比如 Hibernate 的方言探测)可能受影响。我的做法是:主链路不加这层,保持简单;只有少数确实需要在事务内跨库读取的场景才走一个单独包装的 Bean。
4. ORM 层的适配:方言、分页与主键策略
数据源通了不代表能跑,SQL Server 和 PostgreSQL 在 SQL 方言上的差异足够让一套 Mapper 报错半天。
4.1 MyBatis 双会话工厂的正确拆法
路由式数据源只需要一个SqlSessionFactory,因为它面向的是那个动态数据源。但前面提到我额外留了独立通道,所以要多配一套。
@Bean("collectSqlSessionFactory") public SqlSessionFactory collectSqlSessionFactory( @Qualifier("collectDataSource") DataSource ds) throws Exception { MybatisSqlSessionFactoryBean bean = new MybatisSqlSessionFactoryBean(); bean.setDataSource(ds); bean.setMapperLocations( new PathMatchingResourcePatternResolver() .getResources("classpath*:mapper/collect/**/*.xml")); // 关键:给新库指定方言,否则分页插件按主库语法生成 SQL MybatisConfiguration cfg = new MybatisConfiguration(); cfg.setDatabaseId("postgresql"); bean.setConfiguration(cfg); return bean.getObject(); }MapperScan要把两个包分开,用basePackages和sqlSessionFactoryRef分别指定。如果两个 Mapper 包有交叉,就会踩到"Mapper 被重复注册"的坑,报Mapped Statements collection already contains value。
4.2 两个库的语法差异对照表
下面这张表是我在实际迁移中逐条验证过的,建议动手前先过一遍:
| 差异点 | SQL Server | PostgreSQL | 处理建议 |
|---|---|---|---|
| 分页 | OFFSET n ROWS FETCH NEXT m ROWS ONLY,必须带ORDER BY | LIMIT m OFFSET n,无排序要求 | 用 MyBatis-Plus 或 PageHelper 按方言自动生成,别手写 |
| 自增主键 | IDENTITY(1,1),插入后SELECT SCOPE_IDENTITY() | GENERATED BY DEFAULT AS IDENTITY或SERIAL,RETURNING id | 用useGeneratedKeys="true"+keyProperty,两边都能兼容 |
| 布尔类型 | BIT,值为 0/1 | BOOLEAN,值为 true/false | 实体字段统一用Boolean,不要用int |
| 时间类型 | DATETIME2精确到 100ns,无时区 | TIMESTAMPTZ带时区,TIMESTAMP不带 | 统一用带时区的类型存绝对时间,业务层做展示转换 |
| 标识符大小写 | 不区分,保留原样 | 未加引号时全部折叠为小写 | 表名和字段名统一小写下划线,别用驼峰 |
| 字符串拼接 | +或CONCAT | ` | |
| 空值排序 | NULL排在最前 | 默认NULL排最后,需NULLS LAST | 需要固定顺序时显式写 |
| 批量插入 | useBulkCopyForBatchInsert=true | reWriteBatchedInserts=true | 两个参数都要开,实测差 3 到 5 倍 |
| 字符串比较 | 默认不区分大小写(取决于排序规则) | 默认区分大小写 | 需要模糊匹配时用LOWER()包一层 |
表里最容易翻车的是"标识符大小写"这一条。PostgreSQL 里写SELECT userId FROM User会被解析成select userid from user,直接报错。我当时的做法是把所有建表语句和 Mapper 里的字段名统一改成小写蛇形,虽然改了两天,但一次性解决了后续所有隐患。
4.3 主键与批量插入的性能实测
自增主键这块,MyBatis 的useGeneratedKeys在两个库上都能用,但 PostgreSQL 走的是RETURNING,批量插入时能一次拿回所有主键;SQL Server 的SCOPE_IDENTITY()只返回最后一条,批量场景下要么逐条插,要么用OUTPUT INSERTED.id。这个差异导致批量写入的 Mapper 不能共用,我在新库那边单独写了一个insertBatch返回List<Long>。
实测数据(10000 条,单条 8 个字段,本机万兆内网):
| 方式 | SQL Server | PostgreSQL |
|---|---|---|
| 逐条插入 | 41.2 秒 | 38.6 秒 |
| 批量(每批 1000,未开优化参数) | 6.8 秒 | 7.1 秒 |
| 批量(每批 1000,开优化参数) | 1.9 秒 | 1.4 秒 |
这个差距足以说明为什么那两个参数必须开。需要注意的是,批量参数开启后如果单批过大,可能触发数据库端的日志膨胀,PostgreSQL 尤其明显。我一般把批次控制在 500 到 1000 之间,超过 2000 反而会变慢。
5. 事务边界的划分与现实取舍
多数据源下的事务是最容易想当然的地方。很多人第一反应是"上分布式事务",实际落地时会发现成本远超收益。
5.1 单库事务:每个数据源一个管理器
不要试图用一个DataSourceTransactionManager管两个库,它做不到。正确做法是每个数据源配一个,@Primary标在主库那个上,其他显式指定名字。
@Bean("primaryTxManager") @Primary public PlatformTransactionManager primaryTxManager( @Qualifier("primaryDataSource") DataSource ds) { return new DataSourceTransactionManager(ds); } @Bean("collectTxManager") public PlatformTransactionManager collectTxManager( @Qualifier("collectDataSource") DataSource ds) { return new DataSourceTransactionManager(ds); }用的时候必须写全:@Transactional(transactionManager = "collectTxManager", rollbackFor = Exception.class)。漏写transactionManager的后果是默认用主库管理器,然后你的新库操作压根不在事务里,出异常不回滚,而且没有任何报错提示。这个坑我踩过一次,损失是两万多条脏数据。
还有一点,rollbackFor要显式写Exception.class。Spring 默认只对RuntimeException和Error回滚,受检异常不回滚,这个默认行为在多数据源场景下更容易被忽略。
5.2 跨库写入:放弃强一致,改用补偿
如果业务必须同时写两个库,我不建议上 XA。原因很实在:SQL Server 和 PostgreSQL 的 XA 实现细节差异较大,Spring 的JtaTransactionManager需要额外的 JTA 实现,配置复杂度和运维成本都不低,而且两阶段提交的性能损耗在批量场景下很难接受。
我在项目里用的是"本地事务 + 事务性消息 + 幂等重试"的组合。具体做法是:主库操作和消息落表在同一个本地事务里,提交后由后台任务扫描消息表投递到新库,新库侧保证幂等(用业务唯一键加唯一索引)。失败就重试,超过阈值进死信表人工介入。
这套方案的缺点是存在短暂不一致窗口,一般是秒级到分钟级。评估时要问清楚:业务能不能接受这个窗口?我这个场景是报表统计,能接受。如果是资金类业务,那就得换方案,不是这篇文章能覆盖的。
6. 问题排查速查表与几条血泪经验
下面这些是我在开发和上线过程中真实遇到过的问题,按出现频率排序。
6.1 启动期与连接期问题
| 现象 | 根因 | 处理方式 |
|---|---|---|
Cannot determine embedded database driver class | 排除了自动配置但没提供数据源 | 检查@Configuration是否被扫描到 |
The connection string contains no value for ... | 配置用了url而非jdbc-url | 改字段名,见 2.3 节 |
The driver could not establish a secure connection | SQL Server 驱动默认encrypt=true | 加trustServerCertificate=true或配证书 |
Schema "xxx" does not exist | PostgreSQL 未指定 schema | URL 加currentSchema=xxx |
No suitable driver found | 驱动包未引入或版本与 JDK 不匹配 | 选.jre11对应的分支 |
Connection is not available, request timed out | 连接池耗尽 | 查慢查询、看max-lifetime是否短于对端断连时间 |
6.2 运行期切换与事务问题
路由失效是最难查的一类,因为代码看着完全正确。我总结了三类常见原因:
一是切面顺序不对。事务切面先执行,切换后执行,结果就是连接早绑好了。表现是"注解写了但查的还是主库"。验证方法很简单:在切面里打一行日志,看它和事务开启日志的先后顺序。
二是自调用导致 AOP 失效。同一个类里 A 方法调 B 方法,B 上的@DS注解不生效,因为走了this调用,没有经过代理。解决方式是拆到两个 Bean,或者注入自身代理。这个问题在新人代码里出现率极高。
三是异步线程丢了上下文。ThreadLocal不会跨线程传递,@Async或者自己提交线程池的任务里,切库注解必然失效。我的处理是显式传参:任务提交时把数据源标识作为参数带过去,在任务体开头set(),结束clear()。用InheritableThreadLocal只能解决新建线程的场景,线程池复用时同样会串。
6.3 几个不太常见但很要命的细节
第一个,max-lifetime和数据库端空闲超时的关系。SQL Server 侧的remote query timeout和网络设备的空闲连接回收策略都可能悄悄掐断连接,如果池子的max-lifetime比它们长,就会出现"取出的连接已经死了"。表现是每天固定时间段偶发报错,重启就好,过一天又犯。解决办法是把max-lifetime设短,并开启connection-test-query。
第二个,PostgreSQL 的stringtype=unspecified。往jsonb字段插字符串时,如果不加这个参数,驱动会把参数当varchar发过去,数据库报类型不匹配。加了之后所有字符串参数都变成unknown类型,可能影响索引使用,所以要谨慎,最好只在特定数据源上开。
第三个,两个库的字符集和排序规则不一致导致的"看起来一样但不相等"。SQL Server 的排序规则如果是SQL_Latin1_General_CP1_CI_AS,比较时不区分大小写和重音;PostgreSQL 的默认排序规则区分大小写。同一批数据在两边查出来的结果数量可能不同。这个问题在数据核对时会被当成"数据丢了",其实只是比较规则不同。
第四个,长时间运行后内存缓慢上涨。如果DataSourceContextHolder用的是ThreadLocal而某个分支漏了clear(),在高并发下会持续持有引用。虽然 key 是弱引用,但 value 不是,配合线程池的长生命周期,泄漏是肯定的。我在 Code Review 里把"每个 set 必须配对 clear"写进了检查清单,用try-finally强制约束。
最后一个经验:上线前一定要做一次"断网演练"。手动把新库的网络断掉,观察主库业务是否受影响。在我的方案里,因为两个连接池完全独立,主库业务不受影响,只是新库相关接口报错。如果用的是共享连接池或者单事务管理器,可能会出现主库接口也跟着超时的情况——这种耦合故障在生产环境里非常致命。
7. 我个人在这套配置上的几点取舍
最后分享几条纯个人偏好,不保证通用,但至少在我这里跑了两年没出大问题。
我坚持不把@DS注解打在 Service 类上,只打在具体方法上。类级别的注解会让整个类的所有方法都切库,包括那些本该查主库的辅助方法,出问题的概率大幅上升。方法级虽然啰嗦,但边界清晰。
我给两个数据源分别配了独立的线程池,不共用。理由是库的响应特性不同,主库快而新库慢,混在一个池里会被慢任务拖垮。线程池隔离之后,新库抖动不会传导到主库业务。
我在切面里加了一行 debug 日志,记录切换前后和当前线程名。这行日志平时不看,但只要出现切库异常,它就是最快的定位手段——直接 grep 线程名,就能看出上下文在哪个环节丢了。
至于后续扩展,如果某天新库的表结构稳定下来、读取量也上来了,我会考虑给它加一层只读从库,把报表类查询分流出去。到那时路由的 key 就不只是库名,还要带上读写标识,AbstractRoutingDataSource的 key 换成组合字符串就行,架子不用动。