Note
2025-09-25追記 本記事執筆後Aurora DSQL JDBC Connectorがリリースされているので、こちらを使ったほうがいいかもしれません
Amazon Aurora DSQLをSpring Bootから使用してみましたので、いくつかのTipsをまとめておきます。
サンプルアプリのコードはこちらです。
サンプルアプリの動かし方はREADMEを参照してください。
依存ライブラリの追加
DSQLにアクセスするには、PostgreSQL JDBCやSpring JDBCなどの依存ライブラリに加えて、AWS SDKが必要です。これは、DSQLのパスワード(トークン)をAWS SDKから動的に取得する必要があるためです。
AWS Credentialsの管理を簡素化するためにSpring Cloud AWSを使用します。Core機能のio.awspring.cloud:spring-cloud-aws-starterだけで十分です。DSQLのトークンを取得するために、software.amazon.awssdk:dsqlも追加します。
必須ではありませんが、筆者はaws sso loginを使ってCredentialsを取得しているため、SSO対応のためにsoftware.amazon.awssdk:ssoも追加しています。
<dependencies>
<dependency>
<groupId>io.awspring.cloud</groupId>
<artifactId>spring-cloud-aws-starter</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>sso</artifactId>
<exclusions>
<exclusion>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>dsql</artifactId>
<exclusions>
<exclusion>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
Spring Cloud AWSは次のBOMを使います。
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.awspring.cloud</groupId>
<artifactId>spring-cloud-aws-dependencies</artifactId>
<version>3.3.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
この設定により、aws CLI用のCredentialsを使用してDSQLのパスワードを動的に取得できます。
Note
開発環境ではなく、本番環境にデプロイする場合はその他のCredentialsプロバイダーを検討してください。
DataSourceの設定
DSQLはコンソールで作成済みとします。記事執筆時点では、Tokyo(ap-northeast-1)ではシングルリージョンしか選択できませんでした。
今回は、ローカル環境からpublic endpointにアクセスすることを想定しています。また、adminユーザーを使用します。

コンソールからpublic endpointを取得し、以下のようにapplication.propertiesに設定します。
spring.datasource.url=jdbc:postgresql://<public_endpoint>/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の登録も行っています。
/*
* Copyright (C) 2025 Toshiaki Maki <makingx@gmail.com>
*
* 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<String> 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<GenerateAuthTokenRequest.Builder> 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<String> 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<String> 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を使用して実行時にパスワードを変更することが可能です。また、HikariPoolMXBeanのsoftEvictConnectionsを使用することで、アイドル状態のコネクションを破棄し、アクティブなコネクションはプールに戻ったタイミングで破棄されます。
Note
Aurora DSQLのサンプルコードを見ても、トークンのローテーションについては言及されていませんでした。AWS Lambdaでの使用を想定しているためでしょうか?
デフォルトのSQLExceptionTranslatorを使用した場合、楽観的排他制御エラーが発生するとCannotAcquireLockExceptionがスローされます。この例外をそのままハンドリングすることも可能ですが、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を使用すると簡単に実装できます。
上記の設定により、楽観的排他制御エラーが発生した場合にOptimisticLockingFailureExceptionがスローされるようになります。OptimisticLockingFailureExceptionに対するリトライ設定は、@Retryableアノテーションを使用して行います。
@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コマンドを使用します。
# 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 <<EOF > 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に移行することは困難と思われます。
とはいえ、無料枠も充実しているため、様々な機能を試すことができます。