☰
Spring Boot 多数据源实战:SQL Server 与 PostgreSQL 双库配置
2026/10/23 3:46:54 网站建设 项目流程

做后端这些年,我接手过不止一个“新老数据库并存”的系统。最常见的一种组合,是一头连着若干年前上线、至今还在跑核心业务的 SQL Server,另一头是新业务线选型时上的 PostgreSQL。两边都有数据,两边都不能停,业务方要求新页面上一个报表同时显示两边的客户信息和订单明细。这种需求落到开发身上,就变成了一个很朴素的问题:一个 Spring Boot 应用怎么同时管理两个数据源,而且两边的 Mapper、事务、连接池都不能打架。这篇就把这套 Spring Boot 多数据源配置的完整过程摊开讲——从为什么这么选、依赖怎么配、Bean 怎么建、Mapper 怎么分包、事务怎么绑定,到跨库 Join 做不了时怎么绕、时区和驱动那些让人调一晚上的坑。内容偏向已经有 Spring Boot 基础、正在或即将面对双数据源改造的中高级开发,新手也能看懂,但建议先熟悉一下 MyBatis 的基本用法再读。

1. 双数据源场景到底难在哪

1.1 先把真实需求拆开看

很多人拿到“集成两个数据源”这个任务,第一反应是搜一个注解,往方法上加一下就完了。但真正落地之前,得先问清楚三件事,否则技术选型一定跑偏。

第一件,这两个库是读写都要还是一读一写。如果 PostgreSQL 只是用来做报表查询、SQL Server 才是业务写入的主库,那配置上可以更省事,甚至可以考虑只给 PostgreSQL 配一个只读账号,把连接池调小、加上read-only标记,从根上避免误写。如果两个库都要写,那就必须面对事务一致性的问题,这个后面会单独讲。

第二件,跨库的关联查询有多少。如果只是偶尔一个列表页需要拼两边的数据,那应用层做内存聚合完全够用;如果每天有几十个报表需要两边数据做 Join,那应用层聚合会很痛苦,得考虑在数据库层面建外部表或者做同步表。

第三件,数据量和一致性要求。核心交易数据和新业务数据的更新频率、容忍延迟都不一样,这直接决定了你是用实时双写、定时同步,还是干脆老库只做冷数据归档。

我在一个模拟项目里就吃过没问清楚的亏:一开始按“只读”假设配的 PostgreSQL 连接池,最大连接只给了 5,结果上线第二天业务方要求在页面上做手工补录,写操作一上来,5 个连接瞬间打满,页面直接卡死转圈。后来重新调整连接池、加了写库的独立事务管理器,才把问题压下去。这种返工完全是沟通不到位造成的,跟技术难度无关。

所以我的建议是,动手写第一行配置之前,先画一张表,把这几个维度列清楚,跟业务方和技术负责人对一遍。表大概长这样:

维度需要确认的内容影响的设计决策
读写属性每个库是读写还是只读账号权限、连接池大小、是否加只读标记
跨库查询每月需要跨库 Join 的接口数量应用层聚合 / 数据库外部表 / 数据同步
一致性是否要求两库同时成功或同时回滚是否需要分布式事务
数据量单次跨库查询的最大结果集规模分页策略、是否走异步
变更频率表结构多久动一次是否用代码生成、是否上 Flyway

这张表看着像流程化的东西,但它确实能把后面 80% 的返工挡在门外。

1.2 三种技术路线的取舍逻辑

市面上做 Spring Boot 多数据源,主流是三条路,我把它们的特点摆在一起对比,你就知道该选哪条了。

第一条,显式多数据源配置。也就是手工声明多个DataSourceBean、多个SqlSessionFactory、多个DataSourceTransactionManager,Mapper 接口按包路径分开扫描。这条路的特点是“笨”,配置代码大概两三百行,但每一行都是你能看懂、能调试、能改的。出问题时堆栈清晰,不会出现“不知道走了哪个库”的情况。

第二条,动态数据源切换框架。核心思路是用一个AbstractRoutingDataSource包一层,通过 AOP 在方法执行前把数据源 key 塞进ThreadLocal,方法上加个注解就切换。看着优雅,但坑在于:事务一旦开启,连接就已经绑定了,方法内部再切换注解是不生效的;异步线程、线程池复用、嵌套调用都会让ThreadLocal里的 key 串味。用这套方案排查过一次“偶发写错库”的问题,最后定位到是线程池复用了上一个请求的 key,追了两天才找到。

