アプリの挙動が変わった後もキャッシュを使い続けると起こる問題

デシリアライズに失敗する or 追加項目がnullになる

メソッド修正を行い戻り値の型が変わったりしても、キャッシュのキーに変更がなければ以前のデータを返してしまう。

例えば以下のクラスがあり、これをキャッシュしているとする。

public record Some(String x) implements Serializable {}

改修に伴い、クラスが以下に変わったとする。

public record Some(String x, String y) implements Serializable {}

アプリをリリースしてもキャッシュが残っている間は、キャッシュからSomeにデシリアライズできなかったり、デシリアライズできてもynullになってしまう。

シリアライズの方法

クラス構造が大幅に変わるわけではなく、String yが追加されるだけ(あるいはその逆)の些細な改修であっても、デシリアライズできる/できないはシリアライズの方法や設定次第。classをJavaオブジェクトのままシリアライズするのか、recordをJavaオブジェクトのままシリアライズするのか、JSONとしてシリアライズするのかなど。

もう少し具体的には以下のようになる。

  • classをJavaオブジェクトとしてシリアライズするなら、 serialVersionUID が変わればデシリアライズできなくなる
  • recordをJavaオブジェクトとしてシリアライズするなら、serialVersionUID が意味を持たないため、 serialVersionUID に関わらずデシリアライズできるし、 serialVersionUID を定義することも通常ない
  • JSONとしてシリアライズするなら、 ObjectMapper の設定に影響され、余分なJSON keyや足りないJSON keyをどのように扱うか次第でデシリアライズ可否が変わる

以降ではserialVersionUIDObjectMapperではなく、キャッシュのキーに対する工夫でキャッシュを無効にする方法を考える。この方法はどのようなシリアライズ方法であっても対応可能であることに加え、仕組みが明瞭である点にメリットがある。

キャッシュのキーにバージョン等を入れて、キャッシュを無効にする

対策としてキャッシュのキーに原則バージョン等を入れるようにすることで、アプリ改修後は常にキャッシュが存在しない状況を作る。

バージョンの代わりに最も簡単な実装としてアプリの起動時刻を設定するサンプルコードを記載する。

Application

ポイントはキャッシュを有効にするために@EnableCachingを設定している点。

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;

@EnableCaching
@SpringBootApplication
public class Application {

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

CacheConfig

5分をTTLとするRedisキャッシュの設定をしている。特筆すべき点はなし。

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;

import java.time.Duration;

@Configuration
public class CacheConfig {

    public static final String CACHE_5_MIN = "5min";

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        return RedisCacheManager.builder(connectionFactory)
                .withCacheConfiguration(CACHE_5_MIN,
                                        RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofMinutes(5)))
                .build();
    }
}

Version

versionの実装としてアプリケーションの起動時刻をString型で返却している。Gitのハッシュ値を返すようにCI中に文字列変換してからビルドするなど、他の方法も考えられる。

@Cacheableで使用しやすいように@Componentをつける。

import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.time.ZoneOffset;

@Component
public class Version {

    long startedTime = LocalDateTime.now().toEpochSecond(ZoneOffset.ofHours(9));

    public String version() {
        return Long.toString(startedTime);
    }
}

Controller

シンプルなRestController。

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class Controller {

    private final ServiceA serviceA;

    public Controller(ServiceA serviceA) {
        this.serviceA = serviceA;
    }

    @GetMapping("/")
    String test() {
        return serviceA.test1("1");
    }

}

Service

Serviceのメソッドにキャッシュを入れている。

import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

import static com.example.sample.CacheConfig.CACHE_5_MIN;

@Service
public class ServiceA {

    @Cacheable(cacheNames = CACHE_5_MIN,
            key = "T(String).format('ServiceA::test::%s::%s', @version.version(), #param1)")
    public String test1(String param1) {
        System.out.println("method executed. param1=" + param1);
        return param1;
    }
}

@Cacheableで使用しやすいように@Componentをつける」と先ほど記載したが、@CacheablekeyでSpringのコンポーネントを使用するには@version.version()のように@をつければよい。

キャッシュキーとしてよく用いられるように、以下を文字列連結している。

  • クラス名
  • メソッド名
  • 引数

これらにバージョンを加えることで、引数が変わらないが戻り値の型に修正が入るようなケースであっても、改修後に自動でキャッシュが無効になってくれるから意図しない実行時エラーを回避できる。

Component以外のVersion実装とService

VersionをComponentとして実装しない場合も考える。

import java.time.LocalDateTime;
import java.time.ZoneOffset;

public class Version {

    public static final String VERSION = Long.toString(LocalDateTime.now().toEpochSecond(ZoneOffset.ofHours(9)));
}

VERSIONを定数として定義し、それをServiceで扱う。

import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

import static com.example.sample.CacheConfig.CACHE_5_MIN;

@Service
public class ServiceA {

    @Cacheable(cacheNames = CACHE_5_MIN,
            key = "T(String).format('ServiceA::test::%s::%s', T(com.example.sample.Version).VERSION, #param1)")
    public String test1(String param1) {
        System.out.println("method executed. param1=" + param1);
        return param1;
    }
}

利用側のServiceでは@version.version()の代わりにT(com.example.sample.Version).VERSIONとなった。

文字列連結する際にString#formatを利用していたが、@CacheablekeyではSpEL(Spring 式言語)が使用できる。java.lang配下のクラスについてはT(クラス).メソッド()と記述できる。しかしjava.lang配下でなければT(パッケージ.クラス)と Fully Qualified Names で記載しないといけない。

つまり以下のデメリットが発生する。

  • パッケージ名のリファクタリングに弱い
  • パッケージ名は通常長いため読み辛い

バージョンがついたRedisのキーをRedis Insightで確認する

画像の通り、キーは5min::ServiceA::test::1716103789::1となっていることがわかる。