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ユーザーを使用します。

image

コンソールから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を使用して実行時にパスワードを変更することが可能です。また、HikariPoolMXBeansoftEvictConnectionsを使用することで、アイドル状態のコネクションを破棄し、アクティブなコネクションはプールに戻ったタイミングで破棄されます。

Note

Aurora DSQLのサンプルコードを見ても、トークンのローテーションについては言及されていませんでした。AWS Lambdaでの使用を想定しているためでしょうか?

デフォルトのSQLExceptionTranslatorを使用した場合、楽観的排他制御エラーが発生するとCannotAcquireLockExceptionがスローされます。この例外をそのままハンドリングすることも可能ですが、CannotAcquireLockExceptionPessimisticLockingFailureExceptionを継承しており、悲観的排他制御エラー(SELECT FOR UPDATEなど)を想定した例外クラスです。そのため、より適切なOptimisticLockingFailureExceptionをスローするためにDSQL専用のSQLExceptionTranslatorを作成しました。

Note

Spring Boot 3.5では https://github.com/spring-projects/spring-boot/pull/43511 により、SQLExceptionTranslatorがBean登録されると自動的にJdbcTemplateHibernateJpaDialectに設定されるようになりました。

ただし、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に移行することは困難と思われます。

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

Found a mistake? Update the entry.
Share this article: