LoginSignup
17
19

More than 5 years have passed since last update.

SpringのAbstractRoutingDataSourceを使ってシャーディングっぽいことをしてみる!

Posted at

今回は、Springが提供しているAbstractRoutingDataSource + Spring AOPを利用して、アプリケーションレイヤでシャーディングっぽいことを実現する方法を紹介します。
シャーディングはデータを複数のサーバに(透過的に?)分散させる機能のことで、負荷分散による性能向上やリソース分散によるコストパフォーマンス向上などを目的に使います。本エントリーではアプリケーションレイヤでシャーディングっぽいことを実現する方法を紹介しますが、RDBMS、NoSQL、KVSなど自体にシャーディング機能をもっていることも多いため、まずはインフラストラクチャレイヤでシャーディングを実現する方法を検討する方がよいと思います!!

動作検証バージョン

  • Spring Boot 1.5.1.RELEASE
  • Spring Framework 4.3.6.RELEASE
  • H2 Database 1.4.193

(なんちゃって)シャーディング要件

ユーザアカウントの「アカウントID(10桁固定)」の末尾1桁毎にデータベースを用意して、アカウントに紐づくデータへのアクセスを負荷分散する。(完全になんちゃって要件なので、実際のシャーディング要件を決めるときの参考にしないでね :sweat_smile:
絵にする必要はないけど・・・↓のような感じです(無駄に絵にしてみる)。

spring-routing-ds-sharding.png

AbstractRoutingDataSourceとSpring AOPを使用した透過的なDataSourceの切替方法

さ〜(なんちゃって)シャーディング要件をどうやってアプリケーションに組み込むか考えてみましょう!!
ぱっと思いつくのは・・・Mapを使ってデータベース名とDataSourceを紐付けておいて、DBアクセスする際にアカウントIDに対応するDataSourceMapから取得する方法でしょうか?

ソースコードで表現すると、以下のような感じになります。
ぱっと見問題なさそうに見えますが、サービスクラス(アプリの機能要件をみたす処理を提供するクラス)の中に「アカウントIDに対応するデータソースを解決する」という機能要件に関係ない処理(=非機能要件をみたすための処理)が実装されてしまっています・・・。

@Service
public class AccountService {
    private final Map<String, DataSource> dataSources;

    public AccountService(Map<String, DataSource> dataSources) {
        this.dataSources = dataSources;
    }

    public void create(String id, String name) {
        JdbcTemplate jdbcTemplate = new JdbcTemplate(determineDataSource(id));
        jdbcTemplate.update("INSERT INTO account (id, name) VALUES(?,?)", id, name);
    }

    public Map<String, Object> find(String id) {
        JdbcTemplate jdbcTemplate = new JdbcTemplate(determineDataSource(id));
        return jdbcTemplate.queryForMap("SELECT id, name FROM account WHERE id = ?", id);
    }

    private DataSource determineDataSource(String id) { // 機能要件に関係ない処理・・・
        String key = "db" + Optional.ofNullable(id)
            .filter(x -> x.length() == 10)
            .map(x -> x.substring(9)).get();
        return this.dataSources.get(key);
    }

}

本来であれば・・・、サービスクラスの実装をシャーディングの実現方法(アプリケーションレイヤ vs インフラストラクチャレイヤ)などに依存させたくないはずなので、以下のような実装になるのがベストだと思います。

@Service
public class AccountService {
    private final JdbcTemplate jdbcTemplate;

    public AccountService(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public void create(String id, String name) {
        this.jdbcTemplate.update("INSERT INTO account (id, name) VALUES(?,?)", id, name);
    }

    public Map<String, Object> find(String id) {
        return this.jdbcTemplate.queryForMap("SELECT id, name FROM account WHERE id = ?", id);
    }

}

上記のような実装にするために登場するのが、AbstractRoutingDataSourceとSpring AOPを組み合わせた方法になります。本エントリーでは、以下のようなクラスを作成することで、アプリケーションレイヤでのシャーディングをアプリケーション(サービスクラス)に対して透過的に組み込んでみます。

  • AbstractRoutingDataSourceの匿名オブジェクト
  • ShardKeyStoreクラス (シャードキーをRoutingDataSourceに連携するための器)
  • ShardKeyAdviceクラス (シャードキーをShardKeyStoreに格納するAOP)

なお、本エントリーで紹介する実現方法を図で表現すると、以下のような感じになります。

spring-routing-ds-sharding-image.png

項番 説明
ControllerからServiceクラスのメソッドを呼び出す。実際にはProxyオブジェクトのメソッドが呼び出され、シャードキーをAbstractRoutingDataSourceの継承クラスに連携するためのAdviceクラス(ShardKeyAdvice)のメソッドが呼び出される。
ShardKeyAdviceは、Serviceクラスのメソッド呼び出し時に指定された「アカウントID」よりシャードキーを計算してShardKeyStoreに設定した後に、Serviceクラスのメソッドを呼び出す。
ServiceからJdbcTemplateのメソッドを呼び出す。
JdbcTemplateは、DataSourceUtil#getConnection(DataSource)を介してAbstractRoutingDataSourceの継承クラスからConnectionを取得する。AbstractRoutingDataSourceの継承クラスでは、ShardKeyStoreに設定されているシャードキーに対応するDataSourceからConnectionを取得するように実装する。
取得したConnectionのメソッドを使用してSQLの実行を依頼することで、シャードキーに対応するデータベースにアクセスする。

検証用のSpring Bootプロジェクトの作成

実際に↑で紹介したような構成のアプリケーションを作って、アプリケーションレイヤでのシャーディングを体感してみましょう。

まず、検証用のSpring Bootプロジェクト作成しましょう(Dependencieにはjdbc,aop,h2を指定)。ここではコマンドラインでプロジェクトを作成する例になっていますが、SPRING INITIALIZRのWeb UIやお使いのIDEの機能で生成しても(もちろん)OKです!!

$ curl -s https://start.spring.io/starter.tgz\
       -d name=spring-routing-ds-demo\
       -d artifactId=spring-routing-ds-demo\
       -d dependencies=jdbc,aop,h2\
       -d baseDir=spring-routing-ds-demo\
       | tar -xzvf -

プロジェクトを生成すると、以下のような構成のMavenプロジェクトが生成されます。

$ cd spring-routing-ds-demo
$ tree
.
├── mvnw
├── mvnw.cmd
├── pom.xml
└── src
    ├── main
    │   ├── java
    │   │   └── com
    │   │       └── example
    │   │           └── SpringRoutingDsDemoApplication.java
    │   └── resources
    │       └── application.properties
    └── test
        └── java
            └── com
                └── example
                    └── SpringRoutingDsDemoApplicationTests.java

必要に応じてお使いのIDE上にインポートしてください!!

データベース(データソース)のセットアップ

今回はH2のインメモリDBを使用して検証します。

src/main/resources/schema.sql
  id CHAR(10) PRIMARY KEY,
  name VARCHAR(255)
);

データソースが1つの場合は、↑のようにschema.sqlをつくれば自動でデータベースを初期化してくれますが、今回はシャーディング用に10個のデータベース(データソース)を作る必要があるので、Spring Bootのデフォルトの機能は使わずにデータベース(データソース)をセットアップします。

package com.example;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

import javax.sql.DataSource;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceBuilder;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ResourceLoader;
import org.springframework.jdbc.datasource.init.DatabasePopulatorUtils;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;


@SpringBootApplication
public class SpringRoutingDsDemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(SpringRoutingDsDemoApplication.class, args);
    }

    // ★★★ シャーディング検証用のデータベース(データソース)のセットアップ ★★★
    @Configuration
    @ConfigurationProperties(prefix = "datasource")
    static class DataSourceConfiguration {

        private final DataSourceProperties properties;
        private final ResourceLoader resourceLoader;
        private List<String> instanceNames;

        DataSourceConfiguration(DataSourceProperties properties, ResourceLoader resourceLoader) {
            this.properties = properties;
            this.resourceLoader = resourceLoader;
        }

        public List<String> getInstanceNames() {
            return instanceNames;
        }

        public void setInstanceNames(List<String> instanceNames) {
            this.instanceNames = instanceNames;
        }

        @Bean
        Map<Object, DataSource> targetDataSources() {
            ResourceDatabasePopulator populator = new ResourceDatabasePopulator(
                    this.resourceLoader.getResource("classpath:schema.sql"));
            Map<Object, DataSource> targetDataSources = new LinkedHashMap<>();
            getInstanceNames().forEach(name -> {
                DataSource dataSource = DataSourceBuilder.create()
                    .driverClassName(this.properties.determineDriverClassName())
                    .url(this.properties.determineUrl().replaceFirst("testdb", name))
                    .username(this.properties.determineUsername())
                    .password(this.properties.determinePassword()).build();
                DatabasePopulatorUtils.execute(populator, dataSource);
                targetDataSources.put(name, dataSource);
            });
            return targetDataSources;
        }

    }

}

Spring Bootが提供しているデータソースの初期化機能を無効化(spring.datasource.initialize=false)し、シャーディング用DBのインスタンス名を10個指定する。

src/main/resources/application.properties
spring.datasource.initialize=false
datasource.instanceNames=db0,db1,db2,db3,db4,db5,db6,db7,db8,db9

Serviceクラスの作成

機能要件をみたすための処理を実装するServiceクラスを作成します。

package com.example;

import java.util.Map;

import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;

@Service
public class AccountService {
    private final JdbcTemplate jdbcTemplate;

    public AccountService(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public void create(String id, String name) {
        this.jdbcTemplate.update("INSERT INTO account (id, name) VALUES(?,?)", id, name);
    }

    public Map<String, Object> find(String id) {
        return this.jdbcTemplate.queryForMap("SELECT id, name FROM account WHERE id = ?", id);
    }

}

ShardKeyStoreの作成

シャードキーをスレッドローカルの変数に保持するクラスを作成します。

package com.example;

import org.springframework.stereotype.Component;

@Component
public class ShardKeyStore {

    private final ThreadLocal<String> keyStore = new ThreadLocal<>();

    public void set(String key) {
        this.keyStore.set(key);
    }

    public String get() {
        return this.keyStore.get();
    }

    public void clear() {
        this.keyStore.remove();
    }
}

ShardKeyAdviceの作成

シャードキーをServiceクラスのメソッド引数(第一引数)から計算し、ShardKeyStoreに設定するクラスを作成します。

package com.example;

import java.util.Optional;

import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class ShardKeyAdvice {

    private final ShardKeyStore shardKeyStore;

    public ShardKeyAdvice(ShardKeyStore shardKeyStore) {
        this.shardKeyStore = shardKeyStore;
    }

    // ★★★ Serviceクラスのメソッド呼び出し前に「db + アカウントIDの末尾1桁」をシャードキーに設定 ★★★
    @Before("execution(* *..AccountService.*(..)) && args(id,..)")
    public void set(String id) {
        String key = "db" + Optional.ofNullable(id)
            .filter(x -> x.length() == 10)
            .map(x -> x.substring(9))
            .get();
        this.shardKeyStore.set(key);
    }

    // ★★★ Serviceクラスのメソッド呼び出し終了後にシャードキーをクリア ★★★
    // ★★★ スレッドがプールされる環境下では不要になったら時にクリアする ★★★
    @After("execution(* *..AccountService.*(..))")
    public void clear() {
        this.shardKeyStore.clear();
    }

}

AbstractRoutingDataSourceのBean定義

シャーディング用のデータソースを切替対象のデータソースとして保持するAbstractRoutingDataSourceのBeanを定義します。なお、本エントリーではデフォルトのデータソースを設定していませんが、シャードキーが未設定の場合やシャードキーに対応するデータソースが見つからない場合に使うデータソースを指定することもできます。

package com.example;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

import javax.sql.DataSource;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceBuilder;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ResourceLoader;
import org.springframework.jdbc.datasource.init.DatabasePopulatorUtils;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;

@SpringBootApplication
public class SpringRoutingDsDemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(SpringRoutingDsDemoApplication.class, args);
    }

    @Configuration
    @ConfigurationProperties(prefix = "datasource")
    static class DataSourceConfiguration {

        private final DataSourceProperties properties;
        private final ResourceLoader resourceLoader;
        private List<String> instanceNames;

        DataSourceConfiguration(DataSourceProperties properties, ResourceLoader resourceLoader) {
            this.properties = properties;
            this.resourceLoader = resourceLoader;
        }

        public List<String> getInstanceNames() {
            return instanceNames;
        }

        public void setInstanceNames(List<String> instanceNames) {
            this.instanceNames = instanceNames;
        }

        @Bean
        Map<Object, DataSource> targetDataSources() {
            ResourceDatabasePopulator populator = new ResourceDatabasePopulator(
                    this.resourceLoader.getResource("classpath:schema.sql"));
            Map<Object, DataSource> targetDataSources = new LinkedHashMap<>();
            getInstanceNames().forEach(name -> {
                DataSource dataSource = DataSourceBuilder.create()
                    .driverClassName(this.properties.determineDriverClassName())
                    .url(this.properties.determineUrl().replaceFirst("testdb", name))
                    .username(this.properties.determineUsername())
                    .password(this.properties.determinePassword()).build();
                DatabasePopulatorUtils.execute(populator, dataSource);
                targetDataSources.put(name, dataSource);
            });
            return targetDataSources;
        }

        // ★★★ AbstractRoutingDataSourceのBean定義 ★★★ 
        @Bean
        DataSource dataSource(ShardKeyStore shardKeyStore) {
            AbstractRoutingDataSource routingDataSource = new AbstractRoutingDataSource() {
                @Override
                protected Object determineCurrentLookupKey() {
                    return shardKeyStore.get(); // ★★★ データソースのルックアップキーとしてShardKeyStoreに設定されているシャードキーを利用
                }
            };
            routingDataSource.setTargetDataSources(new LinkedHashMap<>(targetDataSources())); // ★★★ シャーディング用のデータソースを設定
            return routingDataSource;
        }

    }

}

