Solonを書いているうちに、最初に驚いたことのひとつは、キャッシュ層がどれほどクリーンかということでした。革命的なことをしているわけではありませんが、本番環境で実際に遭遇するエッジケースを処理しつつ、邪魔をしない点が優れています。

学んだことを順に紹介していきます。

3つのアノテーション

Solonが提供するキャッシュ用アノテーションは、ちょうど3つだけです。それ以上でもそれ以下でもありません。

  • @Cache — リードスルーキャッシュ。キーが存在すればそれを返し、存在しなければメソッドを実行して結果を保存します。
  • @CachePut — 常にメソッドを実行し、結果でキャッシュを更新します。
  • @CacheRemove — メソッドを実行し、1つまたは複数のキャッシュエントリを削除します。

3つすべてを使った最小限の例を以下に示します。

@Controller
public class CacheController {
    @Cache(key = "user:${userId}", seconds = 30)
    @Mapping("/user/{userId}")
    public User getUser(int userId) {
        return userService.findById(userId);
    }

    @CachePut(key = "user:${userId}")
    @Mapping("/user/{userId}/refresh")
    public User refreshUser(int userId) {
        return userService.findById(userId);
    }

    @CacheRemove(tags = "users")
    @Mapping("/user/{userId}/delete")
    public String deleteUser(int userId) {
        userService.delete(userId);
        return "deleted";
    }
}

Enter fullscreen mode Exit fullscreen mode

@CacheRemove(tags = "users") の行は注目に値します。ほとんどのアノテーションベースのキャッシュシステムで実現が難しいことを行っています。

タグのトリック

ほとんどのキャッシュフレームワークはキーベースの削除を提供します。キーがわかっていれば削除できます。シンプルなケースには適していますが、「ユーザー関連のキャッシュをすべて無効化したい」場合はどうでしょうか。

そこでタグの出番です。@Cache(tags = "users") でメソッドに注釈を付けると、Solon は計算されたキーに値を保存するだけでなく、そのキーをタグインデックスにも記録します。その後 @CacheRemove(tags = "users") を呼び出すと、そのタグに関連付けられたすべてのキーを検索して一括で削除します。

@Cache(key = "user:${userId}", tags = "users")
@Mapping("/user/{userId}")
public User getUser(int userId) { ... }

@Cache(key = "user:${userId}:profile", tags = "users")
@Mapping("/user/{userId}/profile")
public Profile getProfile(int userId) { ... }

// 両方のキャッシュが一度にクリアされる
@CacheRemove(tags = "users")
@Mapping("/admin/users/flush")
public String flushUsers() { ... }

Enter fullscreen mode Exit fullscreen mode

このパターンはページネーションされたリストで頻繁に使っています。検索結果の1ページ目、2ページ目、3ページ目をすべて "search" タグでキャッシュしておき、新しいデータが来たときに @CacheRemove(tags = "search") を1回呼ぶだけで全部消せます。

ドキュメントでは1点注意を促しています。タグは収集したキーをリストとして保存するため、同じタグで数千のキーをタグ付けしないようにしてください。現実的なシナリオ(1タグあたり数十〜数百のキー)であれば問題ありません。

キーテンプレート:単純な文字列を超えて

key パラメータは ${} 式をサポートしており、メソッド引数から値を解決できます。

// 単純なパラメータ
@Cache(key = "user:${userId}")
public User getUser(int userId) { ... }

// ネストしたプロパティ
@Cache(key = "user:${user.id}")
public User getUser(UserDto user) { ... }

// Map のキー
@Cache(key = "result:${params.type}")
public List<Result> search(Map<String, String> params) { ... }

Enter fullscreen mode Exit fullscreen mode

キーを指定しない場合、Solon は全パラメータ名と値の MD5 からキーを自動生成します。動作はしますが、デバッグや手動確認を容易にするため、常に明示的なキーを指定することを推奨します。

また、戻り値を参照するための特殊な ${.property} 構文もあり、主に @CachePut で有用です。

@CachePut(key = "order:${.orderId}")
public Order createOrder(CreateRequest req) {
    // ... 注文を作成して返す
}

Enter fullscreen mode Exit fullscreen mode

交換を可能にするインターフェース

内部的には、すべてのキャッシュ処理が CacheService を介して行われます。

public interface CacheService {
    void store(String key, Object obj, int seconds);
    Object get(String key);
    void remove(String key);
}

Enter fullscreen mode Exit fullscreen mode

