エンタープライズアプリケーションでは、複数のデータベースを同時に扱う要件が頻繁に発生します。Spring JPA複数データソース設定を適切に行うことで、メインDB・レプリカDB間の読み書き分離、異なるドメインごとのDB分離、レガシーシステムとの連携など、多様なアーキテクチャ要件に対応できます。本記事では、複数DataSource/EntityManagerFactory/TransactionManagerの設定から、@Primary/@QualifierによるBeanの使い分け、AbstractRoutingDataSourceによる動的なデータソース切り替えまで、実践的なコード例とともに解説します。

実行環境と前提条件

本記事の内容を実践するにあたり、以下の環境を前提としています。

項目 バージョン・要件
Java 17以上
Spring Boot 3.4.x
Spring Data JPA 3.4.x(Spring Boot Starterに含まれる)
Hibernate 6.6.x(Spring Data JPAに含まれる)
データベース PostgreSQL 16.x(複数インスタンス)
ビルドツール Maven または Gradle
IDE VS Code または IntelliJ IDEA

事前に以下の準備を完了してください。

  • JDK 17以上のインストール
  • 複数のPostgreSQLデータベースインスタンス(プライマリとレプリカ、または異なるドメイン用)
  • Spring Boot JPAプロジェクトの基本構成

複数データソースが必要になるユースケース

複数のデータソースを使用する典型的なシナリオを理解しておくことで、適切なアーキテクチャ設計が可能になります。

graph TB
    subgraph "Spring Boot Application"
        A[Service Layer]
    end
    subgraph "ユースケース1: 読み書き分離"
        B[(Primary DB<br/>Write)]
        C[(Replica DB<br/>Read)]
    end
    subgraph "ユースケース2: ドメイン分離"
        D[(Order DB)]
        E[(Inventory DB)]
    end
    subgraph "ユースケース3: レガシー連携"
        F[(New System DB)]
        G[(Legacy DB)]
    end
    A --> B
    A --> C
    A --> D
    A --> E
    A --> F
    A --> G
ユースケース 説明 主な要件
読み書き分離 書き込みはプライマリ、読み取りはレプリカに振り分け 動的ルーティング
ドメイン分離 マイクロサービス境界に沿ったDB分割 静的な複数DataSource
レガシー連携 新旧システム間でのデータ連携 異なるスキーマ対応
マルチテナント テナントごとに異なるDB 動的ルーティング

依存関係の設定

複数データソースを使用するための依存関係を設定します。

Mavenの場合

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <scope>runtime</scope>
    </dependency>
</dependencies>

Gradleの場合

1
2
3
4
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    runtimeOnly 'org.postgresql:postgresql'
}

複数DataSourceの基本設定

複数のDataSourceを設定する基本的なアプローチを解説します。Spring Boot 3.4.xでは、@Primary@Qualifierを組み合わせて複数のDataSourceを管理します。

application.ymlでの接続情報定義

まず、複数のデータソース接続情報をapplication.ymlに定義します。

 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
spring:
  datasource:
    primary:
      url: jdbc:postgresql://localhost:5432/primary_db
      username: primary_user
      password: primary_pass
      driver-class-name: org.postgresql.Driver
      hikari:
        maximum-pool-size: 10
        minimum-idle: 5
        connection-timeout: 30000
    secondary:
      url: jdbc:postgresql://localhost:5433/secondary_db
      username: secondary_user
      password: secondary_pass
      driver-class-name: org.postgresql.Driver
      hikari:
        maximum-pool-size: 5
        minimum-idle: 2
        connection-timeout: 30000

  jpa:
    hibernate:
      ddl-auto: validate
    show-sql: true
    properties:
      hibernate:
        format_sql: true

プライマリDataSourceの設定

@Primaryアノテーションを付与したDataSourceがデフォルトとして使用されます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.example.config;

import com.zaxxer.hikari.HikariDataSource;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;

import javax.sql.DataSource;

@Configuration
public class PrimaryDataSourceConfig {

    @Bean
    @Primary
    @ConfigurationProperties("spring.datasource.primary")
    public DataSource primaryDataSource() {
        return DataSourceBuilder.create()
                .type(HikariDataSource.class)
                .build();
    }
}

セカンダリDataSourceの設定

セカンダリDataSourceには@Qualifierを使用して明示的に識別します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.example.config;

import com.zaxxer.hikari.HikariDataSource;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.sql.DataSource;

@Configuration
public class SecondaryDataSourceConfig {

    @Qualifier("secondaryDataSource")
    @Bean(name = "secondaryDataSource")
    @ConfigurationProperties("spring.datasource.secondary")
    public DataSource secondaryDataSource() {
        return DataSourceBuilder.create()
                .type(HikariDataSource.class)
                .build();
    }
}

複数EntityManagerFactoryの設定

各DataSourceに対応するEntityManagerFactoryを設定します。パッケージスキャンの範囲を明確に分離することで、エンティティの混在を防ぎます。

プライマリEntityManagerFactoryの設定

 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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
package com.example.config;

import jakarta.persistence.EntityManagerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;

import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;

@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(
    basePackages = "com.example.primary.repository",
    entityManagerFactoryRef = "primaryEntityManagerFactory",
    transactionManagerRef = "primaryTransactionManager"
)
public class PrimaryJpaConfig {

    @Primary
    @Bean(name = "primaryEntityManagerFactory")
    public LocalContainerEntityManagerFactoryBean primaryEntityManagerFactory(
            EntityManagerFactoryBuilder builder,
            @Qualifier("primaryDataSource") DataSource dataSource) {
        
        return builder
                .dataSource(dataSource)
                .packages("com.example.primary.entity")
                .persistenceUnit("primary")
                .properties(hibernateProperties())
                .build();
    }

    @Primary
    @Bean(name = "primaryTransactionManager")
    public PlatformTransactionManager primaryTransactionManager(
            @Qualifier("primaryEntityManagerFactory") EntityManagerFactory entityManagerFactory) {
        return new JpaTransactionManager(entityManagerFactory);
    }

    private Map<String, Object> hibernateProperties() {
        Map<String, Object> properties = new HashMap<>();
        properties.put("hibernate.hbm2ddl.auto", "validate");
        properties.put("hibernate.dialect", "org.hibernate.dialect.PostgreSQLDialect");
        properties.put("hibernate.show_sql", true);
        properties.put("hibernate.format_sql", true);
        return properties;
    }
}

セカンダリEntityManagerFactoryの設定

 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
41
42
43
44
45
46
47
48
49
50
51
52
package com.example.config;

import jakarta.persistence.EntityManagerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.transaction.PlatformTransactionManager;

import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;

@Configuration
@EnableJpaRepositories(
    basePackages = "com.example.secondary.repository",
    entityManagerFactoryRef = "secondaryEntityManagerFactory",
    transactionManagerRef = "secondaryTransactionManager"
)
public class SecondaryJpaConfig {

    @Bean(name = "secondaryEntityManagerFactory")
    public LocalContainerEntityManagerFactoryBean secondaryEntityManagerFactory(
            EntityManagerFactoryBuilder builder,
            @Qualifier("secondaryDataSource") DataSource dataSource) {
        
        return builder
                .dataSource(dataSource)
                .packages("com.example.secondary.entity")
                .persistenceUnit("secondary")
                .properties(hibernateProperties())
                .build();
    }

    @Bean(name = "secondaryTransactionManager")
    public PlatformTransactionManager secondaryTransactionManager(
            @Qualifier("secondaryEntityManagerFactory") EntityManagerFactory entityManagerFactory) {
        return new JpaTransactionManager(entityManagerFactory);
    }

    private Map<String, Object> hibernateProperties() {
        Map<String, Object> properties = new HashMap<>();
        properties.put("hibernate.hbm2ddl.auto", "validate");
        properties.put("hibernate.dialect", "org.hibernate.dialect.PostgreSQLDialect");
        properties.put("hibernate.show_sql", true);
        properties.put("hibernate.format_sql", true);
        return properties;
    }
}

パッケージ構成の設計

複数DataSourceを使用する場合、エンティティとリポジトリのパッケージ構成を明確に分離することが重要です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
src/main/java/com/example/
├── config/
│   ├── PrimaryDataSourceConfig.java
│   ├── SecondaryDataSourceConfig.java
│   ├── PrimaryJpaConfig.java
│   └── SecondaryJpaConfig.java
├── primary/
│   ├── entity/
│   │   └── Order.java
│   ├── repository/
│   │   └── OrderRepository.java
│   └── service/
│       └── OrderService.java
└── secondary/
    ├── entity/
    │   └── Inventory.java
    ├── repository/
    │   └── InventoryRepository.java
    └── service/
        └── InventoryService.java

プライマリ側のエンティティとリポジトリ

 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
package com.example.primary.entity;

import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;

@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "customer_id", nullable = false)
    private Long customerId;

    @Column(name = "total_amount", nullable = false)
    private BigDecimal totalAmount;

    @Column(name = "created_at", nullable = false)
    private LocalDateTime createdAt;

    // コンストラクタ、ゲッター、セッター
    public Order() {}

    public Order(Long customerId, BigDecimal totalAmount) {
        this.customerId = customerId;
        this.totalAmount = totalAmount;
        this.createdAt = LocalDateTime.now();
    }

    // Getter/Setter省略
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package com.example.primary.repository;

import com.example.primary.entity.Order;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
    List<Order> findByCustomerId(Long customerId);
}

セカンダリ側のエンティティとリポジトリ

 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
package com.example.secondary.entity;

import jakarta.persistence.*;

@Entity
@Table(name = "inventory")
public class Inventory {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "product_id", nullable = false, unique = true)
    private Long productId;

    @Column(name = "quantity", nullable = false)
    private Integer quantity;

    @Column(name = "warehouse_code", nullable = false)
    private String warehouseCode;

    // コンストラクタ、ゲッター、セッター
    public Inventory() {}

    public Inventory(Long productId, Integer quantity, String warehouseCode) {
        this.productId = productId;
        this.quantity = quantity;
        this.warehouseCode = warehouseCode;
    }

    // Getter/Setter省略
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
package com.example.secondary.repository;

import com.example.secondary.entity.Inventory;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public interface InventoryRepository extends JpaRepository<Inventory, Long> {
    Optional<Inventory> findByProductId(Long productId);
}

@Transactionalによるトランザクション制御

複数のTransactionManagerが存在する場合、@Transactionalアノテーションでトランザクションマネージャーを明示的に指定する必要があります。

単一データソースのトランザクション

 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
package com.example.primary.service;

import com.example.primary.entity.Order;
import com.example.primary.repository.OrderRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;

@Service
public class OrderService {

    private final OrderRepository orderRepository;

    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    // プライマリTransactionManagerを使用(@Primaryが付いているため省略可能)
    @Transactional
    public Order createOrder(Long customerId, BigDecimal amount) {
        Order order = new Order(customerId, amount);
        return orderRepository.save(order);
    }

    // 明示的にトランザクションマネージャーを指定
    @Transactional("primaryTransactionManager")
    public Order createOrderExplicit(Long customerId, BigDecimal amount) {
        Order order = new Order(customerId, amount);
        return orderRepository.save(order);
    }
}

セカンダリデータソースのトランザクション

 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
package com.example.secondary.service;

import com.example.secondary.entity.Inventory;
import com.example.secondary.repository.InventoryRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class InventoryService {

    private final InventoryRepository inventoryRepository;

    public InventoryService(InventoryRepository inventoryRepository) {
        this.inventoryRepository = inventoryRepository;
    }

    // セカンダリTransactionManagerを明示的に指定(必須)
    @Transactional("secondaryTransactionManager")
    public Inventory updateStock(Long productId, Integer quantity) {
        Inventory inventory = inventoryRepository.findByProductId(productId)
                .orElseThrow(() -> new IllegalArgumentException(
                    "Inventory not found for product: " + productId));
        
        inventory.setQuantity(quantity);
        return inventoryRepository.save(inventory);
    }

    @Transactional(value = "secondaryTransactionManager", readOnly = true)
    public Inventory getInventory(Long productId) {
        return inventoryRepository.findByProductId(productId)
                .orElseThrow(() -> new IllegalArgumentException(
                    "Inventory not found for product: " + productId));
    }
}

複数データソースをまたぐトランザクション

複数のデータソースをまたぐ場合、分散トランザクション(XA)またはSagaパターンを検討する必要があります。以下は、補償トランザクションを使用した例です。

 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
41
42
43
44
45
46
package com.example.service;

import com.example.primary.entity.Order;
import com.example.primary.service.OrderService;
import com.example.secondary.service.InventoryService;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;

@Service
public class OrderOrchestrationService {

    private final OrderService orderService;
    private final InventoryService inventoryService;

    public OrderOrchestrationService(OrderService orderService,
                                      InventoryService inventoryService) {
        this.orderService = orderService;
        this.inventoryService = inventoryService;
    }