CommandLineRunnerの実装

Spring BootアプリケーションクラスにCommandLineRunnerインタフェースのrunメソッドを実装し、実際にDBアクセスしてみましょう。

package com.example;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.IntStream;

import javax.sql.DataSource;

import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceBuilder;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ResourceLoader;
import org.springframework.jdbc.datasource.init.DatabasePopulatorUtils;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;

@SpringBootApplication
public class SpringRoutingDsDemoApplication implements CommandLineRunner { // ★★★ CommandLineRunnerをimplementsに追加

    public static void main(String[] args) {
        SpringApplication.run(SpringRoutingDsDemoApplication.class, args);
    }

    private final AccountService accountService;

    public SpringRoutingDsDemoApplication(AccountService accountService) {
        this.accountService = accountService;
    }

    // ★★★ CommandLineRunnerのrunメソッドを実装 ★★★
    @Override
    public void run(String... args) throws Exception {
        // A000000001 〜 A000000010のアカウントIDを使用してDBアクセス(INSERT+SELECT)を試みる
        IntStream.range(1, 11).forEach(x -> {
            String id = String.format("A0000000%02d", x);
            this.accountService.create(id, String.format("user%02d", x));
            System.out.println(this.accountService.find(id));
        });
    }

    @Configuration
    @ConfigurationProperties(prefix = "datasource")
    static class DataSourceConfiguration {

