Redis and Spring Cache: The Caching Abstraction in Practice
Picture the little convenience store behind your school campus. Buy a bottle of water and it is either on the shelf — three seconds — or the clerk has to fetch it from the back-room warehouse, which takes two minutes; if the warehouse has none either, they phone the supplier, and that is half an hour away. Asking a database for a row is that errand: slow, and during a sale tens of thousands of people send the same errand at once, which is how a warehouse gets carried off. A cache does something very plain: put the frequently asked items on the shelf so most requests never leave the shop. The price is that once the shelf and the warehouse disagree (the data changed, the shelf did not), you now have to manage two copies of the truth. That is what this article is about: how to stock the shelf (read/write paths and annotations) and what to do when stocking goes wrong (penetration, breakdown, avalanche, consistency).
Five words, one line each (used throughout):
- Cache: a copy that is easy to grab but may be stale, kept somewhere far faster than the database (usually in memory)
- key: the address label of that copy, e.g.
app:user::42. Every lookup depends on it — get the label wrong and you hand someone else's data to the wrong person - TTL: the expiry date on that label (time to live). When it lapses the copy is voided and the next read must re-check reality; no TTL means permanently stale
- Refill (go back to source): the trip to the warehouse when the shelf is empty. The whole craft of caching is "refill as rarely as possible, but always from the right place"
- Serialization: packing a Java object into bytes or JSON so it fits into Redis, and unpacking it again. Pack it badly and redis-cli shows you nothing but garbage
the whole caching system is that shop. Redis is the shelf (within arm's reach, but small and perishable), MySQL is the back-room warehouse (has everything, slow to visit), other services or master/replica sync are the supplier (furthest away and most likely to let you down). "Penetration" is customers repeatedly asking for a SKU the shop has never sold, forcing a warehouse trip every single time; "breakdown" is the one slot for the best-seller being empty right now, with thirty customers crowding the back door at once; "avalanche" is the entire shelf being cleared simultaneously while everyone runs into the warehouse aisle together. The three failures differ by one sentence only: the thing asked for does not exist / one hot item is gone / a whole batch vanished at the same moment.

That animation draws the happy path and four failure scenes on one route: the first two steps are identical for every request, and the fork happens at step two — "is the key actually in the cache?". To locate a production incident fast, first recognise which branch you are on; the table in Section 2 hands out the matching cure.
After this article you should be able to answer three questions:
- I added a cache, so why is the endpoint still slow? (Is it genuinely missing, or hitting yet still querying the database?)
- Operations changes a price in the admin console — when will users see it, and how do I force that delay into a range I choose?
- After rewriting
@Cacheableas athis.getXxx()self-invocation it silently stops working. What single log setting proves it on the spot?
Caching works because of one plain fact: memory access and disk access differ by three to five orders of magnitude. Put the common operations side by side and the gap is obvious:
| Operation | Typical latency | Sense of scale |
|---|---|---|
| CPU register access | ~0.3 ns | 1x |
| Read L1 / L2 cache | ~1–10 ns | tens of times |
| A single Redis GET (same DC) | ~0.1–0.5 ms | millions of times |
| A MySQL primary-key lookup (index hit) | ~1–5 ms | tens of millions |
| A complex MySQL JOIN / aggregation | ~50–500 ms | even slower |
A MySQL query walks the whole chain: parse the SQL, build an execution plan, descend the B+ tree, possibly do a table lookup, then ship the result over the network. Redis is a single hash lookup in memory. The difference is not "a bit faster" — it gets amplified by concurrency: at 1000 QPS, hitting MySQL means the database serves a thousand queries a second, while hitting Redis may never touch the database at all.
Redis is fast not because of black magic, but because it keeps everything in memory, runs single-threaded to avoid lock contention, and uses IO multiplexing to handle concurrency. Still, a large share of its latency is network round-trip — 0.1 ms in the same datacenter is excellent, and across datacenters it can degrade to several milliseconds. The real value of a cache is that it holds read pressure outside the database door.

What does "slow" actually look like inside a real request? The lab below carries one request from the entry point all the way to the database. Switch to the "Slow request" position and watch which layer the latency piles into — see that clearly and you know which segment the cache is supposed to shield, instead of slapping @Cacheable onto every endpoint you meet:
But there is no free lunch — once you introduce a cache, the data exists in two places, and three classic failures come with it.
| Problem | Symptom | Root cause | Common fixes | Cost |
|---|---|---|---|---|
| Penetration | Requests hammer a key that does not exist | Neither cache nor database has it, so every request hits the DB | Null caching, Bloom filter, input validation | Nulls take memory / Bloom is probabilistic |
| Breakdown | A hot key expires at the worst moment | High concurrency misses at once and stampedes the DB | Mutual-exclusion rebuild, logical expiry | Locking adds wait / logical expiry is complex |
| Avalanche | A large batch of keys expires together | Uniform TTLs fire at once, or Redis goes down | Randomized TTL, multi-level cache, rate limiting | Randomization range must be tuned |
Three rows are quick to read, but during an incident all you have is one sentence of symptoms. So here the two columns are laid out side by side — failure fingerprint on the left, matching first medicine on the right — worth pinning above your desk rather than memorising three times:

The breakdown scene: at 8 p.m. sharp, the sale starts. A hit product's cache key has a 3600-second TTL, and it was written exactly one hour ago — so on the hour, that key expires. Tens of thousands of requests discover an empty cache in the same millisecond and all stampede toward the database for the same row, which saturates the database with a single-row query, exhausts the connection pool, and takes the product page down. Note: the database was not broken; it was sold out by its own caching strategy.
Here is the code behind two of the fixes. First, penetration and breakdown:
// Penetration fix 1: null caching — cache a short-lived "NULL" marker to block repeatspublic User findById(Long id) { String key = "user:" + id; String cached = redis.opsForValue().get(key); if (cached != null) { return "NULL".equals(cached) ? null : JSON.parseObject(cached, User.class); } User user = userMapper.selectById(id); if (user == null) { // short TTL — keep it brief or it becomes "cache pollution" redis.opsForValue().set(key, "NULL", Duration.ofMinutes(2)); return null; } redis.opsForValue().set(key, JSON.toJSONString(user), Duration.ofMinutes(30)); return user;}// Breakdown fix: mutual-exclusion rebuild — only one thread queries the DB, others waitpublic User findByIdWithLock(Long id) { String key = "user:" + id; User user = getFromCache(key); if (user != null) return user; String lockKey = "lock:user:" + id; // SETNX: the winner rebuilds, the losers retry the cache shortly after Boolean locked = redis.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(10)); try { if (Boolean.TRUE.equals(locked)) { user = userMapper.selectById(id); // only the lock holder hits the DB redis.opsForValue().set(key, JSON.toJSONString(user), Duration.ofMinutes(30)); } else { Thread.sleep(50); // wait a beat, then re-read the cache return getFromCache(key); } } finally { if (Boolean.TRUE.equals(locked)) redis.delete(lockKey); } return user;}An avalanche usually needs no code — strategy is enough to blunt it:
- Randomized TTL: write with
TTL + random(0, 300)seconds to spread expiries instead of a synchronised burst - Multi-level cache: local Caffeine plus Redis, so a Redis hiccup still leaves the local layer shielding the DB
- Rate limiting and fallback: add a circuit breaker (e.g. Resilience4j) in front of the database; better to reject a few requests than to avalanche it
null caching must use a short TTL. Cache a non-existent id for 30 minutes and an attacker can spray id=-1, -2, -3... to flood Redis with meaningless markers — the moment "null caching" becomes "cache pollution".
The three fixes above are scattered through code, while a real ticket hands you a single sentence of symptoms. So instead of another table, play a round: the left column quotes what tickets actually say, the right column is the diagnosis plus the first medicine — a wrong pick explains itself on the spot, which beats memorising rows.
the shop tells these three apart just as easily. Penetration is a customer repeatedly asking "do you sell size-25 shoes?" — the shop never has sold shoes, yet each time the clerk walks to the warehouse, comes back and says no; the cure is a poster on the door listing what we do not carry (a Bloom filter), or a sticky note saying "asked ten minutes ago: no" (null caching). Breakdown is the single crate of the best-selling water being emptied at exactly this moment, with thirty customers at the back door — only one clerk may go to the warehouse, everyone else waits (a mutex). Avalanche is every price tag on the shelf expiring at the same instant, so the whole crowd surges into the warehouse aisle — either stagger the expiry dates you write (jittered TTL) or control the flow at the warehouse door first (circuit breaking and fallback).
Meet Redis's "five blades"; together they cover nearly every caching scenario:
| Structure | In a sentence | Typical use |
|---|---|---|
| String | The basic key-value | Object JSON, counters (INCR), distributed locks |
| Hash | Field-value map | Part of an object, shopping carts |
| List | Ordered, push/pop both ends | Message queues, latest N items |
| Set | Unordered, deduplicating | Like dedup, mutual friends (intersection) |
| ZSet | Ordered set with scores | Leaderboards, delay queues (scored by timestamp) |
Integration is one starter and a few lines of config:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId></dependency>That one line is the minimum. A real project also needs the datasource, the serializers and the monitoring endpoint next to it — tick the boxes and watch what the dependency tree grows, paying attention to the scope difference between mysql and h2 (the second one should never live outside tests):
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version> <!-- 版本由 BOM 统管,子依赖不写 version -->
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>demo-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>spring: data: redis: host: localhost port: 6379 password: ${REDIS_PASSWORD:} database: 0 timeout: 2s lettuce: pool: max-active: 16 # pool cap — keep it small, Redis itself is single-threaded max-idle: 8 min-idle: 2Writing the whole file is harder than it looks: address, timeout, pool and serialization land in different sections. **Tick Redis + datasource + logging + profile and see where spring.data.redis. and logging.level each end up* — the pool cap in Section 3 and the "mass invalidation" row of the error table both start on these lines.
server:
port: 8080
spring:
application:
name: demo-service
datasource:
url: jdbc:mysql://127.0.0.1:3306/bee_order?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: ${DB_USER:root} # ${} 占位符:环境变量优先,冒号后是默认值
password: ${DB_PASS:}
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
max-lifetime: 1740000 # 必须小于 MySQL 的 wait_timeout
pool-name: beeHikari
data:
redis:
host: ${REDIS_HOST:127.0.0.1}
port: 6379
timeout: 3000ms
lettuce:
pool: { max-active: 16, max-idle: 8, min-idle: 2, max-wait: 2000ms }
Spring Boot auto-wires two templates, and their difference is the first trap a beginner hits:
| Template | key / value serialization | Best for |
|---|---|---|
RedisTemplate<K, V> | JDK serialization by default (binary) | Storing Java objects; configure the serializer yourself |
StringRedisTemplate | Everything as String (UTF-8) | Text keys/values that must match redis-cli |
RedisTemplate<Object, Object> uses JdkSerializationRedisSerializer by default, which turns your object into a blob of binary — you will see it with your own eyes in the next section.
Many people start with this:
@Autowiredprivate RedisTemplate<String, Object> redisTemplate;public void save(User user) { redisTemplate.opsForValue().set("user:1", user); // store a Java object}Then they open redis-cli and see:
127.0.0.1:6379> GET "user:1""\xac\xed\x00\x05sr\x00\x04com..User\x..."That leading \xac\xed is exactly the Java serialization magic number (0xACED). JDK serialization writes the fully qualified class name and field metadata alongside the data, so problems pile up: unreadable content, bloated size, and the moment a class name or field changes, old data fails to deserialize — plus it is a notorious source of deserialization vulnerabilities.
The fix is JSON serialization:
@Configurationpublic class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); // generic JSON serializer: writes @class info so types survive a round-trip GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(); StringRedisSerializer keySerializer = new StringRedisSerializer(); template.setKeySerializer(keySerializer); // keys stay plain strings, readable in redis-cli template.setHashKeySerializer(keySerializer); template.setValueSerializer(jsonSerializer); // values as JSON template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; }}Now the same key in redis-cli becomes:
127.0.0.1:6379> GET "user:1""{\"@class\":\"com.example.User\",\"id\":1,\"username\":\"alice\"}"Key point: GenericJackson2JsonRedisSerializer writes an extra @class field, which lets deserialization restore the original type; the downside is that this JSON is tightly coupled to your package names, so renaming or moving a class breaks old data too. If you want "pure JSON, no type info", use Jackson2JsonRedisSerializer<T> with an explicit target type — more readable, but the caller must know the type.
What makes caching pleasant in practice is Spring Cache, an annotation-based abstraction that removes the "check cache -> query on miss -> refill" boilerplate entirely. It is enabled with one annotation:
@Configuration@EnableCaching // switches on the cache abstraction (proxy-based, same origin as @Transactional)public class CacheConfig {}Four core annotations, each with its own trigger timing:
| Annotation | When it fires | Typical use |
|---|---|---|
@Cacheable | Checks the cache before the call; a hit skips the method | Query methods |
@CachePut | Always runs the method, then writes the result to cache | Refresh cache after an update |
@CacheEvict | Deletes the cache after the method runs | Deletes, or delete-on-update |
@Caching | Combines several cache operations | Delete multiple keys in one update |
The most common shape:
@Servicepublic class UserService { // key built with SpEL: user::42 @Cacheable(cacheNames = "user", key = "#id") public User findById(Long id) { return userMapper.selectById(id); // this line runs only on a miss } // refresh after update: the return value is written to user::#user.id @CachePut(cacheNames = "user", key = "#user.id") public User update(User user) { userMapper.updateById(user); return user; } // delete: remove the specific key @CacheEvict(cacheNames = "user", key = "#id") public void delete(Long id) { userMapper.deleteById(id); }}The SpEL key syntax is where people stumble; the common expressions:
| SpEL expression | Meaning |
|---|---|
#id | The value of the parameter named id |
#user.id | A property of the parameter object |
#p0 / #a0 | The first parameter (by index; p or a both work) |
#root.methodName | The current method name |
#root.targetClass | The target class |
#result.id | The method return value (only for @CachePut / @CacheEvict) |
#id + ':' + #type | A concatenated composite key |
when key is omitted, Spring uses SimpleKeyGenerator to build a key from the arguments. A no-argument method yields a constant SimpleKey.EMPTY, so several no-arg methods sharing one cacheName will overwrite each other. Always write the key explicitly for no-arg methods.
All of those rules come from one execution path. Spread it out as a single-step run: the seven lines on the left, live variables and call stack on the right. Press step repeatedly and watch box ⑤ — on a hit your method body does not run at all — because that single fact is the origin of the first trap in Section 9 and of the data-mixing cell in the sandbox:
userService.findById(42L); // ① the call lands on the cache proxy, not on your ServiceCacheInterceptor.execute() // ② annotation metadata becomes one CacheOperationkey = evaluator.key("#id") -> app:user::42 // ③ the SpEL expression is evaluated herecache = cacheResolver.resolve("user") // ④ cacheNames is bound to a concrete RedisCachevalue = cache.get(key) // ⑤ a hit returns right here — the body never runsresult = method.invoke(target) // ⑥ only on a miss does the database get queriedcache.put(key, result); return result // ⑦ refill (with a TTL) and hand the value back| userService | UserService$$SpringCGLIB$$0 |
| caller thread | http-nio-8080-exec-5 |
UserController.detailproxy.findByIdThe annotations are pleasant, but they have one hard boundary: a cache annotation can only cache "the return value of one method". The moment you need an operation rather than a result — reading many keys at once (MGET/Pipeline), renewing one specific key, grabbing a lock to rebuild exclusively, patching just two fields of an object, or running a Lua script atomically — you go back to RedisTemplate. The picture below sets both roads side by side; note especially the fourth line on the left, because annotations ride on a proxy, self-invocation kills them silently; hand-written code has no proxy and therefore no such trap:

one sentence decides it — one method = one cached object → annotate; you need operations, not just results → write the code. The two styles coexist happily: main lookups via @Cacheable, exclusive rebuild for hot keys and distributed locks via RedisTemplate. Do not jam a hand-written lock inside an annotation just for stylistic unity.
The default RedisCacheManager never expires — a frequent source of incidents. To set a TTL, a shared key prefix, or per-namespace policies, customize it:
@Configuration@EnableCachingpublic class CacheConfig { @Bean public RedisCacheManager cacheManager(RedisConnectionFactory factory) { RedisCacheConfiguration base = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(10)) // default TTL: 10 minutes .prefixCacheNameWith("app:") // key prefix: app:user::42 .serializeKeysWith(RedisSerializationContext.SerializationPair .fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair .fromSerializer(new GenericJackson2JsonRedisSerializer())) .disableCachingNullValues(); // do not cache null; handle penetration yourself Map<String, RedisCacheConfiguration> perCache = Map.of( "user", base.entryTtl(Duration.ofMinutes(30)), // user info: 30 minutes "config", base.entryTtl(Duration.ofHours(6)) // config: 6 hours ); return RedisCacheManager.builder(factory) .cacheDefaults(base) .withInitialCacheConfigurations(perCache) .build(); }}entryTtl: mandatory. A cache with no TTL is a memory leak plus permanent inconsistencyprefixCacheNameWith: a uniform prefix that makes bulk cleanup by business easy and avoids key collisions when several apps share one RedisdisableCachingNullValues: the opposite of null caching — here nulls are not cached and penetration defence falls to a Bloom filter or validation- Different TTLs per business, instead of a "one size fits all" that ties hot and cold data together
How long a TTL should be is the one genuine knob in caching, and its two ends each hold a different failure hostage: the left end bites memory and freshness, the right end bites the database:
- Thirty minutes to two hours is the usual band, balancing freshness against hit rate
- Add jitter: TTL + random(0,300), so nothing aligns with the hour
- @CacheEvict still has to be there — the TTL is a backstop, not the main defence
- Watch hit rate and refill QPS; the number itself means nothing
Attention: `@Cacheable` and `RedisCacheManager` relate as abstraction and implementation — you write annotations, and the `CacheManager` decides which `Cache` implementation, TTL and serialization to use. Swap the implementation (say Redis for Caffeine) and the business annotations **do not change at all**. To pick a cache implementation per method dynamically, implement a `CacheResolver` that returns a different `Cache` based on method metadata.
First, draw the two paths clearly (see Figure 1 at the top). The core conclusion in one line: reads go "cache first", writes go "update the database, then delete the cache" — that is the Cache-Aside pattern.

Why "delete the cache" rather than "update the cache"? One concurrent scenario explains it:
| Moment | Thread A (write) | Thread B (read) |
|---|---|---|
| T1 | Updates database = 20 | |
| T2 | Reads the old value 10 (A's write is not visible yet) | |
| T3 | Updates cache = 20 | |
| T4 | Writes cache = 10 (overwriting 20) | |
| Result | database 20, cache 10 -> inconsistent |
"Update the cache" copies the database's write concurrency straight onto the cache, and the ordering of two writers cannot be guaranteed, so stale data appears. "Delete the cache" reduces the problem to a single atomic action, at the cost of one refill on the next read — one refill buys away the inconsistency risk.
That T1–T4 table is four lines of text; the animation plays it in order. Watch frame ④ — the overwrite happens with no exception and no log line, which is exactly why this class of bug is the hardest to catch:

Then, is "delete the cache first, then update the database" fine? It has its own race (a reader refills the old value after the delete but before the update). So the more robust order is Cache-Aside's "update the DB first, then delete the cache", backed by delayed double delete:
public void updateUser(User user) { userMapper.updateById(user); // 1. persist first redis.delete("app:user::" + user.getId()); // 2. delete the cache immediately // 3. delete again after a delay, to overwrite a stale value refilled between delete and commit delayedExecutor.schedule( () -> redis.delete("app:user::" + user.getId()), 500, TimeUnit.MILLISECONDS);}Note: cache consistency cannot be strongly consistent — only eventually consistent — unless you also add a distributed lock on the read path, which is usually not worth the cost. The engineering trade-off is to accept "a reader may see a stale value within a window" and shrink that window to milliseconds with TTL and delayed double delete, rather than chase theoretical perfection.
Delete-versus-update is only one cell inside five read/write strategies. Spring Cache defaults to the first (aside); the other four each have their own fit and their own way of failing. Click through them, and each box answers the one question that matters: who is responsible for filling the cache?
The mutual-exclusion cache rebuild above is a distributed lock in embryo. A usable lock has three elements: atomic acquisition, expiry as a backstop, and the ability to delete only your own lock:
public class RedisLock { private final StringRedisTemplate redis; private static final String UNLOCK_LUA = "if redis.call('get', KEYS[1]) == ARGV[1] then " + " return redis.call('del', KEYS[1]) " + "else return 0 end"; public boolean tryLock(String key, String token, Duration expire) { // SETNX plus expiry in one write (never split setnx and expire: a crash in between deadlocks) Boolean ok = redis.opsForValue().setIfAbsent(key, token, expire); return Boolean.TRUE.equals(ok); } public boolean unlock(String key, String token) { // Lua keeps "compare + delete" atomic: delete only when the token matches Long r = redis.execute( new DefaultRedisScript<>(UNLOCK_LUA, Long.class), Collections.singletonList(key), token); return r != null && r > 0; }}- Atomic acquisition:
setIfAbsent(key, token, expire)maps toSET key value NX PX ms, doing "set if absent plus expiry" in one step - Expiry as a backstop: if the lock holder crashes, the lock expires and releases itself — no permanent deadlock
- Delete by unique value:
tokenis a unique string generated for this acquisition; unlock compares then deletes, so that "after A's lock times out and B acquires it, A does not delete B's lock"
Trap: a hand-rolled distributed lock will almost always miss an edge case — renewal (when the work outlives the lock's TTL), reentrancy, and lock loss on a primary/replica failover. In production use Redisson's RLock, which ships a watchdog that renews automatically plus reentrancy and RedLock semantics, filling all these holes. The value of the hand-rolled lock here is to understand the mechanics, not to ship it.
@Cacheable silently dies on self-invocation within the same class. Exactly like @Transactional — the annotation relies on an AOP proxy, and this.method() goes through the raw object, bypassing the proxy, so no cache lookup or refill ever happens. Fix it by moving the method into another bean, or injecting your own proxy.
cached nulls need a short TTL too. Otherwise a single "not found" calcifies into "never found" for tens of minutes — the data is later restored, but the cache still says it is missing.
big keys and hot keys need dedicated treatment. A multi-megabyte value slows the single-threaded Redis and blocks other commands; a key hammered at tens of thousands of QPS can saturate a single shard. Remedies: split big keys (shard into a Hash), shield hot keys behind a local cache, and spread the load across key replicas.
Cache, transaction and log failures all stem from the same proxy mechanism; the demo below lets you see exactly what self-invocation bypasses:
The three traps in Section 9 are prose, but each has a shape you can actually see. The five labs below answer, in order: how do the three failures differ in timing? What does it look like when the cache stops shielding the database? How do you learn that Redis is alive before your users do? Which of the three rate limiters survives an avalanche? And what is the relationship between those MyBatis cache layers and Spring Cache?
The first lab is the star. Cycle through hit → miss → key → pen → bust while replaying the matching game in Section 2:
The second turns the sentence "the cache holds read pressure outside the database" into a curve you can watch. Compare "Idle hit" with "Queue at the limit" and feel how the waiting queue grows as the hit rate falls from 95% to zero:
The third answers a very practical anxiety: Redis dies — how do I know before my users do? The Actuator health endpoint aggregates the Redis connection state into a DOWN, which with monitoring becomes the earliest alarm you can get:
The fourth turns Section 2's "put a limiter in front of the database" into concrete algorithms. When an avalanche hits, a fixed window lets roughly double the traffic through at its boundary, a leaky bucket is always smooth but cannot absorb bursts, and a token bucket permits bursts provided you size it — switch to "Choosing and bursts" and the three shapes are drawn side by side:
The fifth settles a question that never stops appearing in comment sections: what is the relationship between MyBatis's first/second-level caches and Spring Cache? On the shared-cache position you see it surviving across sessions and, by default, invalidating a whole namespace — a different model from @CacheEvict deleting one precise key, and running both layers at once can hide stale reads:
Once you are bored of buttons, type the commands yourself. This console talks to the same in-browser Java kernel and every reply is computed there — start with ping to confirm the container is up, then run the three failures and one connection timeout:
run lab pool queue immediately after lab cache bust — only then does the downstream of an avalanche become visible. The first timeline shows hundreds of thousands of requests missing at once; the second shows the queue length the database actually faces.
A wrong strategy shows up late, and by then the database is already taking the hits. This sandbox turns "how stale is acceptable" into one switch; each position shows the TTL, the concurrency protection and the metric to watch, side by side:
TTL = 1800s + random(0,300) <- jitter prevents synchronised expiryrebuild on miss: SETNX mutex, exactly one thread refillswatch: per-key QPS, lock-wait duration# test: high read/write ratio concentrated on one row -> protect that key, do not add machines
the four positions map to four real demands, and the order of reasoning never changes — first measure "how long may this data be stale", then decide which failure to defend against. Starting instead from "should I use a Bloom filter?" usually means you have not yet identified which attack you are under.
A warm-up on the pair of concepts most often stated backwards in interviews:
Now a combined question tying the annotation mechanics of Section 5 to the trap in Section 9:
Beginners hit four walls here: cannot connect, cannot read (garbage), reads the wrong thing (mixed data), and the nastiest of all — nothing happens at all. Every fragment below can be pasted verbatim into a search engine.
| Error text (fragment) | Real cause | 30-second fix | Where to dig deeper |
|---|---|---|---|
org.springframework.data.redis.RedisConnectionFailureException: Unable to connect to Redis | Address/port unreachable: Redis not running, spring.data.redis.host points elsewhere, or localhost inside a container resolving to the container itself | Run redis-cli -h <host> -p 6379 ping first; in Docker use the service name as host; then check whether password is an empty string versus unset | Section 3 |
io.lettuce.core.RedisConnectionException: Unable to connect to localhost/<unresolved>:6379 | Lettuce could not even establish a connection — usually DNS or network policy, not the password | <unresolved> means the hostname never resolved: inspect the compose network and service name | Section 3 |
SerializationException: Could not write JSON: Java 8 date/time type java.time.LocalDateTime not supported by default | Jackson lacks the JSR-310 module, so LocalDateTime blows up on write | Register JavaTimeModule and disable WRITE_DATES_AS_TIMESTAMPS; or store a string / legacy Date instead | Section 4 |
SerializationException: Cannot deserialize value of type ... unknown java class id com.example.User | Old entries were written by GenericJackson2JsonRedisSerializer, which embeds the fully qualified @class name — and the class was renamed or moved | Never rename a class that already lives in the cache; if you must, purge first, or switch to a serializer with an explicit target type | Section 4 |
GET user:1 in redis-cli returns "\xac\xed\x00\x05sr\x00..." | The default JdkSerializationRedisSerializer magic bytes — not an error, but effectively unoperatable | Use StringRedisSerializer for keys and a JSON serializer for values; existing data must be discarded and rebuilt | Section 4 |
@Cacheable added, nothing happens, zero errors | ① missing @EnableCaching; ② self-invocation bypassing the proxy; ③ the bean was newed and never entered the container | Turn on logging.level.org.springframework.cache: TRACE; no cache-related line means the interceptor never ran — check ①②③ in order | Sections 5 and 9 |
| Different users or tenants see each other's data | Several methods share one cacheNames without an explicit key, collapsing to SimpleKey.EMPTY; or the key omits the discriminating dimension | Always write a key for no-arg methods; compose it as #userId + ':' + #type; print the actual generated key while debugging locally | Section 5 |
| Operations changed a price, the storefront keeps showing the old one for ages | Only the database was updated, with no @CacheEvict, or the evict key expression differs from the one used on write | Update paths must come in pairs (@CachePut/@CacheEvict with identical key rules); shorten the TTL as a backstop | Section 7 |
The database pool keeps queuing and Pending requests never drops | Mass cache invalidation (avalanche) sending every request back to the source, dumping the full read load on the database | Throttle first to save the database, then warm the critical keys gradually; afterwards add TTL jitter | Article 28 |
when searching these, use only the first English phrase after the colon (e.g. Unable to connect to Redis). Different Spring Boot versions append extra clauses to the tail, so full-sentence searches often return nothing.
The first row is the one beginners hit most and misjudge most: it looks like a wrong password or a dead Redis, when it is usually just a localhost inside a container pointing at the container itself. Do not read the answer — click the frame you think is guilty:
It worked perfectly on the laptop. The moment it ships to a container every endpoint returns 500, this sentence repeats in the log, and changing the password, the version or restarting Redis accomplishes nothing.
Build the smallest possible "two reads, one query" scene. No Redis required — Spring's built-in ConcurrentMapCacheManager shows every mechanism of these annotations. Fully runnable code:
package com.example.cache;public record Product(Long id, String name, java.math.BigDecimal price) {}package com.example.cache;import org.springframework.stereotype.Service;@Servicepublic class ProductService { private int dbHits = 0; // key written explicitly as #id, so no SimpleKey.EMPTY collision can occur @org.springframework.cache.annotation.Cacheable(cacheNames = "product", key = "#id") public Product find(Long id) { System.out.println("[db] SELECT * FROM product WHERE id=" + (++dbHits)); return new Product(id, "Bee plush", new java.math.BigDecimal("39.00")); } @org.springframework.cache.annotation.CachePut(cacheNames = "product", key = "#id") public Product rename(Long id, String newName) { System.out.println("[db] UPDATE product SET name=? WHERE id=" + id); return new Product(id, newName, new java.math.BigDecimal("39.00")); } @org.springframework.cache.annotation.CacheEvict(cacheNames = "product", key = "#id") public void evict(Long id) { System.out.println("[cache] evict product::" + id); } public int dbHits() { return dbHits; }}package com.example.cache;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;import org.springframework.cache.annotation.EnableCaching;import org.springframework.context.ConfigurableApplicationContext;@SpringBootApplication@EnableCaching // <- delete this line and the four calls below become four [db] lines, with not one error loggedpublic class CacheDemo { public static void main(String[] args) { try (ConfigurableApplicationContext ctx = SpringApplication.run(CacheDemo.class, args)) { ProductService svc = ctx.getBean(ProductService.class); svc.find(1L); // miss -> query and refill svc.find(1L); // hit -> the method does not run at all svc.rename(1L, "Limited"); // @CachePut -> runs and overwrites the entry svc.find(1L); // hit -> returns the renamed object System.out.println("db hits = " + svc.dbHits()); } }}Expected output (db hits must be 1):
[db] SELECT * FROM product WHERE id=1[db] UPDATE product SET name=? WHERE id=1db hits = 1The absence of a third [db] SELECT is the proof that the second read was served from cache. Comment out @EnableCaching, rerun, and you get db hits = 2 with not a single error in the log — the live version of the silent failure listed in Section 13.
Goal: prove that self-invocation bypasses the cache proxy.
Hint: add exactly one method to ProductService: public java.util.List<Product> findTwice(Long id) whose body is return List.of(find(id), find(id)); (calling find directly is this.find(id)), then call svc.findTwice(9L) twice from main.
You should observe: the first call prints two [db] lines where you expected one, and the second call prints two more — four database queries in total. The cache is not broken; those four calls never reached the proxy. Now move findTwice into a separate SearchFacade bean that injects ProductService, and the [db] count collapses to one. Write both SQL-line counts in your notes — that is the evidence for the first trap in Section 9.
Replace Tier 1's map cache with real Redis, then defend against each of the three failures using the table in Section 2.
Acceptance checklist:
- [ ]
KEYS app:product*in redis-cli shows readable JSON with a prefix, not\xac\xedbinary - [ ] TTL works:
TTL app:product::1returns a positive number, and different cacheNames carry different TTLs - [ ] Penetration defence works: after querying 20 non-existent ids, Redis holds null markers for them with TTL ≤ 2 minutes
- [ ] Breakdown defence works: delete the hot key, read it from 50 threads at once, and the
SELECTlog line prints exactly once (the mutex held) - [ ] Avalanche mitigation works: sample ten batch-written keys and their TTLs differ (visible jitter)
- [ ] Updates follow "write the database, then delete the cache", and you can explain why updating the cache is wrong (see the T1–T4 table in Section 7)
- [ ]
/actuator/healthlists the redis component; stopping Redis flips it to DOWN without the whole application returning 500 because of the cache
without looking anything up, match these three symptoms to their failure — "hit rate stays high but one row is hammered", "the overall hit rate falls off a cliff", "requests ask for ids that do not exist" — plus the first medicine for each. Miss one and replay the matching game in Section 2.
what three preconditions does @Cacheable need to take effect? The answer must include @EnableCaching, the call going through the proxy, and the method being public. Missing one sends you back to Sections 5 and 9.
why is the default serializer unreadable in redis-cli, and how many serializers must you configure to switch to JSON (key / hashKey / value / hashValue)? Count them in Section 4.
can you explain Cache-Aside's "delete the cache, do not update it" using the T1–T4 timeline table in Section 7? Say it out loud to a colleague.
should a per-user personalised endpoint be @Cacheable as a whole? If not, which mistake is it — the key dimension or the caching granularity? Check the last position of the sandbox in Section 11.
the shelf stands in front of the warehouse and the TTL is its best-before date; what does not exist penetrates it, what is hot and expires breaks it, what expires in unison avalanches it; annotations ride the proxy, so self-invocation mutes them; write the database first, delete the cache second, and settle for eventual consistency.
Compress this article into a few lines — the value of a cache is not a faster single request but holding read pressure outside the database; penetration uses null caching or a Bloom filter, breakdown uses mutual-exclusion rebuild or logical expiry, avalanche uses randomized TTL and multi-level caching; default JDK serialization is garbage in redis-cli, so switch to a JSON serializer; Spring Cache removes boilerplate with annotations, but write the key explicitly, always set a TTL, and remember self-invocation kills it; consistency follows "update the DB then delete the cache" plus delayed double delete, aiming for eventual rather than strong consistency; do not hand-roll distributed locks — use Redisson in production. Stand on these and your cache will not be a mine buried under the database.