Skip to main content

Spring Cache on Redis: TTLs, a version prefix and deploys

After a release the cache serves old-code data. A version prefix avoids it without FLUSHALL: what it costs in memory, measured on Redis 8.0.5.

What do you think about this article?

Spring Cache on Redis: TTLs, a version prefix and deploys
Redisfedericocalo.dev

At a Glance

After a release the cache serves old-code data. A version prefix avoids it without FLUSHALL: what it costs in memory, measured on Redis 8.0.5.

In short. A Redis cache survives an application restart, so after a release it can keep serving, for hours, values computed by the old code. In my backend the answer is not to flush Redis but to change the prefix of the keys: every key has the shape <version>:<cache>::<key>, the release script writes the new version (the short commit hash) into APP_CACHE_VERSION, and the old keys become unreachable. They are not deleted: they expire with their own TTL. The cost is hidden in that sentence. With the 27 TTLs in the configuration file, ranging from 60 seconds to 7 days, every release leaves an orphaned copy of each cache in memory for as long as its TTL lasts. In my measurements 1,000 keys of 1 KB take up 1.16 MB: an orphaned cache costs as much as a live one, until it expires.

Method. I read the backend code and ran, on Redis 8.0.5, in an instance of my own (port 6391, --save "", --appendonly no, temporary directory), the commands that reproduce what Spring writes. I did not start the backend: so everything I attribute to Spring comes from the code and the documentation, not from a key observed while the application was writing it. Where a block is an excerpt from the repository, I say so. The site's development instance was only read.

The problem: the cache that knows too much

Why does a Redis cache serve stale data after a deploy?

A Spring @Cacheable stores a method's result in Redis, and Redis is a separate process: restarting the JVM does not touch it. If the release changes how a value is computed but not the key it is looked up by, the first request after the release finds the previous value and returns it. The comment in the configuration class says it plainly: the entries "survive deploys" and "the old value keeps being served until the natural expiry of the TTL". The longest TTL in that file is seven days.

The same comment states that "Redis is persistent (RDB/AOF)". On the development instance it is not: appendonly is no and save is 3600 1 300 100 60 10000, that is, only RDB snapshots at intervals. I dropped that sentence from my reasoning: what matters here is that the Redis process stays up, not that the data is written to disk, and the difference is explained in the article on Redis persistence. The check is read-only, on the development instance:

$ redis-cli -p 6379 config get appendonly | tr '\n' ' '
appendonly no
$ redis-cli -p 6379 config get save | tr '\n' ' '
save 3600 1 300 100 60 10000

The two lines contradict the comment, but only for the development environment: I did not check the production configuration, so I cannot say the comment is wrong there too, only that it is not enough to prove it right.

How do you invalidate a Redis cache without FLUSHALL?

There are three ways to avoid the old value, and they differ in what they cost:

  • Flush Redis at release time (FLUSHALL, FLUSHDB): simple, immediate, and it also deletes keys that have nothing to do with the cache.
  • Change the prefix: the old keys stay where they are but nobody looks them up any more; they expire on their own.
  • Invalidate only what changed with @CacheEvict: precise, but it requires knowing, release by release, which values have become wrong.

The first route is ruled out by a condition you can only see by looking at the instance: the same Redis holds keys that do not go through the cache manager, such as the JWT revocation keys or the idempotency ones. Flushing everything would delete them, and I wrote about what happens when they disappear in the article on persistence. The third is the most precise and the most fragile, because it depends on a human memory. The prefix is the only one of the three that does not require deciding anything at each release, and that is why the choice falls there.

How the prefix gets into the key

The configuration, line by line

The RedisCacheConfig class builds a base configuration and copies it for each cache, changing only the TTL. The point that matters is one line: computePrefixWith, which replaces Spring's default prefix (the cache name followed by ::) with the version, the cache name and ::. This is the excerpt, from the repository, not run by me:

private RedisCacheConfiguration buildDefaultConfig(String cacheVersion, Duration defaultTtl) {
    return RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(defaultTtl)
            .disableCachingNullValues()
            .computePrefixWith(cacheName -> cacheVersion + ":" + cacheName + "::")
            .serializeKeysWith(RedisSerializationContext.SerializationPair
                    .fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair
                    .fromSerializer(new GenericJackson2JsonRedisSerializer(buildRedisObjectMapper())));
}

Three lines have an effect you cannot see from the name. entryTtl sets the default expiry; without it the keys would never expire and the orphan of a release would stay forever. disableCachingNullValues prevents storing a null result: a search that finds nothing is redone every time, but a wrong "not found" does not linger in the cache after the data has appeared. And computePrefixWith receives the cache name on every call, which is why the result is a different prefix for each one.

The version comes from the parameter ${app.cache.version:v1}, which in application.yml is ${APP_CACHE_VERSION:v1}. The v1 after the colon is the value used if the environment variable does not exist: whoever launches the backend locally without setting it gets keys that start with v1:, and two different versions of the code in development would share the same space. That is the reason why in production the variable is written by the script.

The key Spring writes, reproduced by hand

To know what a key like this looks like in Redis, I write one with the same shape. It is not an observation of the application: it is the format the configuration above produces, reconstructed. I use aaa as a test version and a TTL of 600 seconds, as for the first cache in the list:

$ redis-cli -p 6391 set aaa:article-metadata::redis-persistenza "{\"slug\":\"redis-persistenza\"}" ex 600
OK
$ redis-cli -p 6391 --scan --pattern 'aaa:*'
aaa:article-metadata::redis-persistenza
$ redis-cli -p 6391 ttl aaa:article-metadata::redis-persistenza
600

The three segments read from left to right: the version (aaa), the cache (article-metadata) and, after the double colon, the method's key. The TTL command returns 600 right after the write, and the number drops by one every second; if it returned -1 it would mean the key does not expire, which is exactly what the configured TTL is there to avoid. The command I would have liked to run on the real application, --scan --pattern '*::*', returned zero keys on the development instance: in the session in which I looked, the backend had not populated any cache.

Twenty-seven TTLs and what a release weighs

The TTL map

The caches have different expiries, chosen case by case in the configuration file. An entry has this shape, taken from the repository:

Map.entry(ARTICLE_METADATA, defaultConfig.entryTtl(Duration.ofMinutes(10))),
Map.entry(ENTITY_STATS,     defaultConfig.entryTtl(Duration.ofMinutes(1))),
Map.entry(SSL_CERT,         defaultConfig.entryTtl(Duration.ofHours(1))),
Map.entry(TARIC_CACHE,      defaultConfig.entryTtl(Duration.ofDays(7))),

To find out how many there are and how they are distributed, I counted them with a small script that reads the file and converts every duration into seconds. The script and its output, both executed:

import re
s = open('backend/src/main/java/dev/federicocalo/portfolio/infrastructure/cache/RedisCacheConfig.java').read()
e = re.findall(r'Map\.entry\((\w+),\s*defaultConfig\.entryTtl\(Duration\.of(\w+)\((\d+)\)', s)
sec = {'Seconds': 1, 'Minutes': 60, 'Hours': 3600, 'Days': 86400}
v = sorted(int(n) * sec[u] for _, u, n in e)
print(len(e), 'entry; min', v[0], 'max', v[-1], 'median', v[len(v)//2])
print('up to 5 min', sum(x <= 300 for x in v), '| 5 min-1 h', sum(300 < x <= 3600 for x in v), '| over 1 h', sum(x > 3600 for x in v))

27 entry; min 60 max 604800 median 3600
up to 5 min 7 | 5 min-1 h 13 | over 1 h 7

The number this article needs is the maximum: in the map it is 604,800 seconds, seven days, and it belongs to the taric-cache cache. But the class's javadoc says that cache is written directly through StringRedisTemplate, outside Spring Cache, and therefore outside the prefix: its entry in the map produces no orphans. For the other 26 the maximum is 86,400 seconds, twenty-four hours, and the median is one hour. If you release ten times in the same afternoon, a one-hour cache has up to ten generations in memory at the same time.

The release, reproduced with real keys

To see the effect in numbers I fill the instance with a cache under version aaa: 10,000 keys of 1,000 bytes with TTL 600, 6,000 with TTL 900 and 4,000 with a TTL of 24 hours, plus two keys foreign to the manager, like the revocation and idempotency ones:

$ for i in $(seq 1 10000); do echo "SET aaa:article-metadata::s$i $V EX 600"; done | redis-cli -p 6391 --pipe | tail -1
errors: 0, replies: 10000
$ for i in $(seq 1 6000); do echo "SET aaa:http-headers::h$i $V EX 900"; done | redis-cli -p 6391 --pipe | tail -1
errors: 0, replies: 6000
$ for i in $(seq 1 4000); do echo "SET aaa:vat-validation::t$i $V EX 86400"; done | redis-cli -p 6391 --pipe | tail -1
errors: 0, replies: 4000
$ redis-cli -p 6391 set jwt:revoked:abc 1 ex 900 ; redis-cli -p 6391 set dq:idem:xyz 1 ex 86400
OK
OK
$ redis-cli -p 6391 info memory | grep -E '^used_memory_human'; redis-cli -p 6391 dbsize
used_memory_human:22.89M
20002

The two foreign keys, jwt:revoked:abc and dq:idem:xyz, are needed further on. The other twenty thousand have three cache prefixes but a single version. Now I simulate the release: the version becomes bbb, the first request to each cache repopulates it, and I count what sits under each prefix:

$ for i in $(seq 1 1000); do echo "SET bbb:article-metadata::s$i $V EX 600"; done | redis-cli -p 6391 --pipe | tail -1
errors: 0, replies: 1000
$ redis-cli -p 6391 --scan --pattern 'aaa:*' --count 1000 | wc -l
20000
$ redis-cli -p 6391 --scan --pattern 'bbb:*' --count 1000 | wc -l
1000
$ redis-cli -p 6391 info memory | grep -E '^used_memory_human'
used_memory_human:24.05M
$ redis-cli -p 6391 ttl aaa:vat-validation::t1 ; redis-cli -p 6391 ttl aaa:article-metadata::s1 ; redis-cli -p 6391 ttl bbb:article-metadata::s1
86399
598
600
$ redis-cli -p 6391 exists jwt:revoked:abc dq:idem:xyz
2

The twenty thousand aaa:* keys are all still there, and the new cache has started with a thousand: used_memory went up from 22.89 to 24.05 MB, that is 1.16 MB per thousand keys of one kilobyte. The orphans keep their TTLs (598 seconds left, 86,399 for the one-day ones) and the new value has fresh ones (600). And the two foreign keys are intact: EXISTS with two arguments returns 2. That is the prefix's advantage over FLUSHALL, and the reason why the comment in the release script says "no FLUSHALL".

From the commit to the environment variable

The release script

The step that changes the version is in block "2c" of scripts/deploy/backend.sh. This is the excerpt, from the repository: it reads the short hash of the commit and writes it into .env.prod, replacing the line if it is there and appending it if it is missing:

cache_version="$(sudo -u "${DEPLOY_USER}" git -C "${REPO_DIR}" rev-parse --short HEAD)"
env_file="${BACKEND_DIR}/.env.prod"
if grep -q '^APP_CACHE_VERSION=' "${env_file}"; then
    sed -i "s/^APP_CACHE_VERSION=.*/APP_CACHE_VERSION=${cache_version}/" "${env_file}"
else
    echo "APP_CACHE_VERSION=${cache_version}" >> "${env_file}"
fi

Choosing the commit hash as the version has a consequence the comment does not name: every release, even one that changes the text of a page, invalidates the whole cache. It is safe and coarse. The value is the commit's even if the release touches no code that influences the cached data, and whoever looks at the database after a release sees a spike in reads: the cache starts empty. There are two ways to reduce it (a fixed version until a code file that produces data changes, or targeted @CacheEvict), but both shift the responsibility onto the memory of whoever releases.

The case of an interrupted release

The variable is written before compilation and the backend reads it only at startup. If the release stops between the two moments, the .env.prod file already contains the new version while the running process still uses the old one, and the first restart, even a manual one, will change prefix without anyone having released anything. It is correct behaviour and it can be surprising: the prefix follows the file, not the running code. I did not reproduce it; I infer it from reading the script and from how Spring reads properties at startup; whoever operates the service should check it with grep APP_CACHE_VERSION before drawing conclusions from a cold cache.

Two facts the code states and the comment does not

Does a spring.cache.redis property in the YAML always take effect?

The Spring Boot documentation explains that with Redis the cache can also be configured through the spring.cache.redis.* properties, and that for instance spring.cache.redis.time-to-live=10m creates caches with a ten-minute TTL. In my case that property would be inert: the RedisCacheConfig class defines its own CacheManager, and Boot's auto-configuration declines to create another when it finds one. The application.yml file says so in a comment and keeps the commented-out lines. Here is the excerpt, from the repository:

# NOTE: `spring.cache.redis.*` (Boot's auto-configured RedisCacheManager) is INERT here -
# RedisCacheConfig defines its OWN @Bean CacheManager (@ConditionalOnMissingBean wins the
# race), so the real default TTL/null-caching knobs are `app.cache.default-ttl` below and
# `.disableCachingNullValues()` hardcoded in RedisCacheConfig#buildDefaultConfig.

The proof I would have liked to give is to read the TTL of a key written by the application, set the property and read it again: it should not change. I did not do it, because it requires starting the backend. So this is a statement from reading: the repository's comment, the Spring Boot page that says what happens when a CacheManager is defined (auto-configuration tries to detect a provider only if you have not defined a bean of that type) and the fact that the property, in the file, is already commented out. The value that actually governs the default is app.cache.default-ttl, PT5M if the environment variable is absent.

KEYS to empty a cache

There is also a manual route: CacheAdminService.evict, reached through an administration endpoint, empties a single cache. Its comment says that Cache#clear() is a "SCAN+DEL limited to the prefix". The Spring Data Redis documentation says otherwise: the default implementation uses KEYS and DEL, and to switch to SCAN you have to build the writer with a BatchStrategy. In the code, the manager is built with RedisCacheManager.builder(factory) and I found no scanning strategy. The doubt remains a hypothesis: I did not confirm it with MONITOR on a running application, which would be the proof. The cost, however, I did measure, and it is the reason why it matters.

KEYS versus SCAN, in microseconds

The measurement setup

I filled an instance up to 201,003 keys and looked for the 1,000 that start with bbb:, first with KEYS, then with SCAN in blocks of a thousand. To measure the duration inside the server and not the full round trip, I enabled the SLOWLOG with a threshold of zero and read the duration recorded for each command. It is the same tool as in the article on latency, SLOWLOG and memory per key, and there is a trap I ran into: the SLOWLOG keeps 128 entries by default, so the 201 SCAN rounds had pushed out my KEYS. I raised slowlog-max-len to 1024 and repeated.

r.config_set('slowlog-max-len', 1024); r.config_set('slowlog-log-slower-than', 0); r.slowlog_reset()
for rep in range(3): r.keys('bbb:*')
cur = 0
while True:
    cur, ks = r.scan(cur, match='bbb:*', count=1000)
    if cur == 0: break
sl = r.slowlog_get(1000)

KEYS bbb:* (201003 keys in db) server duration us: [14862, 19821, 15334]
SCAN count 1000: calls 201 max us 276 median us 229 sum us 46014

A KEYS blocks the server for between 14.9 and 19.8 milliseconds, all in one go, and for that time no other command is executed. SCAN makes 201 calls of 0.23 ms median and 0.28 ms maximum, for a total of 46 ms: it costs two to three times more in total and never blocks for more than a quarter of a millisecond. With 200,000 keys the effect of KEYS is tens of milliseconds, not seconds; with a keyspace ten times larger, the cost grows with the number of keys, which is why the command's page advises against it in production. I did not measure that case, nor the same test on Redis 7.4.7.

The trade-off: what I would do

I would keep the version prefix, and I prefer it to FLUSHALL for one reason only, which is the presence of non-rebuildable keys in the same instance. I would pay for it with the memory of the orphans and with a spike of reads on the database after every release. I discarded the fixed version (the commit hash) with manual invalidation because it moves a decision onto whoever releases at six in the evening, and I discarded @CacheEvict as the only mechanism for the same reason.

  • Always set a TTL on every cache: without it the orphan does not expire and the prefix becomes a memory leak.
  • Keep non-rebuildable keys out of the manager, or in a separate instance: the prefix works because it does not touch them.
  • Do not trust a YAML property without checking which bean reads it.
  • Check how your manager empties a cache: if it is KEYS, on a large keyspace it is a block.
  • After a release, look at the database reads, not only at the errors: the cold cache shows up there.

The five rules are ordered by cost: the first and the second are decided once and protect against the serious damage, the third and the fourth are read-through checks that take an hour each, the last is a habit. If you can do only one this week, do the first: a count of the keys without a TTL (redis-cli --scan and TTL on a sample) tells you whether the prefix you have is costing you a leak.

What you can do now

You can explain why a Redis cache serves old-code data after a release, how the <version>:<cache>::<key> prefix avoids it without touching the other keys, and how much it costs to leave the orphaned keys in memory: one generation for each TTL. You can read a cache configuration file and derive the TTL distribution from it with ten lines of script, and you know that emptying a cache with the default manager can mean a KEYS that blocks the server for a few tens of milliseconds for every two hundred thousand keys.

To understand what survives a restart of the Redis process, and why the comment on persistence is not enough, there is the article on RDB, AOF and what survives; for what happens when the instance is full and the orphans weigh, maxmemory, eviction and OOM. For the architectural side (where the cache sits relative to the domain) there is the article on the infrastructure layer with JPA and Redis, and on production patterns in general twelve Spring Boot patterns for production. The next step is the lock with an owner; when the cache or Redis stops responding, the answer is in the article on fail-open and fail-closed.

Want to review how your Spring backend uses the cache? See the Clean Architecture consulting →

Sources

  • Spring Boot, Caching - provider detection only if you have not defined a CacheManager bean, the spring.cache.redis.time-to-live property and the advice to keep the key prefix enabled. Consulted on 2026-11-12.
  • Spring Data Redis, Redis Cache - computePrefixWith and the sentence "The cache implementation defaults to use KEYS and DEL to clear the cache", with the BatchStrategy for switching to SCAN. Consulted on 2026-11-12.
  • Redis, KEYS - the warning about production use. Read on 8 November 2026 for the article on measurement, not reread today.
  • Redis, SCAN - cursor-based block iteration, used in the measurement. Not reread for this article: I only cite its name and its cursor-based operation, already used in the measurement.
  • Own measurements: the commands in the blocks above, run on 12 November 2026 on native Redis 8.0.5 (port 6391, --save "", --appendonly no); the site's development instance (port 6379) was only read.
  • This site's code, read on 12 November 2026: RedisCacheConfig.java (lines 40-70, 232-300), CacheAdminService.java, application.yml (lines 92-101 and 192-196), scripts/deploy/backend.sh (block 2c, lines 59-73).

The first four entries are documentation: the first and the second support the statements about Spring that I could not prove by running the application, and if a page changes, it is the page that prevails. The last two can be redone: the commands on an instance of your own, and the lines of code by opening the files. Where a file's comment and its code diverge, I followed the code.

Try These Tools

Free developer tools related to this topic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects