Spring Bootで、DBのPrimary/ReplicationインスタンスにSQLを振り分ける2つの方法

こんにちは。 エキサイト株式会社の三浦です。

DBでは、可用性の担保のために PrimaryReplicationインスタンスをそれぞれ用意することが多々あります。 こうしたDBをSpring Bootで扱う際、適切にSQL実行先を振り分けないとせっかく分けた意味がなくなってしまいます。

今回は、適切に Primary / Replication インスタンスSQLを振り分ける方法を2通り紹介します。

DBの可用性と Primary / Replication インスタンス

DBは、多くのアプリケーションにとって、アプリケーション上で扱うデータが入っている重要なサービスであり、DBに障害が起きてしまった場合サービス自体に障害が起きてしまうケースがほとんどです。 そのため、DBの可用性を高めることは、DBを扱うアプリケーションにとってはかなり重要性の高い要件と言えます。

DBの可用性を高める方法の1つに、 Primary / Replication インスタンスを別々に用意する、というものがあります。

Primary インスタンスではDBへのデータの書き込み、及び読み込みの両方が可能であり、常にオリジナルのデータが入っています。 データの整合性を保つため、基本的に Primary インスタンスは1台のみで、かつ書き込みはこのインスタンスのみにしか行なえません。

Replication インスタンスではデータの読み込みのみが可能であり、 Primary インスタンスのデータを常時同期するようになっています。 Replication インスタンスではあくまで複製されたデータのみを扱い、かつ読み込みのみを行うため、 Primary インスタンスと異なり複数台作ることが可能です。

多くのアプリケーションでは書き込みに対して読み込みの量のほうが圧倒的に多いため、上記の構成にした上で、基本的に Primary インスタンスでは書き込みのみ、 Replication インスタンスでは読み込みのみを行うようにし、アクセス量に応じて Replication インスタンスの数を増やすことで負荷を分散して、可用性を担保します。

Spring Bootで上記のような構成のDBを扱う際も、もちろん Primary インスタンスでは書き込みのみ、 Replication インスタンスでは読み込みのみを行うようにする必要があります。

Spring Bootで Primary / Replication インスタンスを持つDBを扱う方法

Spring BootでこういったDBを扱う際は、 JDBC の機能を使用する方法と、 DataSource クラスをカスタマイズする方法の2通りが主に存在します。

JDBCの機能を使用する方法

おそらく最も簡単な方法が JDBC の機能を使用する方法です。

JDBCには replication の機能があり、以下の2工程でPrimary / Replication に適切に振り分けてくれるようになります。

1. @Transactional を使用する

Primary インスタンスにアクセスしてほしいメソッドには @Transactional() アノテーションを、 Replication インスタンスにアクセスしてほしいメソッドには @Transactional(readOnly = true) アノテーションを付与する

2. アクセス先DBのURLを以下のように指定する

jdbc:mysql:replication://primary_db:3306,replication_db:3306/sample_schema

これだけで、適切にSQLを割り振ってくれます。 これには以下のメリット・デメリットが存在します。

メリット

  • 非常に簡単に指定できる

デメリット

  • JDBCのreplication機能が使えない場合は使用できない
  • コネクションプールで保持されるコネクション数が PrimaryReplication で同数になってしまう
    • Primary インスタンスは1台しか作れないため、 Primary の最大同時接続数にコネクション数を合わせると Replication 用のコネクションが不足したり、 Replication で必要なコネクション数に合わせると Primary の最大同時接続数が足りなくなってしまう恐れがある

DataSource クラスをカスタマイズする方法

DataSouce を使う方法は少し複雑ですが、JDBCに比べて柔軟な対応が可能になります。

DataSource を使う場合も、JDBCと同じく @Transactional で振り分け先を指定します。 変わるのはその後です。

1. @Transactional を使用する

Primary インスタンスにアクセスしてほしいメソッドには @Transactional() アノテーションを、 Replication インスタンスにアクセスしてほしいメソッドには @Transactional(readOnly = true) アノテーションを付与する

2. DataSource 周りをカスタマイズする

DataSource 周りをカスタマイズします。 ここでは HikariDataSource を使用していますが、おそらく他の DataSource を使っても大丈夫だと思います。 また、MaximumPoolSize 等の細かい設定も、必要に応じて変更してください。

今回はコード内に直接各種プロパティを書いていますが、実際に使用する際はそういったプロパティは application.properties などに書いておき、このコード内で呼び出すようにするほうが管理しやすいでしょう。

import com.zaxxer.hikari.HikariDataSource;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionSynchronizationManager;

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

@Configuration
public class DataSourceConfig {
    private final String primaryDataSourcePropertiesName = "primaryDataSourceProperties";
    private final String replicationDataSourcePropertiesName = "replicationDataSourceProperties";
    private final String primaryDataSourceName = "primaryDataSource";
    private final String replicationDataSourceName = "replicationDataSource";
    private final String routerDataSourceName = "routingDataSource";
    private final String mainDataSourceName = "dataSource";

    public enum DataSourceType {
        PRIMARY, REPLICATION
    }

    /**
     * TransactionのreadOnlyをもとにDataSourceをルーティングするためのカスタムルーティングクラス
     */
    public static class CustomRoutingDataSource extends AbstractRoutingDataSource {
        @Override
        protected Object determineCurrentLookupKey() {
            return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
                    ? DataSourceType.REPLICATION
                    : DataSourceType.PRIMARY;
        }
    }

