Believe you can

If you can dream it, you can do it.

aws-advanced-jdbc-wrapperでハマったこと

ZOZO Advent Calendar 2023 カレンダー Vol.5 の 20 日目の記事です。

12/20は今年家族となった黒トイプードルの1歳の誕生日で、記念すべき日にアドベントカレンダーを書くことにしました。
Happy Birthday!!

aws-advanced-jdbc-wrapperとは

AWS Aurora MySQLを利用されている方は大変多いかと思いますが、aws-advanced-jdbc-wrapper(以下、jdbc-wrapper)はご存知でしょうか?

github.com

AWSが開発しているOSSで既存のJDBCをラップしてクラスター化されたAuroraを安全・安心、そして便利になるようにするドライバです。
プラグイン形式で必要に応じて各種機能を追加することができます。代表的なプラグイン1は以下になります。

機能名 概要
フェイルオーバー接続プラグイン Aurora クラスターおよび RDS Multi-AZ DB クラスターにおいてフェイルオーバー後に古いノードに接続するこを防ぐ
ホスト監視プラグイン ホスト接続障害監視。より高速な障害検知を可能にする
IAM認証接続プラグイン IAMを利用してAuroraクラスターに接続できるようにする
AWS Secrets Manager 接続プラグイン Secrets Manager サービスからデータベース認証情報を取得
読み書き分割プラグイン データベースのリーダーとライターのインスタンスを切り替える

MySQLに特化したものではありますが、aws-mysql-jdbcというドライバもあります。jdbc-wrapperの方が後発になっており、プラグイン機能によってより強力になっているものと思います。

github.com

試してみる

次の環境で接続を行います。

  • Java 17
  • Springboot 3.2
  • MySQL Connector/J 8.0.33
  • aws-advanced-jdbc-wrapper v2.3.1

jdbc-wrapperの設定はapplication.yamlに定義するだけで特段難しいことはありません。

spring:
  datasource:
    url: jdbc:aws-wrapper:mysql://{クラスタエンドポイント}/test
    driver-class-name: software.amazon.jdbc.Driver
    type: com.zaxxer.hikari.HikariDataSource
    hikari:
      data-source-properties:
        wrapperPlugins: readWriteSplitting,failover,efm
        readerHostSelectorStrategy: leastConnections
        failoverMode: reader-or-writer
      exception-override-class-name: software.amazon.jdbc.util.HikariCPSQLException

設定内容はマニュアルを見ていただくほうが良いかと思いますが、簡単に概要を説明します。

プロパティ 概要
spring.datasource.url JDBC URLにjdbc:aws-wrapper:mysqlを付与することでwrapperの利用を宣言する
spring.datasource.driver-class-name wrapperのドライバクラスとしてsoftware.amazon.jdbc.Driverを設定する
spring.datasource.hikari.data-source-properties.wrapperPlugins プラグインをカンマ区切りで指定
spring.datasource.hikari.data-source-properties.readerHostSelectorStrategy Auroraクラスタに複数のリーダーが接続している場合にどのように接続先を選択するかを指定
spring.datasource.hikari.data-source-properties.failoverMode フェイルオーバー時の挙動を指定
spring.datasource.hikari.exception-override-class-name コネクションプールがフェイルオーバー時に例外をハンドリングするために指定

これでbootRunを行えばMySQL Connector/Jと変わらずAuroraに接続することができます。jdbc-wrapperはAuroraを監視してくれており、定期的に障害が起きていないかSQLを実行して確認してくれています。

動かしてみよう

hogeテーブルから条件に合致する全件数と先頭5件を取得するロジックを組んでみます。細かい実装は省いていますがページング的な動きです。
sql_calc_found_rowsfound_rows()はMySQL8以降、非推奨になってしまいましたが利用されている方は多いのではないでしょうか?便利なので利用させてもらいます。

雑に説明するとsql_calc_found_rowslimitを無視して件数を取得してくれる宣言で、found_rows()はそのLimitを無視した件数を取得する関数になります。

@RestController
@RequestMapping(
  value = "/hoges",
  produces = {"application/json"})
public class HogeController {
  private final HogeService hogeService;

  public HogeController(HogeService hogeService) {
    this.hogeService = hogeService;
  }

  @GetMapping
  public ResponseEntity<HogeResponse> getHoges() {
    return ResponseEntity.ok(hogeService.getHoges());
  }
}


@Service
public class HogeService {
  private final JdbcTemplate jdbcTemplate;

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

  public HogeResponse getHoges() {
    var list = jdbcTemplate.query(
      """
        select sql_calc_found_rows
          id,
          message,
          point
        from
          hoge
        where
          point > 5
        order by
          point desc
        limit 5
        """, (rs, rowNum) ->
          new HogeEntity(
            rs.getInt("id"),
            rs.getString("message"),
            rs.getInt("point")
          )
    );

    if (list.isEmpty()) {
      return new HogeResponse(0, emptyList());
    }

    var allCount = jdbcTemplate.queryForObject(
      """
        select found_rows()
        """, Integer.class
    );

    return new HogeResponse(allCount, list);
  }
}

実行結果はこんな感じ。

❯ curl http://localhost:8080/hoges | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   212    0   212    0     0   1109      0 --:--:-- --:--:-- --:--:--  1145
{
  "allCount": 2,
  "data": [
    {
      "id": 275,
      "message": "177",
      "point": 10
    },
    {
      "id": 253,
      "message": "42",
      "point": 10
    },
    {
      "id": 249,
      "message": "87",
      "point": 10
    },
    {
      "id": 237,
      "message": "333",
      "point": 10
    },
    {
      "id": 182,
      "message": "244",
      "point": 10
    }
  ]
}

おや?

ポケモンが進化するような雰囲気ですが、上記の結果に違和感を覚えた方もいるかと思います。
dataは5件あるのにallCountが2になっていて結果に不整合が起きています。
これはfound_rows()jdbc-wrapperの組み合わせで稀に起きる事象です。

found_rows()は最後にSELECTした行数をカウントする関数です。一方のjdbc-wrapperはAuroraの障害検知のために定期的にSQLを実行し監視を行ってくれます(発行されるSQLこちら)。

勘のいい方ならお気づきでしょう。
そうです、sql_calc_found_rowsfound_rows()の間でjdbc-wrapperの障害検知SQLが実行されていたのが原因でした。取得された2件はAuroraクラスタに接続されているRead・Writeの2台という意味になります。

対策はSELECT COUNTにすることで解消することができます。found_rows()はMySQL8から非推奨となっているので利用は避けるべきですね。

さいごに

発生頻度も低く特定に困難な事象でしたので、発見するまでにだいぶハマってしまいました。。
わかってしまえばなんてことないですが、ページング機能はORマッパーでサポートしていて内部的にfound_rows()を利用しているものもいくつかあるようです。
そういった場合は問題の特定に時間がかかってしまったりするので、この記事をしくじり先生として覚えておいていただけると自分の失敗も救われます。