@shinyaz

JDBC Wrapper Valkey キャッシュ検証 — ElastiCache Serverless で初回タイムアウトの落とし穴

目次

はじめに

前回の記事では、ノードベースの ElastiCache for Valkey と100万行のデータを使い、Remote Query Cache Plugin がクエリレイテンシを最大300倍改善することを確認した。

本記事では、同じプラグインを ElastiCache for Valkey Serverless で検証した結果を共有する。結論から言うと、ElastiCache Serverless との組み合わせでは初回接続時に必ずタイムアウトが発生し、CacheMonitor が不安定な状態に陥るという問題を発見した。公式ドキュメントは Caching database query results および Remote Query Cache Plugin を参照。

検証環境

項目
リージョンap-northeast-1(東京)
DBAurora PostgreSQL Serverless v2(16.6、0.5-2 ACU)
キャッシュElastiCache for Valkey Serverless(Valkey 8.1)
実行環境EC2 t3.small(Amazon Linux 2023、同一 VPC 内)
JavaAmazon Corretto 21.0.10
AWS JDBC Wrapper3.3.0
PostgreSQL JDBC42.7.8
Valkey Glide2.3.0
Commons Pool2.12.0

前提条件:

  • AWS CLI セットアップ済み(rds:*elasticache:*ec2:* の操作権限)
  • Java 21 + Maven

結果だけ知りたい場合はまとめにスキップできる。

環境構築

インフラ構築手順(VPC / Aurora / ElastiCache / EC2)

VPC・サブネット・セキュリティグループ

Terminal
# 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 ap-northeast-1)
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 ap-northeast-1a --query 'Subnet.SubnetId' --output text --region ap-northeast-1)
SUBNET_C=$(aws ec2 create-subnet --vpc-id $VPC_ID --cidr-block 10.0.2.0/24 \
  --availability-zone ap-northeast-1c --query 'Subnet.SubnetId' --output text --region ap-northeast-1)
SUBNET_D=$(aws ec2 create-subnet --vpc-id $VPC_ID --cidr-block 10.0.3.0/24 \
  --availability-zone ap-northeast-1d --query 'Subnet.SubnetId' --output text --region ap-northeast-1)
 
# IGW(EC2 への SSH 用)
IGW_ID=$(aws ec2 create-internet-gateway --query 'InternetGateway.InternetGatewayId' \
  --output text --region ap-northeast-1)
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 ap-northeast-1)
aws ec2 create-route --route-table-id $RTB_ID --destination-cidr-block 0.0.0.0/0 \
  --gateway-id $IGW_ID --region ap-northeast-1
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 ap-northeast-1)
SG_AURORA=$(aws ec2 create-security-group --group-name jdbc-cache-test-aurora \
  --description "Aurora" --vpc-id $VPC_ID --query 'GroupId' --output text --region ap-northeast-1)
SG_CACHE=$(aws ec2 create-security-group --group-name jdbc-cache-test-cache \
  --description "ElastiCache" --vpc-id $VPC_ID --query 'GroupId' --output text --region ap-northeast-1)
 
MY_IP="$(curl -s https://checkip.amazonaws.com)/32"
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_EC2

Aurora PostgreSQL Serverless v2

Terminal
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 ap-northeast-1
 
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 ap-northeast-1
 
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 ap-northeast-1
 
aws rds wait db-instance-available --db-instance-identifier jdbc-cache-test-writer --region ap-northeast-1

ElastiCache for Valkey Serverless

Terminal
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 ap-northeast-1

EC2 インスタンス

Terminal
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 ap-northeast-1)
 
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 ap-northeast-1
 
# Java + Maven + psql インストール
ssh ec2-user@<public-ip> 'sudo dnf install -y java-21-amazon-corretto-devel maven postgresql16'

テストデータ