    /**
     * Primary用DataSourceのプロパティを作成
     * @return Primary用DataSourceのプロパティ
     */
    @Bean(name = primaryDataSourcePropertiesName)
    @Primary
    public DataSourceProperties primaryDataSourceProperties() {
            DataSourceProperties dataSourceProperties = new DataSourceProperties();
            dataSourceProperties.setDriverClassName("com.mysql.cj.jdbc.Driver");
            dataSourceProperties.setUsername("user");
            dataSourceProperties.setPassword("password");
            dataSourceProperties.setUrl("jdbc:mysql://primary_db:3306/sample_schema");

            return dataSourceProperties;
    }

    /**
     * Replication用DataSourceのプロパティを作成
     * @return Replication用DataSourceのプロパティ
     */
    @Bean(name = replicationDataSourcePropertiesName)
    public DataSourceProperties replicationDataSourceProperties() {
            DataSourceProperties dataSourceProperties = new DataSourceProperties();
            dataSourceProperties.setDriverClassName("com.mysql.cj.jdbc.Driver");
            dataSourceProperties.setUsername("user");
            dataSourceProperties.setPassword("password");
            dataSourceProperties.setUrl("jdbc:mysql://replication_db:3306/sample_schema");

            return dataSourceProperties;
    }

    /**
     * Primary用DBエンドポイントのDataSourceを作成
     * @return Primary用DBエンドポイントのDataSource
     */
    @Bean(name = primaryDataSourceName)
    @Primary
    public DataSource primaryDataSource(@Qualifier(primaryDataSourcePropertiesName) DataSourceProperties dataSourceProperties) {
        HikariDataSource hikariDataSource = dataSourceProperties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
        hikariDataSource.setMaxLifetime(600000);
        hikariDataSource.setMaximumPoolSize(10);
        hikariDataSource.setReadOnly(false);

        return hikariDataSource;
    }

    /**
     * Replication用DBエンドポイントのDataSourceを作成
     * @return Replication用DBエンドポイントのDataSource
     */
    @Bean(name = replicationDataSourceName)
    public DataSource replicationDataSource(@Qualifier(replicationDataSourcePropertiesName) DataSourceProperties dataSourceProperties) {
        HikariDataSource hikariDataSource = dataSourceProperties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
        hikariDataSource.setMaxLifetime(600000);
        hikariDataSource.setMaximumPoolSize(20);
        hikariDataSource.setReadOnly(true);

        return hikariDataSource;
    }

    /**
     * SQLを実行するDataSourceを、Transactionによってルーティングするための設定を作成
     * @param primaryDataSource Primary用DBエンドポイントのDataSource
     * @param replicationDataSource Replication用DBエンドポイントのDataSource
     * @return SQLを実行するDataSourceを、Transactionによってルーティングするための設定
     */
    @Bean(name = routerDataSourceName)
    public DataSource routingDataSource(
            @Qualifier(primaryDataSourceName) final DataSource primaryDataSource,
            @Qualifier(replicationDataSourceName) final DataSource replicationDataSource
    ) {
        CustomRoutingDataSource routingDataSource = new CustomRoutingDataSource();
        Map<Object, Object> dataSourceMap = new HashMap<>();
        dataSourceMap.put(DataSourceType.PRIMARY, primaryDataSource);
        dataSourceMap.put(DataSourceType.REPLICATION, replicationDataSource);

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

        return routingDataSource;
    }

    /**
     * TransactionによってDataSourceをルーティングするDataSourceを作成
     * @param routingDataSource SQLを実行するDataSourceを、Transactionによってルーティングするための設定
     * @return TransactionによってDataSourceをルーティングするDataSource
     */
    @Bean(name = mainDataSourceName)
    public DataSource dataSource(@Qualifier(routerDataSourceName) DataSource routingDataSource) {
        return new LazyConnectionDataSourceProxy(routingDataSource);
    }

    /**
     * トランザクションマネージャを作成
     * @param dataSource TransactionによってDataSourceをルーティングするDataSource
     * @return トランザクションマネージャ
     */
    @Bean
    @Primary
    public PlatformTransactionManager transactionManager(@Qualifier(mainDataSourceName) DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }

    /**
     * SQLセッションファクトリを作成
     * @param dataSource TransactionによってDataSourceをルーティングするDataSource
     * @return SQLセッションファクトリ
     * @throws Exception 例外
     */
    @Bean
    @Primary
    public SqlSessionFactory sqlSessionFactory(@Qualifier(mainDataSourceName) DataSource dataSource) throws Exception {
        SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
        sqlSessionFactoryBean.setDataSource(dataSource);

        return sqlSessionFactoryBean.getObject();
    }
}

少し長くなりましたが、この設定で適切にSQLを割り振ってくれます。 これには以下のメリット・デメリットが存在します。

メリット

  • JDBCのreplicationを使えなくても使用することが出来る
  • Primary / Replication でコネクションプールのコネクション数などを完全に別々に設定することが出来る

デメリット

  • 設定が少し複雑

最後に

今回は、 Primary / Replication 構成のDBにSpring Bootからアクセスする方法を2つ紹介しました。 個人的には、

  • アクセスがかなり少なく、JDBCのreplicationが使えるアプリケーション:JDBCを使用する方法
  • それ以外:DataSource をカスタマイズする方法

が適切だと思っており、そのため、多くの場合 DataSource をカスタマイズする方法が適切なのではないかと考えています。

もちろん上記で挙げた以外の思わぬ副作用がある可能性もありますし、アプリケーションごとに様々な要件もあると思いますので、一概に「どちらがいい」と言うのは難しいところです。 このあたりは、各アプリケーションごとに判断していただけると良いかと思います。

この記事が何かしら参考になりましたら幸いです。