為什麼需要雙資料源
有些服務天生就需要連兩個不同的資料庫。在這個專案裡,Query-API 同時連:
- Primary DB:主要業務資料庫(資產資料、帳戶資料)
- Secondary DB:另一個業務系統的資料庫(特定流程的補充資料)
最簡單的做法是把兩個 DB 的查詢都丟給同一個 DataSource 配置,但這樣有幾個問題:
- 查哪個庫不透明,出錯難排查
- 連線池參數無法分開調校(兩個 DB 的負載特性不同)
- 交易邊界不清楚
正確的做法是為每個資料源獨立配置一套完整的 DataSource → EntityManagerFactory → TransactionManager。
完整配置結構
Spring Boot Application
├── PrimaryDataSourceConfig
│ ├── @Primary DataSource (primary)
│ ├── primaryEntityManagerFactory
│ ├── primaryTransactionManager
│ └── @EnableJpaRepositories(basePackages = "...repository.primary")
│
└── SecondaryDataSourceConfig
├── DataSource (secondary,不加 @Primary)
├── secondaryEntityManagerFactory
├── secondaryTransactionManager
└── @EnableJpaRepositories(basePackages = "...repository.secondary")Primary DataSource 配置
@Configuration
@EnableJpaRepositories(
basePackages = {
"com.example.query.api.adapter.out.repository.primary",
},
entityManagerFactoryRef = "primaryEntityManagerFactory",
transactionManagerRef = "primaryTransactionManager"
)
public class PrimaryDataSourceConfig {
@Primary
@Bean
@ConfigurationProperties(prefix = "spring.datasource.primary")
public DataSource primaryDataSource() {
return DataSourceBuilder.create().build();
}
@Primary
@Bean
public LocalContainerEntityManagerFactoryBean primaryEntityManagerFactory(
@Qualifier("primaryDataSource") DataSource dataSource,
JpaProperties jpaProperties) {
LocalContainerEntityManagerFactoryBean factory =
new LocalContainerEntityManagerFactoryBean();
factory.setDataSource(dataSource);
factory.setPackagesToScan("com.example.query.api.domain.primary");
factory.setPersistenceUnitName("primary");
HibernateJpaVendorAdapter adapter = new HibernateJpaVendorAdapter();
factory.setJpaVendorAdapter(adapter);
factory.setJpaPropertyMap(jpaProperties.getProperties());
return factory;
}
@Primary
@Bean
public PlatformTransactionManager primaryTransactionManager(
@Qualifier("primaryEntityManagerFactory")
EntityManagerFactory entityManagerFactory) {
return new JpaTransactionManager(entityManagerFactory);
}
}Secondary DataSource 配置
@Configuration
@EnableJpaRepositories(
basePackages = "com.example.query.api.adapter.out.repository.secondary",
entityManagerFactoryRef = "secondaryEntityManagerFactory",
transactionManagerRef = "secondaryTransactionManager"
)
public class SecondaryDataSourceConfig {
// 注意:沒有 @Primary
@Bean
@ConfigurationProperties(prefix = "spring.datasource.secondary")
public DataSource secondaryDataSource() {
return DataSourceBuilder.create().build();
}
@Bean
public LocalContainerEntityManagerFactoryBean secondaryEntityManagerFactory(
@Qualifier("secondaryDataSource") DataSource dataSource,
JpaProperties jpaProperties) {
LocalContainerEntityManagerFactoryBean factory =
new LocalContainerEntityManagerFactoryBean();
factory.setDataSource(dataSource);
factory.setPackagesToScan("com.example.query.api.domain.secondary");
factory.setPersistenceUnitName("secondary");
// 和 primary 完全一樣的結構,不同的 dataSource 和 persistenceUnitName
HibernateJpaVendorAdapter adapter = new HibernateJpaVendorAdapter();
factory.setJpaVendorAdapter(adapter);
factory.setJpaPropertyMap(jpaProperties.getProperties());
return factory;
}
@Bean
public PlatformTransactionManager secondaryTransactionManager(
@Qualifier("secondaryEntityManagerFactory")
EntityManagerFactory entityManagerFactory) {
return new JpaTransactionManager(entityManagerFactory);
}
}YAML 配置
spring:
datasource:
primary:
jdbc-url: jdbc:oracle:thin:@//primary-db:1521/PRIMARYSVC
username: ${PRIMARY_DB_USER}
password: ${PRIMARY_DB_PASS}
minimum-idle: 10
maximum-pool-size: 80 # 根據 DB 可用連線數和服務實例數計算
connection-timeout: 30000
idle-timeout: 300000
connection-test-query: SELECT 1 FROM DUAL
hikari:
max-lifetime: 1200000
keepalive-time: 300000
secondary:
jdbc-url: jdbc:oracle:thin:@//secondary-db:1521/SECONDARYSVC
username: ${SECONDARY_DB_USER}
password: ${SECONDARY_DB_PASS}
minimum-idle: 5
maximum-pool-size: 30 # 用量少,設保守一些
connection-timeout: 30000
idle-timeout: 300000
connection-test-query: SELECT 1 FROM DUAL
hikari:
max-lifetime: 1200000
keepalive-time: 300000用 Package 切分 Repository,不是 Annotation
Repository 歸屬哪個資料源,推薦用 package 結構來決定,而不是在每個 Repository 上加 annotation:
adapter/out/repository/
├── primary/
│ ├── AccountRepository.java → 自動對應 primaryEntityManagerFactory
│ ├── AssetRepository.java
│ └── BalanceRepository.java
└── secondary/
├── StockAssetRepository.java → 自動對應 secondaryEntityManagerFactory
└── ProductRepository.java為什麼 package 比 annotation 好:
| 做法 | 優點 | 缺點 |
|---|---|---|
| Package 切分(推薦) | 看位置就知道連哪個庫,新增 repository 不需要多想 | 需要維護 package 命名規範 |
| Annotation 標記 | 彈性 | 容易忘記加、容易加錯,review 時不直觀 |
@Transactional 的邊界問題
雙資料源最容易踩的坑在這裡。
@Transactional 預設使用 @Primary 的 TransactionManager。如果你的 Service 同時操作兩個資料源的 Repository:
@Service
public class SomeService {
private final PrimaryRepository primaryRepo; // 連 primary DB
private final SecondaryRepository secondaryRepo; // 連 secondary DB
@Transactional // ← 只有 primaryTransactionManager 在管
public void doSomething() {
primaryRepo.save(data1); // 在 primary 交易內
secondaryRepo.save(data2); // ❌ 不在同一個交易!
// 如果這裡拋異常:
// - primaryRepo.save() 會 rollback ✓
// - secondaryRepo.save() 不會 rollback ✗
}
}@Transactional 不會自動管兩個 TransactionManager,只管你指定的那個(或 Primary)。
解法一:指定 transactionManager
@Transactional("primaryTransactionManager")
public void primaryOperation() { ... }
@Transactional("secondaryTransactionManager")
public void secondaryOperation() { ... }解法二:設計上避免跨資料源寫入同一交易
這是更根本的解法。如果一個業務操作需要同時寫入兩個資料庫,應該考慮:
正確設計:
UseCase A → 只操作 primary repository
UseCase B → 只操作 secondary repository
需要協調時 → 用 Outbox / Saga / 事件補償
❌ 不建議:
UseCase X → 同時操作兩個 repository(交易語義不清楚)跨資料源要做到真正的 ACID 需要 XA Transaction(分散式兩階段提交),引入的複雜度和效能代價非常高。多數場景用最終一致性方案就夠了。
幾個容易踩的坑
Bean 名稱衝突
// ❌ 兩個 Config 都宣告同樣的 bean name,後面的會覆蓋前面的
@Bean
public DataSource dataSource() { ... } // 兩個 Config 都有這行,壞掉
// ✅ 每個 Config 的 bean name 要唯一
@Bean(name = "primaryDataSource")
public DataSource primaryDataSource() { ... }
@Bean(name = "secondaryDataSource")
public DataSource secondaryDataSource() { ... }@Primary 只能有一個
如果你把兩個 DataSource 都標了 @Primary,Spring 啟動就會炸。只有 primary 那個標,secondary 的都不標。
EntityManagerFactory 命名要和 @EnableJpaRepositories 一致
這個不一致會讓 repository 全部找不到 EntityManager:
// @EnableJpaRepositories 裡寫的
entityManagerFactoryRef = "primaryEntityManagerFactory"
// @Bean 必須同名
@Bean(name = "primaryEntityManagerFactory") // ← 名字要完全一致
public LocalContainerEntityManagerFactoryBean primaryEntityManagerFactory(...) { ... }雙資料源的連線池要分開算
多機器環境下,HikariCP 的連線池大小計算要考慮所有服務實例:
假設:
- Primary DB 允許最大連線數:300
- 保留給 DBA 工具:50
- 可用連線:250
- Query-API 有 3 台實例
每台實例 primary 連線池上限 = 250 / 3 ≈ 83 → 取 80
Secondary DB 允許最大連線數:150
可用連線:120
每台實例 secondary 連線池上限 = 120 / 3 = 40這個計算同樣適用於只有一個 DataSource 的情境,多機器環境時容易被忽略——每台設太大,加起來超過 DB 上限,就會出現連線被拒絕的錯誤。
啟動時的兩個常見報錯
1. No qualifying bean of type 'EntityManagerFactory'
原因通常是 entityManagerFactoryRef 名稱和 @Bean(name = ...) 不一致,或者忘記加 @Bean。
2. Repository is under multiple entity managers
Repository 的 package 同時落在兩個 @EnableJpaRepositories 的 basePackages 範圍內。檢查 package 路徑有沒有重疊。
設定好的好處
配置完成後,整個系統的資料源歸屬完全透明:
一眼就知道這個 Repository 連哪個庫:
adapter/out/repository/primary/AccountRepository.java → Primary DB
adapter/out/repository/secondary/StockAssetRepository.java → Secondary DB
交易邊界清楚:
UserConsentLogService → @Transactional("primaryTransactionManager")
AssetStatusLogService → @Transactional("secondaryTransactionManager")
連線池各自調校:
Primary:高負載,max-pool-size: 80
Secondary:低頻查詢,max-pool-size: 30對於需要長期維護的服務來說,這種明確性比靈活性更有價值。