    /**
     * 複数データソースをまたぐ操作
     * 注意: 各操作は別々のトランザクションで実行されるため、
     * 完全なACID特性は保証されません。
     * 必要に応じて補償トランザクションを実装してください。
     */
    public Order placeOrder(Long customerId, Long productId, 
                            BigDecimal amount, Integer quantity) {
        // 1. 在庫を減らす(セカンダリDB)
        try {
            inventoryService.updateStock(productId, quantity);
        } catch (Exception e) {
            throw new RuntimeException("Failed to update inventory", e);
        }

        // 2. 注文を作成(プライマリDB)
        try {
            return orderService.createOrder(customerId, amount);
        } catch (Exception e) {
            // 補償トランザクション: 在庫を戻す
            inventoryService.updateStock(productId, -quantity);
            throw new RuntimeException("Failed to create order, inventory rolled back", e);
        }
    }
}

AbstractRoutingDataSourceによる動的切り替え

AbstractRoutingDataSourceを使用すると、実行時に動的にデータソースを切り替えることができます。これは読み書き分離やマルチテナントの実装に特に有用です。

データソースキーの定義

1
2
3
4
5
6
package com.example.routing;

public enum DataSourceType {
    PRIMARY,
    REPLICA
}

コンテキストホルダーの実装

スレッドローカルを使用して、現在のデータソースキーを保持します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package com.example.routing;

public class DataSourceContextHolder {

    private static final ThreadLocal<DataSourceType> contextHolder = 
            new ThreadLocal<>();

    public static void setDataSourceType(DataSourceType dataSourceType) {
        contextHolder.set(dataSourceType);
    }

    public static DataSourceType getDataSourceType() {
        return contextHolder.get();
    }

    public static void clearDataSourceType() {
        contextHolder.remove();
    }
}

RoutingDataSourceの実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package com.example.routing;

import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;

public class RoutingDataSource extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        DataSourceType dataSourceType = DataSourceContextHolder.getDataSourceType();
        // デフォルトはPRIMARY
        return dataSourceType != null ? dataSourceType : DataSourceType.PRIMARY;
    }
}

RoutingDataSourceの設定

 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
41
42
43
44
45
46
47
48
49
package com.example.config;

import com.example.routing.DataSourceType;
import com.example.routing.RoutingDataSource;
import com.zaxxer.hikari.HikariDataSource;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;

import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;

@Configuration
public class RoutingDataSourceConfig {

    @Bean
    @ConfigurationProperties("spring.datasource.primary")
    public DataSource primaryDataSource() {
        return DataSourceBuilder.create()
                .type(HikariDataSource.class)
                .build();
    }

    @Bean
    @ConfigurationProperties("spring.datasource.replica")
    public DataSource replicaDataSource() {
        return DataSourceBuilder.create()
                .type(HikariDataSource.class)
                .build();
    }

    @Bean
    @Primary
    public DataSource routingDataSource() {
        RoutingDataSource routingDataSource = new RoutingDataSource();

        Map<Object, Object> targetDataSources = new HashMap<>();
        targetDataSources.put(DataSourceType.PRIMARY, primaryDataSource());
        targetDataSources.put(DataSourceType.REPLICA, replicaDataSource());

        routingDataSource.setTargetDataSources(targetDataSources);
        routingDataSource.setDefaultTargetDataSource(primaryDataSource());

        return routingDataSource;
    }
}

読み取り専用アノテーションの作成

カスタムアノテーションを使用して、宣言的にデータソースを切り替えます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
package com.example.routing;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ReadOnlyConnection {
}

AOPによる自動切り替え

 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
package com.example.routing;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Aspect
@Component
@Order(0) // トランザクション開始前に実行
public class ReadOnlyConnectionAspect {

    @Around("@annotation(readOnlyConnection)")
    public Object proceed(ProceedingJoinPoint joinPoint, 
                          ReadOnlyConnection readOnlyConnection) throws Throwable {
        try {
            DataSourceContextHolder.setDataSourceType(DataSourceType.REPLICA);
            return joinPoint.proceed();
        } finally {
            DataSourceContextHolder.clearDataSourceType();
        }
    }

    @Around("@annotation(transactional) && @annotation(readOnlyConnection)")
    public Object proceedWithTransactional(ProceedingJoinPoint joinPoint,
                                           Transactional transactional,
                                           ReadOnlyConnection readOnlyConnection) 
            throws Throwable {
        try {
            if (transactional.readOnly()) {
                DataSourceContextHolder.setDataSourceType(DataSourceType.REPLICA);
            }
            return joinPoint.proceed();
        } finally {
            DataSourceContextHolder.clearDataSourceType();
        }
    }
}

readOnlyフラグに基づく自動ルーティング

@Transactional(readOnly = true)を検知して自動的にレプリカに切り替える実装です。

 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
package com.example.routing;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Aspect
@Component
@Order(0)
public class TransactionalReadOnlyAspect {

    @Around("@annotation(transactional)")
    public Object routeBasedOnTransactional(ProceedingJoinPoint joinPoint,
                                             Transactional transactional) 
            throws Throwable {
        try {
            if (transactional.readOnly()) {
                DataSourceContextHolder.setDataSourceType(DataSourceType.REPLICA);
            } else {
                DataSourceContextHolder.setDataSourceType(DataSourceType.PRIMARY);
            }
            return joinPoint.proceed();
        } finally {
            DataSourceContextHolder.clearDataSourceType();
        }
    }
}

サービスでの使用例

 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
package com.example.service;

import com.example.primary.entity.Order;
import com.example.primary.repository.OrderRepository;
import com.example.routing.ReadOnlyConnection;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.util.List;

@Service
public class OrderQueryService {

    private final OrderRepository orderRepository;

    public OrderQueryService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    // レプリカDBから読み取り
    @ReadOnlyConnection
    @Transactional(readOnly = true)
    public List<Order> findOrdersByCustomer(Long customerId) {
        return orderRepository.findByCustomerId(customerId);
    }

    // プライマリDBに書き込み
    @Transactional
    public Order createOrder(Long customerId, BigDecimal amount) {
        Order order = new Order(customerId, amount);
        return orderRepository.save(order);
    }
}

読み書き分離のアーキテクチャ

読み取り専用レプリカへのルーティングを活用したアーキテクチャ全体像を示します。

sequenceDiagram
    participant Client
    participant Controller
    participant Aspect
    participant ContextHolder
    participant RoutingDataSource
    participant Primary as Primary DB
    participant Replica as Replica DB

    Client->>Controller: GET /orders/{customerId}
    Controller->>Aspect: findOrdersByCustomer()
    Aspect->>ContextHolder: setDataSourceType(REPLICA)
    Aspect->>Controller: proceed
    Controller->>RoutingDataSource: getConnection()
    RoutingDataSource->>ContextHolder: getDataSourceType()
    ContextHolder-->>RoutingDataSource: REPLICA
    RoutingDataSource->>Replica: SELECT * FROM orders
    Replica-->>Controller: Result
    Aspect->>ContextHolder: clearDataSourceType()
    Controller-->>Client: Orders JSON

LazyConnectionDataSourceProxyの活用

LazyConnectionDataSourceProxyを使用すると、実際にSQLが実行されるまでコネクション取得を遅延できます。これにより、トランザクション開始時点でデータソースが決定されていなくても、後からルーティングを決定できます。

 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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
package com.example.config;

import com.example.routing.DataSourceType;
import com.example.routing.RoutingDataSource;
import com.zaxxer.hikari.HikariDataSource;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy;

import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;

@Configuration
public class LazyRoutingDataSourceConfig {

    @Bean
    @ConfigurationProperties("spring.datasource.primary")
    public DataSource primaryDataSource() {
        return DataSourceBuilder.create()
                .type(HikariDataSource.class)
                .build();
    }

    @Bean
    @ConfigurationProperties("spring.datasource.replica")
    public DataSource replicaDataSource() {
        return DataSourceBuilder.create()
                .type(HikariDataSource.class)
                .build();
    }

    @Bean
    public DataSource routingDataSource() {
        RoutingDataSource routingDataSource = new RoutingDataSource();

        Map<Object, Object> targetDataSources = new HashMap<>();
        targetDataSources.put(DataSourceType.PRIMARY, primaryDataSource());
        targetDataSources.put(DataSourceType.REPLICA, replicaDataSource());

        routingDataSource.setTargetDataSources(targetDataSources);
        routingDataSource.setDefaultTargetDataSource(primaryDataSource());

        return routingDataSource;
    }

    @Bean
    @Primary
    public DataSource dataSource() {
        // LazyConnectionDataSourceProxyでラップ
        return new LazyConnectionDataSourceProxy(routingDataSource());
    }
}

よくある誤解とアンチパターン

複数データソース設定における典型的な問題とその回避策を解説します。

アンチパターン1: @Primaryの誤用

