PolarDBとAuroraの比較及びSpring Boot上の対応

# はじめに
[KINTO Technologies Advent Calendar 2021 - Qiita](https://qiita.com/advent-calendar/2021/kinto-technologies) の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を切り替えるだけで、
アプリケーション側はカスタムエンドポイント向けコネクションを作成するだけだと、以下の問題が発生します。
１．Readノード向け、DB書き込み、エラー発生する可能性がある。
２．ラウンドロビンの負荷分散ができず、一定時間間隔内にすべての負荷は1つのノードに集中する。
**要は、Auroraは以上の2点でアプリ側にとってシームレスではありません。**
**以上の機能をアプリ側は実装する必要があります。**

### Aliyun PolarDB
PolarDBのクラスタエンドポイントはプロキシのような存在で、
アプリケーション側はクラスタポイント向けコネクションを作成したら、
１．検索処理とアップデート処理を自動的に認識され、ReadノードとWriteノードに自動的に振り分けます。
２．ラウンドロビンの負荷分散ができる。
**要は、PolarDBは以上の2点でアプリ側にとってシームレスです。**

# Spring Boot上でAuroraの対応案
それでは、Spring Bootは以上の2点に対してAuroraへの実装対応案を提示します。

### 案1:個別実装
Spring Boot + Mybatisで一般的に以下のステップでReadとWriteの自動振り分けを実装します。

#### Datasourceコンフィギュレーション
**①application.xml**

```yaml:application.yml
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
```
**②マルチデータソース設定**

```java:DataSourceConfig.java
@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設定**

```java:MyBatisConfig.java
@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タイプ定義**

```java:DBTypeEnum.java 
public enum DBTypeEnum {

    MASTER, SLAVE1, SLAVE2;

}
```
**②ThreadLocalにDataSource源を保存し、アクセスするたびにラウンドロビンを行う**

```java:DBContextHolder.java
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ルーティングキーを設定する**

```java:MyRoutingDataSource.java
public class MyRoutingDataSource extends AbstractRoutingDataSource {
    @Nullable
    @Override
    protected Object determineCurrentLookupKey() {
        return DBContextHolder.get();
    }
}
```
**④AOPでmasterとslaveノードを切り替える（メソッド名により）**

```java:DataSourceAop.java
@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ノードへ向かせるアノテーション**

```java:Master.java
public @interface Master {
}
```

#### Service実装例

```java:MemberService.java
@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](https://shardingsphere.apache.org/document/legacy/4.x/document/en/overview/)
以下の3つのサブプロダクトがある
１．Sharding-JDBC
        クライアント側のjavaライブラリ

２．Sharding-Proxy
        DBプロキシ

３．Sharding-Sidecar
        DB Meshみたいなもの(開発中)

１と２を使用し、それぞれの対応案は以下となります。
#### 案2-1:Sharding-JDBCを使用する
**①build.gradleにSharding-JDBCの依存関係を追加する。**

```gradle:build.gradle
    implementation 'io.shardingsphere:sharding-jdbc-spring-boot-starter:3.1.0'
    implementation 'io.shardingsphere:sharding-jdbc-spring-namespace:3.1.0'
```

**②application.yaml**

```yaml: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](https://shardingsphere.apache.org/document/legacy/4.x/document/en/manual/sharding-proxy/)

**①Sharding-Proxyサーバの設定**

```yaml:config-master_slave.yaml
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に依存関係の追加が不要**

```yaml:application.yaml
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）