Spring Boot Caching: From @Cacheable to Multi-Level Consistency Defense
This comprehensive guide explores Spring Boot's caching abstraction, detailing @Cacheable execution flow, source code internals, four consistency strategies (Cache Aside, delayed double-delete, binlog), multi-level Caffeine+Redis architecture, and eight production pitfalls with troubleshooting techniques.
1. Problem Definition: Three Costs of Caching
Caching trades data replicas for speed, introducing three inherent costs:
Consistency (Dirty Data) : Database and cache are independent stores with no atomic synchronization. The core conflict: a write updates DB then deletes cache, but a concurrent read may miss, fetch stale DB value, and repopulate cache before the delete arrives.
Cache Failures (Three Brothers) :
Penetration : Queries for non-existent keys bypass cache to DB. Defense: cache empty objects (short TTL) or Bloom filter.
Breakdown : Hot key expires, causing concurrent back-to-origin. Defense: singleflight mutex — only one request fetches, others wait.
Avalanche : Mass key expiration or cache cluster crash. Defense: TTL random jitter (10–20%), cluster HA, service-side rate limiting.
Replica Proliferation (Multi-level) : L1 Caffeine (microseconds) handles head traffic; L2 Redis (milliseconds) handles long tail; DB is source of truth. Each level adds a staleness window.
2. Spring Cache Abstraction & Multi-level Structure
2.1 Three Roles
Cache: the slot — get/put/evict CacheManager: manages a set of named caches
Cache annotations: declarative entry points ( @Cacheable, @CachePut, @CacheEvict, @Caching, @CacheConfig)
Spring defines contracts, not implementations — same annotation works with Caffeine, Redis, Hazelcast, etc.
2.2 Annotation Responsibilities
@Cacheable: read path — check cache, execute method on miss, store non-null result @CachePut: write path — always execute method and force-refresh cache @CacheEvict: invalidation — delete cache (optionally before invocation) @Caching: compose multiple operations @CacheConfig: class-level defaults (cacheNames, etc.)
2.3 Implementation Selection
L1 Local : Caffeine — Microsecond, W-TinyLFU eviction for hot spots
L2 Remote : Redis Cluster — Millisecond, full replica
L3 Fallback : Database — Single source of truth
Caffeine chosen over Guava Cache because its W-TinyLFU (Count-Min Sketch + windowed admission) yields higher hit rates; it's Spring Boot's default local cache.
2.4 Multi-level Organization
Manual composition : read L1→L2→DB; write invalidates both layers + async fallback
Middleware : Redis-recommended pub/sub invalidation for L1 CompositeCacheManager only queries sequentially; it does not support coordinated multi-layer invalidation on write — custom wrapper required (see Section 6).
3. Core Flow: Complete @Cacheable Execution Chain
3.1 Proxy Origin
@EnableCachingimports CachingConfigurationSelector, registering a CacheAspectSupport + BeanFactoryCacheOperationSourceAdvisor AOP aspect. Beans matching pointcuts are proxied (JDK dynamic proxy or CGLIB). Annotation only works when called via the proxy — this.method() bypasses it entirely.
3.2 Eight-Step Execution
Caller → proxy object CacheInterceptor.invoke() → CacheAspectSupport.execute() Parse @Cacheable metadata
Generate key (KeyGenerator / SpEL) CacheResolver obtains Cache instance cache.get(key) Hit? → return value immediately
Miss → invoke target method
Result non-null? → cache.put(key, result) Return result
Four high-frequency trouble spots: key generation (step 4), why null isn't cached (step 9), condition evaluation timing (before step 6), exception behavior (step 8 aborts entire caching logic).
3.3 Key Generation Rules
Default SimpleKeyGenerator: no args → SimpleKey.EMPTY; one arg → arg itself; multiple → SimpleKey(array). Danger : single parameter used directly as key — if it's a custom object without proper equals/hashCode, every call misses. Production rule: always write explicit SpEL keys with business prefixes, e.g. @Cacheable(key = "'sku:' + #id").
3.4 Write & Invalidation Paths
@CachePut: execute method (update DB) → force put new value @CacheEvict: beforeInvocation=false (default) → delete after method success; true → delete before method. Trade-off: default retains stale cache on exception; true leaves cache empty on failure, relying on read repair.
3.5 Transaction Timing
@Transactional+ @CacheEvict on same method: cache deleted before commit. If transaction rolls back, DB reverts but cache is already gone — harmless miss. Danger : @CachePut inside transaction writes new value to cache; rollback leaves cache new, DB old — dirty until TTL. Iron law : cache writes must occur after transaction commit. Use TransactionSynchronization.afterCommit or place cache operation in outer non-transactional method.
4. Core Source Code: Five Decisive Classes
4.1 CacheInterceptor: Entry Point
public class CacheInterceptor extends CacheAspectSupport implements MethodInterceptor, Serializable { public Object invoke(final MethodInvocation inv) throws Throwable { return invokeWithinInvocation(() -> inv.proceed(), inv); } }Thin wrapper; all logic in parent CacheAspectSupport.execute(). Same pattern as TransactionInterceptor.
4.2 CacheAspectSupport.execute(): Core Engine
private Object execute(...) { CacheOperationContext opCtx = getOperationContext(...); if (!opCtx.isConditionPassing()) { return invokeOperation(); } Cache cache = ...; ValueWrapper wrapper = cache.get(key); if (wrapper != null) { return wrapper.get(); } Object result = invokeOperation(); if (result != null) { cache.put(key, result); } return result; }Two critical details: condition SpEL short-circuits entire caching logic (no read, no write); null results are intentionally not cached — prevents cementing "not found" but is the root cause of penetration.
4.3 CaffeineCache: Local Wrapper
public class CaffeineCache implements Cache { private final com.github.benmanes.caffeine.cache.Cache<Object, Object> cache; public ValueWrapper get(Object key) { Object value = this.cache.getIfPresent(key); return (value != null ? new ValueWrapper(value) : null); } public void put(Object key, Object value) { this.cache.put(key, value); } }Near-transparent delegation. Real interest is Caffeine's W-TinyLFU: Count-Min Sketch for frequency + admission window. Capacity and expiry configured on Caffeine builder, not Spring annotations.
4.4 RedisCache: TTL Three Sources
RedisCache.put: ├─ compute TTL │ ├─ RedisCacheConfiguration.entryTtl(Duration) │ ├─ RedisCacheWriter TTL function (dynamic) │ └─ no config → NEVER EXPIRES! └─ serialize & write (value via RedisSerializer)RedisCache defaults to no expiration — common cause of unbounded memory growth. Must declare explicit entryTtl per cacheNames via RedisCacheManager.builder.withCacheConfiguration.
4.5 RedisCacheWriter: Batch Pipeline
Default DefaultCacheWriter writes singly. BatchStrategies: simple (default, one-by-one) vs batch (pipeline/mset). clean() (evict all) uses keyspace scan — performance bomb when key count is high ; avoid @CacheEvict(allEntries=true) in production.
5. Consistency Defense: Four Strategies Evolved
Scenario: product price changes from 100 → 99.
5.1 Update Cache → Update DB (Discarded)
T1 update cache = 99 T2 update DB FAILS! → cache 99, DB 100 → dirty until TTLCache is volatile/rebuildable; DB is truth. Letting volatile side move first exposes irreversible state.
5.2 Update DB → Update Cache (Discarded)
Thread A: DB=99, preparing cache write Thread B: DB=98, preparing cache write Thread B writes cache=98 (later) Thread A writes cache=99 (slower) → cache=99, DB=98 DIRTY!Concurrent writes can reach cache in reverse commit order. Locking is too costly; solution: don't update cache, delete it.
5.3 Cache Aside: Update DB → Delete Cache (Default)
Read thread: miss → read DB → repopulate Write thread: update DB → delete cache Race window (normal): T1 write commit DB=99 T2 read miss, reads 99 T3 write delete cache (no-op) T4 read repopulates 99 → consistent ✓ Reverse window (rare): T1 read miss, reads DB=100 (stale) T2 write commit DB=99 & delete cache T3 read repopulates 100 → DIRTY! (requires read slower than write + precise interleaving; low probability + TTL self-heals)Industry default: best cost/benefit. Residual risk bounded by TTL; for strong-consistency fields, simply don't cache.
5.4 Delayed Double-Delete & Binlog Subscription (Strong Consistency)
Delayed double-delete : delete cache → update DB → delay N ms → delete cache again (clears any stale repopulation during window). N ≈ read-miss-to-repopulate latency (500 ms–1 s), executed async via MQ/scheduler.
Binlog subscription : DB commit → Canal/Debezium reads binlog → delete/update corresponding cache. Decouples, guarantees DB-order consistency. Adds operational components; overkill for small/medium scale.
5.5 Strategy Comparison
Cache→DB : Window — Large; Cost — Low; Verdict — Discarded
DB→Cache : Window — Medium; Cost — Low; Verdict — Discarded
Cache Aside : Window — Small; Cost — Low; Verdict — Default
Double-delete / Binlog : Window — Minimal; Cost — Medium-High; Verdict — Only when short-window dirty read causes direct revenue loss
Selection principle : default Cache Aside + TTL; upgrade only when business cannot tolerate even brief staleness.
6. Multi-level Cache Implementation: Caffeine + Redis
6.1 Read Path
get(key): ├─ L1 Caffeine.getIfPresent → hit → return (µs) ├─ L2 Redis.get → hit → backfill L1 → return └─ DB → backfill L2 (with TTL) → backfill L1 (shorter TTL) → returnKey design : L1 TTL << L2 TTL (e.g., L1 30 s, L2 30 min). L1 staleness auto-heals in seconds; L2 carries hit rate, L1 carries ultra-low latency.
6.2 Write Path & Cross-node Invalidation
put/evict(key): ├─ delete L2 (Redis) ├─ delete local L1 └─ broadcast invalidation (pub/sub) → other nodes receive → delete their L1 Each node's L1 differs but all have TTL self-healPub/sub is best-effort (node restart loses messages). Therefore L1 short TTL is not an optimization — it's a correctness requirement .
6.3 Minimal Two-level Cache
public class L2Cache implements Cache { private final Cache l1; // Caffeine private final Cache l2; // Redis private final Publisher pub; public ValueWrapper get(Object k) { ValueWrapper v = l1.get(k); if (v != null) return v; v = l2.get(k); if (v != null) { l1.put(k, v.get()); // backfill with shorter TTL } return v; } public void evict(Object k) { l2.evict(k); l1.evict(k); pub.broadcast((String) k); } }Implementation notes: L2 hit → backfill L1 with shorter expiry; broadcast channel per cacheNames to avoid unrelated invalidation storms.
6.4 What Data Fits Multi-level Cache
Suitable : Read-heavy, write-light (product, config, dictionary); tolerates second-level staleness; bounded key space; small values (<100 KB)
Unsuitable : Write-then-read (order query after placement); strong consistency (balance); unbounded key growth; large objects / collections
Large keys in Caffeine quickly consume heap and trigger GC spikes — L1 only for small, hot objects.
7. Practical Integration: Product Service
7.1 Dependencies & Cache Config
spring: cache: type: redis data: redis: host: redis.prod @Bean RedisCacheManager cacheManager(RedisConnectionFactory f) { RedisCacheConfiguration def = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(30)) .serializeValuesWith(fromSerializer(new GenericJackson2JsonRedisSerializer())); return RedisCacheManager.builder(f) .cacheDefaults(def) .withCacheConfiguration("product", def.entryTtl(Duration.ofMinutes(60))) .withCacheConfiguration("product:stock", def.entryTtl(Duration.ofSeconds(5))) .build(); }7.2 Read Annotations
@Cacheable(cacheNames = "product", key = "'id:' + #skuId") public Product getProduct(Long skuId) { return productMapper.selectById(skuId); } // Anti-penetration: cache null with short TTL @Cacheable(cacheNames = "product", key = "'id:' + #skuId", unless = "#result == null") // Note: unless excludes null; to cache null need custom Cache7.3 Write Path: Cache Aside via Annotations
@CacheEvict(cacheNames = "product", key = "'id:' + #cmd.skuId") @Transactional public void updatePrice(UpdatePriceCmd cmd) { productMapper.updatePrice(cmd); } // Cache delete occurs after transaction commit (proxy handles ordering — see 3.5 & Pitfall 4)High-churn stock: 5-second TTL short cache absorbs read spikes + DB row lock for deduction . Never cache real-time "remaining stock" — consistency battle you'll lose.
7.4 Before/After Metrics
Product detail P99 : Before — 85 ms; After — 3 ms
DB read QPS : Before — 12k; After — 600
Promo peak DB spike : Before — Yes; After — None (breakdown protection)
Price change visibility : Before — Instant; After — ≤60 s (TTL)
Cost : Before — None; After — Invalidation broadcast infra
8. Eight Pitfalls & Troubleshooting Guide
8.1 Eight High-frequency Pitfalls
Self-invocation : this.method() bypasses proxy. Fix: extract to separate bean or inject self-proxy via AopContext.currentProxy().
RedisCache default no TTL : memory creeps up. Every cacheNames must have explicit TTL.
Cache write inside transaction : rollback leaves cache new, DB old. Move cache ops after commit.
@CacheEvict(allEntries=true) abuse : underlying keyspace scan — performance bomb with many keys.
Large keys/collections in cache : serialization slow, network heavy, Caffeine OOM. Keep single value <100 KB.
Uniform TTL at exact hour : avalanche recipe. Add 10–20% random jitter.
Null caching dilemma : anti-penetration needs empty-value caching (short TTL), but unless="#result==null" excludes null — pick one; caching null requires custom Cache implementation.
Multi-level cache missing a layer on invalidation : write path must delete L1, L2, broadcast — omit one = persistent dirty reads.
8.2 Symptom-based Troubleshooting
Frontend doesn't reflect update : check write path actually deleted cache → self-invocation? transaction rollback? multi-level missed a layer?
Periodic DB spikes : TTL aligned to hour? hot keys expiring together? → TTL jitter + breakdown mutex.
Abnormally low hit rate : key generation stable? (object missing equals) → key contains per-request changing value?
Redis memory grows unbounded : any cacheNames missing TTL? large keys continuously written?
8.3 Essential Diagnostic Switch
logging.level.org.springframework.cache=TRACE # Output example: # Cache 'product': condition evaluated..."true" # Cache 'product': entry hit under key 'id:1001' # (Instantly reveals miss vs condition short-circuit vs proxy not applied)9. Learning Path & 12 Interview Questions
9.1 Four Stages
Stage 1 — Wire it up (1 day) : @EnableCaching + three annotations, explicit TTL + explicit keys. Acceptance: hit-rate dashboard visible.
Stage 2 — Defend (1 week) : penetration/breakdown/avalanche countermeasures, empty-value caching, mutex back-to-origin, TTL jitter. Acceptance: chaos injection survives.
Stage 3 — Attack/Defend (2 weeks) : Cache Aside sequence reasoning, delayed double-delete, binlog subscription, transaction-cache timing. Acceptance: concurrent write-read stays clean.
Stage 4 — Design (ongoing) : Caffeine+Redis two-level wrapper, broadcast invalidation, tiered TTL. Acceptance: promo load test passes.
9.2 Twelve Interview Questions (with answers)
@Cacheable execution chain? Proxy intercept → parse ops → generate key → get → miss execute method → non-null backfill.
Why same-class call fails? this is raw target, not proxy; AOP never runs.
Why Cache Aside deletes instead of updates? Concurrent updates race and reverse order in cache; delete is idempotent.
Cache Aside residual dirty window? Read repopulate slower than write commit interleaving; low probability, TTL heals.
Penetration/breakdown/avalanche defenses? Empty-value/Bloom; mutex back-to-origin; TTL jitter + cluster + rate limit.
Delayed double-delete two deletes purpose? First clears pre-write stale; second clears window repopulation.
Transaction-cache timing rule? Cache ops after commit, prevent rollback leaving cache new/DB old.
Why null not cached? Cost? Avoid cementing "not found"; cost is penetration — mitigate with empty-value cache or Bloom.
Why Caffeine fast? W-TinyLFU hit rate > LRU; in-process, zero network.
Multi-level consistency? Write: triple delete (L1/L2/broadcast) + L1 short TTL self-heal.
RedisCache default TTL? Never expires — must declare entryTtl explicitly.
High-churn stock handling? No long cache; ultra-short TTL absorbs spike + DB row lock deduction.
10. One-page Cheatsheet
[Essence] Replica for speed Costs: dirty / breach / multi-layer [Chain] Proxy → parse → key → get → miss exec → non-null backfill [Invalidation root cause] this-call bypasses proxy [Key] Explicit SpEL + biz prefix Param objects override equals [Write path] Cache Aside: update DB first, then delete cache Delete is idempotent, update races [Safety net] TTL is last line Add 10-20% jitter [Three brothers] Penetration → empty value Breakdown → mutex back-source Avalanche → simultaneous expiry [Strong consistency] Double-delete / Binlog Only upgrade for revenue-impact scenarios [Multi-level] L1 Caffeine short TTL L2 Redis long TTL Write: triple delete [Red lines] No cache write inside transaction RedisCache defaults to no expiry allEntries use with caution [Stock-like] Ultra-short TTL + DB row lock No real-time cacheCaching is trivial to launch in a day; its complexity lives after launch: every hit-rate number hides a business decision — "how long may this replica stay dirty?" Hand-trace Cache Aside timing once, treat TTL and invalidation broadcast as correctness config not performance knobs, and the system stands.
If you take one sentence: All cache design answers "how long can this copy be stale, and who heals it when it is?" TTL is the cheapest healer; get its jitter and tiering right and you've won most scenarios.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