1
2
3
4
5
6
7
8
// 悪い例: 両方に@Primaryを付けてしまう
@Bean
@Primary
public DataSource primaryDataSource() { ... }

@Bean
@Primary // エラー: 複数の@Primaryは許可されない
public DataSource secondaryDataSource() { ... }

解決策: @Primaryは1つのBeanにのみ付与し、他は@Qualifierで識別します。

アンチパターン2: パッケージスキャンの重複

1
2
3
4
5
6
// 悪い例: 同じパッケージを複数のEntityManagerFactoryでスキャン
@EnableJpaRepositories(basePackages = "com.example.repository")
public class PrimaryJpaConfig { ... }

@EnableJpaRepositories(basePackages = "com.example.repository") // 重複
public class SecondaryJpaConfig { ... }

解決策: パッケージを明確に分離し、重複しないように設計します。

1
2
3
4
5
6
// 良い例
@EnableJpaRepositories(basePackages = "com.example.primary.repository")
public class PrimaryJpaConfig { ... }

@EnableJpaRepositories(basePackages = "com.example.secondary.repository")
public class SecondaryJpaConfig { ... }

アンチパターン3: トランザクションマネージャーの指定忘れ

1
2
3
4
5
6
7
8
9
// 悪い例: セカンダリDBへの操作でトランザクションマネージャーを指定しない
@Service
public class InventoryService {
    
    @Transactional // primaryTransactionManagerが使用され、動作しない
    public void updateInventory() {
        inventoryRepository.save(inventory); // セカンダリDBのリポジトリ
    }
}

解決策: 非プライマリのデータソースを使用する場合、必ずトランザクションマネージャーを明示的に指定します。

1
2
3
4
5
// 良い例
@Transactional("secondaryTransactionManager")
public void updateInventory() {
    inventoryRepository.save(inventory);
}

アンチパターン4: ThreadLocalのクリア忘れ

1
2
3
4
5
6
// 悪い例: ThreadLocalをクリアしない
public Object proceed(ProceedingJoinPoint joinPoint) throws Throwable {
    DataSourceContextHolder.setDataSourceType(DataSourceType.REPLICA);
    return joinPoint.proceed();
    // ThreadLocalがクリアされない -> 次のリクエストに影響
}

解決策: 必ずfinallyブロックでThreadLocalをクリアします。

1
2
3
4
5
6
7
8
9
// 良い例
public Object proceed(ProceedingJoinPoint joinPoint) throws Throwable {
    try {
        DataSourceContextHolder.setDataSourceType(DataSourceType.REPLICA);
        return joinPoint.proceed();
    } finally {
        DataSourceContextHolder.clearDataSourceType();
    }
}

アンチパターン5: レプリカ遅延の考慮不足

非同期レプリケーションを使用している場合、書き込み直後の読み取りでデータが反映されていないことがあります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 悪い例: 書き込み直後にレプリカから読み取り
@Service
public class OrderService {
    
    @Transactional
    public Order createAndFetch(Long customerId, BigDecimal amount) {
        Order order = orderRepository.save(new Order(customerId, amount));
        
        // 直後にレプリカから読み取り -> データが見つからない可能性
        return readOnlyOrderService.findById(order.getId());
    }
}

解決策: 書き込み直後の読み取りはプライマリから行うか、適切な遅延を設けます。

まとめと実践Tips

Spring JPA複数データソース設定のポイントをまとめます。

設計時のチェックリスト

チェック項目 説明
パッケージ分離 エンティティとリポジトリを明確に分離しているか
@Primary/@Qualifier 適切にBeanを識別できているか
トランザクションマネージャー 各操作で正しいマネージャーを指定しているか
ThreadLocalクリア finally句でコンテキストをクリアしているか
レプリカ遅延 書き込み直後の読み取りを考慮しているか

実践Tips

  1. テストの分離: 各DataSourceに対するテストを独立して実行できるよう、テスト設定を分離します。

  2. ヘルスチェック: 各DataSourceの接続状態を監視するヘルスチェックエンドポイントを実装します。

  3. フェイルオーバー: レプリカが利用できない場合にプライマリにフォールバックする仕組みを検討します。

  4. コネクションプール監視: HikariCPのメトリクスをMicrometerで収集し、接続プールの状態を可視化します。

  5. 設定の外部化: 本番環境ではKubernetes SecretsやVaultなどで接続情報を管理します。

参考リンク