Spring Cache Inconsistency: Write Timing vs Transaction Commit Order

This article explains how Spring's CacheInterceptor and TransactionInterceptor execution order causes cache-database inconsistency when cache writes occur before transaction commit, and presents solutions including explicit order configuration, TransactionAwareCacheManagerProxy, and afterCommit callbacks to ensure cache updates only reflect committed transactions.

Java Tech Workshop
Java Tech Workshop
Java Tech Workshop
Spring Cache Inconsistency: Write Timing vs Transaction Commit Order

A production incident revealed a subtle cache-database inconsistency: a price update failed validation and rolled back, yet the cache held the new price with no TTL, causing subsequent reads to return stale data. The root cause was not misconfiguration but the execution order of two AOP interceptors on the same proxy.

Two Interceptors on One Call Chain

@Cacheable

, @CachePut, @CacheEvict are handled by CacheInterceptor; @Transactional by TransactionInterceptor. Both are around advices on the same proxy. When a method is invoked, the advices execute in order of their order property: lower order enters first and exits last.

@Service
public class SkuPriceService {

    @Transactional(rollbackFor = Exception.class)
    public void updatePrice(Long skuId, BigDecimal price) {
        skuMapper.updatePrice(skuId, price);
        // Commit hasn't happened yet, but cache is already written
        cache.put("sku:" + skuId, skuMapper.selectById(skuId));
        if (price.signum() < 0) {
            throw new IllegalArgumentException("价格不能为负");
        }
    }
}

The cache.put sits inside the transactional method before the throw. On rollback the database update is undone, but the cache write has already taken effect — leaving the two stores permanently out of sync until manual eviction.

Reversing the Order Makes It Safe

If the cache interceptor wraps the transaction interceptor (cache outside, transaction inside), the inner method returns only after commit or rollback. The cache write then occurs after the transaction outcome, so a rollback path never reaches the put. This is why "putting cache logic in another bean" often works: layering fixes the nesting order.

Same Method, Two Annotations — Order Is Non-Deterministic

In Spring Boot both TransactionInterceptor and CacheInterceptor default to Ordered.LOWEST_PRECEDENCE ( Integer.MAX_VALUE). With identical priority, which advisor registers first depends on auto-configuration load order — not covered by Spring's compatibility guarantees. A minor version upgrade or configuration change can flip the order. Observing "cache writes after commit" on one version is an accident, not a rule.

To make it deterministic, declare explicit orders:

@EnableCaching(order = Ordered.LOWEST_PRECEDENCE - 1)
@EnableTransactionManagement(order = Ordered.LOWEST_PRECEDENCE)
@SpringBootApplication
public class Application { }

Smaller order = outer layer. Cache interceptor enters first, exits last, so its put runs after commit. However, this relies on global config; another @EnableCaching in a new module can reset the order silently.

Prefer Separation Over Ordering

The author recommends not mixing cache and transaction annotations on the same method. Use @Cacheable with @Transactional(readOnly = true) for reads — even if order flips, no dirty data is produced because reads don't write. For writes, keep only @Transactional and move cache operations to a separate bean or execute them explicitly in an afterCommit callback.

Defer Writes Until After Commit

Spring provides TransactionAwareCacheDecorator which wraps a Cache and intercepts put, evict, clear, registering them as transaction synchronizations to run at afterCommit. On rollback the callbacks are discarded. Without an active transaction it passes through unchanged. Apply it at the CacheManager level:

@Bean
public CacheManager cacheManager(RedisConnectionFactory factory) {
    RedisCacheManager target = RedisCacheManager.builder(factory)
            .cacheDefaults(RedisCacheConfiguration.defaultCacheConfig()
                    .entryTtl(Duration.ofMinutes(30)))
            .build();
    // All cache put/evict wait until transaction commits
    return new TransactionAwareCacheManagerProxy(target);
}

Spring Boot's RedisCacheManager does not wrap caches this way by default, so @CachePut hits Redis immediately. Adding the proxy ensures rolled-back transactions never leave stale entries in Redis. Note: putIfAbsent and evictIfPresent cannot be deferred (they return values), so the decorator executes them immediately — use with caution. @Cacheable(sync = true) uses get(key, loader) and is unaffected. Direct calls to cache.putIfAbsent() bypass the protection.

Manual Callback Registration

Without global config, register a synchronization inside the transactional method:

TransactionSynchronizationManager.registerSynchronization(
    new TransactionSynchronization() {
        @Override
        public void afterCommit() {
            cache.evict("sku:" + skuId);
        }
    });

Same effect, but every cache-write site must remember to add it; a single omission creates a permanent dirty value.

Cache Eviction Has the Same Time Window

@CacheEvict

runs after normal return by default. If it sits inside the transaction interceptor, eviction occurs before commit. A concurrent reader (under read-committed isolation) sees the old database value, loads it into cache, then the transaction commits — leaving stale data until expiry. The window scales with transaction length (remote calls, batch updates can stretch it to hundreds of milliseconds). Common mitigation: delayed double delete — evict immediately after update, then again after a short delay (typically master-slave sync latency, hundreds of ms to 1s). This reduces but cannot eliminate the race; for price/inventory sensitivity, add version numbers or logical expiry to cache entries.

Another @CacheEvict nuance: by default beforeInvocation=false, so it does not run on exception. If the inner layer swallows the exception and only marks rollback, the outer cache interceptor sees a normal return and evicts anyway. The cache is cleared, data unchanged, next read reloads the old value — harmless but wasteful.

Cache Hit Means No Transaction Starts

When @Cacheable hits, the method body is skipped. The outer @Transactional also never activates because the transaction interceptor is inside the cache interceptor and never gets invoked. For pure reads this is fine. But if the method body contains side effects (metrics, permission checks, lazy-loading associations), hit and miss paths diverge: metrics go unreported, checks bypassed, LazyInitializationException on serialization because no Session exists. These bugs surface only in production where hit rates exceed 90%. Fix: extract non-query logic out of the method; if impossible, the method shouldn't carry @Cacheable.

Only Two Reliable Patterns

To guarantee "cache value comes from a committed state" you have exactly two options: (1) perform cache writes after commit — via TransactionAwareCacheManagerProxy or layering — or (2) embed a version in the cached value so it can self-validate. Relying on annotation order is gambling. When debugging cache-db inconsistency, verify three things: whether cache write is inside or outside the transactional method, whether the CacheManager is wrapped by TransactionAwareCacheManagerProxy, and the transaction isolation level plus cache TTL. These three pinpoint the offending line in most cases.

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.

CachetransactionSpringcache consistencyafterCommitCacheInterceptorTransactionAwareCacheManagerProxyTransactionInterceptor
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.