JDBC Wrapper Valkey キャッシュ検証 — Spring Boot + HikariCP で導入する
目次
はじめに
シリーズ第1回〜第4回では、素の JDBC コードで Remote Query Cache Plugin の動作を検証してきた。しかし、実際の Java アプリケーションの多くは Spring Boot + HikariCP を使っている。本記事では、Spring Boot アプリケーションに Remote Query Cache Plugin を統合する方法を示す。
公式ドキュメントの Remote Query Cache Plugin には Spring JDBC(DriverManagerDataSource)と Hibernate の例があるが、Spring Boot + HikariCP + application.yml での設定例はない。本記事ではこのギャップを埋める。
既存の Spring Boot アプリへの変更は最小限だ。
pom.xmlに依存関係を3つ追加application.ymlにdata-source-propertiesを2行追加- Serverless 利用時は
ApplicationRunnerでウォームアップを1ファイル追加 - キャッシュしたいクエリに
/* CACHE_PARAM(ttl=Xs) */ヒントを追加
検証環境
| 項目 | 値 |
|---|---|
| リージョン | ap-northeast-1(東京) |
| DB | Aurora PostgreSQL Serverless v2(16.6、0.5-2 ACU) |
| キャッシュ① | ElastiCache for Valkey ノードベース(cache.t3.micro、TLS 有効) |
| キャッシュ② | ElastiCache for Valkey Serverless(Valkey 8) |
| 実行環境 | EC2 t3.small(Amazon Linux 2023、同一 VPC 内) |
| Java | Amazon Corretto 21 |
| Spring Boot | 3.4.4 |
| AWS JDBC Wrapper | 3.3.0 |
| テストデータ | products テーブル 100万行 |
前提条件:
- AWS CLI セットアップ済み(
rds:*、elasticache:*、ec2:*の操作権限) - Java 21 + Maven
インフラ構築手順(VPC / Aurora / ElastiCache × 2 / EC2)
VPC・サブネット・セキュリティグループ
export AWS_REGION=ap-northeast-1
MY_IP="$(curl -s https://checkip.amazonaws.com)/32"
# VPC
VPC_ID=$(aws ec2 create-vpc --cidr-block 10.0.0.0/16 \
--tag-specifications 'ResourceType=vpc,Tags=[{Key=Name,Value=jdbc-cache-test}]' \
--query 'Vpc.VpcId' --output text --region $AWS_REGION)
aws ec2 modify-vpc-attribute --enable-dns-hostnames '{"Value":true}' --vpc-id $VPC_ID
# サブネット(3 AZ)
SUBNET_A=$(aws ec2 create-subnet --vpc-id $VPC_ID --cidr-block 10.0.1.0/24 \
--availability-zone ${AWS_REGION}a --query 'Subnet.SubnetId' --output text --region $AWS_REGION)
SUBNET_C=$(aws ec2 create-subnet --vpc-id $VPC_ID --cidr-block 10.0.2.0/24 \
--availability-zone ${AWS_REGION}c --query 'Subnet.SubnetId' --output text --region $AWS_REGION)
SUBNET_D=$(aws ec2 create-subnet --vpc-id $VPC_ID --cidr-block 10.0.3.0/24 \
--availability-zone ${AWS_REGION}d --query 'Subnet.SubnetId' --output text --region $AWS_REGION)
# IGW(EC2 への SSH 用)
IGW_ID=$(aws ec2 create-internet-gateway --query 'InternetGateway.InternetGatewayId' \
--output text --region $AWS_REGION)
aws ec2 attach-internet-gateway --internet-gateway-id $IGW_ID --vpc-id $VPC_ID
RTB_ID=$(aws ec2 describe-route-tables --filters "Name=vpc-id,Values=$VPC_ID" \
--query 'RouteTables[0].RouteTableId' --output text --region $AWS_REGION)
aws ec2 create-route --route-table-id $RTB_ID --destination-cidr-block 0.0.0.0/0 --gateway-id $IGW_ID
aws ec2 modify-subnet-attribute --subnet-id $SUBNET_A --map-public-ip-on-launch
# セキュリティグループ
SG_EC2=$(aws ec2 create-security-group --group-name jdbc-cache-test-ec2 \
--description "EC2" --vpc-id $VPC_ID --query 'GroupId' --output text --region $AWS_REGION)
SG_AURORA=$(aws ec2 create-security-group --group-name jdbc-cache-test-aurora \
--description "Aurora" --vpc-id $VPC_ID --query 'GroupId' --output text --region $AWS_REGION)
SG_CACHE=$(aws ec2 create-security-group --group-name jdbc-cache-test-cache \
--description "ElastiCache" --vpc-id $VPC_ID --query 'GroupId' --output text --region $AWS_REGION)
aws ec2 authorize-security-group-ingress --group-id $SG_EC2 --protocol tcp --port 22 --cidr $MY_IP
aws ec2 authorize-security-group-ingress --group-id $SG_AURORA --protocol tcp --port 5432 --source-group $SG_EC2
aws ec2 authorize-security-group-ingress --group-id $SG_CACHE --protocol tcp --port 6379 --source-group $SG_EC2Aurora PostgreSQL Serverless v2
aws rds create-db-subnet-group --db-subnet-group-name jdbc-cache-test \
--db-subnet-group-description "JDBC cache test" \
--subnet-ids "$SUBNET_A" "$SUBNET_C" "$SUBNET_D" --region $AWS_REGION
aws rds create-db-cluster --db-cluster-identifier jdbc-cache-test \
--engine aurora-postgresql --engine-version 16.6 \
--master-username postgres --master-user-password '<password>' \
--db-subnet-group-name jdbc-cache-test \
--vpc-security-group-ids $SG_AURORA \
--serverless-v2-scaling-configuration MinCapacity=0.5,MaxCapacity=2 \
--storage-encrypted --no-deletion-protection --region $AWS_REGION
aws rds create-db-instance --db-instance-identifier jdbc-cache-test-writer \
--db-cluster-identifier jdbc-cache-test \
--db-instance-class db.serverless --engine aurora-postgresql --region $AWS_REGION
aws rds wait db-instance-available --db-instance-identifier jdbc-cache-test-writer --region $AWS_REGIONElastiCache for Valkey ノードベース(TLS 有効)
aws elasticache create-cache-subnet-group \
--cache-subnet-group-name jdbc-cache-test \
--cache-subnet-group-description "JDBC cache test" \
--subnet-ids "$SUBNET_A" "$SUBNET_C" "$SUBNET_D" --region $AWS_REGION
aws elasticache create-replication-group \
--replication-group-id jdbc-cache-test-tls \
--replication-group-description "JDBC cache test node-based TLS" \
--engine valkey \
--cache-node-type cache.t3.micro \
--num-cache-clusters 1 \
--cache-subnet-group-name jdbc-cache-test \
--security-group-ids $SG_CACHE \
--transit-encryption-enabled \
--region $AWS_REGION
aws elasticache wait replication-group-available \
--replication-group-id jdbc-cache-test-tls --region $AWS_REGIONElastiCache for Valkey Serverless
aws elasticache create-serverless-cache \
--serverless-cache-name jdbc-cache-test \
--engine valkey \
--subnet-ids "$SUBNET_A" "$SUBNET_C" "$SUBNET_D" \
--security-group-ids $SG_CACHE --region $AWS_REGIONEC2 インスタンス
AMI_ID=$(aws ec2 describe-images --owners amazon \
--filters "Name=name,Values=al2023-ami-2023.*-x86_64" "Name=state,Values=available" \
--query 'sort_by(Images, &CreationDate)[-1].ImageId' --output text --region $AWS_REGION)
aws ec2 create-key-pair --key-name jdbc-cache-test --key-type ed25519 \
--query 'KeyMaterial' --output text > jdbc-cache-test.pem
chmod 600 jdbc-cache-test.pem
aws ec2 run-instances --image-id $AMI_ID --instance-type t3.small \
--key-name jdbc-cache-test --security-group-ids $SG_EC2 \
--subnet-id $SUBNET_A \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=jdbc-cache-test}]' \
--region $AWS_REGION
ssh ec2-user@<public-ip> 'sudo dnf install -y java-21-amazon-corretto-devel maven postgresql16'テストデータ(100万行)
PGPASSWORD='<password>' psql -h <aurora-endpoint> -U postgres -d postgres -c "
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
category VARCHAR(50) NOT NULL,
price NUMERIC(10,2) NOT NULL,
stock INT NOT NULL DEFAULT 0
);
INSERT INTO products (name, category, price, stock)
SELECT
'Product-' || i,
(ARRAY['laptop','phone','tablet','audio','camera','monitor','keyboard','mouse'])[1 + (i % 8)],
(random() * 500000 + 1000)::numeric(10,2),
(random() * 1000)::int
FROM generate_series(1, 1000000) AS i;
ANALYZE products;
"Spring Boot アプリケーション
application.yml
核心は spring.datasource.hikari.data-source-properties だ。ここに JDBC Wrapper のプロパティを設定すると、HikariCP が接続作成時にこれらのプロパティを JDBC ドライバに渡す。
spring:
datasource:
url: jdbc:aws-wrapper:postgresql://<aurora-endpoint>:5432/postgres
username: postgres
password: <password>
driver-class-name: software.amazon.jdbc.Driver
hikari:
data-source-properties:
wrapperPlugins: remoteQueryCache
cacheEndpointAddrRw: <cache-endpoint>:6379ポイントは3つ。
urlのスキームをjdbc:aws-wrapper:postgresql://にするdriver-class-nameをsoftware.amazon.jdbc.Driverにするdata-source-propertiesにwrapperPluginsとcacheEndpointAddrRwを追加する
ノードベース TLS 有効の場合、cacheUseSSL はデフォルト true なので明示不要。ノードベース TLS なしの場合は cacheUseSSL: "false" を追加する。Serverless の場合はエンドポイントを差し替えるだけで、他の設定は同じだ。
コントローラ
クエリに /* CACHE_PARAM(ttl=Xs) */ ヒントを付けるだけでキャッシュ対象になる。ヒントのないクエリは通常通り DB に直接実行される。
@GetMapping("/stats")
public List<Map<String, Object>> categoryStats() {
return jdbc.queryForList(
"/* CACHE_PARAM(ttl=300s) */ "
+ "SELECT category, COUNT(*) as cnt, "
+ "AVG(price)::numeric(10,2) as avg_price "
+ "FROM products GROUP BY category ORDER BY cnt DESC");
}ApplicationRunner によるウォームアップ
第3回記事で確認した通り、Serverless では初回接続でタイムアウトが発生する。Spring Boot では ApplicationRunner を使い、アプリ起動時にウォームアップを自動実行する。
@Component
public class CacheWarmupRunner implements ApplicationRunner {
private final DataSource dataSource;
public CacheWarmupRunner(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public void run(ApplicationArguments args) throws Exception {
log.info("[Warmup] Sending dummy query...");
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(
"/* CACHE_PARAM(ttl=1s) */ SELECT 1")) {
rs.next();
}
log.info("[Warmup] Waiting 5 seconds for CacheMonitor recovery...");
Thread.sleep(5000);
log.info("[Warmup] Done. Cache is ready.");
}
}ノードベースでは初回タイムアウトが発生しないため、ウォームアップは必須ではない。ただし、後述の検証で示す通り、ノードベースでもウォームアップにより初回リクエストのレイテンシが改善する。
pom.xml(全体)
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.4</version>
</parent>
<groupId>cachetest</groupId>
<artifactId>spring-cache-test</artifactId>
<version>1.0</version>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.jdbc</groupId>
<artifactId>aws-advanced-jdbc-wrapper</artifactId>
<version>3.3.0</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.8</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
<version>2.12.0</version>
</dependency>
<dependency>
<groupId>io.valkey</groupId>
<artifactId>valkey-glide</artifactId>
<version>2.3.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>既存の Spring Boot プロジェクトに追加が必要な依存は aws-advanced-jdbc-wrapper、commons-pool2、valkey-glide の3つ。postgresql は既に含まれているケースが多い。
ソースコード全体(Application.java / ProductController.java / CacheWarmupRunner.java)
package cachetest;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}package cachetest;
import java.util.List;
import java.util.Map;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ProductController {
private final JdbcTemplate jdbc;
public ProductController(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@GetMapping("/stats")
public List<Map<String, Object>> categoryStats() {
return jdbc.queryForList(
"/* CACHE_PARAM(ttl=300s) */ "
+ "SELECT category, COUNT(*) as cnt, "
+ "AVG(price)::numeric(10,2) as avg_price "
+ "FROM products GROUP BY category "
+ "ORDER BY cnt DESC");
}
@GetMapping("/stats/nocache")
public List<Map<String, Object>> categoryStatsNoCache() {
return jdbc.queryForList(
"SELECT category, COUNT(*) as cnt, "
+ "AVG(price)::numeric(10,2) as avg_price "
+ "FROM products GROUP BY category "
+ "ORDER BY cnt DESC");
}
}package cachetest;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.Statement;
import javax.sql.DataSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
@Component
public class CacheWarmupRunner implements ApplicationRunner {
private static final Logger log =
LoggerFactory.getLogger(CacheWarmupRunner.class);
private final DataSource dataSource;
public CacheWarmupRunner(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public void run(ApplicationArguments args) throws Exception {
log.info("[Warmup] Sending dummy query to "
+ "initialize cache connection...");
long start = System.currentTimeMillis();
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(
"/* CACHE_PARAM(ttl=1s) */ SELECT 1")) {
rs.next();
}
log.info("[Warmup] Dummy query completed in {} ms",
System.currentTimeMillis() - start);
log.info("[Warmup] Waiting 5 seconds for "
+ "CacheMonitor recovery...");
Thread.sleep(5000);
log.info("[Warmup] Done. Cache is ready.");
}
}ビルドと実行
export JAVA_HOME=/usr/lib/jvm/java-21-amazon-corretto
cd spring-cache-test && mvn package -q -DskipTests
java -jar target/spring-cache-test-1.0.jar起動後、別ターミナルから curl http://localhost:8080/stats でアクセスする。
検証1: ノードベース + TLS
まずノードベース(TLS 有効)で動作を確認する。
ウォームアップなし
CacheWarmupRunner を無効化(@Component をコメントアウト)して起動し、5回リクエストを送った。
=== Node-based TLS, no warmup ===
Request 1: 2443 ms
Request 2: 25 ms
Request 3: 21 ms
Request 4: 20 ms
Request 5: 19 msRequest 1 が 2443ms かかっている。第3回記事の素の JDBC テストではノードベース+TLS で初回タイムアウトは発生しなかった(910ms)が、Spring Boot 経由だと HikariCP のコネクションプール初期化 + TLS ハンドシェイク + キャッシュプラグイン初期化のオーバーヘッドが加わり、初回リクエストが遅くなる。
ウォームアップあり
CacheWarmupRunner を有効化して起動。起動ログにはダミークエリが 1907ms で完了し、TimeoutException は発生していない。
[Warmup] Dummy query completed in 1907 ms
[Warmup] Waiting 5 seconds for CacheMonitor recovery...
[Warmup] Done. Cache is ready.=== Node-based TLS, with warmup ===
Request 1: 264 ms
Request 2: 21 ms
Request 3: 85 ms
Request 4: 40 ms
Request 5: 22 msウォームアップにより Request 1 が 2443ms → 264ms に改善。 ウォームアップのダミークエリが HikariCP のコネクションプール初期化とキャッシュプラグインの初期化を済ませるため、最初のユーザーリクエストではキャッシュミス → DB アクセス → キャッシュ書き込みだけで済む。
Request 2 以降は 21-85ms で安定。素の JDBC テスト(2-8ms)より高いのは、HTTP 処理、HikariCP のコネクション borrow、JSON シリアライズのオーバーヘッドが加わるためだ。
検証2: Serverless
application.yml の cacheEndpointAddrRw を Serverless エンドポイントに差し替えて同じテストを実行した。
ウォームアップなし
=== Serverless, no warmup ===
Request 1: 4461 ms
Request 2: 25 ms
Request 3: 20 ms
Request 4: 20 ms
Request 5: 18 msRequest 1 が 4461ms。Serverless 固有の初回タイムアウト(TimeoutException)が発生し、DB フォールバック後にキャッシュに書き込まれる。ノードベースの 2443ms と比べて約2秒遅い。
ウォームアップあり
起動ログでは TimeoutException が発生するが、ウォームアップが吸収している。
[Warmup] Dummy query completed in 4007 ms
[Warmup] Waiting 5 seconds for CacheMonitor recovery...
[Warmup] Done. Cache is ready.=== Serverless, with warmup ===
Request 1: 698 ms
Request 2: 23 ms
Request 3: 19 ms
Request 4: 19 ms
Request 5: 52 msウォームアップにより Request 1 が 4461ms → 698ms に改善。 Request 2 以降は 19-52ms で安定。
安定性(20リクエスト)
ウォームアップあり・Serverless で20回連続リクエストを送った結果。
=== Serverless, with warmup (20 requests) ===
Request 1: 20 ms Request 11: 17 ms
Request 2: 19 ms Request 12: 52 ms
Request 3: 18 ms Request 13: 43 ms
Request 4: 18 ms Request 14: 18 ms
Request 5: 17 ms Request 15: 17 ms
Request 6: 20 ms Request 16: 16 ms
Request 7: 19 ms Request 17: 16 ms
Request 8: 18 ms Request 18: 16 ms
Request 9: 17 ms Request 19: 16 ms
Request 10: 16 ms Request 20: 16 ms20回中18回が 16-20ms、2回が 43-52ms。第4回記事で確認した CacheMonitor のヘルスチェック影響による散発的なスパイクと同じパターンだ。
構成別比較
| 構成 | ウォームアップ | Request 1 | Request 2-5 | 備考 |
|---|---|---|---|---|
| ノードベース+TLS | なし | 2443ms | 19-25ms | HikariCP 初期化のオーバーヘッド |
| ノードベース+TLS | あり | 264ms | 21-85ms | ウォームアップで初期化を事前完了 |
| Serverless | なし | 4461ms | 18-25ms | 初回タイムアウト + HikariCP 初期化 |
| Serverless | あり | 698ms | 19-52ms | ウォームアップで回避 |
| キャッシュなし | — | 248ms | 196-248ms | 毎回 DB アクセス |
どちらの構成でもウォームアップの効果は大きい。 特に Serverless では 4461ms → 698ms と劇的に改善する。ノードベースでも 2443ms → 264ms と効果がある。Spring Boot + HikariCP 環境では、構成に関わらず ApplicationRunner によるウォームアップを推奨する。
まとめ
- application.yml の設定だけで動く —
driver-class-nameをsoftware.amazon.jdbc.Driverに変更し、data-source-propertiesにwrapperPluginsとcacheEndpointAddrRwを追加するだけ。既存のクエリロジックは変更不要 - ウォームアップは ApplicationRunner で実装 —
DataSourceから接続を取得してダミークエリを発行し、5秒待機する。Serverless では必須、ノードベースでも推奨 - 既存アプリへの変更は最小限 — pom.xml に依存3つ追加、application.yml に2行追加、ApplicationRunner 1ファイル追加、キャッシュしたいクエリにヒント追加。アプリケーションコードの構造変更は不要
本記事では Spring JDBC(JdbcTemplate)での統合を示したが、Spring Data JPA(Hibernate)でも同じ application.yml 設定で動作する。JPA の場合はクエリヒントの指定方法が異なり、@QueryHint アノテーションや Query.setHint() を使う。詳細は公式ドキュメントの Hibernate セクションを参照。
クリーンアップ
リソース削除コマンド
# ElastiCache ノードベース TLS
aws elasticache delete-replication-group \
--replication-group-id jdbc-cache-test-tls \
--no-final-snapshot-identifier \
--region ap-northeast-1
# ElastiCache Serverless
aws elasticache delete-serverless-cache \
--serverless-cache-name jdbc-cache-test \
--region ap-northeast-1
# Aurora
aws rds delete-db-instance --db-instance-identifier jdbc-cache-test-writer \
--skip-final-snapshot --region ap-northeast-1
aws rds wait db-instance-deleted \
--db-instance-identifier jdbc-cache-test-writer --region ap-northeast-1
aws rds delete-db-cluster --db-cluster-identifier jdbc-cache-test \
--skip-final-snapshot --region ap-northeast-1
# EC2
aws ec2 terminate-instances --instance-ids <instance-id> --region ap-northeast-1
# 削除完了を待ってからネットワークリソースを削除
aws ec2 delete-key-pair --key-name jdbc-cache-test --region ap-northeast-1
aws ec2 delete-security-group --group-id <sg-aurora> --region ap-northeast-1
aws ec2 delete-security-group --group-id <sg-cache> --region ap-northeast-1
aws ec2 delete-security-group --group-id <sg-ec2> --region ap-northeast-1
aws elasticache delete-cache-subnet-group \
--cache-subnet-group-name jdbc-cache-test --region ap-northeast-1
aws rds delete-db-subnet-group \
--db-subnet-group-name jdbc-cache-test --region ap-northeast-1
aws ec2 detach-internet-gateway \
--internet-gateway-id <igw-id> --vpc-id <vpc-id> --region ap-northeast-1
aws ec2 delete-internet-gateway \
--internet-gateway-id <igw-id> --region ap-northeast-1
aws ec2 delete-subnet --subnet-id <subnet-a> --region ap-northeast-1
aws ec2 delete-subnet --subnet-id <subnet-c> --region ap-northeast-1
aws ec2 delete-subnet --subnet-id <subnet-d> --region ap-northeast-1
aws ec2 delete-vpc --vpc-id <vpc-id> --region ap-northeast-1