---
title: Amazon Aurora DSQLにSpring BootでアクセスするTips
summary: この記事では、Spring BootでAmazon Aurora DSQLを利用するための依存設定、DataSource構成、トークンローテーション、楽観的排他制御のリトライ方法を解説します。
tags: ["Spring Boot", "Aurora DSQL", "Java", "AWS", "PostgreSQL", "Spring Retry"]
categories: ["Programming", "Java", "org", "springframework", "boot"]
date: 2025-05-30T06:26:32Z
updated: 2025-05-30T10:27:25Z
---

[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
<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を使います。

```xml
  <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プロバイダー](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ユーザーを使用します。

![image](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1852/6200eda3-528b-4f38-9c48-c641ba7bcae3.png)

コンソールからpublic endpointを取得し、以下のように`application.properties`に設定します。

```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証明書が必要となります。<br>
> `org.postgresql.ssl.DefaultJavaSSLFactory`を使用すると、JavaのTrustStoreが使用されます。<br>
> `sslmode=require`であれば`sslfactory`は不要ですが、MitM攻撃のリスクが残るため、パブリックエンドポイントにアクセスする場合は`sslmode=verify-full`を使用することをお勧めします。


DSQLのトークンを取得してDataSourceに設定するため、以下のような`DataSourceConfig`を作成します。このクラスでは、DSQLのパスワードを定期的に更新するためのタスクのスケジューリングや、DSQLで楽観的排他制御エラーが発生した際に`OptimisticLockingFailureException`に変換するための`SQLExceptionTranslator`の登録も行っています。


```java
/*
 * 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`を使用して[実行時にパスワードを変更](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`に設定されるようになりました。<br>
> ただし、3.5.0時点では`JdbcTransactionManager`には自動設定されないため、`DsqlDataSourceConfig`クラス内で手動設定しています。<br>
> 今後、Pull Requestを提出してこの設定を自動化する予定です。<br>

### 楽観的排他制御エラーのリトライ

楽観的排他制御エラーが発生した場合は、アプリケーション側でリトライする必要があります。リトライ処理は[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 <<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に移行することは困難と思われます。

とはいえ、無料枠も充実しているため、様々な機能を試すことができます。
