Why MyBatis Second-Level Cache Returns Stale Data: Namespace Isolation Explained

This article explains why MyBatis second-level cache returns stale data, detailing first and second-level cache mechanics, cache key composition, write timing, namespace isolation issues, statement-level cache controls, Redis implementation, and conditions for safe cache usage.

Java Tech Workshop
Java Tech Workshop
Java Tech Workshop
Why MyBatis Second-Level Cache Returns Stale Data: Namespace Isolation Explained

Problem Phenomenon

After deployment, a puzzling issue occurred: operations updated a product price in the backend, the database reflected the new value, and direct queries returned the correct price, yet the product list page continued showing the old price for over ten minutes. Restarting the application immediately resolved it. Initial suspicion fell on Redis, but the project did not use Redis; the issue lay in MyBatis's own second-level cache.

The reproduction path is short: the same product is queried via two different Mapper methods — one for detail (namespace ProductMapper) and one for list (namespace ProductListMapper). The update uses ProductMapper.updatePrice. Both mappers have second-level cache enabled. The list query joins the product table to fetch the price. The update only clears the cache under its own namespace (ProductMapper), leaving ProductListMapper's cache untouched. Consequently, the list page keeps returning pre-update data until cache expiry or application restart. Such issues are hard to diagnose because the database data is correct, logs show no anomalies, and the only clue is that one interface returns data slightly slower or one version behind.

First-Level Cache Scope

The first-level cache, also called local cache, is scoped to a single SqlSession. It resides not on the Mapper interface or namespace but inside BaseExecutor, using a PerpetualCache to map CacheKey to results. In other words, once the session changes, this cache ceases to exist.

public abstract class BaseExecutor implements Executor { protected PerpetualCache localCache; protected Configuration configuration; protected BaseExecutor(Configuration configuration, Transaction transaction) { this.localCache = new PerpetualCache("LocalCache"); // ... } }

PerpetualCache internally uses a HashMap with no eviction policy and no capacity limit. Its name implies it remains valid for the lifetime of the object that created it.

public class PerpetualCache implements Cache { private final String id; private final Map<Object, Object> cache = new HashMap<>(); @Override public void putObject(Object key, Object value) { cache.put(key, value); } @Override public Object getObject(Object key) { return cache.get(key); } }

The first-level cache is enabled by default; localCacheScope defaults to SESSION. Thus, within the same SqlSession, two identical queries will not issue SQL on the second call.

try (SqlSession session = sqlSessionFactory.openSession()) { ProductMapper mapper = session.getMapper(ProductMapper.class); Product first = mapper.detail(1L); // issues SQL Product second = mapper.detail(1L); // hits local cache, no SQL System.out.println(first == second); // true }

The output is true, which is noteworthy. The cache stores object references, so both queries return the exact same instance, not two separate copies with identical content. This detail leads directly to the next issue.

Hazard of Shared Object Reference

Because the same reference is returned, any modification made by business code to the returned object directly alters the cached instance. The database remains unchanged, but the cache has been mutated.

Product product = mapper.detail(1L); product.setPrice(new BigDecimal("9.90")); // only intended to tweak display value Product again = mapper.detail(1L); System.out.println(again.getPrice()); // 9.90, database still 99.00

No update statement was executed, the database did not change, yet the second query retrieved the modified object. Worse, this contamination persists for the lifetime of the SqlSession until the cache is cleared or the session closes. In a transaction where multiple services reuse the same SqlSession, such changes can propagate through the call chain into unrelated business logic, with no SQL-level anomaly visible during troubleshooting.

First-Level Cache Invalidation Timing

BaseExecutor's update method clears the local cache unconditionally before executing any insert, update, or delete. This step is independent of the SQL's actual effect; it is a mandatory pre-processing action.

@Override public int update(MappedStatement ms, Object parameter) throws SQLException { ErrorContext.instance().resource(ms.getSqlId()).activity("executing update").object(ms.getId()); if (closed) { throw new ExecutorException("Executor was closed."); } clearLocalCache(); return doUpdate(ms, parameter); }

clearLocalCache() runs unconditionally, regardless of how many rows the SQL actually affects. Therefore, any MyBatis update statement clears the entire local cache, even if zero rows are modified.

SqlSession's commit and rollback also clear the first-level cache, with logic identical to the update path. Additionally, two other triggers exist: a query statement with flushCache=true, and a manual call to clearCache().

Conversely, the following operations do NOT clear the first-level cache, yet they appear frequently in transactional code. First, updates via JdbcTemplate or raw JDBC bypass MyBatis's Executor, so clearLocalCache() is never invoked. Second, updates in another thread cannot clear your cache because the first-level cache is bound to the SqlSession, which is not shared across threads. Third, stored procedure calls that modify data similarly leave the cache untouched unless flushCache is explicitly configured.

The second scenario is especially problematic in transactions: Transaction A queries and caches the old value; Transaction B updates and commits; Transaction A queries again and still receives the old value because it never hits the database — the cache returns the result directly.

Spring Transaction Impact on SqlSession Lifecycle

With Spring integration, SqlSession lifecycle is managed by SqlSessionTemplate. Without a transaction, each Mapper invocation opens a new SqlSession, uses it, and closes it, leaving almost no room for the first-level cache to take effect. This explains why many developers feel the first-level cache doesn't exist in their projects — that perception only holds in non-transactional scenarios. Once @Transactional is added, the entire transaction reuses a single SqlSession, and the first-level cache genuinely starts working. The longer the transaction, the more the cache is reused, and the longer the window for reading stale data.

@Transactional public void refreshPrice(Long id) { Product before = productMapper.detail(id); // query DB, enters local cache productMapper.updatePrice(id, new BigDecimal("88.00")); // clears local cache Product after = productMapper.detail(id); // re-queries DB, gets new value log.info("before={}, after={}", before.getPrice(), after.getPrice()); }

This code works correctly because updatePrice clears the cache, forcing the second query to hit the database. The real pitfall is a different pattern: using JdbcTemplate for batch updates within a transaction, then querying via Mapper — the query still returns pre-update data.

@Transactional public void batchRefresh(Long id) { Product before = productMapper.detail(id); // enters local cache jdbcTemplate.update("update product set price = ? where id = ?", new BigDecimal("88.00"), id); Product after = productMapper.detail(id); // hits local cache, still old value }

To avoid this, set localCacheScope to STATEMENT, which clears the local cache after each query. The trade-off is that repeated queries within the same transaction will re-issue SQL, adding network round-trips for query-intensive transactions.

mybatis: configuration: local-cache-scope: statement

Second-Level Cache Enablement Conditions

The second-level cache is bound to the namespace and shared across SqlSessions, so its invalidation scope is much larger than the first-level cache. It can be enabled in two ways: adding a <cache> tag in XML, or annotating the interface with @CacheNamespace.

<mapper namespace="com.demo.mapper.ProductMapper"> <cache eviction="LRU" flushInterval="600000" size="1024" readOnly="false"/> </mapper>

The four attributes deserve clarification. eviction is the eviction policy (LRU, FIFO, SOFT, WEAK). flushInterval is the periodic flush interval in milliseconds; if omitted, cache is only cleared on updates. size is the maximum number of result objects to cache. readOnly determines whether the shared instance or a copy is returned.

When readOnly is false, cache objects undergo serialization, requiring the entity class to implement Serializable; otherwise a NotSerializableException is thrown at runtime, typically on the first cache hit, not at startup, making it easy to miss in local testing.

@Data public class Product implements Serializable { private static final long serialVersionUID = 1L; private Long id; private String name; private BigDecimal price; }

The annotation equivalent mirrors XML parameters. Note the field name difference: readOnly vs readWrite — their semantics are opposite, and a configuration mistake flips the cache behavior entirely.

@CacheNamespace(eviction = LruCache.class, flushInterval = 600000, size = 1024, readWrite = true) public interface ProductMapper { Product detail(@Param("id") Long id); }

Another often-overlooked prerequisite: the global switch cacheEnabled must be true. MyBatis defaults this to true, but some projects explicitly disable it in mybatis-config.xml, causing annotations and XML cache tags to be silently ignored without any log warning.

mybatis: configuration: cache-enabled: true

CacheKey Composition

The CacheKey consists of five concatenated segments; any difference yields a distinct query. It overrides equals and hashCode to compare the combined segments.

public class CacheKey implements Cloneable, Serializable { private static final int DEFAULT_MULTIPLYER = 37; private static final int DEFAULT_HASHCODE = 17; private int hashcode; private long checksum; private int count; private List<Object> updateList; public CacheKey update(Object object) { // statementId, offset, limit, sql, parameters added sequentially // ... return this; } }

The segments, in order, are: the MappedStatement's id, pagination offset and limit, the SQL text from BoundSql, and the expanded parameter values. Collection parameters are added element by element, so different list lengths produce different keys.

A practical implication: the same Mapper method called with null versus an empty string generates two different keys, so the cache is not shared. When writing cache-related code, accounting for this prevents confusion about cache misses. Every segment participates in the hash; any subtle difference in parameter type, order, or pagination offset places two seemingly identical queries into separate cache slots.

Cache Write Timing

Second-level cache writes do not occur immediately at query end. MyBatis wraps the cache with TransactionalCache: query results are first placed into a pending list, and only committed to the shared cache when the transaction ends.

public class TransactionalCache implements Cache { private final Cache delegate; private boolean clearOnCommit; private final Map<Object, Object> entriesToAddOnCommit; private final Set<Object> entriesMissedInCache; @Override public void putObject(Object key, Object value) { entriesToAddOnCommit.put(key, value); } @Override public void commit() { if (clearOnCommit) { delegate.clear(); } flushPendingEntries(); reset(); } private void flushPendingEntries() { for (Map.Entry<Object, Object> entry : entriesToAddOnCommit.entrySet()) { delegate.putObject(entry.getKey(), entry.getValue()); } } }

This explains a common phenomenon: while a transaction is uncommitted, other sessions querying the same data cannot see the newly cached content because it resides only in entriesToAddOnCommit; the shared cache remains empty. Only after commit() executes are the entries flushed.

The clearOnCommit flag is another detail worth noting. If an update executes within the transaction, this flag is set to true. On commit, the entire namespace's cache is cleared first, then the pending entries are written. Reversing this order would cause the old value to be written back after the update; MyBatis handles this correctly.

Namespace Isolation Causing Stale Data

Returning to the opening problem: second-level cache invalidation operates at the namespace level. ProductMapper's update only clears ProductMapper's cache. The list query in ProductListMapper, which joins the product table, stores its result under its own namespace, and no mechanism notifies it that the underlying data changed.

This is why the official documentation repeatedly states that second-level cache is only suitable for single-table operations. Any query involving multiple tables, where any of those tables is updated in a different namespace, renders that query's cache unreliable. The cache framework itself is not at fault; it simply never receives the "data changed" signal, which is the sole basis for invalidation logic.

Common pollution scenarios include: a list query joining a dictionary table — after the dictionary is updated, the list still shows old names until restart; a detail query with a count subquery — the main table changes but the count doesn't update, showing mismatched quantity and details; an order query joining the user table for nickname — after the user changes their nickname, historical order pages still show the old name.

Another scenario is cross-service updates: another service modifies the database directly, and this service has no awareness, so its cache never invalidates. This is especially prevalent during early service decomposition when multiple services read/write the same table without coordination.

The common thread is that the cache's lifecycle is decoupled from the scope of data changes. As long as the writer and the cache holder are not in the same namespace, the second-level cache cannot guarantee consistency.

Statement-Level Cache Switches

MyBatis provides two switches to control cache behavior at the statement level without altering global configuration.

<!-- Clear this namespace's cache before each query, suitable for high real-time requirements --> <select id="detail" resultType="com.demo.entity.Product" flushCache="true"> select * from product where id = #{id} </select> <!-- Completely bypass cache read/write, suitable for large result sets like reports --> <select id="reportList" resultType="com.demo.entity.Product" useCache="false"> select * from product where create_time > #{start} </select>

The annotation equivalent uses @Options, with identical effect. The enum values are compile-time checked.

@Options(flushCache = Options.FlushCachePolicy.TRUE) @Select("select * from product where id = #{id}") Product detail(@Param("id") Long id); @Options(useCache = false) @Select("select * from product where category = #{category}") List<Product> listByCategory(@Param("category") String category);

flushCache=true on a query is a heavy operation: it clears the entire namespace's cache on every query, effectively disabling most of the second-level cache for that namespace. Use it sparingly, only for frequently changing data that demands real-time consistency; applying it to a list page will noticeably degrade response time.

If two namespaces need to share the same cache, use cache-ref to point to the other namespace.

<mapper namespace="com.demo.mapper.ProductListMapper"> <cache-ref namespace="com.demo.mapper.ProductMapper"/> </mapper>

After this, both namespaces share one cache, and ProductMapper's update clears both sides. The cost: query statements in the two namespaces must not share the same id, otherwise identical CacheKeys would cause result collisions — a bug that rarely surfaces in unit tests but appears in production.

Redis Second-Level Cache Implementation

In a single-instance environment, PerpetualCache suffices. In multi-instance deployments, each JVM holds its own copy; after instance A updates, instance B's cache remains stale, and whether a user sees the new value depends on which instance the load balancer routes them to.

MyBatis provides a Cache interface; implementing a Redis-backed version requires implementing five methods.

public class RedisCache implements Cache { private final String id; private final RedisTemplate<String, Object> redisTemplate; private static final long EXPIRE_SECONDS = 600; public RedisCache(String id, RedisTemplate<String, Object> redisTemplate) { this.id = id; this.redisTemplate = redisTemplate; } @Override public String getId() { return id; } @Override public void putObject(Object key, Object value) { redisTemplate.opsForValue().set(redisKey(key), value, Duration.ofSeconds(EXPIRE_SECONDS)); } @Override public Object getObject(Object key) { return redisTemplate.opsForValue().get(redisKey(key)); } @Override public Object removeObject(Object key) { return redisTemplate.delete(redisKey(key)); } @Override public void clear() { Set<String> keys = redisTemplate.keys("mybatis:" + id + ":*"); if (keys != null && !keys.isEmpty()) { redisTemplate.delete(keys); } } private String redisKey(Object key) { return "mybatis:" + id + ":" + key.toString(); } }

A factory class is then provided to MyBatis to replace the default cache creation.

public class RedisCacheFactory { private static RedisTemplate<String, Object> template; public static void setTemplate(RedisTemplate<String, Object> t) { template = t; } public static Cache build(String id) { return new RedisCache(id, template); } }
<cache type="com.demo.cache.RedisCacheFactory"/>

Existing open-source implementations like mybatis-redis or MyBatis-Plus can be used directly. However, the clear() method's use of KEYS scanning is dangerous in production: with large data volumes it blocks Redis, and even a few thousand keys can stall the cluster for seconds. Replace with SCAN or maintain a per-namespace key set for safety. If KEYS must be kept, narrow the match pattern to a specific business prefix and set command timeout to tens of milliseconds to avoid a single cache clear dragging down the entire request chain.

CacheKey's toString() yields a deterministic string usable as a Redis key. However, CacheKey serialization is not guaranteed compatible across MyBatis versions; if the key format changes during an upgrade, all old cache entries become unreadable, manifesting as a sudden drop of hit rate to zero — monitoring would show it as an abrupt cache failure.

Conditions for Enabling Second-Level Cache

Summarizing the above, the conditions for safely using second-level cache are few; checking them against your scenario is faster than reading the documentation. The following four are necessary — if any is missing, do not enable it.

Data is read and written only within a single table, with no joins and no subqueries. This alone eliminates most real-world scenarios, as list queries rarely touch just one table.

All updates to that table go through the same namespace's Mapper — no JdbcTemplate, no stored procedures, no other services directly connecting to the database. Any missed write entry means no one is responsible for clearing that cache.

Short-term inconsistency is acceptable. Second-level cache invalidation relies on this namespace's update statements; any write bypassing them causes cache-data divergence, which can only be resolved by expiration.

Single-instance deployment, or the cache has been replaced with Redis and its serialization overhead is accepted. The combination of multi-instance deployment and local cache is fundamentally unusable because consistency cannot be guaranteed.

If any of these four conditions is not met, do not enable second-level cache. In real projects, the vast majority of queries fail the first condition, so the more common practice is to disable second-level cache globally, and for the few places that truly need caching, use Spring Cache or custom Redis logic, keeping full control over key design and invalidation timing. mybatis: configuration: cache-enabled: false Disabling it causes @CacheNamespace and <cache> tags to be ignored, so you don't have to delete them one by one; legacy code is automatically neutralized. This switch is ideal for new projects: start with it off, then selectively enable caching only where the need is clear and conditions are satisfied.

MyBatis's two-level cache design is not overly complex; the trouble lies in the misalignment between its invalidation boundaries and the actual scope of data changes. The first-level cache's boundary is the SqlSession; the second-level cache's boundary is the namespace; but business data changes are often cross-table and cross-service. When these three boundaries don't align, the cache diverges from the data. Deciding whether to enable caching is essentially asking whether these boundaries can be aligned — not whether caching can improve performance, because in single-table read-only scenarios performance has never been the issue.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

MyBatiscache configurationcache invalidationfirst-level cachesecond-level cacheRedis cachenamespace isolationCacheKeystale datatransactional cache
Java Tech Workshop
Written by

Java Tech Workshop

Focused on Java backend technologies, sharing fundamentals, multithreading, JVM, the Spring ecosystem, microservices, distributed systems, high concurrency, source‑code analysis, and practical experience. Continuously delivers high‑quality original content, interview guides, and learning roadmaps to help Java developers progress from beginner to advanced, enhancing technical skills and core competitiveness.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.