When MyBatis Mappers include SELECTs with the same search conditions, it’s common for those queries to hit the database every single time they’re called. JPA has a second-level cache, and MyBatis provides the same kind of mechanism out of the box. In this article, we’ll walk through enabling caching with <cache/> and @CacheNamespace, flush behavior on updates, sharing caches across multiple Mappers, and swapping in Redis. The basics of writing Mappers are covered in the MyBatis implementation guide, so this time we’ll focus on caching.
What is MyBatis’s second-level cache?
The first-level cache operates automatically within the scope of the SqlSession — it’s always enabled and requires no configuration. The second-level cache is scoped per Mapper namespace and is shared across SqlSessions. The default implementation, PerpetualCache, is a simple HashMap with no size limit or TTL, and it’s retained for the entire lifetime of the application. This mechanism is well suited to tables that are read frequently but updated rarely.
Enabling it with <cache/> and @CacheNamespace
For XML mappers, you can enable it by adding a single line.
<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>
If you prefer Mapper interfaces, you can achieve the same thing with @CacheNamespace.
@CacheNamespace(size = 512, flushInterval = 60000)
@Mapper
public interface ProductMapper {
@Select("SELECT * FROM products WHERE id = #{id}")
Product findById(Long id);
}
The attributes correspond almost one-to-one.
| Meaning | <cache/> | @CacheNamespace |
|---|---|---|
| Eviction algorithm (LRU/FIFO/SOFT/WEAK) | eviction | eviction |
| Upper limit on the number of references held | size | size |
| Automatic flush interval (ms) | flushInterval | flushInterval |
| Whether to serialize | readOnly | readWrite |
Note that readOnly and readWrite are inverses of each other. readOnly="true" means the same thing as readWrite = false; if you set both to the same value (e.g., both true), you’ll get the opposite of the intended behavior. When omitted, each uses its default value (readOnly=false equivalent, readWrite=true), so start with a minimal configuration and test it: call the same query twice, and if the SQL log only shows the query being issued once, caching is working.
What gets cached, and the Serializable requirement
With readWrite (default true), MyBatis protects the cache from mutation by callers by serializing the cached object on each access. This means the cached class must implement Serializable; if you forget, you’ll get an org.apache.ibatis.cache.CacheException (with a message like “failed to make a copy of a non-serializable object”).
public class Product implements Serializable {
private Long id;
private String name;
private BigDecimal price;
// getters/setters omitted
}
If you want to avoid the cost of serialization, you can set readOnly="true" (or readWrite = false with the annotation).
@CacheNamespace(size = 512, readWrite = false)
@Mapper
public interface ProductMapper {
// ...
}
However, in this mode, changes made by the caller are reflected directly in the cached object, so it should only be used for reference-only data.
Automatic flushing on updates, and sharing across multiple Mappers
When an INSERT/UPDATE/DELETE is executed within the same namespace, MyBatis automatically clears that namespace’s cache. There’s no need to clear it manually.
One thing to watch out for: this automatic flush only applies to updates within the “same namespace.” If the products table is updated from a different Mapper (a different namespace), the cache on the ProductMapper side will remain stale. This is where @CacheNamespaceRef comes in.
@CacheNamespaceRef(ProductMapper.class)
@Mapper
public interface ProductAdminMapper {
@Update("UPDATE products SET price = #{price} WHERE id = #{id}")
int updatePrice(Long id, BigDecimal price);
}
In addition to a class reference, you can also specify the namespace name as a string. Since value() is an attribute that accepts a Class<?>, if you want to specify it as a string you need to pass it to the name attribute instead.
@CacheNamespaceRef(name = "com.example.mapper.ProductMapper")
@Mapper
public interface ProductAdminMapper { /* ... */ }
This makes ProductAdminMapper use the same cache space as ProductMapper, so the cache is correctly flushed regardless of which one performs the update.
Swapping in Redis with a custom Cache implementation
In environments with multiple instances, each process holds its own separate PerpetualCache. To address this, you can implement the org.apache.ibatis.cache.Cache interface yourself and swap it in.
public class RedisCache implements Cache {
private final String id;
private final RedisTemplate<String, Object> redisTemplate;
public RedisCache(String id) {
this.id = id;
// SpringContextHolder is a custom holder class that implements ApplicationContextAware,
// used to retrieve Beans via a static method (not a standard 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 carries a risk of blocking in production, so this is simplified for the sake of example.
// In a real deployment, replace it with SCAN-based iteration or a separately managed key list.
redisTemplate.keys(id + ":*").forEach(redisTemplate::delete);
}
@Override
public int getSize() { return 0; } // If you need count tracking, maintain a separate counter
}
You specify the replacement via the implementation attribute.
@CacheNamespace(implementation = RedisCache.class)
@Mapper
public interface ProductMapper {
// ...
}
MyBatis’s builder only applies the standard decorators (eviction / size, etc., including SynchronizedCache, which makes it thread-safe) when the base implementation class is PerpetualCache. Once you specify RedisCache.class for implementation, none of these are applied at all, so size limiting and synchronization control become the responsibility of the implementation itself (Redis’s TTL and the thread-safety of RedisTemplate itself). Strict consistency in a distributed environment is outside the scope of this article.
Checking the cacheEnabled setting
MyBatis’s cacheEnabled defaults to true, so writing <cache/> or @CacheNamespace enables caching with no additional configuration needed. If you want to make it explicit that it hasn’t been unintentionally turned off, it’s safe to add it to application.yml.
mybatis:
configuration:
cache-enabled: true
Tips for verifying it works
The easiest way to check whether caching is working is to count how many times the query is issued in the SQL log. Call the same SELECT repeatedly, and if no SQL is logged from the second call onward, it’s working. One more thing to keep in mind: since the second-level cache is updated at transaction commit time, updating and then immediately re-reading within the same transaction can lead to confusing timing issues. The Caffeine tuning article is a useful reference for this kind of cache design.
Summary
MyBatis’s second-level cache can be started simply by writing <cache/> or @CacheNamespace, and since updates automatically flush the same namespace, it’s safe to introduce. If a table is shared across multiple Mappers, use @CacheNamespaceRef to share the cache space; if you have multiple instances, consider swapping in Redis. Start by picking one Mapper that’s read frequently and updated rarely, and give it a try. If you’re unsure whether MyBatis or JPA is the better fit, the MyBatis vs. JPA comparison article should also be helpful.