メソッドは3つだけです。デフォルト実装は LocalCacheService で、単一インスタンスのアプリケーションに適したインメモリキャッシュです。

しかし本当の強みは、1つの Bean 定義で簡単に置き換えられる点です。

@Configuration
public class Config {
    @Bean
    public CacheService cache(@Inject("${cache}") Properties props) {
        return new MemCacheService(props);
    }
}

Enter fullscreen mode Exit fullscreen mode

CacheService の Bean を定義すると、デフォルトをグローバルに置き換えます。すべての @Cache@CachePut@CacheRemove アノテーションが自動的に新しいバックエンドを使用します。

設定によるバックエンドの切り替え

Solon は設定に基づいてバックエンドを選択する CacheServiceSupplier も提供しています。

demo.cache1:
  driverType: "local"
  # driverType: "redis"
  # driverType: "memcached"

Enter fullscreen mode Exit fullscreen mode

@Bean
public CacheService cache1(@Inject("${demo.cache1}") CacheServiceSupplier supplier) {
    return supplier.get();
}

Enter fullscreen mode Exit fullscreen mode

標準でサポートされているバックエンドは以下の通りです。

Backend Plugin Notes
Local solon-data (built-in) Default, no extra dependency
Redis solon-cache-jedis or solon-cache-redisson Pick your client
Memcached solon-cache-spymemcached Old but still around

セカンドレベルキャッシュ:ローカル+リモート

読み取り負荷の高いサービスで有用なパターンのひとつが、セカンドレベルキャッシュです。リモートキャッシュ(Redis、Memcached)の前にローカルのインメモリキャッシュを配置します。

@Bean
public CacheService cache1(@Inject("${demo.cache1}") CacheServiceSupplier supplier) {
    CacheService remote = supplier.get();

    if (remote instanceof LocalCacheService) {
        return remote;
    }

    // ローカル L1 + リモート L2、5秒のバッファ付き
    LocalCacheService local = new LocalCacheService();
    return new SecondCacheService(local, remote, 5);
}

Enter fullscreen mode Exit fullscreen mode

「バッファ」とは、ローカルキャッシュがミスしてもリモートキャッシュがヒットした場合、その結果をローカルに5秒間保存することを意味します。以降の5秒以内の読み取りはローカルキャッシュに直接ヒットし、ネットワークラウンドトリップが発生しません。ホットデータの場合、Redisの負荷を大幅に削減できます。

パーティションデザイン

複数のサービスが同じ Redis インスタンスを共有する場合、キャッシュをパーティション化する必要があります。Solon は名前付きキャッシュサービスを通じてこれをサポートしています。

demo.cache.user:
  keyHeader: "user"
  server: "localhost:6379"
  db: 0

demo.cache.order:
  keyHeader: "order"
  server: "localhost:6379"
  db: 1

Enter fullscreen mode Exit fullscreen mode

@Configuration
public class Config {
    @Bean("cache_user")
    public CacheService cacheUser(@Inject("${demo.cache.user}") RedisCacheService cache) {
        return cache.enableMd5key(false);
    }

    @Bean("cache_order")
    public CacheService cacheOrder(@Inject("${demo.cache.order}") RedisCacheService cache) {
        return cache.enableMd5key(false);
    }
}

Enter fullscreen mode Exit fullscreen mode

サービス内で利用する例:

@Component
public class OrderService {
    @Cache(service = "cache_order")
    public String hello(String name) {
        return String.format("Hello %s!", name);
    }
}

Enter fullscreen mode Exit fullscreen mode

異なるデータベース、異なるキープレフィックスを指定でき、アノテーションに service 属性を付けるだけで適切なキャッシュを選択できます。

改善してほしい点

1点挙げるとすれば、キーを指定しなかったときに自動生成されるキーが全パラメータの MD5 である点です。実際には常に明示的なキーを指定しているので問題にはなりませんが、フレームワークを初めて使う人にとっては、不透明なハッシュ文字列がキャッシュストアに表示されて混乱するかもしれません。

まとめ

Solon のキャッシュ層は驚くほどシンプルです。3つのアノテーション、1つのインターフェース、そしてバッチ無効化を他のほとんどのフレームワークより優れた方法で扱うタグシステム。 CacheService インターフェースは実装が簡単なので、基本的に任意のバックエンドをプラグインしたり、特定のユースケース向けにカスタム実装を書いたりできます。

すでに Solon を利用している場合、キャッシュアノテーションは solon-data モジュールに含まれており、依存関係としてすでに持っている可能性が高いです。@Cache を追加するだけで完了です。