MyBatisでMapperを書いていると、同じ検索条件のSELECTが何度も呼ばれて毎回DBに問い合わせているケースがよくありますよね。JPAには2次キャッシュがありますが、MyBatisにも標準で同じ仕組みが用意されています。この記事では <cache/> と @CacheNamespace によるキャッシュ有効化から、更新時のフラッシュ挙動、複数Mapper間での共有、Redisへの差し替えまでを見ていきます。Mapperの基本的な書き方はMyBatisの実装ガイドで扱っているので、今回はキャッシュに絞ります。

MyBatisの2次キャッシュとは何か

1次キャッシュは SqlSession のスコープで自動的に効いていて、設定不要で常に有効です。2次キャッシュはMapperの名前空間(Namespace)単位でスコープされ、SqlSessionをまたいで共有されます。デフォルト実装の PerpetualCache は単純な HashMap で、サイズ上限もTTLもなくアプリ起動中はずっと保持されます。読み取りが多く更新が少ないテーブルに向いた仕組みです。

<cache/>と@CacheNamespaceで有効化する

XMLマッパーなら1行追加するだけで有効化できます。

<mapper namespace="com.example.mapper.ProductMapper">
    <cache eviction="LRU" flushInterval="60000" size="512"/>

    <select id="findById" resultType="Product">
        SELECT * FROM products WHERE id = #{id}
    </select>
</mapper>

Mapperインターフェース派なら @CacheNamespace で同じことができます。

@CacheNamespace(size = 512, flushInterval = 60000)
@Mapper
public interface ProductMapper {

    @Select("SELECT * FROM products WHERE id = #{id}")
    Product findById(Long id);
}

属性はほぼ1対1で対応しています。

意味<cache/>@CacheNamespace
追い出しアルゴリズム(LRU/FIFO/SOFT/WEAK)evictioneviction
保持する参照数の上限sizesize
自動フラッシュ間隔(ms)flushIntervalflushInterval
シリアライズするかreadOnlyreadWrite

readOnly と readWrite は真偽値が反転する関係にある点に注意してください。readOnly="true" は readWrite = false と同じ意味で、両方を同じ値(例えば両方true)にすると意図と逆の挙動になります。省略時はそれぞれデフォルト値(readOnly=false相当、readWrite=true)が使われるので、まずは最小構成で試し、同じクエリを2回呼んでSQLログが1回しか出なければ有効化成功です。

キャッシュ対象とSerializable要件

readWrite(デフォルト true)では、キャッシュするオブジェクトを都度シリアライズすることで呼び出し元の書き換えからキャッシュを守っています。そのためキャッシュ対象のクラスは Serializable を実装する必要があり、忘れると org.apache.ibatis.cache.CacheException が発生します(「failed to make a copy of a non-serializable object」のようなメッセージです)。

public class Product implements Serializable {
    private Long id;
    private String name;
    private BigDecimal price;
    // getter/setterは省略
}

シリアライズのコストを避けたい場合は readOnly="true" (アノテーションなら readWrite = false)にできます。

@CacheNamespace(size = 512, readWrite = false)
@Mapper
public interface ProductMapper {
    // ...
}

ただしこのモードでは呼び出し側の変更がキャッシュオブジェクトにも反映されてしまうため、参照専用データに限って使いましょう。

更新時の自動フラッシュと複数Mapperでの共有

同じNamespace内でINSERT/UPDATE/DELETEを実行すると、MyBatisはそのNamespaceのキャッシュを自動でクリアします。手動で消す必要はありません。

注意したいのは、フラッシュが効くのは「同じNamespace内」の更新に限られることです。別のMapper(別Namespace)からproductsテーブルを更新すると、ProductMapper側のキャッシュは古いままになります。これを解決するのが @CacheNamespaceRef です。

@CacheNamespaceRef(ProductMapper.class)
@Mapper
public interface ProductAdminMapper {

    @Update("UPDATE products SET price = #{price} WHERE id = #{id}")
    int updatePrice(Long id, BigDecimal price);
}

クラス参照のほか、Namespace名を文字列で指定することもできます。value() は Class<?> を受け取る属性なので、文字列で指定する場合は name 属性に渡す必要があります。

@CacheNamespaceRef(name = "com.example.mapper.ProductMapper")
@Mapper
public interface ProductAdminMapper { /* ... */ }

これで ProductAdminMapper は ProductMapper と同じキャッシュ空間を使うようになり、どちらで更新してもキャッシュが正しくフラッシュされます。

カスタムCache実装でRedisに差し替える

インスタンスが複数ある環境では各プロセスが別々の PerpetualCache を持ちます。これを解決したい場合は、org.apache.ibatis.cache.Cache インターフェースを自分で実装して差し替えられます。

public class RedisCache implements Cache {
    private final String id;
    private final RedisTemplate<String, Object> redisTemplate;

    public RedisCache(String id) {
        this.id = id;
        // SpringContextHolderはApplicationContextAwareを実装した自作のホルダークラスで、
        // staticメソッド経由でBeanを取得するための補助クラス(MyBatis/Springの標準APIではない)
        this.redisTemplate = SpringContextHolder.getBean(RedisTemplate.class);
    }

    @Override
    public String getId() { return id; }

    @Override
    public void putObject(Object key, Object value) {
        redisTemplate.opsForValue().set(id + ":" + key, value, Duration.ofMinutes(30));
    }

    @Override
    public Object getObject(Object key) {
        return redisTemplate.opsForValue().get(id + ":" + key);
    }

    @Override
    public Object removeObject(Object key) {
        return redisTemplate.delete(id + ":" + key);
    }

    @Override
    public void clear() {
        // KEYSは本番でブロッキングの危険があるため簡略化したサンプルとして割り切る。
        // 実運用ではSCANベースの走査か、キー一覧を別途管理する方式に置き換える。
        redisTemplate.keys(id + ":*").forEach(redisTemplate::delete);
    }

    @Override
    public int getSize() { return 0; } // 件数管理が必要なら別途カウンタを持たせる
}

差し替えは implementation 属性で指定します。

@CacheNamespace(implementation = RedisCache.class)
@Mapper
public interface ProductMapper {
    // ...
}

MyBatisのビルダーは、baseの実装クラスが PerpetualCache の場合にのみ eviction / size などの標準デコレータ(スレッドセーフにする SynchronizedCache を含む)を適用します。implementation に RedisCache.class を指定した時点でこれらは一切適用されなくなるため、上限管理や同期制御は実装側(RedisのTTLやRedisTemplate自体のスレッドセーフ性)に委ねることになります。分散環境での厳密な整合性は本記事の範囲外とします。

cacheEnabledの設定を確認する

MyBatisの cacheEnabled はデフォルトで true なので、<cache/> や @CacheNamespace を書けば追加設定なしで有効になります。意図せずOFFにしていないか明示したい場合は、application.yml に書いておくと安心です。

mybatis:
  configuration:
    cache-enabled: true

動作確認のコツ

キャッシュが効いているかはSQLログで発行回数を数えるのが手軽です。同じ条件のSELECTを連続で呼び、2回目以降にSQLが出ていなければ成功です。もう一点、2次キャッシュへの反映はトランザクションのコミット時点になるため、同一トランザクション内で更新してすぐ参照しなおすとタイミングのズレでハマることがあります。この手のキャッシュ設計はCaffeineでのチューニング記事が参考になります。

まとめ

MyBatisの2次キャッシュは <cache/> か @CacheNamespace を書くだけで始められ、更新時は同じNamespace内が自動でフラッシュされるので安全に導入できます。複数Mapperをまたぐテーブルなら @CacheNamespaceRef で空間を共有し、インスタンスが複数あるならRedisへの差し替えを検討しましょう。まず読み取り頻度が高く更新が少ないMapperを1つ選んで試してみてください。JPAとどちらが向いているか迷っているなら、MyBatisとJPAの比較記事も参考になるはずです。