第三条,数据库层面的联邦查询。SQL Server 支持把别的库注册成链接服务器,PostgreSQL 有外部数据包装器,配好之后可以直接写跨库 SQL。这条路对应用完全透明,代码里只连一个库就行。但它把复杂度推给了 DBA,网络抖动、驱动版本、权限配置全是运维侧的事,而且一旦那台机器挂了,你的应用也跟着挂。

我的实际选择是第一条加第三条的组合:应用层用显式多数据源配置,保证每个库的访问路径清晰可控;个别高频的跨库报表,跟 DBA 商量在数据库层做外部表。动态数据源框架只在“表多、库多、切换频繁”的大规模场景才值得引入,两个库的规模用它属于给自己找麻烦。

提示:如果你的团队里只有一两个人维护这套代码,别碰动态数据源框架。省下的配置代码,会在未来某次诡异的“数据写错库”事故里加倍还回来。

1.3 为什么坚持用 HikariCP 而不是别的连接池

Spring Boot 2.x 之后默认连接池就是 HikariCP,这一点很多人没注意。我的建议是别折腾,就用默认的。原因不在于性能跑分,而在于双数据源场景下,你需要两个独立、配置隔离、互不影响的连接池实例,HikariCP 的配置项命名清晰、监控指标齐全,配两个 Bean 各管各的,出问题时看日志里HikariPool-1和HikariPool-2的池名就能立刻区分是哪个库在等连接。

有个细节值得说:HikariCP 的池名默认是自动生成的,两个数据源容易搞混。你可以通过pool-name显式指定,比如sqlserver-pool和postgres-pool,日志和 JMX 里一眼能认出来。这个小动作在排障时价值极高,尤其是两个库同时报连接超时的时候。

另外,HikariCP 的maximum-pool-size默认是 10,这个值对双数据源来说经常偏大。两个数据源各 10,加上其他服务实例,很容易把数据库的连接数上限打满。后面第 6 节会给一个具体的计算公式。

2. 依赖与版本:最容易埋雷的地方

2.1 依赖清单怎么定

依赖这块其实不多,核心就三个:Spring Boot 的 JDBC 支持、两个数据库的驱动、以及 ORM 层(我这边用 MyBatis,用 JPA 的话换成对应的 starter 就行)。

Maven 里的核心依赖大概是这样:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>com.microsoft.sqlserver</groupId> <artifactId>mssql-jdbc</artifactId> <version>12.4.2.jre11</version> </dependency> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <version>42.7.1</version> </dependency>

注意mssql-jdbc的版本号后面带jre11这种后缀,这是微软驱动的惯例,同一个版本会发布 jre8、jre11、jre17 等多个编译产物。选错了会在启动时直接抛UnsupportedClassVersionError或者莫名其妙的类找不到。判断方法很简单:看你的 JDK 主版本,JDK 8 用.jre8,JDK 11 用.jre11,JDK 17 用.jre17。别图省事用不带后缀的通用包,那个通常是给老环境兼容用的,性能不一定最优。

PostgreSQL 驱动相对省心,版本号没有后缀,但要注意 42.x 系列的版本和 PostgreSQL 服务端版本的对应关系。42.7 系列基本能覆盖 PostgreSQL 9.6 到 16,如果你的服务端是老版本,建议别用太新的驱动,避免协议握手阶段的兼容问题。

MyBatis 的 starter 版本要和 Spring Boot 版本对齐,这一条踩过坑。MyBatis Starter 2.3.x 对应 Spring Boot 2.7.x,3.0.x 对应 Spring Boot 3.x。混用的典型症状是启动时报NoSuchMethodError,或者 Mapper 扫描死活不生效。如果你用的是 Spring Boot 3,还牵扯到javax.*到jakarta.*的包名迁移,MyBatis Starter 必须同步升级,否则连编译都过不去。

2.2 驱动层面三个容易被忽略的坑

坑一:SQL Server 新版驱动默认开启加密连接。从 mssql-jdbc 10.x 开始,encrypt参数的默认值变成了true。如果你的 SQL Server 没有配置有效证书,连接会直接失败,报错信息大致是“驱动无法通过 SSL 建立安全连接”。解决办法是在 JDBC URL 里显式加encrypt=true;trustServerCertificate=true,或者干脆设encrypt=false(内网环境可以接受,公网环境别这么干)。我第一次遇到这个问题时,SQL Server 客户端工具能连上,Java 程序连不上,卡了整整一个下午。

坑二:PostgreSQL 的 schema 搜索路径。默认情况下连接只能看到publicschema 下的表,如果你的表建在业务 schema 里(比如biz_order),不加配置会报“表不存在”。两种解决办法:一是在 JDBC URL 里加?currentSchema=biz_order,二是在每个连接的会话里执行SET search_path TO biz_order, public。推荐第一种,配置集中、一目了然。第二种需要在连接池的回调里做,容易漏。

坑三:时区参数。两个库对时间的处理逻辑完全不同。SQL Server 的datetime类型没有时区概念,存的是“墙上的时间”;PostgreSQL 的timestamptz存的是 UTC 时刻,读出来再按客户端时区转换。如果应用的 JVM 时区和数据库服务器时区不一致,同一个时间字段在两个库里查出来能差好几个小时。我的做法是全局统一:JVM 启动参数固定-Duser.timezone=Asia/Shanghai,SQL Server 连接串加sendTimeAsDatetime=false让驱动按datetime2处理,PostgreSQL 连接串加?options=-c%20TimeZone%3DAsia%2FShanghai固定会话时区。三处对齐之后,时间字段才算是能对得上。

注意:这三个坑的共同特点是“本地测试环境不复现”。本地数据库多半是默认配置、localhost 连接,一旦部署到测试环境或者生产环境,加密、schema、时区全都会变。所以配置写完之后,一定要在贴近生产的测试环境跑一遍连通性验证,别只在本机跑。

2.3 配置文件的分层结构

配置文件我习惯按“公共配置 + 各自私有配置”两层来组织,用自定义前缀区分,不打乱 Spring Boot 默认的spring.datasource结构。

spring: datasource: sqlserver: driver-class-name: com.microsoft.sqlserver.jdbc.SQLServerDriver jdbc-url: jdbc:sqlserver://10.0.0.11:1433;databaseName=legacy_db;encrypt=true;trustServerCertificate=true;sendTimeAsDatetime=false username: app_reader password: ${SQLSERVER_PWD} hikari: pool-name: sqlserver-pool maximum-pool-size: 12 minimum-idle: 4 connection-timeout: 3000 idle-timeout: 300000 max-lifetime: 1200000 connection-test-query: SELECT 1 postgres: driver-class-name: org.postgresql.Driver jdbc-url: jdbc:postgresql://10.0.0.21:5432/new_biz?currentSchema=biz_order&reWriteBatchedInserts=true username: app_rw password: ${PG_PWD} hikari: pool-name: postgres-pool maximum-pool-size: 16 minimum-idle: 6 connection-timeout: 3000 idle-timeout: 300000 max-lifetime: 1500000

这里有两个细节要强调。一是属性名必须是jdbc-url而不是url,因为我们是手工用DataSourceProperties绑定,url那个键在 Spring Boot 的自动配置里会被DataSourceProperties识别,但在自定义前缀下用url会绑定失败,报“url 属性不存在”或者干脆静默为 null。这个错误信息非常误导人,我第一次遇到时以为是依赖没生效。

二是密码不要写死在配置文件里,用环境变量或者配置中心注入。上面示例里的${SQLSERVER_PWD}就是占位符,配合启动参数或者容器的环境变量使用。这一条没有技术含量,但每年都能看到泄露的案例。

3. 数据源 Bean 的完整配置实现

3.1 DataSource 实例怎么建

先看数据源本身。核心思路是:用DataSourceProperties把配置绑定成一个对象,再用initializeDataSourceBuilder()生成DataSource实例,最后显式指定 HikariCP 类型和池参数。

@Configuration public class SqlServerDataSourceConfig { @Primary @Bean(name = "sqlServerDataSource") @ConfigurationProperties(prefix = "spring.datasource.sqlserver") public DataSourceProperties sqlServerDataSourceProperties() { return new DataSourceProperties(); } @Primary @Bean(name = "sqlServerDataSource") @ConfigurationProperties(prefix = "spring.datasource.sqlserver.hikari") public HikariDataSource sqlServerDataSource( @Qualifier("sqlServerDataSourceProperties") DataSourceProperties props) { return props.initializeDataSourceBuilder() .type(HikariDataSource.class) .build(); } }

这段代码有几个点值得展开。

