今回は、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桁毎にデータベースを用意して、アカウントに紐づくデータへのアクセスを負荷分散する。(完全になんちゃって要件なので、実際のシャーディング要件を決めるときの参考にしないでね )
絵にする必要はないけど・・・↓のような感じです(無駄に絵にしてみる)。
AbstractRoutingDataSource
とSpring AOPを使用した透過的なDataSource
の切替方法
さ〜(なんちゃって)シャーディング要件をどうやってアプリケーションに組み込むか考えてみましょう!!
ぱっと思いつくのは・・・Map
を使ってデータベース名とDataSource
を紐付けておいて、DBアクセスする際にアカウントIDに対応するDataSource
をMap
から取得する方法でしょうか?
ソースコードで表現すると、以下のような感じになります。
ぱっと見問題なさそうに見えますが、サービスクラス(アプリの機能要件をみたす処理を提供するクラス)の中に「アカウント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)
なお、本エントリーで紹介する実現方法を図で表現すると、以下のような感じになります。
項番 | 説明 |
---|---|
① | 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を使用して検証します。
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個指定する。
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.properteislogging.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アプリケーションを起動してみると・・・なんと、ま〜エラーになっちゃいました・・・
$ ./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
...
ログをみると、データソースのルッキアップキー(つまりシャードキー)が解決できていないことが原因のようです。なぜシャードキーが解決できなかったかというと・・・TransactionInterceptor
がShardKeyAdvice
より前に実行されてしまったからで、下記の図で示すようにShardKeyAdvice
-> TransactionInterceptor
の順番で適用されないとダメなんです。
具体的には、ShardKeyAdvice
の優先度を調整することで、TransactionInterceptor
より前にShardKeyAdvice
が適用されるようにする必要があります。
Spring Bootの自動コンフィギュレーションの仕組みを利用してTransactionInterceptor
をセットアップした場合は、TransactionInterceptor
の優先順は最低(Ordered.LOWEST_PRECEDENCE
)になるため、ShardKeyAdvice
の優先順を「Ordered.LOWEST_PRECEDENCE -1
」(優先順が高くなる値)にすればOKです。
Note:
Spring Bootの自動コンフィギュレーションの仕組みを利用しないで明示的に
@EnableTransactionManagement
を指定している場合は、@EnableTransactionManagement
のorder
属性に指定している値より小さい値を指定するようにしてください。
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のリファレンスページに登場しないため存在があまり知られていない?気がしますが、シャーディングなど複数のデータソースを動的な条件で切り替えたい場合につかえる便利なクラスです。
なお、本エントリーではデータソースの切替条件となる値(シャードキー)をスレッドローカルな変数で持つよう実装しましたが、切り替え要件に応じた実装にするようにしてください。
参考サイト
- http://terasolunaorg.github.io/guideline/5.2.0.RELEASE/ja/ArchitectureInDetail/DataAccessDetail/DataAccessCommon.html#id14
- http://docs.spring.io/spring/docs/current/javadoc-api/org/springframework/jdbc/datasource/lookup/AbstractRoutingDataSource.html
- http://qiita.com/kazuki43zoo/items/48061b82933e9b3d4ca8