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)はご存知でしょうか?
AWSが開発しているOSSで既存のJDBCをラップしてクラスター化されたAuroraを安全・安心、そして便利になるようにするドライバです。
プラグイン形式で必要に応じて各種機能を追加することができます。代表的なプラグイン1は以下になります。
| 機能名 | 概要 |
|---|---|
| フェイルオーバー接続プラグイン | Aurora クラスターおよび RDS Multi-AZ DB クラスターにおいてフェイルオーバー後に古いノードに接続するこを防ぐ |
| ホスト監視プラグイン | ホスト接続障害監視。より高速な障害検知を可能にする |
| IAM認証接続プラグイン | IAMを利用してAuroraクラスターに接続できるようにする |
| AWS Secrets Manager 接続プラグイン | Secrets Manager サービスからデータベース認証情報を取得 |
| 読み書き分割プラグイン | データベースのリーダーとライターのインスタンスを切り替える |
MySQLに特化したものではありますが、aws-mysql-jdbcというドライバもあります。jdbc-wrapperの方が後発になっており、プラグイン機能によってより強力になっているものと思います。
試してみる
次の環境で接続を行います。
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_rowsやfound_rows()はMySQL8以降、非推奨になってしまいましたが利用されている方は多いのではないでしょうか?便利なので利用させてもらいます。
雑に説明するとsql_calc_found_rowsはlimitを無視して件数を取得してくれる宣言で、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_rowsとfound_rows()の間でjdbc-wrapperの障害検知SQLが実行されていたのが原因でした。取得された2件はAuroraクラスタに接続されているRead・Writeの2台という意味になります。
対策はSELECT COUNTにすることで解消することができます。found_rows()はMySQL8から非推奨となっているので利用は避けるべきですね。
さいごに
発生頻度も低く特定に困難な事象でしたので、発見するまでにだいぶハマってしまいました。。
わかってしまえばなんてことないですが、ページング機能はORマッパーでサポートしていて内部的にfound_rows()を利用しているものもいくつかあるようです。
そういった場合は問題の特定に時間がかかってしまったりするので、この記事をしくじり先生として覚えておいていただけると自分の失敗も救われます。