        private final DataSourceProperties properties;
        private final ResourceLoader resourceLoader;
        private List<String> instanceNames;

        DataSourceConfiguration(DataSourceProperties properties, ResourceLoader resourceLoader) {
            this.properties = properties;
            this.resourceLoader = resourceLoader;
        }

        public List<String> getInstanceNames() {
            return instanceNames;
        }

        public void setInstanceNames(List<String> instanceNames) {
            this.instanceNames = instanceNames;
        }


        @Bean
        DataSource dataSource(ShardKeyStore shardKeyStore) {
            AbstractRoutingDataSource routingDataSource = new AbstractRoutingDataSource() {
                @Override
                protected Object determineCurrentLookupKey() {
                    return shardKeyStore.get();
                }
            };
            routingDataSource.setTargetDataSources(new LinkedHashMap<>(targetDataSources()));
            return routingDataSource;
        }

        @Bean
        Map<Object, DataSource> targetDataSources() {
            ResourceDatabasePopulator populator = new ResourceDatabasePopulator(
                    this.resourceLoader.getResource("classpath:schema.sql"));
            Map<Object, DataSource> targetDataSources = new LinkedHashMap<>();
            getInstanceNames().forEach(name -> {
                DataSource dataSource = DataSourceBuilder.create()
                    .driverClassName(this.properties.determineDriverClassName())
                    .url(this.properties.determineUrl().replaceFirst("testdb", name))
                    .username(this.properties.determineUsername())
                    .password(this.properties.determinePassword()).build();
                DatabasePopulatorUtils.execute(populator, dataSource);
                targetDataSources.put(name, dataSource);
            });
            return targetDataSources;
        }

    }

}

Spring Bootアプリケーションとして実行すると、以下のようなログがコンソールに出力されます。

$ ./mvnw spring-boot:run
...

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v1.5.1.RELEASE)
...
2017-02-14 19:50:16.211  INFO 52891 --- [           main] s.c.a.AnnotationConfigApplicationContext : Refreshing org.springframework.context.annotation.AnnotationConfigApplicationContext@2bbb6e7f: startup date [Tue Feb 14 19:50:16 JST 2017]; root of context hierarchy
...
2017-02-14 19:50:17.789  INFO 52891 --- [           main] o.s.j.e.a.AnnotationMBeanExporter        : Registering beans for JMX exposure on startup
{ID=A000000001, NAME=user01}
{ID=A000000002, NAME=user02}
{ID=A000000003, NAME=user03}
{ID=A000000004, NAME=user04}
{ID=A000000005, NAME=user05}
{ID=A000000006, NAME=user06}
{ID=A000000007, NAME=user07}
{ID=A000000008, NAME=user08}
{ID=A000000009, NAME=user09}
{ID=A000000010, NAME=user10}
2017-02-14 19:50:17.876  INFO 52891 --- [           main] c.e.SpringRoutingDsDemoApplication       : Started SpringRoutingDsDemoApplication in 2.32 seconds (JVM running for 6.303)
...

なにやら、INSERTしたデータがSELECTできているみたいですが、ちゃんとシャーディングできているか怪しいところなので、アプリケーション終了時に各データベースに登録されているレコードをダンプするクラスを追加してみます。

package com.example;

import java.util.Map;

import javax.annotation.PreDestroy;
import javax.sql.DataSource;

import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;

@Component
public class DataSourcesDump {
    private final Map<Object, DataSource> dataSources;

    public DataSourcesDump(Map<Object, DataSource> dataSources) {
        this.dataSources = dataSources;
    }

    @PreDestroy
    public void dumpOnDestroy() {
        this.dataSources.forEach((lookupKey, dataSource) -> {
            System.out.println("--------------------------");
            System.out.println("Lookup Key : " + lookupKey);
            JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
            jdbcTemplate.queryForList("SELECT id, name FROM account ORDER BY id").forEach(System.out::println);
        });

    }

}

再度Spring Bootアプリケーションとして実行すると、以下のようなログがコンソールに出力され、シャーディングが正しく行われていることが確認できます。

$ ./mvnw spring-boot:run
...
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 4.145 s
[INFO] Finished at: 2017-02-14T19:59:10+09:00
[INFO] Final Memory: 24M/393M
[INFO] ------------------------------------------------------------------------
2017-02-14 19:59:10.841  INFO 52928 --- [       Thread-3] s.c.a.AnnotationConfigApplicationContext : Closing org.springframework.context.annotation.AnnotationConfigApplicationContext@25c20220: startup date [Tue Feb 14 19:59:09 JST 2017]; root of context hierarchy
2017-02-14 19:59:10.842  INFO 52928 --- [       Thread-3] o.s.j.e.a.AnnotationMBeanExporter        : Unregistering JMX-exposed beans on shutdown
--------------------------
Lookup Key : db0
{ID=A000000010, NAME=user10}
--------------------------
Lookup Key : db1
{ID=A000000001, NAME=user01}
--------------------------
Lookup Key : db2
{ID=A000000002, NAME=user02}
--------------------------
Lookup Key : db3
{ID=A000000003, NAME=user03}
--------------------------
Lookup Key : db4
{ID=A000000004, NAME=user04}
--------------------------
Lookup Key : db5
{ID=A000000005, NAME=user05}
--------------------------
Lookup Key : db6
{ID=A000000006, NAME=user06}
--------------------------
Lookup Key : db7
{ID=A000000007, NAME=user07}
--------------------------
Lookup Key : db8
{ID=A000000008, NAME=user08}
--------------------------
Lookup Key : db9
{ID=A000000009, NAME=user09}

トランザクション管理配下での利用

ここまでのサンプルではトランザクション管理していませんが、実際のアプリケーションだと確実にトランザクション制御が必要になります。トランザクション管理下でServiceクラスを実行する場合は、クラスまたはメソッドに@Transactionalを付与するだけです。

Note:

Springが提供するDBアクセス機能(Spring JDBC)のログ(デバッグログ)を出力するようにしておくと、トランザクションが実際にどのように働いているか確認しやすくなります。

src/main/resources/application.properteis
logging.level.org.springframework.jdbc=debug
package com.example;

import java.util.Map;

import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional // ★★★ 追加 ★★★ 
public class AccountService {
    private final JdbcTemplate jdbcTemplate;

    public AccountService(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public void create(String id, String name) {
        this.jdbcTemplate.update("INSERT INTO account (id, name) VALUES(?,?)", id, name);
    }

    public Map<String, Object> find(String id) {
        return this.jdbcTemplate.queryForMap("SELECT id, name FROM account WHERE id = ?", id);
    }

}

実際に動かしてトランザクションが効いていることを確認するためにSpring Bootアプリケーションを起動してみると・・・なんと、ま〜エラーになっちゃいました・・・:scream: :scream: :scream:

$ ./mvnw spring-boot:run
...
2017-02-14 20:30:36.919 ERROR 53003 --- [           main] o.s.boot.SpringApplication               : Application startup failed

java.lang.IllegalStateException: Failed to execute CommandLineRunner
    at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:779) [spring-boot-1.5.1.RELEASE.jar:1.5.1.RELEASE]
    at org.springframework.boot.SpringApplication.callRunners(SpringApplication.java:760) [spring-boot-1.5.1.RELEASE.jar:1.5.1.RELEASE]
    at org.springframework.boot.SpringApplication.afterRefresh(SpringApplication.java:747) [spring-boot-1.5.1.RELEASE.jar:1.5.1.RELEASE]
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:315) [spring-boot-1.5.1.RELEASE.jar:1.5.1.RELEASE]
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:1162) [spring-boot-1.5.1.RELEASE.jar:1.5.1.RELEASE]
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:1151) [spring-boot-1.5.1.RELEASE.jar:1.5.1.RELEASE]
    at com.example.SpringRoutingDsDemoApplication.main(SpringRoutingDsDemoApplication.java:27) [classes/:na]
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:1.8.0_121]
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:1.8.0_121]
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:1.8.0_121]
    at java.lang.reflect.Method.invoke(Method.java:498) ~[na:1.8.0_121]
    at org.springframework.boot.maven.AbstractRunMojo$LaunchRunner.run(AbstractRunMojo.java:527) [spring-boot-maven-plugin-1.5.1.RELEASE.jar:1.5.1.RELEASE]
    at java.lang.Thread.run(Thread.java:745) [na:1.8.0_121]
Caused by: org.springframework.transaction.CannotCreateTransactionException: Could not open JDBC Connection for transaction; nested exception is java.lang.IllegalStateException: Cannot determine target DataSource for lookup key [null]
    at org.springframework.jdbc.datasource.DataSourceTransactionManager.doBegin(DataSourceTransactionManager.java:252) ~[spring-jdbc-4.3.6.RELEASE.jar:4.3.6.RELEASE]
    at org.springframework.transaction.support.AbstractPlatformTransactionManager.getTransaction(AbstractPlatformTransactionManager.java:373) ~[spring-tx-4.3.6.RELEASE.jar:4.3.6.RELEASE]
    at org.springframework.transaction.interceptor.TransactionAspectSupport.createTransactionIfNecessary(TransactionAspectSupport.java:447) ~[spring-tx-4.3.6.RELEASE.jar:4.3.6.RELEASE]
    at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:277) ~[spring-tx-4.3.6.RELEASE.jar:4.3.6.RELEASE]
    at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:96) ~[spring-tx-4.3.6.RELEASE.jar:4.3.6.RELEASE]
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179) ~[spring-aop-4.3.6.RELEASE.jar:4.3.6.RELEASE]
    at org.springframework.aop.interceptor.ExposeInvocationInterceptor.invoke(ExposeInvocationInterceptor.java:92) ~[spring-aop-4.3.6.RELEASE.jar:4.3.6.RELEASE]
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179) ~[spring-aop-4.3.6.RELEASE.jar:4.3.6.RELEASE]
    at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:656) ~[spring-aop-4.3.6.RELEASE.jar:4.3.6.RELEASE]
    at com.example.AccountService$$EnhancerBySpringCGLIB$$2c13cf6f.create(<generated>) ~[classes/:na]
    at com.example.SpringRoutingDsDemoApplication.lambda$run$0(SpringRoutingDsDemoApplication.java:40) [classes/:na]
    at java.util.stream.Streams$RangeIntSpliterator.forEachRemaining(Streams.java:110) ~[na:1.8.0_121]
    at java.util.stream.IntPipeline$Head.forEach(IntPipeline.java:557) ~[na:1.8.0_121]
    at com.example.SpringRoutingDsDemoApplication.run(SpringRoutingDsDemoApplication.java:38) [classes/:na]
    at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:776) [spring-boot-1.5.1.RELEASE.jar:1.5.1.RELEASE]
    ... 12 common frames omitted
Caused by: java.lang.IllegalStateException: Cannot determine target DataSource for lookup key [null]
    at org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource.determineTargetDataSource(AbstractRoutingDataSource.java:202) ~[spring-jdbc-4.3.6.RELEASE.jar:4.3.6.RELEASE]
    at org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource.getConnection(AbstractRoutingDataSource.java:164) ~[spring-jdbc-4.3.6.RELEASE.jar:4.3.6.RELEASE]
    at org.springframework.jdbc.datasource.DataSourceTransactionManager.doBegin(DataSourceTransactionManager.java:211) ~[spring-jdbc-4.3.6.RELEASE.jar:4.3.6.RELEASE]
    ... 26 common frames omitted
...

ログをみると、データソースのルッキアップキー(つまりシャードキー)が解決できていないことが原因のようです。なぜシャードキーが解決できなかったかというと・・・TransactionInterceptorShardKeyAdviceより前に実行されてしまったからで、下記の図で示すようにShardKeyAdvice -> TransactionInterceptorの順番で適用されないとダメなんです。

spring-routing-ds-sharding-tx-image.png

具体的には、ShardKeyAdviceの優先度を調整することで、TransactionInterceptorより前にShardKeyAdviceが適用されるようにする必要があります。
Spring Bootの自動コンフィギュレーションの仕組みを利用してTransactionInterceptorをセットアップした場合は、TransactionInterceptorの優先順は最低(Ordered.LOWEST_PRECEDENCE)になるため、ShardKeyAdviceの優先順を「Ordered.LOWEST_PRECEDENCE -1」(優先順が高くなる値)にすればOKです。

Note:

Spring Bootの自動コンフィギュレーションの仕組みを利用しないで明示的に@EnableTransactionManagementを指定している場合は、@EnableTransactionManagementorder属性に指定している値より小さい値を指定するようにしてください。

package com.example;

import java.util.Optional;

import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

@Aspect
@Component
@Order(Ordered.LOWEST_PRECEDENCE - 1) // ★★★ 優先順を指定 ★★★
public class ShardKeyAdvice {

    private final ShardKeyStore shardKeyStore;

    public ShardKeyAdvice(ShardKeyStore shardKeyStore) {
        this.shardKeyStore = shardKeyStore;
    }

    @Before("execution(* *..AccountService.*(..)) && args(id,..)")
    public void set(String id) {
        String key = "db" + Optional.ofNullable(id)
            .filter(x -> x.length() == 10)
            .map(x -> x.substring(9))
            .get();
        this.shardKeyStore.set(key);
    }

