環境

  • Java 17
  • Spring Boot 2.5.6
  • Redis 6.2.5

関連記事

ローカルキャッシュとRedisキャッシュ

大きなRedisキャッシュの弊害

同時リクエスト数とアプリケーションサーバーのメモリ

ローカルキャッシュとRedisキャッシュで、RedisキャッシュからJavaがデータを取得した場合、取得するたびに別のデータとしてメモリが使用されるため、リクエスト数次第でアプリケーションサーバーのメモリに悪影響を与えることを書いた。

1キャッシュのデータ量がある程度の大きさまでであれば、よほどリクエスト数が多くないと問題にならないし、リクエスト数が多くてもメモリに問題が発生する前にCPU等の他の資源の問題が発生する可能性も高いので、通常はあまり気にしなくてもいい。

ただし、1キャッシュのデータ量が大きい場合は、リクエスト数が特段多くなくてもメモリ消費量が大きくなってしまう。

ネットワーク負荷

ローカルキャッシュとRedisキャッシュで、ローカルキャッシュはネットワーク通信が不要だから高速ということも書いた。

1キャッシュのデータ量がある程度の大きさまでであれば、ネットワークの問題はほとんどでない。しかし、1キャッシュがMB単位の大きさとなってくると、リクエストの度にアプリケーションサーバーとRedisサーバーの間でMB単位のデータが流れるのは、明らかに問題がある。

Redisキャッシュをリクエスト間で共有する

Redisキャッシュを利用していて先ほど記載した問題にぶつかった際に、Redisキャッシュの前段にローカルキャッシュを入れることで問題解決できる。しかし、Spring BootのComponentがSingletonでありインスタンス変数がリクエスト間で共有されることを利用すれば、Redisとcaffeineローカルキャッシュライブラリといった複数のキャッシュマネージャーを扱うよりも簡単に問題解決できる。

ユースケースと実装

ユースケース

今回の問題のユースケースを考える。

  1. バッチアプリケーションで1時間ごとに大量のデータを取得・加工して、Redisに上書き保存する。1MB ~ 10MB程度のデータ量になるとする。
  2. Webアプリケーションでリクエストが来たときに、Redisからデータを取得し、そのデータをリクエストしているユーザー情報と掛け合わせてフィルタリングし、レスポンスを作成する(レスポンスデータはフィルタリングのおかげで十分に小さい)。

このような仕組みのときに、2でRedisからデータを取得する際の通信量とメモリが問題になる。

通常の実装

通信量とメモリの問題を気にせずに実装してみる。

実装を簡略化するためにバッチとWebのソースを共用するが、同じjarを別々のサーバーで動かす。

また、さらに簡略化するために「大量のデータを取得・加工」や「リクエストしているユーザー情報と掛け合わせて」といった詳細な業務ロジックは省略する。

1のバッチ処理はcurl -XPOST localhost:8080/で実行される。2のWebリクエストはcurl -w'\n' localhost:8080/で実行される。

@EnableCaching
@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

    @RequiredArgsConstructor
    @RestController
    static class CacheController {

        private final CacheService cacheService;

        @PostMapping("/")
        void store() {
            cacheService.storeCache();
        }

        @GetMapping("/")
        List<Integer> retrieve() {
            // 取得したデータの再加工は省略し、そのままレスポンスする
            return cacheService.retrieveCache();
        }
    }

    @CacheConfig(cacheNames = "CacheService")
    @Service
    static class CacheService {

        @CachePut(key = "'store'")
        public List<Integer> storeCache() {
            // キャッシュに格納するデータを算出するロジックは省略
            return new ArrayList<>(List.of(1, 2, 3, 4, 5));
        }

        @Cacheable(key = "'store'", unless = "#result == null")
        public List<Integer> retrieveCache() {
            return null;
        }
    }
}

cacheService.retrieveCache()がリクエストごとにRedisからデータを取得してしまい、通信量とメモリが問題になる。

インスタンス変数をリクエスト間で共有する実装

curl -w'\n' localhost:8080/?sharedでアクセスするエンドポイントを追加する。

@GetMapping(value = "/", params = "shared")
List<Integer> retrieveShared() {
    return cacheService.retrieveShared();
}

前回Redisから取得した時刻を持つretrieved変数とRedisから取得したデータを持つmemo変数をCacheServiceのインスタンス変数として定義する。memoにまだデータがない初回アクセス時と前回Redisから取得してから一定時間が経った時に、Redisに再アクセスするようにする。同時にリクエストが来たときに何度もRedisにアクセスが行かないように念の為synchronizedで排他制御も入れる。

private long retrieved = Instant.now().getEpochSecond();
private List<Integer> memo;

public List<Integer> retrieveShared() {
    long now = Instant.now().getEpochSecond();
    if (requireRetrieve(now)) {
        synchronized (this) {
            if (requireRetrieve(now)) {
                memo = currentProxy().retrieveCache();
                retrieved = now;
            }
        }
    }
    return memo;
}

private boolean requireRetrieve(long now) {
    return memo == null || now - retrieved > 300;
}

private CacheService currentProxy() {
    return (CacheService) AopContext.currentProxy();
}

※AopContextを使うために別途以下を実施

  • @EnableAspectJAutoProxy(exposeProxy = true)をApplicationに付与
  • implementation 'org.springframework.boot:spring-boot-starter-aop'をbuild.gradleに追加

synchronized + 再度のチェックでRedisアクセスを1度に制限する

同時にリクエストが来たときに何度もRedisにアクセスが行かないように念の為synchronizedで排他制御も入れている。

synchronizedsynchronizedブロックの排他制御をして1スレッド(つまり今回でいうと1リクエスト)ずつしか実行できないようにしているだけなので、synchronizedの直後でもrequireRetrieveで再度Redisアクセスの必要性をチェックしないと、Redisに何度もアクセスが行くので注意。

IntelliJ IDEAのマルチスレッドプログラムのデバッグ方法を使いながら、バックグラウンド実行でcurlを二度実行すれば動作がわかりやすい。

https://pleiades.io/help/idea/detect-concurrency-issues.html

$ curl -w'\n' localhost:8080/?shared &
$ curl -w'\n' localhost:8080/?shared &

gradle, application.properties

application.properties

spring.redis.host=localhost
spring.redis.port=6379
spring.redis.password=null
spring.redis.database=0

gradle抜粋

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-cache'
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-aop'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}