---
title: Amazon Aurora DSQLにSpring BootでアクセスするTips
tags: ["Spring Boot", "Aurora DSQL", "Java", "AWS", "PostgreSQL", "Spring Retry"]
categories: ["Programming", "Java", "org", "springframework", "boot"]
date: 2025-05-30T06:26:32Z
updated: 2025-09-26T01:16:04.494369Z
---
> [!NOTE] 2025-09-25追記 本記事執筆後[Aurora DSQL JDBC Connector](https://github.com/awslabs/aurora-dsql-jdbc-connector)がリリースされているので、こちらを使ったほうがいいかもしれません
[Amazon Aurora DSQL](https://docs.aws.amazon.com/aurora-dsql/)をSpring Bootから使用してみましたので、いくつかのTipsをまとめておきます。
サンプルアプリのコードは[こちら](https://github.com/making/demo-dsql)です。
サンプルアプリの動かし方はREADMEを参照してください。
### 依存ライブラリの追加
DSQLにアクセスするには、PostgreSQL JDBCやSpring JDBCなどの依存ライブラリに加えて、AWS SDKが必要です。これは、DSQLのパスワード(トークン)をAWS SDKから動的に取得する必要があるためです。
AWS Credentialsの管理を簡素化するために[Spring Cloud AWS](https://docs.awspring.io/spring-cloud-aws/docs/3.3.1/reference/html/index.html)を使用します。Core機能の`io.awspring.cloud:spring-cloud-aws-starter`だけで十分です。DSQLのトークンを取得するために、`software.amazon.awssdk:dsql`も追加します。
必須ではありませんが、筆者は`aws sso login`を使ってCredentialsを取得しているため、SSO対応のために`software.amazon.awssdk:sso`も追加しています。
```xml
io.awspring.cloud
spring-cloud-aws-starter
software.amazon.awssdk
sso
commons-logging
commons-logging
software.amazon.awssdk
dsql
commons-logging
commons-logging
```
Spring Cloud AWSは次のBOMを使います。
```xml
io.awspring.cloud
spring-cloud-aws-dependencies
3.3.1
pom
import
```
この設定により、`aws` CLI用のCredentialsを使用してDSQLのパスワードを動的に取得できます。
> [!NOTE]
> 開発環境ではなく、本番環境にデプロイする場合は[その他のCredentialsプロバイダー](https://docs.awspring.io/spring-cloud-aws/docs/3.3.1/reference/html/index.html#credentials)を検討してください。
### DataSourceの設定
DSQLはコンソールで作成済みとします。記事執筆時点では、Tokyo(ap-northeast-1)ではシングルリージョンしか選択できませんでした。
今回は、ローカル環境からpublic endpointにアクセスすることを想定しています。また、adminユーザーを使用します。

コンソールからpublic endpointを取得し、以下のように`application.properties`に設定します。
```properties
spring.datasource.url=jdbc:postgresql:///postgres?sslmode=verify-full&sslfactory=org.postgresql.ssl.DefaultJavaSSLFactory
spring.datasource.username=admin
# ~/.aws/config にregionが設定されていたり、AWS上で実行する場合は、以下の設定は不要です。
spring.cloud.aws.region.static=ap-northeast-1
```
> [!NOTE]
> `sslmode=verify-full`の場合、デフォルトの`sslfactory`である`org.postgresql.ssl.jdbc4.LibPQFactory`では`$HOME/.postgresql/root.crt`にサーバーのCA証明書が必要となります。
> `org.postgresql.ssl.DefaultJavaSSLFactory`を使用すると、JavaのTrustStoreが使用されます。
> `sslmode=require`であれば`sslfactory`は不要ですが、MitM攻撃のリスクが残るため、パブリックエンドポイントにアクセスする場合は`sslmode=verify-full`を使用することをお勧めします。
DSQLのトークンを取得してDataSourceに設定するため、以下のような`DataSourceConfig`を作成します。このクラスでは、DSQLのパスワードを定期的に更新するためのタスクのスケジューリングや、DSQLで楽観的排他制御エラーが発生した際に`OptimisticLockingFailureException`に変換するための`SQLExceptionTranslator`の登録も行っています。
```java
/*
* Copyright (C) 2025 Toshiaki Maki
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.config;
import com.zaxxer.hikari.HikariDataSource;
import com.zaxxer.hikari.SQLExceptionOverride;
import java.sql.SQLException;
import java.time.Duration;
import java.time.Instant;
import java.util.function.Consumer;
import java.util.function.Supplier;
import javax.sql.DataSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.task.SimpleAsyncTaskSchedulerBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.dao.DataAccessException;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.jdbc.support.JdbcTransactionManager;
import org.springframework.jdbc.support.SQLExceptionTranslator;
import org.springframework.jdbc.support.SQLStateSQLExceptionTranslator;
import org.springframework.scheduling.concurrent.SimpleAsyncTaskScheduler;
import org.springframework.util.StringUtils;
import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.regions.providers.AwsRegionProvider;
import software.amazon.awssdk.services.dsql.DsqlUtilities;
import software.amazon.awssdk.services.dsql.model.GenerateAuthTokenRequest;
@Configuration(proxyBeanMethods = false)
@Profile("!testcontainers")
public class DsqlDataSourceConfig {
private final Logger logger = LoggerFactory.getLogger(DsqlDataSourceConfig.class);
private final Duration tokenTtl = Duration.ofMinutes(60);
@Bean
@ConfigurationProperties("spring.datasource")
DataSourceProperties dsqlDataSourceProperties() {
return new DataSourceProperties();
}
@Bean
Supplier dsqlTokenSupplier(DataSourceProperties dsqlDataSourceProperties,
AwsRegionProvider awsRegionProvider, AwsCredentialsProvider credentialsProvider) {
Region region = awsRegionProvider.getRegion();
DsqlUtilities utilities = DsqlUtilities.builder()
.region(region)
.credentialsProvider(credentialsProvider)
.build();
String username = dsqlDataSourceProperties.getUsername();
String hostname = dsqlDataSourceProperties.getUrl().split("/")[2];
return () -> {
Consumer request = builder -> builder.hostname(hostname)
.region(region)
.expiresIn(tokenTtl);
return "admin".equals(username) ? utilities.generateDbConnectAdminAuthToken(request)
: utilities.generateDbConnectAuthToken(request);
};
}
@Bean
@ConfigurationProperties("spring.datasource.hikari")
HikariDataSource dsqlDataSource(DataSourceProperties dsqlDataSourceProperties, Supplier dsqlTokenSupplier) {
HikariDataSource dataSource = dsqlDataSourceProperties.initializeDataSourceBuilder()
.type(HikariDataSource.class)
.build();
String token = dsqlTokenSupplier.get();
if (StringUtils.hasText(dataSource.getPassword())) {
logger.warn("Overriding existing password for the datasource with DSQL token.");
}
dataSource.setPassword(token);
dataSource.setExceptionOverrideClassName(DsqlExceptionOverride.class.getName());
return dataSource;
}
@Bean
DsqlSQLExceptionTranslator dsqlSQLExceptionTranslator() {
return new DsqlSQLExceptionTranslator();
}
@Bean
JdbcTransactionManager transactionManager(DataSource dataSource,
DsqlSQLExceptionTranslator dsqlSQLExceptionTranslator) {
JdbcTransactionManager jdbcTransactionManager = new JdbcTransactionManager(dataSource);
jdbcTransactionManager.setExceptionTranslator(dsqlSQLExceptionTranslator);
return jdbcTransactionManager;
}
@Bean
SimpleAsyncTaskScheduler taskScheduler(SimpleAsyncTaskSchedulerBuilder builder) {
return builder.build();
}
@Bean
InitializingBean tokenRefresher(DataSource dataSource, Supplier dsqlTokenSupplier,
SimpleAsyncTaskScheduler taskScheduler) throws Exception {
HikariDataSource hikariDataSource = dataSource.unwrap(HikariDataSource.class);
Duration interval = tokenTtl.dividedBy(2);
return () -> taskScheduler.scheduleWithFixedDelay(() -> {
try {
String token = dsqlTokenSupplier.get();
hikariDataSource.getHikariConfigMXBean().setPassword(token);
hikariDataSource.getHikariPoolMXBean().softEvictConnections();
}
catch (RuntimeException e) {
logger.error("Failed to refresh DSQL token", e);
}
}, Instant.now().plusSeconds(interval.toSeconds()), interval);
}
// https://catalog.workshops.aws/aurora-dsql/en-US/04-programming-with-aurora-dsql/02-handling-concurrency-conflicts
private static final String DSQL_OPTIMISTIC_CONCURRENCY_ERROR_STATE = "40001";
static class DsqlSQLExceptionTranslator implements SQLExceptionTranslator {
SQLStateSQLExceptionTranslator delegate = new SQLStateSQLExceptionTranslator();
@Override
public DataAccessException translate(String task, String sql, SQLException ex) {
if (DSQL_OPTIMISTIC_CONCURRENCY_ERROR_STATE.equals(ex.getSQLState())) {
throw new OptimisticLockingFailureException(ex.getMessage(), ex);
}
return delegate.translate(task, sql, ex);
}
}
public static class DsqlExceptionOverride implements SQLExceptionOverride {
@java.lang.Override
public Override adjudicate(SQLException ex) {
if (DSQL_OPTIMISTIC_CONCURRENCY_ERROR_STATE.equals(ex.getSQLState())) {
return Override.DO_NOT_EVICT;
}
return Override.CONTINUE_EVICT;
}
}
}
```
有効期限を超えたトークンを使用してコネクションを作成しようとすると認証エラーが発生するため、常駐アプリケーションの場合は定期的にローテートする必要があります。HikariCPでは、`HikariConfigMXBean`を使用して[実行時にパスワードを変更](https://github.com/brettwooldridge/HikariCP/wiki/FAQ#q-can-i-change-the-usernamepassword-or-other-pool-properties-at-runtime)することが可能です。また、`HikariPoolMXBean`の`softEvictConnections`を使用することで、アイドル状態のコネクションを破棄し、アクティブなコネクションはプールに戻ったタイミングで破棄されます。
> [!NOTE]
> Aurora DSQLのサンプルコードを見ても、トークンのローテーションについては言及されていませんでした。AWS Lambdaでの使用を想定しているためでしょうか?
デフォルトの`SQLExceptionTranslator`を使用した場合、楽観的排他制御エラーが発生すると[`CannotAcquireLockException`](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/dao/CannotAcquireLockException.html)がスローされます。この例外をそのままハンドリングすることも可能ですが、`CannotAcquireLockException`は`PessimisticLockingFailureException`を継承しており、悲観的排他制御エラー(SELECT FOR UPDATEなど)を想定した例外クラスです。そのため、より適切な`OptimisticLockingFailureException`をスローするためにDSQL専用の`SQLExceptionTranslator`を作成しました。
> [!NOTE]
> Spring Boot 3.5では https://github.com/spring-projects/spring-boot/pull/43511 により、`SQLExceptionTranslator`がBean登録されると自動的に`JdbcTemplate`や`HibernateJpaDialect`に設定されるようになりました。
> ただし、3.5.0時点では`JdbcTransactionManager`には自動設定されないため、`DsqlDataSourceConfig`クラス内で手動設定しています。
> 今後、Pull Requestを提出してこの設定を自動化する予定です。
### 楽観的排他制御エラーのリトライ
楽観的排他制御エラーが発生した場合は、アプリケーション側でリトライする必要があります。リトライ処理は[Spring Retry](https://github.com/spring-projects/spring-retry)を使用すると簡単に実装できます。
上記の設定により、楽観的排他制御エラーが発生した場合に`OptimisticLockingFailureException`がスローされるようになります。`OptimisticLockingFailureException`に対するリトライ設定は、`@Retryable`アノテーションを使用して行います。
```java
@Service
@Transactional
@Retryable(retryFor = OptimisticLockingFailureException.class, maxAttempts = 4,
backoff = @Backoff(delay = 100, multiplier = 2, random = true))
public class CartService {
// ...
}
```
注意すべき点は、この`OptimisticLockingFailureException`がトランザクションコミット時に発生することです。単純に`@Transactional`と`@Retryable`を組み合わせるだけでは不十分で、`@Transactional`アノテーションが付いたメソッドがネストしている場合は、外側の`@Transactional`メソッドでリトライを設定する必要があります。
READMEに記載していますが、サンプルアプリを使用して以下の手順で楽観的排他制御エラーを発生させることができます。負荷テストには[`vegeta`](https://github.com/tsenart/vegeta)コマンドを使用します。
```bash
# Create a cart if not exists
curl -s "http://localhost:8080/api/v1/carts?userId=user123" | jq .
# Clear the cart
curl -s -X DELETE "http://localhost:8080/api/v1/carts/items?userId=user123" | jq .
# Add an item to the cart
curl -s -X POST "http://localhost:8080/api/v1/carts/items?userId=user123" \
--json '{
"productId": "product-001",
"productName": "iPhone 15",
"price": 999.99,
"quantity": 1
}' | jq .
ITEM_ID=$(curl -s "http://localhost:8080/api/v1/carts?userId=user123" | jq -r ".items[0].id")
cat < body.json
{
"quantity": 3
}
EOF
# Run the attack
echo "PATCH http://localhost:8080/api/v1/carts/items/${ITEM_ID}?userId=user123" | vegeta attack -duration=10s -rate=30 -body=body.json -header='Content-Type: application/json' | vegeta report
```
### その他の注意点
Spring Bootとは直接関係ありませんが、アプリケーションを実装する際に気になったDSQL使用時の現在の制約をいくつか挙げておきます。
* 外部キー制約が使えない
* シーケンスが使えない
* extensionが使えない
主キーにはUUIDを使用するのが適しているでしょう。
既存のPostgreSQLアプリケーションをそのままDSQLに移行することは困難と思われます。
とはいえ、無料枠も充実しているため、様々な機能を試すことができます。