Caching & Resilience
Two techniques that keep a Spring service fast and alive: caching removes repeated work, and Resilience4j (retry, circuit breaker, rate limiter, bulkhead, time limiter) contains failures. This page shows what the proxy does on every call, how Caffeine and Redis differ, why caches stampede and go stale, how the breaker state machine decides, and how to order the annotations - then builds a cache and a breaker from scratch in plain Java.
Repeated Work and Failing Dependencies
Every production Spring service eventually meets two physical facts. First, some work is expensive and repeated: the same product, the same exchange rate, the same permission set is loaded thousands of times a minute, and the answer changes once an hour. Second, every remote dependency you call will, sooner or later, be slow, wrong, or gone. Caching attacks the first fact by not doing the work again. Resilience patterns attack the second by containing the damage when a dependency misbehaves. They look like different topics but share one trait in Spring: both are applied as annotations on a proxy, so they share the same traps (self-invocation, proxy visibility, ordering).
The second half of the picture matters more than most teams expect. A slow dependency is far more dangerous than a dead one: a dead one fails fast, but a slow one lets requests pile up, each holding a servlet thread (Tomcat defaults to 200) until the whole service stops answering - even for endpoints that never touch the slow dependency. This is a cascading failure, and it is what retry limits, timeouts, bulkheads and circuit breakers exist to stop.
HashMapshared across request threads: lost updates and, historically, infinite loops during resize. Use a real cache, never a bare map.- No eviction policy: the map grows until
java.lang.OutOfMemoryError: Java heap space. Symptom: heap graph climbs for days, GC time rises, pod restarts. - No TTL: price changes never appear. The bug report reads "we updated the price but customers still see the old one until restart".
- The caching logic is tangled into business code, so it cannot be turned off for a test or tuned per environment.
The method body is now only the business call. Whether the result is cached, for how long, in which store, and what happens when the call fails are all policy - changeable in configuration without touching the code. The rest of this page shows exactly how each piece works at runtime so that you can predict its behaviour instead of trusting it.
Interceptors on a Proxy
Both features are implemented the same way: Spring wraps your bean in a proxy (see AOP & Proxies) whose interceptor runs logic before and after your method. For caching that interceptor is CacheInterceptor, a subclass of CacheAspectSupport, registered by @EnableCaching (Boot turns this on automatically when spring-boot-starter-cache is present and a CacheManager can be built). For resilience it is a set of Resilience4j aspects (CircuitBreakerAspect, RetryAspect, ...) registered by the Resilience4j Spring Boot starter on top of Spring AOP.
@CacheableAnnotationCacheOperationSource and builds a CacheOperation (cache names, key expression, condition) per methodCacheInterceptor.invoke runs first, evaluates the key (SpEL or KeyGenerator) and asks the CacheManager for the named CacheCache.get(key) returns a valueCache.put(key, result), then returns the result. @CachePut always does the invoke+put; @CacheEvict does evict(key)this.priceFor(1) from the same class- Only public methods called through the proxy are intercepted;
privatemethods can never be intercepted, and relying on non-public visibility is fragile. - The key is computed before the method runs, so a SpEL key can use parameters (
#id,#p0,#root.methodName) but#resultis only available inunless(and in@CacheEvict/@CachePutexpressions evaluated after invocation). - Caching and resilience compose: a
@Cacheablehit returns without ever entering a@CircuitBreakerthat sits below it in the chain.
@Cacheable, @CachePut, @CacheEvict and the Key
The abstraction lives in org.springframework.cache. You program against two small interfaces - CacheManager (a registry: getCache(name)) and Cache (get, put, evict, clear, and get(key, Callable) for atomic load) - and the annotations decide which operations to apply.
| Annotation | Runs method? | Cache effect | Typical use |
|---|---|---|---|
@Cacheable | Only on miss | get; on miss invoke then put | Read-through lookups |
@CachePut | Always | Invoke then put (overwrites) | Update methods that return the new value |
@CacheEvict | Always | evict(key) or clear() with allEntries=true; beforeInvocation flag | Delete / update where the new value is not returned |
@Caching | Per its parts | Groups several of the above on one method | Evict from two caches, put into a third |
@CacheConfig(cacheNames = "products") on the class supplies defaults (cache name, key generator, manager) so that methods do not repeat them.
| Situation | Key used | Consequence |
|---|---|---|
No key attribute, no params | SimpleKey.EMPTY | One entry per cache - fine for "load all config", wrong if you expected per-user data |
No key, exactly one param | The param itself | Uses its equals/hashCode; a mutable key object that changes after caching can never be found again |
No key, several params | new SimpleKey(params...) | All params participate - including a Pageable or Locale you did not mean to key on |
key = "#id" (SpEL) | Evaluated expression | Needs parameter names: compile with -parameters (Boot parent/Gradle plugin do) or use #a0/#p0 |
keyGenerator = "myGen" | Your KeyGenerator bean | Use for conventions such as prefixing tenant id; key and keyGenerator are mutually exclusive |
| Attribute | Evaluated | Can see | Use it for |
|---|---|---|---|
condition | Before invocation (and before the lookup) | Parameters only | Skip the cache entirely for some inputs - neither read nor write |
unless | After invocation | Parameters and #result | Look up normally but refuse to store some results (null, empty, error markers) |
sync = true | On miss | - | Make concurrent misses for one key run the method once (single cache only) |
- Self-invocation:
this.find(id)from another method in the same class bypasses the proxy. Symptom: the log line "DB hit" appears on every call. Fix: move the cached method to another bean or inject the bean into itself via a collaborator. - Cached
Optional: Spring unwrapsOptionalreturn values and stores the content, soOptional.empty()is stored asnull(when null caching is allowed). Combine withunless = "#result == null"- note that SpEL#resultrefers to the unwrapped value. - Mutable cached objects: with an in-memory cache the same instance is handed to every caller. One caller adds an element to a cached
Listand every other caller sees it. Return immutable records or defensive copies. - Missing
-parameters:key = "#id"evaluates tonulland Spring throwsjava.lang.IllegalArgumentException: Null key returned for cache operation. Fix the compiler flag or use#p0. - @Cacheable on a method that is also @Transactional: the cache write happens after the method returns but before the transaction commits if the cache interceptor is inner; with a later rollback the cache holds data that never existed. See chapter 05.
- Caching inside a controller or repository interface default method works only if that bean is proxied; always confirm with a second call and a log line.
Caffeine vs Redis, TTL and Serialization
Spring Cache is only the front door; the provider decides speed, memory, TTL, eviction and visibility across instances. Boot auto-configures one when it finds it on the classpath (spring.cache.type overrides auto-detection; none disables caching). Without any provider you get a ConcurrentMapCacheManager: unbounded, no TTL, fine for tests only.
| Aspect | Caffeine (local) | Redis (shared) |
|---|---|---|
| Where data lives | JVM heap of each pod | Separate server / cluster |
| Read latency | Tens to hundreds of nanoseconds | Network round trip, typically around a millisecond |
| Eviction | Size-based (W-TinyLFU), time-based, weak/soft refs | TTL, plus Redis maxmemory-policy |
| Per-cache TTL in Spring | Needs per-cache config (custom manager); the Boot spec is global | RedisCacheConfiguration.entryTtl per cache name |
| Consistency across pods | None - every pod has its own copy | Single copy, shared |
| Values | Any object, no serialization | Must serialize (JDK by default, JSON recommended) |
| Survives restart | No | Yes (subject to persistence config) |
| Failure mode | OOM if unbounded; cold start | Redis down: every call errors unless you handle it |
Key format is <prefix><cacheName>::<key>. The @class property is what lets the generic JSON serializer rebuild the right type - and also why renaming or moving a class makes old entries undeserializable.
- Default JDK serialization: caching a class that is not
Serializablefails withorg.springframework.data.redis.serializer.SerializationException: Cannot serialize(causejava.io.NotSerializableException). Records and DTOs needimplements Serializableor switch to JSON. - Deploy changes the class shape: a rolling deploy mixes old and new pods reading each other's entries. Symptom:
SerializationException: Could not read JSON/InvalidClassExceptionspikes during deploys. Fix: version the cache prefix (v3) or tolerate unknown properties. - No TTL on Redis:
defaultCacheConfig()has no expiry, so the dataset only grows until Redis evicts or you flush. Always setentryTtl. - Redis outage = application outage: a connection failure surfaces as
RedisConnectionFailureExceptionfrom the cache call, failing the request. Provide aCacheErrorHandler(viaCachingConfigurer.errorHandler()) that logs and falls through to the real method. - One global Caffeine spec:
spring.cache.caffeine.specgives every cache the same size/TTL. If you need different TTLs, build caches explicitly as above. - No
recordStats: Caffeine hit/miss metrics are all zero; you will think the cache is unused.
Note the trade-off in the handler above: failing open on evict errors can leave stale data in the cache. For correctness-critical evictions rethrow instead, or rely on a short TTL as the safety net.
Stampede, Penetration and Consistency
A cache is a second copy of the truth, and every second copy can disagree with the first. Three failure families account for almost every cache incident: stampede (a hot key expires and everyone recomputes at once), penetration (requests for keys that do not exist bypass the cache every time) and staleness (the cache keeps serving what the database no longer says).
| Problem | What happens | Fix | Spring tool |
|---|---|---|---|
| Stampede / thundering herd | One hot key expires; N threads miss and query the DB simultaneously | Single-flight (one loader per key), refresh-ahead, serve stale while revalidating | @Cacheable(sync = true); Caffeine refreshAfterWrite with a LoadingCache |
| Avalanche | Many keys share the same TTL and all expire together after a bulk load or restart | Randomize TTL (jitter), stagger warm-up | Custom RedisCacheWriter / TTL function (RedisCacheConfiguration.entryTtl(TtlFunction) in newer Spring Data Redis) |
| Penetration | Lookups for non-existent ids (typos, scans, attackers) miss every time and hit the DB | Cache the negative result briefly; Bloom filter; validate id format first | Allow null caching with a short-TTL cache; do not use unless=#result==null there |
| Stale data | Writer updated DB, cache still holds old value | Evict/put on write, short TTL as safety net, versioned keys | @CachePut, @CacheEvict, transactionAware() |
| Inconsistent evict timing | Evict runs, then a concurrent reader re-populates from the not-yet-committed old row | Evict after commit, optionally delayed double-delete | TransactionAwareCacheManagerProxy, @TransactionalEventListener(AFTER_COMMIT) |
With sync = true the interceptor calls Cache.get(key, Callable) instead of get + put, so the provider can take a lock around the load. Constraints: one cache only, no other cache operation on the method, and the guarantee is only as strong as the provider. Caffeine gives you exact single-flight inside one JVM. With a shared Redis cache the default lock is per JVM, so ten pods can still send ten loads - add a distributed lock or a short randomized refresh if the load is truly expensive.
| Strategy | Write path | Read path | Risk |
|---|---|---|---|
| Cache-aside (what @Cacheable does) | Update DB, then evict the key | Miss: load from DB, then put | Race: a reader that loaded the old row before the update can put it back after your evict |
| Write-through | Update DB and cache together (@CachePut) | Reads always hit cache | Two writes, not atomic; a failed cache write leaves old data |
| Write-behind | Update cache, flush to DB asynchronously | Reads hit cache | Data loss if the node dies before flush - rarely right for business data |
| TTL only | Nothing | Entry expires on its own | Staleness window = TTL; simplest, often good enough for reference data |
- Evict-before-commit race: thread A evicts inside its transaction; thread B reads, misses, loads the old committed row and caches it; A commits. The cache is stale until TTL. The after-commit evict above (or
transactionAware()) narrows the window; a short TTL closes it. unless = "#result == null"on a public lookup: every request for a missing id reaches the DB. During a scan or bot attack the DB sees the full request rate. Cache the miss for a few seconds instead.allEntries = trueon a large Redis cache: Spring clears by pattern scan/delete of the cache's key prefix, which can be slow on millions of keys and blocks other work. Prefer targeted evicts or version the key prefix.- Caching per-user data under a global key: a key that omits the user id serves one user's data to another - a security incident, not a performance bug. Review every key expression for the full set of inputs that affect the result.
- Diagnosis: temporarily log hit/miss with the cache metrics (chapter 12), compare the cached value's timestamp with the row's
updated_at, and look for the exact write path that skipped the evict (bulk SQL updates, other services, migrations).
A Tiny Cache with TTL, LRU and Single-Flight
To see why single-flight matters and how TTL and LRU really work, here is a ~60-line cache in plain Java - no Spring. It has lazy TTL expiry (checked on read, using an injectable clock so the demo is deterministic), LRU eviction through an access-ordered LinkedHashMap, and a ConcurrentHashMap of in-flight loads so concurrent misses share one loader call - the same idea as sync = true and Caffeine's LoadingCache.
- Expiry is an inclusive boundary in this design: at
t=999the entry lives, att=1000it is gone. Real caches define this precisely too; Caffeine expires lazily and also cleans up on write. - LRU needs access order: touching
abefore insertingcmadebthe eviction victim. Caffeine uses the smarter W-TinyLFU policy, which also resists scans that would flush a pure LRU. - The 8-thread run is real concurrency: one thread becomes the leader, seven
join()its future. Remove theinFlightmap and the printed number rises to between 2 and 8, depending on timing. - This version has a flaw worth finding: a loader that returns
nullcaches nothing and followers receivenull- exactly the negative-caching decision from chapter 05.
Retry, Backoff and Timeouts
Everything from here on is Resilience4j, the library Spring Boot teams use for fault tolerance (Hystrix is end-of-life; Spring Cloud CircuitBreaker is a thin abstraction that can sit on top of it). For Boot 3 you add io.github.resilience4j:resilience4j-spring-boot3, spring-boot-starter-aop (the annotations are AOP aspects) and spring-boot-starter-actuator (for health, metrics and event endpoints). Each pattern is a separate decorator with its own name-keyed configuration under resilience4j.<pattern>.instances.<name>.
Retry re-invokes a failed call after a wait. It is the cheapest tool and the easiest to misuse: retries multiply load on a dependency that is already struggling and are only safe for idempotent operations (GET, PUT, a POST with an idempotency key). Retrying a non-idempotent payment POST can charge twice.
- Retrying everything: without
retryExceptions, a400 Bad Request(a bug in your request) is retried three times. Limit retries to transient errors: timeouts, connection resets, 502/503/504. - maxAttempts off by one:
maxAttempts: 3means one call plus two retries. - Blocking waits: annotation-based retry sleeps on the request thread. With 3 attempts and a 2 s backoff, one bad dependency holds threads for seconds; combine with a short timeout and a bulkhead.
- Retry amplification: if service A retries 3x against B, and B retries 3x against C, a failing C receives 9 calls per original request. Retry at one layer only.
- Retry +
@Transactionalin the same method: each attempt must run in a fresh transaction. Put@Retryon the outer caller or ensure the retry aspect wraps the transactional proxy, never the other way round.
A retry, a breaker or a bulkhead cannot help if a call never returns. Set connect and read timeouts on the HTTP client itself - on Spring Boot 3.4+ via spring.http.client.connect-timeout and spring.http.client.read-timeout, or on the request factory (JdkClientHttpRequestFactory, HttpComponentsClientHttpRequestFactory) for earlier versions. Choose them from the dependency's p99 latency, not from hope. Resilience4j's TimeLimiter (chapter 09) is for async calls; it cannot interrupt a blocking socket read.
The State Machine, Window and Thresholds
A circuit breaker watches recent calls and, once the dependency looks unhealthy, stops calling it for a while. Rejected calls fail in microseconds with io.github.resilience4j.circuitbreaker.CallNotPermittedException instead of tying up a thread for a timeout. That does two things: the caller's threads stay free, and the sick dependency gets quiet time to recover instead of being hammered by retries.
| Config key | Default | Meaning |
|---|---|---|
slidingWindowType | COUNT_BASED | Window is the last N calls, or (TIME_BASED) the last N seconds |
slidingWindowSize | 100 | N for the window above |
minimumNumberOfCalls | 100 | No tripping until this many calls are recorded - the most common reason "the breaker never opens" in tests |
failureRateThreshold | 50 (%) | Failure % at/above which the breaker opens |
slowCallDurationThreshold / slowCallRateThreshold | 60 s / 100 % | Calls slower than the duration count as slow; open when the slow % reaches the rate. Default effectively disables slow-call tripping |
waitDurationInOpenState | 60 s | How long to stay OPEN before allowing probes |
permittedNumberOfCallsInHalfOpenState | 10 | Probe calls in HALF_OPEN; their failure rate decides CLOSED vs OPEN |
automaticTransitionFromOpenToHalfOpenEnabled | false | false: the OPEN→HALF_OPEN move happens when the first call arrives after the wait. true: a background timer does it |
recordExceptions / ignoreExceptions | any exception recorded | Which exceptions count as failures; ignored ones are neither success nor failure |
- Breaker never opens in a test:
minimumNumberOfCallsdefaults to 100. Ten failing calls will not trip a default breaker. Lower it for tests or send enough calls. - Fallback swallows the failure: if the method itself catches the exception and returns a default, the breaker sees a success and never opens. Let the exception propagate to the aspect and handle it in the
fallbackMethod. - Fallback signature mismatch: the fallback must live in the same class, return the same type and take the same parameters plus a trailing exception. Otherwise startup/first call fails with
NoSuchMethodException: class ... does not have fallback method(wording varies by version). - Business exceptions counted as failures: a 404 or validation error opens the breaker and takes down a perfectly healthy dependency. Put them in
ignoreExceptions. - One breaker for many endpoints: a slow
/reportsendpoint trips the breaker and blocks fast/pricescalls. Use one named instance per dependency capability. - Diagnosis:
GET /actuator/circuitbreakersshows each instance's state and failure rate;GET /actuator/circuitbreakereventslists the recentSTATE_TRANSITIONandNOT_PERMITTEDevents; the metricresilience4j_circuitbreaker_stategraphs transitions.
Rate Limiter, Bulkhead and Time Limiter
Retry and circuit breaker react to failures. The next three limit load: how many calls per unit of time (rate limiter), how many at the same time (bulkhead) and how long any one may take (time limiter).
| Pattern | Question it answers | Key settings (defaults) | Rejection exception |
|---|---|---|---|
@RateLimiter | How many calls per period may I make (or accept)? | limitForPeriod 50, limitRefreshPeriod 500 ns, timeoutDuration 5 s (how long to wait for a permit) | RequestNotPermitted |
@Bulkhead (semaphore) | How many calls may be in flight at once? | maxConcurrentCalls 25, maxWaitDuration 0 | BulkheadFullException |
@Bulkhead(type = THREADPOOL) | Run calls on an isolated, bounded thread pool | coreThreadPoolSize, maxThreadPoolSize, queueCapacity (100); method must return CompletableFuture | BulkheadFullException |
@TimeLimiter | How long may an async call take? | timeoutDuration 1 s, cancelRunningFuture true; method must return CompletableFuture | java.util.concurrent.TimeoutException |
Without a bulkhead, one slow dependency can absorb all 200 Tomcat threads. A semaphore bulkhead caps concurrent calls to that dependency at, say, 20; the 21st caller fails immediately (or waits up to maxWaitDuration) while the other 180 threads keep serving unrelated endpoints.
- Unhandled
RequestNotPermittedreturns 500: map it in a@RestControllerAdviceto429 Too Many Requests(add aRetry-Afterheader) - see Validation & Error Handling. - Per-instance, not global: a Resilience4j rate limiter counts inside one JVM. Ten pods with
limitForPeriod: 100allow 1000/s overall. For a true global limit use a gateway or a Redis-based limiter. TimeLimitercannot kill a blocked thread:cancelRunningFutureonly callsFuture.cancel(true); a task stuck in a socket read does not stop. The HTTP client timeout is what actually frees the thread.- Unbounded executor under a
CompletableFuture:supplyAsyncwithout an executor uses the common ForkJoinPool, which you share with the whole JVM. Pass a dedicated, bounded executor.
Ordering, Layering and Fallbacks
You can put several annotations on one method. They are not applied in the order you type them; each is a separate Spring AOP aspect with a fixed order. The Resilience4j default, outermost to innermost, is Retry → CircuitBreaker → RateLimiter → TimeLimiter → Bulkhead → your method. Each aspect's order can be overridden with resilience4j.<pattern>.<pattern>AspectOrder properties (for example resilience4j.circuitbreaker.circuitBreakerAspectOrder); Spring's Ordered rule applies, lower value = further out.
| What you want | Consequence of the default nesting | Watch out for |
|---|---|---|
| A retry that respects the breaker | Retry is outside, so every attempt goes through the breaker and is recorded | When the breaker opens, the retry sees CallNotPermittedException and may retry it three more times pointlessly - add it to the retry ignoreExceptions |
| The breaker to count each attempt | Yes - three attempts = three records | A single logical request can contribute several failures to the window |
| Bulkhead limits real concurrency | Innermost: only calls that passed the rate limiter and are not yet timed out occupy a permit | Retries re-acquire a permit each attempt |
| Cache hits to skip all of this | Put @Cacheable on an outer bean method that delegates to the resilient gateway | On the same method, order between the cache interceptor and resilience aspects is not something to rely on |
| Per-call timeout inside retry | TimeLimiter is inside Retry, so each attempt gets its own timeout | Total worst case = attempts x timeout + backoff waits |
- Fallback result gets cached: the cache sits outside the gateway, so a degraded
Price.unavailable()returned by the fallback is stored for the full TTL. Mark degraded values and exclude them withunless = "#result.degraded()". - Retry beyond the user's patience: total latency budget = attempts x (timeout + wait). If the browser gives up after 10 s, a 4 x 5 s policy keeps working for a client who is gone.
- Fallbacks that call the same dependency: a fallback that hits a second remote service has its own failure modes. Prefer last-known value, static default or a clear 503.
- Annotations on a method called from the same class: same self-invocation trap - none of the aspects run. Verify with the retry metric or a failing stub in a test.
- Virtual threads / reactive code: annotation-based blocking retry and bulkhead semantics differ with
Mono/Fluxreturn types (use the Reactor operators) - do not mix blocking decorators into reactive chains.
A forward-looking note: newer Spring Framework generations add core resilience annotations (@Retryable, @ConcurrencyLimit) so simple retry/limit cases need no extra library; this page targets the Spring Boot 3 / Resilience4j combination that most production codebases still use.
A Tiny Circuit Breaker
The state machine from chapter 08 is small enough to write in an afternoon. This version is count-based (last windowSize calls), uses an injectable clock, and models CLOSED / OPEN / HALF_OPEN with probe calls. It is a teaching model: Resilience4j evaluates the half-open outcome after all permitted probes complete, whereas this one trips as soon as the probe failure share reaches the threshold - so a still-sick backend is detected one probe sooner.
- The
[state] CLOSED -> OPENline prints while call 5 is being recorded, before the caller's message: 5 successes + 5 failures = 10 recorded calls, 50% failure,minCallsof 5 met, threshold 50% reached. Call 4 was not enough (4 of 9 = 44%). - Calls 6-8 never reach the backend: that is the entire value of the pattern - three rejected calls cost nanoseconds, not three 30-second timeouts.
- After the wait the first call flips OPEN to HALF_OPEN (no background timer - the default Resilience4j behaviour), and that call is a real probe against the backend.
- Probe 2's failure reopens the breaker for another full wait, so probe 3 is rejected without a call. Production breakers use the same "any bad probe reopens" idea.
- What this model leaves out: time-based windows, slow-call detection, ignored exceptions, metrics and thread-safety under heavy contention - which is what the library provides.
Metrics, Health and Tests
Caches and breakers fail silently by design: a cache that never hits looks like a slow service, a breaker stuck open looks like a dead dependency. Wire them into your observability from day one (see Actuator & Observability).
| Signal | Where | What to alert on |
|---|---|---|
| Cache hit ratio | Micrometer cache.gets with tag result=hit|miss (Prometheus: cache_gets_total); Caffeine needs recordStats | Ratio drops suddenly after a deploy (key change, TTL too short) |
| Cache size / evictions | cache.size, cache.evictions | Evictions rising while size sits at the max: cache too small |
| Breaker state | resilience4j_circuitbreaker_state, /actuator/circuitbreakers, health indicator (registerHealthIndicator: true) | State = open for longer than expected |
| Rejected calls | resilience4j_circuitbreaker_not_permitted_calls_total, resilience4j_ratelimiter_*, bulkhead available permits | Sustained rejections: capacity or downstream problem |
| Retry outcome | resilience4j_retry_calls_total with kind=successful_with_retry|failed_with_retry|... | High successful_with_retry ratio: dependency flaky even if users see success |
The pattern: lower the thresholds through properties, drive failures with a mock, assert on CircuitBreakerRegistry state and on the absence of calls once open. Reset breaker state between tests (registry.circuitBreaker("pricing").reset()) or the order of your tests will matter. More on test setup in Testing Spring Applications.
Questions You Should Be Able to Answer
Crisp answers for the questions that come up in interviews on this topic.
- Q: Why does my @Cacheable method run every time?
Almost always self-invocation (callingthis.method()), aprivate/finalmethod or class that cannot be proxied, no@EnableCaching/cache starter, or a key that changes every call (aPageableor timestamp parameter). Verify by checking that the bean is a proxy and logging the key. - Q: @Cacheable vs @CachePut?
@Cacheableskips the method on a hit.@CachePutalways runs the method and overwrites the entry. Never put both on the same method - their semantics conflict. - Q: How is the default cache key generated?
SimpleKeyGenerator: no args givesSimpleKey.EMPTY, one arg uses the arg, several args wrap them in aSimpleKey. Override with SpELkeyor aKeyGenerator. - Q: What does sync = true guarantee?
Concurrent misses for the same key invoke the method once and share the result - within what the provider can lock. Caffeine: per JVM exactly. Shared Redis: not cluster-wide by default. - Q: Local vs distributed cache - how do you choose?
Local (Caffeine) for read-heavy, small, tolerant-of-staleness data and lowest latency; distributed (Redis) when all instances must agree or the data set is large. Often both: Caffeine L1 with short TTL, Redis L2. - Q: What is a cache stampede and how do you prevent it?
Many requests recompute an expired hot key simultaneously. Single-flight loading (sync=true), refresh-ahead, jittered TTLs, serving stale during refresh, and request coalescing. - Q: How do you keep the cache consistent with the database?
Cache-aside with evict/put on write, evict after commit, short TTL as a backstop, versioned keys for schema changes. Accept that a cache is eventually consistent unless you give up its speed. - Q: Circuit breaker states and transitions?
CLOSED (calls flow, outcomes recorded) → OPEN when failure/slow rate ≥ threshold over the window and minimum calls met; OPEN rejects withCallNotPermittedExceptionforwaitDurationInOpenState; then HALF_OPEN allows N probes, which decide CLOSED or OPEN again. - Q: Retry vs circuit breaker - why both?
Retry hides transient blips; the breaker stops you from retrying a dependency that is clearly down. Retry without a breaker amplifies load on a sick dependency; a breaker without retry exposes users to single hiccups. - Q: In what order do Resilience4j annotations apply?
Retry → CircuitBreaker → RateLimiter → TimeLimiter → Bulkhead → method (outer to inner), regardless of the order you write them. Overridable via the*AspectOrderproperties. - Q: Bulkhead vs rate limiter?
Rate limiter bounds calls per time (throughput); bulkhead bounds calls at once (concurrency). A slow dependency exhausts concurrency long before it exceeds a rate limit, so bulkheads protect threads and rate limiters protect the callee. - Q: Why is a retry on a POST dangerous?
POST is not idempotent: if the first request succeeded but the response was lost, the retry creates a duplicate. Use idempotency keys or restrict retries to idempotent methods.
Cheat Sheet and Recap
| Need | Use | Remember |
|---|---|---|
| Skip repeated reads | @Cacheable(key=..., unless=...) | Proxy only; key must include every input; set TTL on the provider |
| Refresh on write | @CachePut / @CacheEvict (after commit) | Evict-then-race: keep a short TTL as a backstop |
| Protect from stampede | sync=true, jitter, refresh-ahead | Provider decides the strength of the lock |
| Survive a blip | @Retry + timeouts | Idempotent calls only; maxAttempts includes the first call |
| Stop hammering a dead dependency | @CircuitBreaker + fallback | minimumNumberOfCalls, ignoreExceptions, never swallow the exception |
| Cap concurrency | @Bulkhead | One per dependency; fail fast |
| Cap throughput | @RateLimiter | Per JVM, not global |
| Bound async latency | @TimeLimiter | Client timeouts are the real fix |
- Both are proxies. Self-invocation,
privatemethods and unproxied beans silently disable every annotation on this page. - Cache = second copy of the truth. Design the key, the TTL and the invalidation path together; a cache without a TTL is a memory leak with a delay.
- Caffeine for speed, Redis for sharing - and serialize JSON, version your prefix, fail open on cache errors.
- Stampede, penetration, avalanche are different problems with different fixes: single-flight, negative caching, TTL jitter.
- Timeouts first. Retry, breaker and bulkhead all assume calls eventually return.
- Breaker = rate over a window, with a minimum call count; it recovers through HALF_OPEN probes.
- Order is outer to inner: Retry, CircuitBreaker, RateLimiter, TimeLimiter, Bulkhead.
- Measure it: hit ratio, breaker state, rejected calls, retry outcomes.
- AOP & Proxies - why every annotation here is a proxy and what self-invocation does.
- Transactions - commit ordering, after-commit events, and retry vs transaction boundaries.
- Actuator & Observability - exposing breaker endpoints and cache metrics.
- Configuration & Profiles - per-environment TTLs and thresholds.
- Testing Spring Applications - how to test the policies above.
- Interview Q&A - more questions across all topics.