アプリの挙動が変わった後もキャッシュを使い続けると起こる問題
デシリアライズに失敗する or 追加項目がnullになる
メソッド修正を行い戻り値の型が変わったりしても、キャッシュのキーに変更がなければ以前のデータを返してしまう。
例えば以下のクラスがあり、これをキャッシュしているとする。
public record Some(String x) implements Serializable {}
改修に伴い、クラスが以下に変わったとする。
public record Some(String x, String y) implements Serializable {}
アプリをリリースしてもキャッシュが残っている間は、キャッシュからSome
にデシリアライズできなかったり、デシリアライズできてもy
がnull
になってしまう。
シリアライズの方法
クラス構造が大幅に変わるわけではなく、String y
が追加されるだけ(あるいはその逆)の些細な改修であっても、デシリアライズできる/できないはシリアライズの方法や設定次第。classをJavaオブジェクトのままシリアライズするのか、recordをJavaオブジェクトのままシリアライズするのか、JSONとしてシリアライズするのかなど。
もう少し具体的には以下のようになる。
- classをJavaオブジェクトとしてシリアライズするなら、
serialVersionUID
が変わればデシリアライズできなくなる - recordをJavaオブジェクトとしてシリアライズするなら、
serialVersionUID
が意味を持たないため、serialVersionUID
に関わらずデシリアライズできるし、serialVersionUID
を定義することも通常ない - JSONとしてシリアライズするなら、
ObjectMapper
の設定に影響され、余分なJSON keyや足りないJSON keyをどのように扱うか次第でデシリアライズ可否が変わる
以降ではserialVersionUID
やObjectMapper
ではなく、キャッシュのキーに対する工夫でキャッシュを無効にする方法を考える。この方法はどのようなシリアライズ方法であっても対応可能であることに加え、仕組みが明瞭である点にメリットがある。
キャッシュのキーにバージョン等を入れて、キャッシュを無効にする
対策としてキャッシュのキーに原則バージョン等を入れるようにすることで、アプリ改修後は常にキャッシュが存在しない状況を作る。
バージョンの代わりに最も簡単な実装としてアプリの起動時刻を設定するサンプルコードを記載する。
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
をつける」と先ほど記載したが、@Cacheable
のkey
で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
を利用していたが、@Cacheable
のkey
ではSpEL
(Spring 式言語)が使用できる。java.lang
配下のクラスについてはT(クラス).メソッド()
と記述できる。しかしjava.lang
配下でなければT(パッケージ.クラス)
と Fully Qualified Names で記載しないといけない。
つまり以下のデメリットが発生する。
- パッケージ名のリファクタリングに弱い
- パッケージ名は通常長いため読み辛い
バージョンがついたRedisのキーをRedis Insightで確認する
画像の通り、キーは5min::ServiceA::test::1716103789::1
となっていることがわかる。