@ConfigurationProperties(prefix = "...")加在DataSourceProperties上,作用是把 yml 里那一段配置自动映射成对象,jdbc-url会被映射到url字段(Spring Boot 内部做了松散绑定)。这里如果有人把配置写成url:而不是jdbc-url:,也能绑上,但不推荐,因为后面连 Hikari 的jdbcUrl设置时容易混。

initializeDataSourceBuilder()是 Spring Boot 提供的一个便捷方法,它会根据 classpath 里存在的连接池自动选型。既然我们已经引入了 HikariCP(Spring Boot 默认就带),.type(HikariDataSource.class)是显式声明,防止未来依赖变动导致选型漂移。

第二个@Bean上的@ConfigurationProperties(prefix = "...hikari")是给 Hikari 的池参数做绑定的,pool-name、maximum-pool-size这些键名要严格对应 Hikari 的 setter,写错了不会报错,只会静默失效——这是最坑的地方。建议配置完之后,在启动日志里搜一下HikariPool的那行输出,它会打印池名和大小,肉眼核对一遍。

提示:两个@Bean方法重名了怎么办?我这边的做法是让DataSourceProperties的 Bean 名和数据源 Bean 名相同,Spring 会按类型区分,但更好读的做法是给DataSourceProperties加个Props后缀。上面的示例为了展示两种写法并存,实际项目里我统一加了后缀,避免阅读时的困惑。

3.2 SqlSessionFactory 与 Mapper 扫描

有了数据源,接下来是 MyBatis 的两件套:SqlSessionFactory和MapperScannerConfigurer。

@Configuration @MapperScan( basePackages = "com.example.demo.mapper.sqlserver", sqlSessionFactoryRef = "sqlServerSqlSessionFactory" ) public class SqlServerMybatisConfig { @Primary @Bean(name = "sqlServerSqlSessionFactory") public SqlSessionFactory sqlServerSqlSessionFactory( @Qualifier("sqlServerDataSource") DataSource dataSource) throws Exception { SqlSessionFactoryBean bean = new SqlSessionFactoryBean(); bean.setDataSource(dataSource); bean.setMapperLocations( new PathMatchingResourcePatternResolver() .getResources("classpath*:mapper/sqlserver/**/*.xml")); org.apache.ibatis.session.Configuration cfg = new org.apache.ibatis.session.Configuration(); cfg.setMapUnderscoreToCamelCase(true); cfg.setDefaultStatementTimeout(30); cfg.setCacheEnabled(false); bean.setConfiguration(cfg); return bean.getObject(); } @Primary @Bean(name = "sqlServerSqlSessionTemplate") public SqlSessionTemplate sqlServerSqlSessionTemplate( @Qualifier("sqlServerSqlSessionFactory") SqlSessionFactory factory) { return new SqlSessionTemplate(factory); } }

@MapperScan的basePackages指向 SQL Server 专用的 Mapper 包,sqlSessionFactoryRef指定用哪个工厂。这里的关键约束是:一个 Mapper 接口只能被一个@MapperScan扫到。如果两个配置类的扫描路径有重叠,启动时会报BeanDefinitionOverrideException或者出现“Mapper 绑定了错误的 SqlSessionFactory”这种更隐蔽的问题。

所以包结构必须严格隔离。我用的结构是这样的:

com.example.demo ├── mapper │ ├── sqlserver // 只放 SQL Server 的 Mapper 接口 │ └── postgres // 只放 PostgreSQL 的 Mapper 接口 ├── entity ├── service └── config ├── SqlServerDataSourceConfig.java ├── SqlServerMybatisConfig.java ├── PostgresDataSourceConfig.java └── PostgresMybatisConfig.java

XML 映射文件也按同样的方式分目录,mapper/sqlserver/和mapper/postgres/。这样一眼就能看出某个 Mapper 属于哪个库,新人接手时不用猜。

bean.setConfiguration(cfg)这一行要特别注意:一旦你手工设置了Configuration对象,Spring Boot 自动从mybatis.*配置项里读取的那些设置就全失效了。也就是说,如果你想用mybatis.configuration.map-underscore-to-camel-case=true这种全局配置,在多数据源场景下是不生效的,必须在两个工厂里各写一遍。这个坑我在两个项目里都遇到过,表现形式是“实体类字段全是 null”,查半天发现是驼峰映射没开。

3.3 事务管理器与 @Primary 的真正影响

事务管理器是双数据源配置里最容易出问题的一环。

@Primary @Bean(name = "sqlServerTransactionManager") public DataSourceTransactionManager sqlServerTransactionManager( @Qualifier("sqlServerDataSource") DataSource dataSource) { return new DataSourceTransactionManager(dataSource); }

PostgreSQL 那边同样配一个,但不加@Primary。@Primary的作用是:当 Spring 需要按类型注入一个DataSource或PlatformTransactionManager而没有明确指定名字时,优先选带@Primary的那个。

这就带来一个很实际的问题:@Transactional注解默认不写参数时,用的是哪个事务管理器?答案是@Primary那个。所以如果你在主库(SQL Server)的方法上写@Transactional,不加参数是对的;但在 PostgreSQL 的方法上写@Transactional而不指定transactionManager,事务会被绑到 SQL Server 的数据源上,结果是——PostgreSQL 的写操作根本没有事务保护,失败也不会回滚。

这个错误的隐蔽性极高,因为业务正常时一切正常,只有在异常路径上才暴露。测试环境跑通不代表没问题,必须专门写一个“让 PostgreSQL 写入中途抛异常,验证是否回滚”的用例。

正确的写法有两种。一种是在每个 PostgreSQL 的 Service 方法上显式指定:

@Transactional(transactionManager = "postgresTransactionManager", rollbackFor = Exception.class) public void saveOrder(OrderDTO dto) { // ... }

另一种是自定义一个组合注解,减少重复:

@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Transactional(transactionManager = "postgresTransactionManager", rollbackFor = Exception.class) public @interface PgTransactional { }

我倾向于第二种,用起来清爽,也不容易漏。但要注意,这个自定义注解必须能被 Spring 的事务切面识别,@Transactional的元注解继承是支持的,这一点没问题。

还有一个容易忽略的点:@Transactional在同一个类内部的方法互调时不生效。这条跟单数据源时一样,但双数据源场景下症状更迷惑——你以为事务没生效是数据源配错了,其实是代理没进去。排查方法很简单,在方法里打一行日志看是否进入,或者用AopContext.currentProxy()验证。

4. 跨库查询与事务一致性的现实解法

4.1 应用层聚合的具体写法

两个库之间没法直接写JOIN,这是物理隔离决定的,任何框架都变不出来。能做的是在应用层把两次查询的结果在内存里拼起来。

最朴素的做法是两步查询:先查 PostgreSQL 拿到订单列表,提取出客户 ID 集合,再用这个集合去 SQL Server 查客户信息,最后用Map做关联。代码大概长这样:

public List<OrderVO> listOrders(Long tenantId) { List<Order> orders = postgresMapper.selectByTenant(tenantId); if (orders.isEmpty()) { return Collections.emptyList(); } Set<Long> customerIds = orders.stream() .map(Order::getCustomerId) .collect(Collectors.toSet()); Map<Long, Customer> customerMap = sqlServerMapper .selectByIds(customerIds) .stream() .collect(Collectors.toMap(Customer::getId, c -> c)); return orders.stream().map(o -> { OrderVO vo = new OrderVO(); vo.setOrderNo(o.getOrderNo()); Customer c = customerMap.get(o.getCustomerId()); vo.setCustomerName(c == null ? "未知客户" : c.getName()); return vo; }).collect(Collectors.toList()); }

这段代码的关键约束是customerIds的规模。如果订单有十万条,IN子句里塞十万个 ID,SQL Server 会因为参数过多或者执行计划退化而变慢。我的经验是单批不超过 1000 个,超了就分批查,然后合并结果。分批的逻辑可以抽成一个工具方法,两个库的selectByIds都走它。

还有一个陷阱:IN子句的顺序和结果顺序无关。有些人会天真地以为查出来的客户顺序和传进去的 ID 顺序一致,然后按下标去对应,结果全乱。必须用Map按 ID 关联,别用下标。

对于“两边数据量都很大、且需要分页”的场景,应用层聚合会很难受,因为你没法在数据库层面做LIMIT。这时候有两个选择:一是把过滤条件下推到某一侧先行裁剪,比如先按 PostgreSQL 的时间范围过滤出一批订单,再拿客户信息;二是跟业务方沟通,把跨库字段做成冗余字段,定时同步到主库。后者是根治方案,但需要业务让步。

4.2 分布式事务要不要上

如果业务要求“往 PostgreSQL 写订单、同时往 SQL Server 写日志表,两边必须同时成功”,那就涉及分布式事务了。我的态度很明确:能用最终一致性解决的,就别引入分布式事务框架。

理由很实际。分布式事务框架需要额外的服务端组件部署,版本要和 Spring Boot、和两个数据库驱动都对得上,一次版本升级可能要排查三天。而且它本质上解决的是“强一致”,代价是性能下降和可用性下降——协调者挂了,整个集群都卡住。

替代方案有几种,按实现成本从低到高排:

第一种,本地消息表。在 PostgreSQL 里建一张outbox表,业务数据和消息在同一个本地事务里写入,然后有个定时任务扫表,把消息投递到 SQL Server。失败就重试,超过次数进死信表人工处理。这套方案代码量大概两三百行,没有额外组件依赖,可靠性够用。

第二种,失败补偿。先写 PostgreSQL,成功了再写 SQL Server,SQL Server 写失败时记一条异常日志,由后台任务定期重试。适合对一致性要求不那么严、允许分钟级延迟的场景。

第三种,异步对账。两边各写各的,每天夜里跑一个对账任务,找出差异再修。适合数据量大但业务容忍度低的场景,实现最简单,但对账逻辑要写得足够细。

我在模拟项目里用的是第一种。落地时最大的坑不是消息投递,而是幂等。重试机制天然会重复投递,接收端必须有去重逻辑,比如按业务唯一键做INSERT ... ON CONFLICT DO NOTHING(PostgreSQL)或者IF NOT EXISTS判断(SQL Server)。没有幂等保护的补偿机制,在出现网络抖动时会造出一堆重复数据,比不一致还难收拾。

注意:别指望用@Transactional同时管两个数据源。Spring 的声明式事务绑定的是单个PlatformTransactionManager,跨数据源的多事务无法原子提交。看到有教程说“配一个 JtaTransactionManager 就行”的,基本是没在生产环境跑过。

4.3 数据库层面的联邦查询作为补充

前面提到过,个别高频报表可以走数据库层的外部表。SQL Server 这边的做法是把 PostgreSQL 注册成链接服务器,通过 ODBC 驱动连接;PostgreSQL 那边则是用外部数据包装器,把 SQL Server 的表映射成外部表。

这两种做法的共同前提是 DBA 愿意配合,而且网络策略允许两个数据库主机之间直连。配置本身不复杂,但排查问题时比较难受——错误信息通常很含糊,日志分散在数据库服务器上,应用侧只能看到一个模糊的“查询失败”。

我的建议是:只有“报表频率高、参与关联的表结构稳定、不常改”的场景才值得做。业务表结构一周改三次的,别碰这个方案,维护成本会超过收益。

5. 常见问题排查实录

5.1 连接层问题速查表

下面这张表是我这几年攒下来的,基本覆盖了双数据源配置阶段 90% 的报错。

报错关键字常见原因处理方式
The server selected protocol version TLS10 is not acceptedSQL Server 老版本协议与新版驱动不兼容升级数据库补丁,或临时在java.security里放开 TLS10(不推荐长期)
PKIX path building failed驱动尝试校验证书但服务端证书不可信JDBC URL 加trustServerCertificate=true
FATAL: password authentication failedPostgreSQL 密码加密方式与驱动不匹配服务端的pg_hba.conf加密方式调整为兼容模式,或升级驱动
current transaction is abortedPostgreSQL 事务中某条 SQL 失败后未回滚就继续执行在代码里对异常做 catch 后立即回滚,不要吞异常
Cannot get a connection, pool error Timeout waiting for idle object连接池被打满查是否有连接泄漏,检查maximum-pool-size是否够用
Invalid object name 'xxx'SQL Server 库名或 schema 不对,或表名大小写问题检查 URL 的databaseName,SQL 里带 schema 前缀
relation "xxx" does not existPostgreSQL 的search_path没配URL 加currentSchema参数
Error attempting to get column 'xxx' from result set字段类型映射不匹配检查实体类型,大字段用String或自定义TypeHandler

这里挑两个展开说。

连接池超时这条最常见。HikariPool-1 - Connection is not available, request timed out after 30001ms。这个报错本身只说明“等不到连接”,不说明原因。要往下挖,得看同一时间段的日志里有没有“连接泄漏”的告警,HikariCP 在检测到连接长时间不归还时会打一行Apparent connection leak detected,带上获取连接的堆栈。看到这行基本就定位到具体代码了。

如果没有泄漏告警,那就是池子真的太小。这时候要算一下并发量,而不是简单调大数值。调大之前先确认数据库端的连接数上限,别应用侧调到 50,数据库只允许 100 个总连接,多实例一部署又打满。

PostgreSQL 事务中止这条是新手最容易懵的。在 PostgreSQL 里,事务中只要有一条 SQL 报错,整个事务就进入 aborted 状态,后续所有语句都会报current transaction is aborted,直到你显式ROLLBACK。这跟 SQL Server 的行为不同(SQL Server 默认某些错误不会中止整个事务),所以从 SQL Server 迁移过来的代码特别容易踩。解决办法是在需要“尝试执行、失败继续”的地方用SAVEPOINT,或者干脆把这类逻辑拆到事务外面。

5.2 语法与类型映射差异

两个库的 SQL 方言差异,在 MyBatis 里会以“同一个 Mapper 方法在两个库都要写一份”的形式体现。我整理了几组高频差异:

能力SQL Server 写法PostgreSQL 写法备注
分页OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLYLIMIT 10 OFFSET 20SQL Server 必须 2012 及以上
自增主键IDENTITY(1,1)GENERATED BY DEFAULT AS IDENTITYMyBatis 都用useGeneratedKeys取回
字符串拼接+或CONCAT`
当前时间GETDATE()NOW()尽量在应用层取时间,减少方言依赖
空值兜底ISNULL(a, 0)COALESCE(a, 0)COALESCE两边都支持,优先用它
批量插入useBulkCopyForBatchInsert=truereWriteBatchedInserts=true都要在 JDBC URL 里开
JSON 字段NVARCHAR(MAX)存字符串jsonb类型jsonb需要自定义 TypeHandler
布尔值BIT,映射Booleanboolean,映射BooleanSQL Server 的 BIT 只存 0/1

关于jsonb的 TypeHandler,这里给一段可直接用的实现思路:继承 MyBatis 的BaseTypeHandler<Map<String,Object>>,setNonNullParameter里用PGobject包装成jsonb类型,getNullableResult里用 Jackson 反序列化。关键是PGobject的type必须设成jsonb而不是json,否则某些运算符会失效。

还有一条血泪教训:PostgreSQL 的标识符大小写。不加双引号时,所有标识符会被折叠成小写;SQL Server 则保留原始大小写(在不区分大小写的排序规则下)。如果你的表在 SQL Server 里叫OrderDetail,在 PostgreSQL 里建表时写成"OrderDetail",那查询时也必须带双引号,否则找不到。我的做法是统一用下划线小写命名,从根上避开这个问题,代价是要在 SQL Server 侧也遵守这个规范,历史表改不了的就用视图包一层。

5.3 连接池参数怎么算才靠谱

前面提过要给公式,这里说清楚。

maximum-pool-size的经验公式是:CPU 核数 × 2 + 有效磁盘数。这个公式来自 PostgreSQL 官方文档的建议,对 SQL Server 同样适用。比如一个 8 核的数据库服务器,磁盘并发能力按 1 算,那单实例的连接数上限大概是 17,考虑到多服务实例竞争,单个应用实例分到 8 到 12 个比较合理。

但公式只是起点,实际还要考虑几个修正项:

  • 慢查询占比高,就往上加,因为连接持有时间变长,需要的并发数增加。
  • 事务里包含远程调用,要严格控制,因为连接被长时间占用,池子小了会雪崩,这时候更该做的是把远程调用挪到事务外面。
  • 多实例部署,总连接数 = 单实例池大小 × 实例数,这个总和不应该超过数据库上限的 70%,留出余量给运维和备份任务。

connection-timeout建议设成 3000 毫秒而不是默认的 30000。理由很直接:数据库出问题时,用户等 3 秒报错,比等 30 秒再报错体验好得多,而且能更快触发熔断降级。设短了会不会误伤?只要平时池子健康,获取连接是毫秒级的,3 秒足够。

max-lifetime必须小于数据库服务端的连接空闲超时。SQL Server 默认没有强制断开,但有些中间件(比如连接池前面的代理)会设置空闲断开,PostgreSQL 的idle_session_timeout也可以配置。如果应用的max-lifetime大于服务端的超时,会出现“拿到一个已被服务端关闭的连接”的情况,报错是connection reset或者socket closed。我的配置是服务端超时的 80% 左右,两边都留了缓冲。

6. 上线前的验证清单与监控要点

6.1 必须跑通的冒烟测试

配置写完、单元测试通过,不代表能上线。下面这几项必须在贴近生产的环境里手工验证一遍,我称之为“双数据源上线十问”。

第一,两个库都连上了吗。看启动日志里两个HikariPool的初始化输出,池名、最大连接数、连接测试结果都要对得上。

第二,Mapper 扫对了吗。随便找一个 PostgreSQL 的 Mapper 和一个 SQL Server 的 Mapper,各调一个简单查询,确认返回的是预期库的数据。这里有个小技巧:在两边各建一张只有一行、内容不同的测试表,查出来就能立刻判断走没走对库。

第三,事务真的生效了吗。在 PostgreSQL 的写方法里故意抛一个RuntimeException,确认数据没有落库;在 SQL Server 侧同样做一遍。这一步千万不能省,前面说过@Primary的坑就藏在这里。

第四,时间字段对得上吗。在两个库各写一条带时间戳的记录,查出来对比,误差应该在秒级以内(同一台机器上应该完全一致)。

第五,中文有没有乱码。SQL Server 的NVARCHAR和 PostgreSQL 的text都应该正常,如果出现问号或者方块,检查连接串的字符集参数和数据库排序规则。

第六,批量操作正常吗。插入 1000 条数据,看耗时是否符合预期,看批量参数有没有生效(可以在数据库侧开慢查询日志确认是不是单条执行)。

第七,连接池会不会泄漏。用压测工具跑 10 分钟,观察连接池活跃连接数的曲线,跑完应该回落到接近 0,不回落就是有泄漏。

第八,跨库聚合接口的响应时间。重点看两侧查询的耗时分布,找出瓶颈在哪一侧。

第九,数据库重启后能不能自愈。把其中一个库停掉再启动,观察应用是否能自动重连,还是必须重启应用。HikariCP 一般能自愈,但connection-test-query必须配。

第十,监控指标有没有接上。至少要有连接池活跃数、等待数、获取连接的平均耗时这三个指标,接到现有的监控平台上。

6.2 监控指标看什么

双数据源的监控,核心是把两个库的指标分开看,混在一起就失去了意义。

最基础的三个指标:

  • hikaricp.connections.active:当前正在使用的连接数。持续接近maximum-pool-size就说明池子不够或者有慢查询。
  • hikaricp.connections.pending:等待获取连接的线程数。这个值大于 0 就是明确的告警信号,说明已经有请求在排队了。
  • hikaricp.connections.acquire:获取连接的耗时分布。P99 超过 100 毫秒就该查原因了,正常情况下这个值是个位数毫秒。

接入方式很简单,Spring Boot Actuator 的metrics端点里已经包含了这些(需要引入spring-boot-starter-actuator并暴露metrics),再配合 Micrometer 推到 Prometheus 就行。如果用的是别的监控体系,HikariCP 也支持 JMX,通过register-mbeans=true开启。

除了连接池,还建议加一个业务侧的埋点:每个跨库接口记录两侧的查询耗时。这个数据比连接池指标更直观,能直接告诉你“慢在哪个库”。我在一个模拟项目里就是靠这个埋点发现,某个报表接口 90% 的时间耗在 SQL Server 侧的一个缺索引的查询上,加索引之后整体耗时从 4 秒降到 300 毫秒。

7. 一些配置之外的体会

写配置本身不难,难的是让这套东西在半年后还有人能看懂、能改。有几个习惯我这些年一直保持着,效果不错。

配置类里写注释,说明“为什么”而不是“是什么”。@Primary那行旁边写清楚“主库标记,影响 @Transactional 默认绑定”,比写“主数据源”有用一百倍。半年后接手的人看到注释能立刻明白因果关系,不用重新翻一遍 Spring 源码。

给每个数据源起一个能自解释的名字。sqlServerDataSource比dataSource1强太多,日志里看到池名就知道是哪个库。同理,Mapper 包名、XML 目录名、事务管理器名,全部保持一致的命名前缀。

把容易配错的地方做成启动自检。我在配置类里加了一个@PostConstruct方法,启动时打印两个数据源的 URL 前缀(隐去密码)、池大小、驱动版本。这几十行代码在排查“配置到底有没有生效”时价值极高,尤其是配置中心推送出错的时候。

最后分享一个小技巧。如果你不确定某个@ConfigurationProperties有没有绑定成功,别猜,直接在配置类里注入一个Environment,把相关前缀下的所有 key 打印出来。Spring Boot 的松散绑定规则挺绕的,jdbc-url、jdbcUrl、JDBC_URL在某些场景下表现不一样,打出来看一眼最省事。这招我用过很多次,比翻文档快。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询