SpringBootで接続先のデータベースを動的に切り替える
はじめに
とあるサービスを作成することになり、想定する利用人数をもとに、負荷分散について調べていた。
現状大丈夫そうだなと思っていても、今後利用人数が増えることを考えると、DBの負荷分散はどうしたら良いのか分からなかった。
負荷分散について考える前までは、レプリケーションやら、AWSのリードレプリカの概要・説明が出てくるけど、アプリケーションの変更が必要なのか良く分からなかった。
調べてみると、やっぱりアプリケーションの改造をやるか、PostgreSQLの場合はpgpoolのようなミドルウェアが必要っぽい。
AWS上の環境をあまり増やしたくないので、SpringBootで作成したアプリケーションに、DBの接続先を切り替える機能を作ることにした。
目次
- はじめに
- 参考サイト
- 環境
- 作業の概要
- 1. コンテキストを保持するクラスを作成
- 2. コンテキストより、使用するBean名を返すクラスの作成
- 3. 接続先を指定するアノテーションを作成
- 4. コンテキストを切り替えるAspectを作成
- 5. 読み取り用と更新用とそれぞれのDB接続設定を追加
- 6. Beanを作成
- 7. コントローラにアノテーションを付与
- おわりに
参考サイト
以下のサイトを参考にした。
環境
- Java 1.8
- Spring Boot 1.5.4.RELEASE
- PostgreSQL 9.6
AWS上で想定している将来の環境は以下の感じ。
ということで、開発環境にDNSサーバ・レプリケーションを構成済みのPostgreSQL2台を構築した。
DBのロードバランスは、DNSサーバにお任せすることにした。
作業の概要
いろいろやることがあったので、ざっくり以下にまとめ
- コンテキストを保持するクラスを作成
- コンテキストより、使用するBean名を返すクラスの作成
- 接続先を指定するアノテーションを作成
- コンテキストを切り替えるAspectを作成
- 読み取り用と更新用とそれぞれのDB接続設定を追加
- Beanを作成
- コントローラにアノテーションを付与
サービスにアノテーションを付与するようにしたかったけれども、サービスに付与するとテストが通らなった。
コントローラに付与すると問題なく動作し、また既に色々処理が出来てしまっていたので、今回はコントローラにアノテーションを付与ようにした。
(コントローラのメソッドの中で、「読み込み用」のサービスメソッドの次に「更新用」サービスメソッドを呼んでいるのが原因。"このコネクションじゃDBの更新は出来ないよ"みたいなことを言われていた気がする。ビジネスロジックが1つのサービスのメソッドでまとまっていれば、サービスにアノテーション付与出来たと思う)
1. コンテキストを保持するクラスを作成
まずは、Enumを作っておく。
public enum DataSourceType { ReadOnly, Updatable, }
接続先の設定を保持するコンテキストクラスを以下のように作成
public class DbContextHolder { private static ThreadLocal<DataSourceType> contextHolder = new ThreadLocal<>(); public static void setDataSourceType(DataSourceType type) { contextHolder.set(type); } public static DataSourceType getDataSourceType() { return contextHolder.get(); } public static void clear() { contextHolder.remove(); } }
2. コンテキストより、使用するBean名を返すクラスの作成
AbstractRoutingDataSourceを継承したクラスを作成。(定数文字列:READ_ONLY_DATA_SOURCE_NAME、UPDATABLE_DATA_SOURCE_NAMEは別のクラスで定義している)
public class RoutingDataSourceResolver extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { if (DbContextHolder.getDataSourceType() == null) { return null; } switch (DbContextHolder.getDataSourceType()) { case ReadOnly: return READ_ONLY_DATA_SOURCE_NAME; case Updatable: return UPDATABLE_DATA_SOURCE_NAME; default: throw new RuntimeException("unknown datasource"); } } }
3. 接続先を指定するアノテーションを作成
Controllerクラスのメソッドに付与し、「読み込み用」か「更新用」どちらに接続するかを設定するアノテーションを作成
@Target({ ElementType.METHOD }) @Retention(RetentionPolicy.RUNTIME) public @interface DataSource { DataSourceType value(); }
4. コンテキストを切り替えるAspectを作成
先程のアノテーションをもとに、コンテキストを設定するアスペクトを作成する。
今回はコントローラのメソッド単位で、接続先を切り替えるので、「@Controller」、「@RestController」が付いていないクラスから呼ばれると例外を投げるようにした。
@Aspect @Component public class SwitchingDataSourceAspect { @Around("@annotation(com.example.sample.aspect.DataSource)") public Object proceed(ProceedingJoinPoint joinPoint) throws Throwable { try { DataSourceType type = this.getDataSourceType(joinPoint); DbContextHolder.setDataSourceType(type); return joinPoint.proceed(); } finally { DbContextHolder.clear(); } } private DataSourceType getDataSourceType(JoinPoint joinPoint) throws NoSuchMethodException { MethodSignature signature = (MethodSignature)joinPoint.getSignature(); String methodName = signature.getMethod().getName(); Class<?>[] parameterTypes = signature.getMethod().getParameterTypes(); Method method = joinPoint.getTarget().getClass().getMethod(methodName, parameterTypes); Controller controller = joinPoint.getTarget().getClass().getAnnotation(Controller.class); RestController restController = joinPoint.getTarget().getClass().getAnnotation(RestController.class); DataSource dataSource = method.getAnnotation(DataSource.class); if (controller == null && restController == null) { throw new IllegalArgumentException("@Datasource is only can use in @Controller or @RestController."); } return dataSource.value(); } }
5. 読み取り用と更新用とそれぞれのDB接続設定を追加
SpringBootの設定ファイルに「readOnly」と「updatable」を追加。
spring: datasource: readOnly: driverClassName: org.postgresql.Driver url: jdbc:postgresql://read-only-db.sample.internal:5432/sample_db username: xxxxxxxx password: xxxxxxxx minIdlePoolSize: 3 maximumPoolSize: 40 idleTimeout_ms: 300000 maxLifetime_ms: 1800000 updatable: driverClassName: org.postgresql.Driver url: jdbc:postgresql://updatable.sample.internal:5432/sample_db username: xxxxxxxx password: xxxxxxxx minIdlePoolSize: 3 maximumPoolSize: 20 idleTimeout_ms: 300000 maxLifetime_ms: 1800000
6. Beanを作成
設定ファイルをもとに、DataSourceを生成するBeanを作成。
DBマイグレーションでFlywayを使用している場合、更新用のBeanに「@FlywayDataSource」を付与する。これがないと起動できなかった。
RoutingDataSourceResolverを返すBean(multiDataSource)に「@Primary」を付与する。これがないと、「multiDataSource、readOnlyDataSource、updatableDataSourceどれを使うんだよ?」的なことを言われて起動できなかった。
@Configuration public class WebConfig extends WebMvcConfigurerAdapter { public static final String READ_ONLY_DATA_SOURCE_NAME = "readOnlyDataSource"; public static final String UPDATABLE_DATA_SOURCE_NAME = "updatableDataSource"; @Autowired private Environment environment; @Autowired @Qualifier(READ_ONLY_DATA_SOURCE_NAME) private DataSource readableDataSource; @Autowired @Qualifier(UPDATABLE_DATA_SOURCE_NAME) private DataSource updatableDataSource; @Bean @Primary public RoutingDataSourceResolver multiDataSource() { RoutingDataSourceResolver resolver = new RoutingDataSourceResolver(); // スイッチするデータソースを設定 Map<Object, Object> dataSources = new HashMap<>(); dataSources.put(READ_ONLY_DATA_SOURCE_NAME, readableDataSource); dataSources.put(UPDATABLE_DATA_SOURCE_NAME, updatableDataSource); resolver.setTargetDataSources(dataSources); resolver.setDefaultTargetDataSource(updatableDataSource); return resolver; } @Bean(READ_ONLY_DATA_SOURCE_NAME) public DataSource readOnlyDataSource() { String baseConfig = "spring.datasource.readOnly.%s"; HikariConfig config = new HikariConfig(); config.setDriverClassName(environment.getProperty(String.format(baseConfig, "driverClassName"))); config.setJdbcUrl(environment.getProperty(String.format(baseConfig, "url"))); config.setUsername(environment.getProperty(String.format(baseConfig, "username"))); config.setPassword(environment.getProperty(String.format(baseConfig, "password"))); HikariDataSource dataSource = new HikariDataSource(config); dataSource.setMinimumIdle(Integer.parseInt(environment.getProperty(String.format(baseConfig, "minIdlePoolSize")))); dataSource.setMaximumPoolSize(Integer.parseInt(environment.getProperty(String.format(baseConfig, "maximumPoolSize")))); dataSource.setIdleTimeout(Long.parseLong(environment.getProperty(String.format(baseConfig, "idleTimeout_ms")))); dataSource.setMaxLifetime(Long.parseLong(environment.getProperty(String.format(baseConfig, "maxLifetime_ms")))); return dataSource; } @Bean(UPDATABLE_DATA_SOURCE_NAME) @FlywayDataSource public DataSource updatableDataSource() { String baseConfig = "spring.datasource.updatable.%s"; HikariConfig config = new HikariConfig(); config.setDriverClassName(environment.getProperty(String.format(baseConfig, "driverClassName"))); config.setJdbcUrl(environment.getProperty(String.format(baseConfig, "url"))); config.setUsername(environment.getProperty(String.format(baseConfig, "username"))); config.setPassword(environment.getProperty(String.format(baseConfig, "password"))); HikariDataSource dataSource = new HikariDataSource(config); dataSource.setMinimumIdle(Integer.parseInt(environment.getProperty(String.format(baseConfig, "minIdlePoolSize")))); dataSource.setMaximumPoolSize(Integer.parseInt(environment.getProperty(String.format(baseConfig, "maximumPoolSize")))); dataSource.setIdleTimeout(Long.parseLong(environment.getProperty(String.format(baseConfig, "idleTimeout_ms")))); dataSource.setMaxLifetime(Long.parseLong(environment.getProperty(String.format(baseConfig, "maxLifetime_ms")))); return dataSource; } }
setDefaultTargetDataSourceメソッドに「updatableDataSource」を突っ込んでいるので、@DataSourceのつけ忘れがあった場合、更新用のDBに接続してくれるはず(ちゃんと調べてない)
DataSourceにはHikariDataSourceを使用するようにした。
良くBasicDataSourceを使っているサンプルを見かけるけど、Webサーバを起動したままPostgreSQLを再起動すると、初回アクセスのときにDBコネクションエラーが発生する。
HikariDataSourceだと勝手にコネクションを再生成してくれて、初回アクセスでも問題なかった。
BasicDataSourceでも、testOnBorrowとかvalidationQueryを設定すれば、再接続してくれるっぽいけど・・・残念ながらうまくいったことがない・・・
あと、BasicDataSourceを使用して実際に負荷をかけてみたところ、上限無しにコネクションを生成し、「クライアント多すぎぃ」とDB怒られた・・・確かにコネクションの上限設定してなかったもんね。
上限の設定について一応調べた気がするけど、面倒くさくなった
ということがあったので、DataSourceにはHikariDataSourceを使用するようにした。(2回目)
7. コントローラにアノテーションを付与
接続先を切り替える処理が完成したので、あとはコントローラのメソッドに、@DataSourceを付与していく。
以下、雑なサンプルコード
@Controller @RequestMapping("api/v1/foo") public class FooController { private final FooService service; @Autowired public FooController(FooService service) { this.service= service; } // 読み取りのみの処理 @DataSource(DataSourceType.ReadOnly) @RequestMapping(value = "", method = RequestMethod.GET) public ResponseEntity getFoo() { return ResponseEntity.ok(this.service.getFoo()); } // 更新を含む処理 @DataSource(DataSourceType.Updatable) @RequestMapping(value = "", method = RequestMethod.DELETE) public ResponseEntity deleteFoo() { this.service.deleteFoo() return ResponseEntity.ok(new MessageOnly("Foo Deleted")); } }
おわりに
少々追加するクラスが多いものの、アノテーションを付与して、DBの接続先を切り替える機能を作成することができた。
SpringBootが設定を勝手にやってくれていたところが結構あり、色んなハマったポイントがあった。
利用人数が増加するアプリを作成するときは、一番最初にこの処理を作っておくべきだと思った。
そういえばDNSサーバの構築について書いてない・・・
今回、初めてCentOSでDNSサーバを構築したので、それについてはまたいつか・・・
追記。DNSサーバの構築についてはコチラ