同じユーザー情報を何度もDBから取得していて、レスポンスが遅いと感じたことはありませんか。毎回DBにアクセスするのは無駄が多いですよね。Spring Bootのキャッシュ機能を使えば、簡単にこの問題を解決できます。
この記事では、Spring Cache Abstractionを使ってアプリケーションのパフォーマンスを改善する方法を実例で解説します。
Spring Cache Abstractionとは
Spring Cache Abstractionは、Spring Frameworkが提供するキャッシュの統一インターフェースです。アノテーションを使ってメソッドの戻り値を簡単にキャッシュできます。
特徴は以下の通りです。
- プロバイダの切り替えが簡単 - CaffeineやRedisなど、実装を変更してもコードはそのまま
- アノテーションベース - @Cacheableなどを付けるだけで動作
- Spring Bootとの統合 - 依存関係を追加するだけで自動設定
キャッシュプロバイダを後から変更できるので、最初は軽量なインメモリキャッシュで始めて、後から分散キャッシュに移行することもできます。
キャッシュ機能の有効化
まず pom.xml に依存関係を追加しましょう。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
次に、設定クラスに @EnableCaching を追加します。
@SpringBootApplication
@EnableCaching
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
これだけで、デフォルトのConcurrentHashMapを使ったキャッシュが有効になります。
@Cacheableでメソッドをキャッシュする
@Cacheable を使うと、メソッドの戻り値を自動的にキャッシュできます。同じ引数で呼ばれたときは、DBアクセスせずキャッシュから値を返します。
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
@Cacheable("users")
public User findById(Long id) {
System.out.println("DB accessed for user: " + id);
return userRepository.findById(id).orElse(null);
}
}
最初の呼び出しではログが出力されますが、2回目以降は出力されません。これはキャッシュから値が返されているためです。
@Cacheable("users") の "users" はキャッシュ名で、複数のキャッシュを区別するために使います。引数が1つの場合、その値がキャッシュキーになります。引数が複数の場合はSimpleKeyというオブジェクトが自動生成され、それがキーになります。
@Cacheableが動作しないケース
@Cacheable はSpringのAOPプロキシを使って実装されているため、同じクラス内からメソッドを呼び出した場合は動作しません。
// これは動作しない例
@Service
public class UserService {
@Cacheable("users")
public User findById(Long id) {
return userRepository.findById(id).orElse(null);
}
public User getUserWithCache(Long id) {
// 同じクラス内からの呼び出しではキャッシュが効かない
return this.findById(id);
}
}
対策としては、キャッシュメソッドを別のクラスに分離するか、ApplicationContext から自分自身のBeanを取得して呼び出す方法があります。
キャッシュキーのカスタマイズ
複数のパラメータがある場合や、特定のフィールドだけをキーにしたい場合は、SpEL(Spring Expression Language)を使ってキーをカスタマイズできます。
@Cacheable(value = "users", key = "#email")
public User findByEmail(String email) {
return userRepository.findByEmail(email);
}
@Cacheable(value = "users", key = "#user.id + '-' + #user.email")
public User findByUser(User user) {
return userRepository.findById(user.getId()).orElse(null);
}
複数の引数を持つメソッドでキーを明示的に指定しない場合、全ての引数を組み合わせたSimpleKeyが自動的に使われます。
// key属性を指定しない場合、idとnameの組み合わせがキーになる
@Cacheable("users")
public User findByIdAndName(Long id, String name) {
return userRepository.findByIdAndName(id, name);
}
// 明示的に指定する場合
@Cacheable(value = "users", key = "#id + '-' + #name")
public User findByIdAndName(Long id, String name) {
return userRepository.findByIdAndName(id, name);
}
条件付きでキャッシュすることもできます。
@Cacheable(value = "users", condition = "#id > 10")
public User findById(Long id) {
return userRepository.findById(id).orElse(null);
}
この例では、IDが10より大きいときだけキャッシュされます。
@CacheEvictで更新時にキャッシュをクリア
データを更新したときは、古いキャッシュをクリアする必要があります。@CacheEvict を使いましょう。
@CacheEvict(value = "users", key = "#id")
public void updateUser(Long id, String name) {
User user = userRepository.findById(id).orElse(null);
if (user != null) {
user.setName(name);
userRepository.save(user);
}
}
@CacheEvict(value = "users", key = "#id")
public void deleteUser(Long id) {
userRepository.deleteById(id);
}
全てのキャッシュをクリアしたい場合は allEntries = true を使います。
@CacheEvict(value = "users", allEntries = true)
public void deleteAllUsers() {
userRepository.deleteAll();
}
デフォルトでは、メソッド実行後にキャッシュがクリアされます。そのため、メソッドが例外をスローした場合はキャッシュが残ります。メソッド実行前にクリアしたい場合は beforeInvocation = true を指定します。
@CacheEvict(value = "users", key = "#id", beforeInvocation = true)
public void updateUser(Long id, String name) {
// 例外が発生してもキャッシュはクリアされる
User user = userRepository.findById(id).orElseThrow();
user.setName(name);
userRepository.save(user);
}
@CachePutでキャッシュを更新
@CachePut は、メソッドを常に実行してその結果でキャッシュを更新します。@Cacheable と違って、キャッシュがあってもメソッドは実行されます。
@CachePut(value = "users", key = "#user.id")
public User updateUser(User user) {
return userRepository.save(user);
}
@Cacheable はキャッシュヒット時にメソッドをスキップしますが、@CachePut は必ずメソッドを実行して結果をキャッシュに保存します。更新メソッドで新しいデータをキャッシュに反映したいときに便利です。
Caffeineで高性能化
デフォルトのConcurrentHashMapは機能が限られています。Caffeineに切り替えることで、TTL(有効期限)や最大サイズの設定ができるようになります。
まず依存関係を追加します。
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
application.yml で設定を記述します。
spring:
cache:
type: caffeine
caffeine:
spec: maximumSize=1000,expireAfterWrite=10m
これで、最大1000エントリまでキャッシュし、書き込み後10分で自動削除されます。
キャッシュごとに異なる設定をしたい場合は、Configurationクラスで設定します。
@Configuration
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager("users", "products");
cacheManager.setCaffeine(Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(10, TimeUnit.MINUTES));
return cacheManager;
}
}
Caffeineは高性能で、Google Guavaの後継として広く使われています。
Redisで分散キャッシュ
複数のサーバーでアプリケーションを動かす場合、各サーバーが独立したキャッシュを持つと不整合が発生します。Redisを使えば、全サーバーで同じキャッシュを共有できます。
依存関係を追加します。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
application.yml でRedisの接続先を設定します。
spring:
data:
redis:
host: localhost
port: 6379
cache:
type: redis
redis:
time-to-live: 10m
Redisでキャッシュするための準備
Redisはデータをシリアライズして保存するため、キャッシュするエンティティクラスは Serializable を実装する必要があります。
@Entity
public class User implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
// getter/setter
}
キャッシュごとにTTLを変えたい場合は、RedisCacheManagerをカスタマイズします。
@Configuration
public class RedisCacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10));
Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
cacheConfigurations.put("users", defaultConfig.entryTtl(Duration.ofMinutes(30)));
cacheConfigurations.put("products", defaultConfig.entryTtl(Duration.ofMinutes(5)));
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(cacheConfigurations)
.build();
}
}
CaffeineとRedisの使い分け
どちらを選ぶかは、以下の基準で判断しましょう。
Caffeineを選ぶケース
- 単一サーバーでの運用
- レイテンシを極限まで小さくしたい(数ミリ秒のオーバーヘッドも許容できない)
- データサイズが小さく、メモリに収まる
Redisを選ぶケース
- 複数サーバーでの運用(ロードバランサー配下など)
- キャッシュデータが大きく、サーバーのメモリだけでは不足する
- キャッシュの永続化やレプリケーションが必要
最初はCaffeineで始めて、スケールアウトの必要が出てきたらRedisに移行するのがおすすめです。
キャッシュ効果の測定と実践戦略
キャッシュがどれだけ効いているか確認しましょう。最も簡単な方法は、メソッド内にログを仕込むことです。
@Cacheable("users")
public User findById(Long id) {
logger.info("Cache miss - loading user from DB: {}", id);
return userRepository.findById(id).orElse(null);
}
このログが出るたびにDBアクセスが発生しています。2回目以降の呼び出しでログが出なければ、キャッシュが効いています。
Spring Boot Actuatorを使えば、より詳細なメトリクスを取得できます。詳しくはSpring Boot Actuatorの記事をご覧ください。
簡単なベンチマークの例を示します(環境や実装により結果は大きく異なります)。
【キャッシュなし】
1回目: 152ms
2回目: 148ms
3回目: 151ms
平均: 150ms
【キャッシュあり】
1回目: 153ms (キャッシュミス)
2回目: 2ms (キャッシュヒット)
3回目: 1ms (キャッシュヒット)
平均: 52ms
キャッシュヒット時は50〜100倍高速になることもあります。
実践的なキャッシュ戦略
キャッシュを適用するメソッドを選ぶときは、以下のポイントを考慮しましょう。
キャッシュに適したメソッド
- 読み取り頻度が高いメソッド(ユーザー情報、商品情報など)
- 計算コストが高いメソッド
- 外部APIへのアクセスが発生するメソッド
キャッシュに不向きなメソッド
- 更新頻度が高いデータ
- リアルタイム性が求められるデータ
- ユーザーごとに結果が異なるデータ(認証情報など)
キャッシュサイズは、データの特性に合わせて調整します。メモリ使用量を監視しながら、適切な最大サイズを設定しましょう。
N+1問題の解決には、キャッシュだけでなくJPAのfetch戦略も重要です。詳しくはSpring Data JPAのパフォーマンス最適化の記事をご覧ください。
まとめ
Spring Cache Abstractionを使えば、アノテーションだけで簡単にキャッシュを導入できます。まずは @Cacheable でよく読み込むデータをキャッシュし、@CacheEvict で更新時にクリアする基本パターンから始めましょう。
最初はCaffeineで十分です。複数サーバーで動かす必要が出てきたら、Redisへの移行を検討しましょう。コードの変更はほとんど必要ありません。
キャッシュは強力ですが、データの鮮度とのトレードオフです。適切なTTLを設定して、アプリケーションの要件に合わせて調整してください。