    @After("execution(* *..AccountService.*(..))")
    public void clear() {
        this.shardKeyStore.clear();
    }

}

優先度を変更したらSpring Bootアプリケーションを起動して、トランザクション管理下でシャーディングが適用されていることを確認してみましょう。Spring JDBCのデバッグログを出力するようにしたことで大量のログが出力されるようになったので、ここでは最初の2件分のログのみ掲載します。

./mvnw spring-boot:run
...
2017-02-14 21:01:17.173 DEBUG 53055 --- [           main] o.s.j.d.DataSourceTransactionManager     : Creating new transaction with name [com.example.AccountService.create]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT; ''
2017-02-14 21:01:17.174 DEBUG 53055 --- [           main] o.s.j.d.DataSourceTransactionManager     : Acquired Connection [ProxyConnection[PooledConnection[conn19: url=jdbc:h2:mem:db1 user=SA]]] for JDBC transaction
2017-02-14 21:01:17.176 DEBUG 53055 --- [           main] o.s.j.d.DataSourceTransactionManager     : Switching JDBC Connection [ProxyConnection[PooledConnection[conn19: url=jdbc:h2:mem:db1 user=SA]]] to manual commit
2017-02-14 21:01:17.184 DEBUG 53055 --- [           main] o.s.jdbc.core.JdbcTemplate               : Executing prepared SQL update
2017-02-14 21:01:17.184 DEBUG 53055 --- [           main] o.s.jdbc.core.JdbcTemplate               : Executing prepared SQL statement [INSERT INTO account (id, name) VALUES(?,?)]
2017-02-14 21:01:17.191 DEBUG 53055 --- [           main] o.s.jdbc.core.JdbcTemplate               : SQL update affected 1 rows
2017-02-14 21:01:17.194 DEBUG 53055 --- [           main] o.s.j.d.DataSourceTransactionManager     : Initiating transaction commit
2017-02-14 21:01:17.194 DEBUG 53055 --- [           main] o.s.j.d.DataSourceTransactionManager     : Committing JDBC transaction on Connection [ProxyConnection[PooledConnection[conn19: url=jdbc:h2:mem:db1 user=SA]]]
2017-02-14 21:01:17.201 DEBUG 53055 --- [           main] o.s.j.d.DataSourceTransactionManager     : Releasing JDBC Connection [ProxyConnection[PooledConnection[conn19: url=jdbc:h2:mem:db1 user=SA]]] after transaction
2017-02-14 21:01:17.201 DEBUG 53055 --- [           main] o.s.jdbc.datasource.DataSourceUtils      : Returning JDBC Connection to DataSource
2017-02-14 21:01:17.202 DEBUG 53055 --- [           main] o.s.j.d.DataSourceTransactionManager     : Creating new transaction with name [com.example.AccountService.find]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT; ''
2017-02-14 21:01:17.202 DEBUG 53055 --- [           main] o.s.j.d.DataSourceTransactionManager     : Acquired Connection [ProxyConnection[PooledConnection[conn19: url=jdbc:h2:mem:db1 user=SA]]] for JDBC transaction
2017-02-14 21:01:17.202 DEBUG 53055 --- [           main] o.s.j.d.DataSourceTransactionManager     : Switching JDBC Connection [ProxyConnection[PooledConnection[conn19: url=jdbc:h2:mem:db1 user=SA]]] to manual commit
2017-02-14 21:01:17.203 DEBUG 53055 --- [           main] o.s.jdbc.core.JdbcTemplate               : Executing prepared SQL query
2017-02-14 21:01:17.204 DEBUG 53055 --- [           main] o.s.jdbc.core.JdbcTemplate               : Executing prepared SQL statement [SELECT id, name FROM account WHERE id = ?]
2017-02-14 21:01:17.219 DEBUG 53055 --- [           main] o.s.j.d.DataSourceTransactionManager     : Initiating transaction commit
2017-02-14 21:01:17.219 DEBUG 53055 --- [           main] o.s.j.d.DataSourceTransactionManager     : Committing JDBC transaction on Connection [ProxyConnection[PooledConnection[conn19: url=jdbc:h2:mem:db1 user=SA]]]
2017-02-14 21:01:17.219 DEBUG 53055 --- [           main] o.s.j.d.DataSourceTransactionManager     : Releasing JDBC Connection [ProxyConnection[PooledConnection[conn19: url=jdbc:h2:mem:db1 user=SA]]] after transaction
2017-02-14 21:01:17.219 DEBUG 53055 --- [           main] o.s.jdbc.datasource.DataSourceUtils      : Returning JDBC Connection to DataSource
{ID=A000000001, NAME=user01}
2017-02-14 21:01:17.220 DEBUG 53055 --- [           main] o.s.j.d.DataSourceTransactionManager     : Creating new transaction with name [com.example.AccountService.create]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT; ''
2017-02-14 21:01:17.220 DEBUG 53055 --- [           main] o.s.j.d.DataSourceTransactionManager     : Acquired Connection [ProxyConnection[PooledConnection[conn29: url=jdbc:h2:mem:db2 user=SA]]] for JDBC transaction
2017-02-14 21:01:17.220 DEBUG 53055 --- [           main] o.s.j.d.DataSourceTransactionManager     : Switching JDBC Connection [ProxyConnection[PooledConnection[conn29: url=jdbc:h2:mem:db2 user=SA]]] to manual commit
2017-02-14 21:01:17.220 DEBUG 53055 --- [           main] o.s.jdbc.core.JdbcTemplate               : Executing prepared SQL update
2017-02-14 21:01:17.220 DEBUG 53055 --- [           main] o.s.jdbc.core.JdbcTemplate               : Executing prepared SQL statement [INSERT INTO account (id, name) VALUES(?,?)]
2017-02-14 21:01:17.220 DEBUG 53055 --- [           main] o.s.jdbc.core.JdbcTemplate               : SQL update affected 1 rows
2017-02-14 21:01:17.220 DEBUG 53055 --- [           main] o.s.j.d.DataSourceTransactionManager     : Initiating transaction commit
2017-02-14 21:01:17.221 DEBUG 53055 --- [           main] o.s.j.d.DataSourceTransactionManager     : Committing JDBC transaction on Connection [ProxyConnection[PooledConnection[conn29: url=jdbc:h2:mem:db2 user=SA]]]
2017-02-14 21:01:17.221 DEBUG 53055 --- [           main] o.s.j.d.DataSourceTransactionManager     : Releasing JDBC Connection [ProxyConnection[PooledConnection[conn29: url=jdbc:h2:mem:db2 user=SA]]] after transaction
2017-02-14 21:01:17.221 DEBUG 53055 --- [           main] o.s.jdbc.datasource.DataSourceUtils      : Returning JDBC Connection to DataSource
2017-02-14 21:01:17.221 DEBUG 53055 --- [           main] o.s.j.d.DataSourceTransactionManager     : Creating new transaction with name [com.example.AccountService.find]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT; ''
2017-02-14 21:01:17.221 DEBUG 53055 --- [           main] o.s.j.d.DataSourceTransactionManager     : Acquired Connection [ProxyConnection[PooledConnection[conn29: url=jdbc:h2:mem:db2 user=SA]]] for JDBC transaction
2017-02-14 21:01:17.221 DEBUG 53055 --- [           main] o.s.j.d.DataSourceTransactionManager     : Switching JDBC Connection [ProxyConnection[PooledConnection[conn29: url=jdbc:h2:mem:db2 user=SA]]] to manual commit
2017-02-14 21:01:17.221 DEBUG 53055 --- [           main] o.s.jdbc.core.JdbcTemplate               : Executing prepared SQL query
2017-02-14 21:01:17.221 DEBUG 53055 --- [           main] o.s.jdbc.core.JdbcTemplate               : Executing prepared SQL statement [SELECT id, name FROM account WHERE id = ?]
2017-02-14 21:01:17.222 DEBUG 53055 --- [           main] o.s.j.d.DataSourceTransactionManager     : Initiating transaction commit
2017-02-14 21:01:17.222 DEBUG 53055 --- [           main] o.s.j.d.DataSourceTransactionManager     : Committing JDBC transaction on Connection [ProxyConnection[PooledConnection[conn29: url=jdbc:h2:mem:db2 user=SA]]]
2017-02-14 21:01:17.222 DEBUG 53055 --- [           main] o.s.j.d.DataSourceTransactionManager     : Releasing JDBC Connection [ProxyConnection[PooledConnection[conn29: url=jdbc:h2:mem:db2 user=SA]]] after transaction
2017-02-14 21:01:17.222 DEBUG 53055 --- [           main] o.s.jdbc.datasource.DataSourceUtils      : Returning JDBC Connection to DataSource
{ID=A000000002, NAME=user02}
...

ログをみると、Serviceのメソッドの呼び出し前後にトランザクション制御のログが出ており、シャードキーに対応するデータソースから取得したConnectionがトランザクションに割り当てられていることが確認できます。

まとめ

今回はアプリケーションレイヤでのシャーディングを例に、AbstractRoutingDataSourceの使用方法を紹介してみました。AbstractRoutingDataSourceはSpringのリファレンスページに登場しないため存在があまり知られていない?気がしますが、シャーディングなど複数のデータソースを動的な条件で切り替えたい場合につかえる便利なクラスです。
なお、本エントリーではデータソースの切替条件となる値(シャードキー)をスレッドローカルな変数で持つよう実装しましたが、切り替え要件に応じた実装にするようにしてください。

参考サイト

17
19
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
19