Terminal
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) VALUES
  ('MacBook Pro 14', 'laptop', 248000, 50),
  ('ThinkPad X1 Carbon', 'laptop', 198000, 30),
  ('Dell XPS 15', 'laptop', 178000, 25),
  ('iPhone 16 Pro', 'phone', 159800, 100),
  ('Galaxy S25 Ultra', 'phone', 189800, 80),
  ('Pixel 9 Pro', 'phone', 129800, 60),
  ('iPad Air', 'tablet', 98800, 70),
  ('Galaxy Tab S10', 'tablet', 118800, 40),
  ('AirPods Pro', 'audio', 39800, 200),
  ('Sony WH-1000XM5', 'audio', 44800, 150);
"

テストアプリケーション

Remote Query Cache Plugin の設定は最小限だ。通常の JDBC 接続との違いは3点のみ。

  1. 接続 URL を jdbc:aws-wrapper:postgresql:// に変更
  2. wrapperPluginsremoteQueryCache を指定
  3. cacheEndpointAddrRw に ElastiCache のエンドポイントを設定

ElastiCache Serverless は TLS が必須であり、cacheUseSSL のデフォルトが true なので明示的な設定は不要だ。1本目の記事のノードベース構成では cacheUseSSL=false を設定したが、Serverless ではこれを省略する。

Java(キャッシュ接続の核心部分)
Properties props = new Properties();
props.setProperty("user", "postgres");
props.setProperty("password", password);
props.setProperty("wrapperPlugins", "remoteQueryCache");
props.setProperty("cacheEndpointAddrRw", "my-cache.serverless.apne1.cache.amazonaws.com:6379");
 
Connection conn = DriverManager.getConnection(
    "jdbc:aws-wrapper:postgresql://my-aurora.cluster-xxx.ap-northeast-1.rds.amazonaws.com:5432/postgres",
    props);

キャッシュ対象のクエリには SQL コメントで TTL を指定する。

Java(クエリヒント)
ResultSet rs = stmt.executeQuery(
    "/* CACHE_PARAM(ttl=300s) */ SELECT * FROM products WHERE category = 'laptop' ORDER BY price DESC");

ヒントのないクエリはキャッシュされず、通常通り DB に直接実行される。

pom.xml(全体)
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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>cachetest</groupId>
    <artifactId>jdbc-cache-test</artifactId>
    <version>1.0</version>
    <properties>
        <maven.compiler.source>21</maven.compiler.source>
        <maven.compiler.target>21</maven.compiler.target>
    </properties>
    <dependencies>
        <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>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-simple</artifactId>
            <version>2.0.16</version>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>3.4.2</version>
                <configuration>
                    <archive><manifest>
                        <mainClass>cachetest.QueryCacheTest</mainClass>
                    </manifest></archive>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-dependency-plugin</artifactId>
                <version>3.8.1</version>
                <executions>
                    <execution>
                        <id>copy-deps</id><phase>package</phase>
                        <goals><goal>copy-dependencies</goal></goals>
                        <configuration>
                            <outputDirectory>${project.build.directory}/lib</outputDirectory>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>
QueryCacheTest.java(テストアプリ全体)
QueryCacheTest.java
package cachetest;
 
import java.sql.*;
import java.time.Duration;
import java.time.Instant;
import java.util.Properties;
 
public class QueryCacheTest {
    public static void main(String[] args) throws Exception {
        String dbEndpoint = args[0], dbPassword = args[1], cacheEndpoint = args[2];
 
        Properties props = new Properties();
        props.setProperty("user", "postgres");
        props.setProperty("password", dbPassword);
        props.setProperty("wrapperPlugins", "remoteQueryCache");
        props.setProperty("cacheEndpointAddrRw", cacheEndpoint + ":6379");
        // Serverless は TLS 必須のため cacheUseSSL はデフォルト(true)のまま
 
        String url = "jdbc:aws-wrapper:postgresql://" + dbEndpoint + ":5432/postgres";
        String query = "/* CACHE_PARAM(ttl=60s) */ SELECT * FROM products "
            + "WHERE category = 'laptop' ORDER BY price DESC";
 
        System.out.println("=== Cache Behavior Test (ElastiCache Serverless) ===");
        try (Connection conn = DriverManager.getConnection(url, props)) {
            for (int i = 1; i <= 8; i++) {
                Instant start = Instant.now();
                try (Statement s = conn.createStatement();
                     ResultSet r = s.executeQuery(query)) {
                    int count = 0;
                    while (r.next()) count++;
                    long ms = Duration.between(start, Instant.now()).toMillis();
                    System.out.printf("  Query %d: %d rows, %d ms%n", i, count, ms);
                }
                if (i <= 2) Thread.sleep(1000);
            }
        }
    }
}
ビルドと実行
Terminal
# ディレクトリ構造を作成
mkdir -p jdbc-cache-test/src/main/java/cachetest
# pom.xml を jdbc-cache-test/ に、QueryCacheTest.java を jdbc-cache-test/src/main/java/cachetest/ に配置
 
export JAVA_HOME=/usr/lib/jvm/java-21-amazon-corretto
cd jdbc-cache-test && mvn package -q
 
CP="target/jdbc-cache-test-1.0.jar"
for jar in target/lib/*.jar; do CP="$CP:$jar"; done
 
java -cp "$CP" cachetest.QueryCacheTest \
  "<aurora-endpoint>" "<password>" "<cache-serverless-endpoint>"

検証1: 初回接続とキャッシュの挙動(10行テーブル)

同一コネクションで同じクエリを8回実行し、キャッシュの動作を確認した。

Output
Query 1: 3 rows, 10568 ms
Query 2: 3 rows, 7 ms
Query 3: 3 rows, 7 ms
Query 4: 3 rows, 19 ms
Query 5: 3 rows, 100 ms
Query 6: 3 rows, 216 ms
Query 7: 3 rows, 110 ms
Query 8: 3 rows, 90 ms

Query 1 が約10.5秒かかっている。ログを確認すると、初回のキャッシュ読み取りで TimeoutException: Request timed out が発生し、CacheMonitor が HEALTHY→SUSPECT に遷移していた。

Output(ログ)
[HEALTHY→SUSPECT] jdbc-cache-test-xxx.serverless.apne1.cache.amazonaws.com:6379
  READ failed: CONNECTION - TimeoutException: Request timed out

Query 1 はキャッシュミス(タイムアウト)として DB から結果を取得し、バックグラウンドでキャッシュに書き込んでいる。Query 2-3 が 7ms と非常に速いのは、CacheMonitor が SUSPECT 状態のためキャッシュをバイパスし、DB に直接クエリしているためだ。その後 CacheMonitor のヘルスチェック(数秒間隔で実行)が成功して HEALTHY に回復し、Query 5 以降の 90-216ms がキャッシュヒット時の実際のレイテンシとなっている。

さらに、連続実行すると CacheMonitor のヘルスチェックで Timeout waiting for idle object, borrowMaxWaitDuration=PT0.1S エラーが多発した。

Output(ログ)
SEVERE: Non-recoverable error (DATA) for jdbc-cache-test-xxx:6379:
  Timeout waiting for idle object, borrowMaxWaitDuration=PT0.1S

これは CacheMonitor 内部の接続プールの borrow タイムアウトが 100ms に固定されており、ElastiCache Serverless の TLS ハンドシェイク時間がこれを超えるために発生している。ヘルスチェックは常に失敗するが、データパス(実際のキャッシュ読み書き)は別の接続プールを使うため、キャッシュ自体は動作する。

検証2: 重いクエリでも同じ問題が発生するか(100万行テーブル)

1本目の記事と同じ100万行のデータと集計クエリで、Serverless でも初回タイムアウトが発生するか、安定後のキャッシュ効果はどうかを確認した。

テストデータは100万行に差し替えた(手順は1本目の記事の環境構築を参照)。

検証2の実行手順

テストデータを100万行に差し替える。

Terminal
PGPASSWORD='<password>' psql -h <aurora-endpoint> -U postgres -d postgres -c "
TRUNCATE products;
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;
"

1本目の記事QueryCacheTest.java を Serverless 用に修正して実行する。変更点は cacheUseSSL の設定を削除するだけだ(Serverless は TLS 必須、デフォルト true)。

Output
[Category stats]
  No Cache: avg=410.3, median=401.0, min=322, max=500
  Cached:   avg=36.9, median=2.5, min=1, max=344
 
[Price tier]
  No Cache: avg=421.4, median=404.0, min=333, max=513
  Cached:   avg=1.4, median=1.0, min=1, max=3
 
[Window function]
  No Cache: avg=847.6, median=753.5, min=643, max=1400
  Cached:   avg=1.1, median=1.0, min=1, max=2

初回タイムアウトと CacheMonitor の SUSPECT 遷移は重いクエリでも変わらず発生した。Category stats の Cached max=344ms は、CacheMonitor が SUSPECT 状態の間にキャッシュがバイパスされ DB にフォールバックした結果だ。

一方、CacheMonitor が HEALTHY に回復した後のキャッシュヒットは 1-2ms で、ノードベースと同等だった。10行テーブルでの検証1では 90-216ms だったキャッシュヒットが、100万行の集計クエリ(結果は数行)では大幅に速い。これは結果セットのサイズが小さいため、TLS のオーバーヘッドが相対的に小さくなるためだ。

初回タイムアウトは Serverless 固有の問題だが、安定後は重いクエリであれば十分にキャッシュの効果が得られる。

まとめ

  • 初回接続で必ずタイムアウトが発生する — ElastiCache Serverless の TLS ハンドシェイクが、プラグイン内部の接続タイムアウト(デフォルト2秒)を超える。cacheConnectionTimeoutMs を 10秒や30秒に増やしても、Valkey Glide クライアント側のタイムアウトで失敗する。初回クエリは常にキャッシュミスとして DB にフォールバックするため、アプリケーションの起動直後に余分なレイテンシが発生する
  • CacheMonitor のヘルスチェックが常に失敗する — 内部の接続プール borrow タイムアウトが 100ms に固定されており、ElastiCache Serverless の TLS 接続確立時間がこれを超える。ヘルスチェックは常に Non-recoverable error (DATA) を報告するが、データパスのキャッシュ読み書きは別経路で動作するため、キャッシュ自体は機能する。ただし、ヘルスチェック失敗によりキャッシュがバイパスされるタイミングがあり、動作が不安定になる
  • Serverless ではキャッシュヒット時のレイテンシが結果セットのサイズに左右される — ノードベースでは結果セットのサイズに関わらず 1-4ms で安定していたが、Serverless では10行テーブルへの単純クエリで 90-216ms、100万行テーブルへの集計クエリ(結果は数行)で 1-2ms と大きく異なった。TLS のオーバーヘッドが結果セットのシリアライズ/デシリアライズに対して相対的に大きい場合にレイテンシが増加する。重いクエリであれば Serverless でもキャッシュの効果は十分に得られる
  • フェイルセーフは正しく動作する — キャッシュ障害時は自動的に DB にフォールバックし、アプリケーションは正常に動作し続ける。failWhenCacheDownfalse(デフォルト)にしておけば、キャッシュの問題がアプリケーション障害に波及しない

ElastiCache Serverless との組み合わせでは、初回接続のタイムアウトと CacheMonitor の不安定さが課題だ。1本目の記事で検証した通り、ノードベースの ElastiCache クラスター(TLS なし)ではこれらの問題は発生せず、初回から正常に動作する。本番導入を検討する場合は、実際のクエリパターンとレイテンシ要件で事前検証することを強く推奨する。

クリーンアップ

リソース削除コマンド
Terminal
# 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
 
# ElastiCache
aws elasticache delete-serverless-cache --serverless-cache-name jdbc-cache-test --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 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

共有する

田原 慎也

田原 慎也

ソリューションアーキテクト @ AWS

AWS ソリューションアーキテクトとして金融業界のお客様を中心に技術支援をしており、クラウドアーキテクチャや AI/ML に関する学びをこのサイトで発信しています。このサイトの内容は個人の見解であり、所属企業の公式な意見や見解を代表するものではありません。

関連記事