はじめに
KINTO Technologies Advent Calendar 2021 - Qiita の12日目の記事です。
Kintoのグローバル展開により、さまざまなクラウドとサービスを触れるチャンスがありました。
その中、Amazon AuroraとAliyun PolarDBの2つのRDBサービスを検証したことがあります。
Amazon Aurora
Amazon Auroraは、MySQL および PostgreSQL と互換性のあるクラウド向けのリレーショナルデータベースであり、従来のエンタープライズデータベースのパフォーマンスと可用性に加え、オープンソースデータベースのシンプルさとコスト効率性も兼ね備えています。Amazon Aurora のストレージシステムは、分散型で耐障害性と自己修復機能を備えており、データベースインスタンスごとに最大 128 TB まで自動的にスケールされます。Amazon Aurora は、最大 15 個の低レイテンシーリードレプリカ、ポイントインタイムリカバリ、Amazon S3 への継続的なバックアップ、3 つのアベイラビリティーゾーン間でのレプリケーションにより、優れたパフォーマンスと可用性を発揮します。
Aliyun PolarDB
Aliyun PolarDBはAmazon Auroraの競合製品として存在しています。
両製品ともフルマネージドで従来の運用時の手間を軽減できるすばらしいサービスだと認識しております。
今回は、インフラではなく、アプリケーション開発側の観点から両製品の利便性と使用する際の注意ポイントと対応事項を考察します。
(弊社サーバ側の実装は大半Spring Bootを使用しているので、Spring Bootを例として対応実装を示します。)
アプリケーション側の観点
AuroraとPolarDBは、両方ともRead-Writeレプリカを自動的にやってくれますが、
アプリケーション側はシームレスで利用できるかどうかを重要視しています。
要は、アプリ側にとって、以下の2点でアプリ側は対応する必要があるのか、どう対応するのかはポイントとなります。
- ReadとWriteのインスタンスノードの振り分け
- 複数Readインスタンスノードへの負荷ロードバランシング
その2点について、簡単にAuroraとPolarDBを以下の表で比較しました。
AuroraとPolarDBの比較
Amazon Aurora | Aliyun PolarDB | |
---|---|---|
Read-Writeの振り分け | アプリ側は対応が必要 | シームレス、アプリ側は対応不要 |
複数Readノードのロードバランシング | アプリ側は対応が必要 | シームレス、アプリ側は対応不要 |
ReadノードとWriteノードをまとめて、
Auroraはカスタムエンドポイントを、PolarDBはクラスタエンドポイントを作成できます。
Amazon Auroraの場合
Auroraのカスタムエンドポイントは一定の時間間隔(デフォルト5秒間隔)でメンバーノード向けにDNSを切り替えるだけで、
アプリケーション側はカスタムエンドポイント向けコネクションを作成するだけだと、以下の問題が発生します。
1.Readノード向け、DB書き込み、エラー発生する可能性がある。
2.ラウンドロビンの負荷分散ができず、一定時間間隔内にすべての負荷は1つのノードに集中する。
要は、Auroraは以上の2点でアプリ側にとってシームレスではありません。
以上の機能をアプリ側は実装する必要があります。
Aliyun PolarDB
PolarDBのクラスタエンドポイントはプロキシのような存在で、
アプリケーション側はクラスタポイント向けコネクションを作成したら、
1.検索処理とアップデート処理を自動的に認識され、ReadノードとWriteノードに自動的に振り分けます。
2.ラウンドロビンの負荷分散ができる。
要は、PolarDBは以上の2点でアプリ側にとってシームレスです。
Spring Boot上でAuroraの対応案
それでは、Spring Bootは以上の2点に対してAuroraへの実装対応案を提示します。
案1:個別実装
Spring Boot + Mybatisで一般的に以下のステップでReadとWriteの自動振り分けを実装します。
Datasourceコンフィギュレーション
①application.xml
spring:
datasource:
master:
jdbc-url: jdbc:mysql://xxxxmaster.ap-northeast-2.rds.amazonaws.com:3306/test
username: master
password: password
driver-class-name: com.mysql.cj.jdbc.Driver
slave1:
jdbc-url: jdbc:mysql://xxxxslave1.ap-northeast-2.rds.amazonaws.com:3306/test
username: slave1
password: password
driver-class-name: com.mysql.cj.jdbc.Driver
slave2:
jdbc-url: jdbc:mysql://xxxxslave2.ap-northeast-2.rds.amazonaws.com:3306/test
username: slave2
password: password
driver-class-name: com.mysql.cj.jdbc.Driver
②マルチデータソース設定
@Configuration
public class DataSourceConfig {
@Bean
@ConfigurationProperties("spring.datasource.master")
public DataSource masterDataSource() {
return DataSourceBuilder.create().build();
}
@Bean
@ConfigurationProperties("spring.datasource.slave1")
public DataSource slave1DataSource() {
return DataSourceBuilder.create().build();
}
@Bean
@ConfigurationProperties("spring.datasource.slave2")
public DataSource slave2DataSource() {
return DataSourceBuilder.create().build();
}
@Bean
public DataSource myRoutingDataSource(@Qualifier("masterDataSource") DataSource masterDataSource,
@Qualifier("slave1DataSource") DataSource slave1DataSource,
@Qualifier("slave2DataSource") DataSource slave2DataSource) {
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put(DBTypeEnum.MASTER, masterDataSource);
targetDataSources.put(DBTypeEnum.SLAVE1, slave1DataSource);
targetDataSources.put(DBTypeEnum.SLAVE2, slave2DataSource);
MyRoutingDataSource myRoutingDataSource = new MyRoutingDataSource();
myRoutingDataSource.setDefaultTargetDataSource(masterDataSource);
myRoutingDataSource.setTargetDataSources(targetDataSources);
return myRoutingDataSource;
}
}
③Mybatis設定
@EnableTransactionManagement
@Configuration
public class MyBatisConfig {
@Resource(name = "myRoutingDataSource")
private DataSource myRoutingDataSource;
@Bean
public SqlSessionFactory sqlSessionFactory() throws Exception {
SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
sqlSessionFactoryBean.setDataSource(myRoutingDataSource);
sqlSessionFactoryBean.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath:mapper/*.xml"));
return sqlSessionFactoryBean.getObject();
}
@Bean
public PlatformTransactionManager platformTransactionManager() {
return new DataSourceTransactionManager(myRoutingDataSource);
}
}
DataSourceルーティング設定
①DataSourceタイプ定義
public enum DBTypeEnum {
MASTER, SLAVE1, SLAVE2;
}
②ThreadLocalにDataSource源を保存し、アクセスするたびにラウンドロビンを行う
public class DBContextHolder {
private static final ThreadLocal<DBTypeEnum> contextHolder = new ThreadLocal<>();
private static final AtomicInteger counter = new AtomicInteger(-1);
public static void set(DBTypeEnum dbType) {
contextHolder.set(dbType);
}
public static DBTypeEnum get() {
return contextHolder.get();
}
public static void master() {
set(DBTypeEnum.MASTER);
}
public static void slave() {
// ロードバランシングラウンドロビンポリシー
int index = counter.getAndIncrement() % 2;
if (counter.get() > 9999) {
counter.set(-1);
}
if (index == 0) {
set(DBTypeEnum.SLAVE1);
}else {
set(DBTypeEnum.SLAVE2);
}
}
}
③DataSourceルーティングキーを設定する
public class MyRoutingDataSource extends AbstractRoutingDataSource {
@Nullable
@Override
protected Object determineCurrentLookupKey() {
return DBContextHolder.get();
}
}
④AOPでmasterとslaveノードを切り替える(メソッド名により)
@Aspect
@Component
public class DataSourceAop {
@Pointcut("!@annotation(com.example.annotation.Master) " +
"&& (execution(* com.example.service..*.select*(..)) " +
"|| execution(* com.example.service..*.get*(..)))")
public void readPointcut() {
}
@Pointcut("@annotation(com.example.annotation.Master) " +
"|| execution(* com.example.service..*.insert*(..)) " +
"|| execution(* com.example.service..*.add*(..)) " +
"|| execution(* com.example.service..*.update*(..)) " +
"|| execution(* com.example.service..*.edit*(..)) " +
"|| execution(* com.example.service..*.delete*(..)) " +
"|| execution(* com.example.service..*.remove*(..))")
public void writePointcut() {
}
@Before("readPointcut()")
public void read() {
DBContextHolder.slave();
}
@Before("writePointcut()")
public void write() {
DBContextHolder.master();
}
}
⑤強制的にmasterノードへ向かせるアノテーション
public @interface Master {
}
Service実装例
@Service
public class MemberService {
@Autowired
private MemberMapper memberMapper;
@Transactional
@Override
public int insert(Member member) {
return memberMapper.insert(member);
}
@Master
@Override
public int save(Member member) {
return memberMapper.insert(member);
}
@Override
public List<Member> selectAll() {
return memberMapper.selectByExample(new MemberExample());
}
@Master
@Override
public String getToken(String appId) {
//強制的にMasterノードから取得
return null;
}
}
案2:ShardingSphereを利用する(Sharding-JDBCかSharding-Proxy)
ShardingSphereはDBシャーディングのOSSプロダクトで、Apacheのトッププロジェクトでもある。
(シャーディングだけではなく、Read-Writeの分離もできる)
ShardingSphere
以下の3つのサブプロダクトがある
1.Sharding-JDBC
クライアント側のjavaライブラリ
2.Sharding-Proxy
DBプロキシ
3.Sharding-Sidecar
DB Meshみたいなもの(開発中)
1と2を使用し、それぞれの対応案は以下となります。
案2-1:Sharding-JDBCを使用する
①build.gradleにSharding-JDBCの依存関係を追加する。
implementation 'io.shardingsphere:sharding-jdbc-spring-boot-starter:3.1.0'
implementation 'io.shardingsphere:sharding-jdbc-spring-namespace:3.1.0'
②application.yaml
sharding:
jdbc:
datasource:
names: ds0,ds1,ds2
ds0:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://xxxxmaster.ap-northeast-2.rds.amazonaws.com:3306/test
username: master
password: password#
ds1:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://xxxxslave1.ap-northeast-2.rds.amazonaws.com:3306/test
username: slave1
password: password#
ds2:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://xxxxslave2.ap-northeast-2.rds.amazonaws.com:3306/test
username: slave2
password: password#
config:
masterslave:
load-balance-algorithm-type: round_robin
name: dataSource
master-data-source-name: ds0
slave-data-source-names: ds1,ds2
以上だけで、ほかの実装はいつもどおりでRead-Writeの自動分離と負荷ロードバランシング(ラウンドロビン)が実現できます。
簡単でしょう。
案2-2:Sharding-Proxyを使用する
インストールなどの詳細は以下をご参考
Sharding-Proxy
①Sharding-Proxyサーバの設定
schemaName: master_slave_db
#
dataSources:
master_ds:
url: jdbc:mysql://xxxxmaster.ap-northeast-2.rds.amazonaws.com:3306/test
username: master
password: password#
slave_ds_0:
url: jdbc:mysql://xxxxslave1.ap-northeast-2.rds.amazonaws.com:3306/test
username: slave1
password: password#
slave_ds_1:
url: jdbc:mysql://xxxxslave2.ap-northeast-2.rds.amazonaws.com:3306/test?serverTimezone=UTC&useSSL=false
username: slave2
password: password#
masterSlaveRule:
name: ms_ds
masterDataSourceName: master_ds
slaveDataSourceNames:
- slave_ds_0
- slave_ds_1
②application.yamlはいつもどおりでいい、build.gradleに依存関係の追加が不要
spring:
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://xxxxxx:3307/master_slave_db
username: shardingProxy
password: shardingProxy
AWSのRDS-ProxyもGAされていますが、Sharding-ProxyのRead-Write自動振り分けと負荷分散ロードバランシング機能がないようです。
まとめ
- PolarDBはアプリ側にとってシームレスです。
- Auroraに対して、アプリ側はRead-Write振り分けと負荷分散ロードバランシング機能を実装する必要があります。
- Auroraに対して、アプリ側は3つの案があります。(個別実装、Sharding-JDBCとSharding-Proxy)