単一DataSourceで運用してきたアプリも、規模が大きくなると「複数のDBに繋ぎたい」「参照系をリードレプリカに逃がしたい」という要求が出てきますよね。ところが application.yml に2つ目のDBを足そうとした瞬間、自動構成が効かなくなって手が止まる。今回はこのあたりを、静的な複数DataSourceと動的な読み書き分離の2パターンに分けて整理していきます。

複数データソースが必要になる2つのシーン

まず自分がどちらの課題に直面しているかをはっきりさせましょう。

1つは 目的の異なる独立したDBに接続する ケース。業務データはメインDB、監査ログは別DBといった構成です。それぞれ別々のスキーマ・別々のリポジトリを持ちます。

もう1つは 単一論理DBの読み書き負荷を分散する ケース。書き込みはプライマリ、参照はリードレプリカへ振り分けたい、というものです。データの中身は同じで、宛先だけを切り替えたい。

前者は静的な複数DataSource、後者は AbstractRoutingDataSource が向いています。本記事はJPA前提で進めます。テナントごとの切り替えはマルチテナント実装の記事、プール設定はHikariCPチューニングの記事に譲ります。

なぜ自動構成が効かなくなるのか

Spring Bootの DataSourceAutoConfiguration は、コンテキストに DataSource Beanが1つだけ存在することを前提に動きます。JpaRepositoriesAutoConfiguration も同様に単一の EntityManagerFactory を想定しています。

だからDataSourceを2つ定義した途端、「どれをデフォルトにすればいいか分からない」という状態になり、どちらか一方に @Primary を付けるか、まるごと手動構成に切り替える必要が出てきます。自動構成の仕組みそのものは自動構成の記事にまとめてあるので、背景を押さえたい人はそちらもどうぞ。

パターン1:静的な複数DataSource構成

独立した2つのDBへ繋ぐ構成から見ていきます。まず application.yml でプロパティを名前空間で分けます。

app:
  datasource:
    primary:
      jdbc-url: jdbc:mysql://localhost:3306/main
      username: app
      password: secret
    secondary:
      jdbc-url: jdbc:mysql://localhost:3306/audit
      username: app
      password: secret

HikariCPを使うので url ではなく jdbc-url を指定する点に注意してください。次にDataSourceごとの構成クラスです。

@Configuration
@EnableJpaRepositories(
    basePackages = "com.example.main.repository",
    entityManagerFactoryRef = "primaryEmf",
    transactionManagerRef = "primaryTx")
public class PrimaryDataSourceConfig {

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

    @Primary
    @Bean
    public LocalContainerEntityManagerFactoryBean primaryEmf(
            EntityManagerFactoryBuilder builder,
            @Qualifier("primaryDataSource") DataSource ds) {
        return builder.dataSource(ds)
                .packages("com.example.main.entity")
                .persistenceUnit("primary")
                .build();
    }

    @Primary
    @Bean
    public PlatformTransactionManager primaryTx(
            @Qualifier("primaryEmf") LocalContainerEntityManagerFactoryBean emf) {
        return new JpaTransactionManager(emf.getObject());
    }
}

セカンダリ側も同じ形で、@Primary を外し、basePackagescom.example.audit.repositorypackagescom.example.audit.entity に変えて用意します。ポイントは片方に @Primary を付けて曖昧さを解消することです。

静的構成のパッケージ設計とハマりどころ

この構成で一番効くのは エンティティとリポジトリのパッケージをDataSource単位で物理的に分ける ことです。@EnableJpaRepositoriesbasePackagesLocalContainerEntityManagerFactoryBeanpackages が重なると、同じリポジトリを両方のEntityManagerFactoryがスキャンしてしまい、起動時にBean定義が衝突します。

つまずきやすいのは次の3点です。

  • @EnableJpaRepositoriesentityManagerFactoryRef / transactionManagerRef の指定漏れ。省略するとデフォルト名で解決され、意図しないEMFに紐づきます。
  • セカンダリ側の処理で @Transactional("secondaryTx") のようにTransactionManagerを明示し忘れ、プライマリのトランザクションで動いてしまうケース。
  • パッケージのスキャン範囲が重複しての衝突。

トランザクションマネージャの指定についてはトランザクション管理の記事も参考になります。

パターン2:AbstractRoutingDataSourceによる動的ルーティング

ここからは読み書き分離です。DataSourceを実行時に切り替えるために AbstractRoutingDataSource を継承します。

public class RoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        return DataSourceContextHolder.get();
    }
}

determineCurrentLookupKey() が返したキーで、登録済みのDataSourceから実際の接続先が選ばれます。Bean定義で primary / replica を登録しましょう。

@Bean
public DataSource routingDataSource(
        @Qualifier("primaryDataSource") DataSource primary,
        @Qualifier("replicaDataSource") DataSource replica) {
    Map<Object, Object> targets = new HashMap<>();
    targets.put("primary", primary);
    targets.put("replica", replica);

    RoutingDataSource routing = new RoutingDataSource();
    routing.setTargetDataSources(targets);
    routing.setDefaultTargetDataSource(primary);
    return routing;
}

静的パターンとの違いは、EntityManagerFactoryに渡すDataSourceが「1本のルーティングDataSource」である点です。リポジトリやエンティティは分割しません。宛先だけが動的に変わります。

ThreadLocalベースのContextHolderで切替キーを管理する

ルーティングキーはリクエスト処理スレッド単位で保持します。ThreadLocal の出番です。

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(); }
}

clear() を忘れるとスレッドプールで使い回されるスレッドに古いキーが残り、次のリクエストが誤った宛先に流れます。ここは必ずクリアする、と覚えておいてください。

@Transactional(readOnly=true)をルーティングのトリガーにする

切り替えを手で書きたくはないですよね。そこで @Transactional(readOnly = true) の属性を判定に使います。TransactionSynchronizationManager.isCurrentTransactionReadOnly() で読み取り専用かどうかが分かるので、determineCurrentLookupKey() をこう書き換えます。

@Override
protected Object determineCurrentLookupKey() {
    return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
            ? "replica" : "primary";
}

これで @Transactional(readOnly = true) を付けた参照系メソッドは自動的にレプリカへ、書き込み系はプライマリへ流れます。ContextHolderを直接触らずに済むので、アプリコードは基本そのままです。読み取り専用トランザクションの伝播についてはトランザクション管理の記事を参照してください。

LazyConnectionDataSourceProxyがルーティング成立の鍵

ここが一番の落とし穴です。Springはトランザクション開始時にコネクションを取得しますが、これは readOnly 判定が確定する前のタイミングになりがちです。素のままだと常にプライマリを掴んでしまい、切り替えが効きません。

そこで LazyConnectionDataSourceProxy でルーティングDataSourceをラップし、実際にクエリを発行するまでコネクション取得を遅延 させます。

@Bean
@Primary
public DataSource dataSource(@Qualifier("routingDataSource") DataSource routing) {
    return new LazyConnectionDataSourceProxy(routing);
}

このラップを入れることで、readOnly属性が確定した後にコネクションが確定し、determineCurrentLookupKey() が正しい宛先を返せるようになります。「切り替えが全く効かず常にプライマリに流れる」という症状の大半はこれが原因です。

動作確認とレプリカ遅延への注意

狙い通りに流れているかは、SQLログとコネクション情報で確認します。HikariCPのプール名やJDBC URLをログに出すと分かりやすいです。

logging:
  level:
    org.hibernate.SQL: DEBUG
    com.zaxxer.hikari: DEBUG

参照系がレプリカ、更新系がプライマリのプールを使っていればOKです。

ただし運用で忘れてはいけないのが レプリケーションラグ です。書き込み直後に同じデータをレプリカから読むと、反映前の古い値が返ることがあります。「登録して即その内容を表示する」ような整合性が要る箇所では、あえてプライマリを選ぶ判断が必要です。ContextHolderを一時的に primary に固定するか、書き込みと同じトランザクション内で読むといった回避策を取りましょう。なお、DBサーバ側のレプリケーション構築そのものは本記事の対象外です。

2方式の使い分けと選定基準

最後に判断の目安をまとめます。

  • 業務DBとログDBなど 独立した複数のDBへ繋ぐ なら静的な複数DataSource。エンティティもリポジトリも分かれているのが自然です。
  • 中身が同じ 単一論理DBの読み書き負荷を分散する なら AbstractRoutingDataSource。宛先だけを動的に切り替えます。

両方が必要になることもあります。その場合はまず静的にDBを分け、そのうちの1つを内部的にルーティングDataSourceにする、という重ね方になります。ただし構成が複雑になるほど保守コストは上がるので、本当に負荷分散が要るのか、リードレプリカ導入前にまずHikariCPのプール設定を見直せないか、といった順序で検討するのがおすすめです。

まとめ

複数データソースは、目的が「複数DB接続」か「読み書き分散」かで取るべき構成がはっきり分かれます。静的パターンはDataSource・EntityManagerFactory・TransactionManagerをDataSourceごとに手で組み、パッケージを物理分割するのが要点。動的パターンは AbstractRoutingDataSource@Transactional(readOnly=true)、そして LazyConnectionDataSourceProxy の3点セットで成立します。自分のユースケースに合った方を選んで、必要な部分だけ手動構成していきましょう。