Skip to main content

Redis with Spring Boot 4.1: RedisTemplate vs StringRedisTemplate, @RedisHash, Pub/Sub and TTL

RedisTemplate vs StringRedisTemplate, @RedisHash, Pub/Sub, TTL and Redis as the Spring cache on Spring Boot 4.1, with the JDK-serialisation default and the Jackson 3 fix shown in redis-cli.

Redis is a server that keeps data in memory under keys you choose: strings, hashes, lists, sets, sorted sets. It is quick enough to hold sessions, counters, caches and small messages, and Spring Boot 4.1 connects to it with one dependency. That ease is also why a lot of people hit the same surprise on day one: you save a value from Java, open redis-cli to look at it, and the key is \xac\xed\x00\x05t\x00\x06user:1, and GET user:1 answers (nil). This page starts there and works outward: why it happens, what to use instead, and then @RedisHash repositories, expiry, Pub/Sub, TTLs and Redis as the Spring cache. Every number and every redis-cli reply below came from a real redis-server that the tests start, driven by the real redis-cli, and every block links to the file it came from in asmhatre/spring-boot-demo, redis module.
Versions. Spring Boot 4.1.1, Spring Data Redis 4.1.1, Lettuce 7.5.2, Jackson 3.1.5 (tools.jackson), Redis server 7.0.15, JDK 25 (Temurin 25.0.4.1). Class names and deprecations were read from the jars with javap (transcript 16), because the reference documentation and most tutorials still show the Jackson 2 classes. Retrieved 21 September 2026.

Add one starter and Redis is already wired up

You need one dependency to talk to Redis. This module also adds the Jackson 3 starter, for a reason the JSON section explains, and the cache starter, for the caching section at the end:
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<!-- Jackson 3 (tools.jackson). The Redis starter does not bring a JSON library. -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-jackson</artifactId>
</dependency>
That is from pom.xml. Start the application with a Redis on localhost:6379 and Boot has already created, without a line of configuration from you, a connection factory, two templates for reading and writing (RedisTemplate and StringRedisTemplate), a container that receives Pub/Sub messages, and, if you declare an interface, a Redis repository. In Boot 4 the properties live under spring.data.redis.*, which is what the tests use to point at their throwaway server (RedisTest.java):
@SpringBootTest(properties = {"spring.data.redis.port=" + LocalRedis.PORT, "spring.main.banner-mode=off"})
Everything you write goes through Spring Data Redis, which turns objects into bytes Your code RedisTemplate StringRedisTemplate @RedisHash repository @RedisListener @Cacheable Spring Data Redis 4.1.1 serializers key-value adapter listener container RedisCacheManager Lettuce 7.5.2 the Java client sends the commands Redis 7.0.15 strings, hashes, sets, TTLs, Pub/Sub all of them bytes The serializer sits at the boundary between your objects and Redis’s bytes.
The picture is the map for the rest of the page. Your code talks to Spring Data Redis, which owns the serializers, the repositories, the listener container and the cache manager, and which hands commands to the Lettuce client. Redis itself only ever sees bytes, so every problem in the next few sections happens at the left-hand edge of the server box: the moment an object or a string becomes bytes. What the auto-configuration registers, read from the Boot jar (transcript 16), is this:
BeanWhat it isWhere this page covers it
redisTemplateRedisTemplate<Object, Object>, JDK serializationthe serializer sections
stringRedisTemplateStringRedisTemplate, text in and outthe StringRedisTemplate section
redisMessageListenerContainerreceives Pub/Sub messages, created for youthe Pub/Sub section
Redis repositories@RedisHash entities, keyspace events offthe two @RedisHash sections
RedisCacheManagerwhen @EnableCaching is onthe caching section
The dependency tree shows what the starter actually brought (transcript 17):
$ mvn dependency:tree -Dincludes=io.lettuce,redis.clients,tools.jackson.core,com.fasterxml.jackson.core,org.springframework.data,org.springframework:spring-messaging
com.ankurm:redis:jar:1.0.0
+- org.springframework.boot:spring-boot-starter-data-redis:jar:4.1.1:compile
|  +- org.springframework.boot:spring-boot-data-redis:jar:4.1.1:compile
|  |  +- org.springframework.boot:spring-boot-data-commons:jar:4.1.1:compile
|  |  |  \- org.springframework.data:spring-data-commons:jar:4.1.1:compile
|  |  +- io.lettuce:lettuce-core:jar:7.5.2.RELEASE:compile
|  |  \- org.springframework.data:spring-data-redis:jar:4.1.1:compile
|  |     \- org.springframework.data:spring-data-keyvalue:jar:4.1.1:compile
|  \- org.springframework:spring-messaging:jar:7.0.9:compile
\- org.springframework.boot:spring-boot-starter-jackson:jar:4.1.1:compile
   \- org.springframework.boot:spring-boot-jackson:jar:4.1.1:compile
      \- tools.jackson.core:jackson-databind:jar:3.1.5:compile
         +- com.fasterxml.jackson.core:jackson-annotations:jar:2.21:compile
         \- tools.jackson.core:jackson-core:jar:3.1.5:compile
Two things in it matter later. The client is Lettuce 7.5.2, and Jackson is there only because this module asked for spring-boot-starter-jackson itself. The starter’s own pom lists three dependencies:
$ grep -E 'artifactId|scope' spring-boot-starter-data-redis-*.pom
<artifactId>spring-boot-starter-data-redis</artifactId>
<artifactId>spring-boot-starter</artifactId>
<scope>compile</scope>
<artifactId>spring-boot-data-redis</artifactId>
<scope>compile</scope>
<artifactId>spring-messaging</artifactId>
<scope>compile</scope>
The Redis starter includes no JSON library. If you want JSON values you add Jackson yourself. In Boot 4 that means Jackson 3 (tools.jackson), which is not the Jackson your older tutorials import. Getting the class name wrong is the second surprise of this page.

Why redis-cli shows garbage instead of your keys

Redis stores bytes. A template is the translator between your Java objects and those bytes, and the translator is called a serializer. Boot registers a RedisTemplate<Object, Object>, and if you inject that one and do nothing else you get the JDK serializer for keys and values:
@Autowired
RedisTemplate<Object, Object> redisTemplate;
(DefaultTemplateTest.java.) Asking the template which serializers it uses (transcript 01):
key serializer:   JdkSerializationRedisSerializer
value serializer: JdkSerializationRedisSerializer
Now save one plain string, and look at it from the Redis side, with redis-cli --no-raw so that binary bytes print as escapes (same transcript, 01-default-template.txt):
redisTemplate.opsForValue().set("user:1", "Ankur")
$ redis-cli -p 6390 --no-raw KEYS '*'
1) "\xac\xed\x00\x05t\x00\x06user:1"
$ redis-cli -p 6390 --no-raw GET user:1
(nil)
$ redis-cli -p 6390 --no-raw EVAL 'return redis.call("GET", redis.call("KEYS", "*")[1])' 0
"\xac\xed\x00\x05t\x00\x05Ankur"
redisTemplate.opsForValue().get("user:1") = Ankur
Two things happened. The stored key is not user:1: it is the bytes Java writes when it serializes a String object. And GET user:1 found nothing, because that key does not exist. Java reading the value back through the same template works fine, which is why this hides so well: every test that goes through the template passes.
The key you wrote is six bytes. The key Redis stored is thirteen. Java key: user:1 JdkSerializationRedisSerializer \xac\xed magic number \x00\x05 version 5 t a String \x00\x06 length 6 user:1 the six characters this whole byte string is the key, so GET user:1 asks for a different key
Reading the bytes left to right: \xac\xed and \x00\x05 are the header every Java serialization stream starts with (a magic number and version 5), t is the type code for a string, \x00\x06 is its length, and only then come the six characters. The type codes are in the Java Object Serialization Specification: stream protocol. A thirteen-byte key that contains your six-byte key is not the same key. The same serializer applies to objects, with two more consequences. A value that does not implement Serializable is refused and nothing is written:
redisTemplate.opsForValue().set("user:2", new User(2, "Ankur"))
org.springframework.data.redis.serializer.SerializationException
  message: Cannot serialize
  root:    DefaultSerializer requires a Serializable payload but received an object of type [com.ankurm.redis.model.User]
A value that does implement it is accepted, and what lands in Redis embeds the fully qualified class name, so a rename or a package move makes existing entries unreadable (transcript 02):
redisTemplate.opsForValue().set("user:3", new SerializableUser(3, "Ankur"))
$ redis-cli -p 6390 --no-raw EVAL 'return redis.call("GET", redis.call("KEYS", "*")[1])' 0
"\xac\xed\x00\x05sr\x00'com.ankurm.redis.model.SerializableUser\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x02J\x00\x02idL\x00\x04namet\x00\x12Ljava/lang/String;xp\x00\x00\x00\x00\x00\x00\x00\x03t\x00\x05Ankur"
The counter is the least obvious. A counter uses increment, which sends Redis’s INCR command. Redis stores the plain text 1 as the value, and the template then tries to read that text back as a Java serialization stream:
redisTemplate.opsForValue().increment("hits") = 1
$ redis-cli -p 6390 --no-raw KEYS '*'
1) "\xac\xed\x00\x05t\x00\x04hits"
$ redis-cli -p 6390 --no-raw EVAL 'return redis.call("GET", redis.call("KEYS", "*")[1])' 0
"1"
redisTemplate.opsForValue().get("hits")
org.springframework.data.redis.serializer.SerializationException
  message: Cannot deserialize
  root:    java.io.EOFException: null
Any command where Redis itself interprets the value (INCR, INCRBYFLOAT, HINCRBY) needs a serializer that writes plain text. The run is transcript 03.
The fingerprint of this bug. You see keys beginning \xac\xed\x00\x05 in redis-cli, a SerializationException: Cannot deserialize on a counter, or a GET that returns (nil) for a key you know you wrote. All three are the JDK serializer, not Redis. Changing the serializer on a Redis that already holds data orphans the old entries, because the new keys have different bytes; this repository does not exercise a migration, so plan one before you change it.

StringRedisTemplate: readable, and a trap when you use both

The fix for text is the other template Boot registers. StringRedisTemplate sends every key, value, hash key and hash value through StringRedisSerializer, which is UTF-8 in and out:
@Autowired
StringRedisTemplate stringTemplate;

@Autowired
RedisTemplate<Object, Object> redisTemplate;
(StringTemplateTest.java.) The transcript (transcript 04) shows the serializers and the same kind of inspection, this time with a hash as well:
key serializer:   StringRedisSerializer
value serializer: StringRedisSerializer
hash key serializer:   StringRedisSerializer
hash value serializer: StringRedisSerializer
stringTemplate.opsForValue().set("user:1", "Ankur")
$ redis-cli -p 6390 --no-raw GET user:1
"Ankur"
$ redis-cli -p 6390 --no-raw HGETALL user:1:profile
1) "city"
2) "Pune"
3) "editor"
4) "vim"
Everything is readable, counters work and read back, and the same goes for lists, sets and sorted sets, which the transcript also shows. That is the whole story for text. The trap is what happens when an application has both templates, or when two services share a Redis and configure their templates differently:
Same name, two byte strings, two different keys in one Redis StringRedisTemplate set(“user:1”, …) RedisTemplate (default) set(“user:1”, …) user:1 \xac\xed\x00\x05t\x00\x06user:1 Redis DBSIZE = 2 redis-cli sees one of them Each template reads back only the key it wrote.
The serializer is part of the key. Write user:1 through one template and read it through the other and you get null, because the second template asks for different bytes (transcript 05):
stringTemplate.opsForValue().get("user:1")  = from-string-template
redisTemplate.opsForValue().get("user:1")   = null
redisTemplate.opsForValue().get("user:1")   = from-default-template
$ redis-cli -p 6390 --no-raw DBSIZE
(integer) 2
$ redis-cli -p 6390 --no-raw GET user:1
"from-string-template"
Redis now holds two entries, and only one of them is the key you can type. This is one way to end up with a cache that works in one service and misses in another: two templates, one Redis, and keys that only look the same in the logs.
One key serializer per Redis. Pick StringRedisSerializer for keys and use it in every template in every service that shares the server. It is what you want unless you have a specific reason, and every template configured in the next section does it.

Store JSON instead, with the Jackson 3 serializers

For objects, the usual answer is JSON: readable in redis-cli, readable from other languages, and not tied to a Java class layout. Spring Data Redis 4.1 has two families of JSON serializer, and their names differ by one character. Reading the jar (transcript 16):
ClassJacksonStatus in 4.1.1
GenericJackson2JsonRedisSerializer2@Deprecated(since = "4.0")
Jackson2JsonRedisSerializer<T>2@Deprecated(since = "4.0")
GenericJacksonJsonRedisSerializer3 (tools.jackson)not deprecated
JacksonJsonRedisSerializer<T>3 (tools.jackson)not deprecated
The evidence for that table is javap -v: the Jackson 2 class carries @Deprecated(since="4.0") in its bytecode, and the Jackson 3 class carries nothing (transcript 16):
$ javap -v -cp spring-data-redis-*.jar org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer | grep -E 'Deprecated|since'
  #377 = Utf8               Deprecated
  #379 = Utf8               Ljava/lang/Deprecated;
  #380 = Utf8               since
Deprecated: true
    java.lang.Deprecated(
      since="4.0"
(end of output for GenericJackson2JsonRedisSerializer)
$ javap -v -cp spring-data-redis-*.jar org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer | grep -E 'Deprecated|since'
(end of output for GenericJacksonJsonRedisSerializer)
If a tutorial imports com.fasterxml.jackson.databind.ObjectMapper into a Redis serializer, it is written for the older generation. The Jackson 3 classes take a tools.jackson.databind.ObjectMapper (Jackson 2 to Jackson 3 Migration Guide covers the rename). The constructors of the typed one, from the same transcript (transcript 16):
$ javap -public -cp <classpath> org.springframework.data.redis.serializer.JacksonJsonRedisSerializer | grep 'JacksonJsonRedisSerializer('
  public org.springframework.data.redis.serializer.JacksonJsonRedisSerializer(java.lang.Class<T>);
  public org.springframework.data.redis.serializer.JacksonJsonRedisSerializer(tools.jackson.databind.JavaType);
  public org.springframework.data.redis.serializer.JacksonJsonRedisSerializer(tools.jackson.databind.ObjectMapper, java.lang.Class<T>);
  public org.springframework.data.redis.serializer.JacksonJsonRedisSerializer(tools.jackson.databind.ObjectMapper, tools.jackson.databind.JavaType);
  public org.springframework.data.redis.serializer.JacksonJsonRedisSerializer(tools.jackson.databind.ObjectMapper, tools.jackson.databind.JavaType, org.springframework.data.redis.serializer.JacksonObjectReader, org.springframework.data.redis.serializer.JacksonObjectWriter);
There are two ways to use them. The typed serializer is for one value type per template and writes plain JSON. The generic one is a single template for any type, and to read a value back as the right class it has to store the class name inside the JSON. The configuration is in JsonRedisConfig.java:
public static GenericJacksonJsonRedisSerializer allowListSerializer() {
    PolymorphicTypeValidator allowList = BasicPolymorphicTypeValidator.builder()
            .allowIfSubType("com.ankurm.redis.")
            .build();
    return GenericJacksonJsonRedisSerializer.builder()
            .enableDefaultTyping(allowList)
            .build();
}
RedisTemplate<String, Object> jsonRedisTemplate(RedisConnectionFactory factory) {
    RedisTemplate<String, Object> template = new RedisTemplate<>();
    template.setConnectionFactory(factory);
    template.setKeySerializer(StringRedisSerializer.UTF_8);
    template.setHashKeySerializer(StringRedisSerializer.UTF_8);
    GenericJacksonJsonRedisSerializer json = allowListSerializer();
    template.setValueSerializer(json);
    template.setHashValueSerializer(json);
    return template;
}
That is JsonRedisConfig.java, which also defines a userRedisTemplate using new JacksonJsonRedisSerializer<>(User.class). Running all three shapes (transcript 06):
userTemplate.opsForValue().set("user:1", new User(1, "Ankur"))
$ redis-cli -p 6390 GET user:1
{"id":1,"name":"Ankur"}
read back: User[id=1, name=Ankur] (User)
jsonTemplate.opsForValue().set("user:2", new User(2, "Ankur"))
$ redis-cli -p 6390 GET user:2
{"@class":"com.ankurm.redis.model.User","id":2,"name":"Ankur"}
plain.opsForValue().set("user:3", new User(3, "Ankur"))
$ redis-cli -p 6390 GET user:3
{"id":3,"name":"Ankur"}
read back: {id=3, name=Ankur} (LinkedHashMap)
Two Jackson 3 serializers, and the gate the generic one needs User(1, “Ankur”) JacksonJsonRedisSerializer <User> {“id”:1,”name”:”Ankur”} User(2, “Ankur”) GenericJacksonJson RedisSerializer + allow-list {“@class”:”…model.User”,”id”:2,…} @class = com.ankurm.other.Outsider denied: not under com.ankurm.redis.
Three shapes: plain JSON from the typed serializer, JSON with an @class property from the generic one with typing turned on, and, if you use the generic serializer without configuring typing, JSON that reads back as a LinkedHashMap instead of your class. The bottom of the diagram is the part to take seriously. @class is not metadata, it is an instruction: whatever is in Redis chooses which class the serializer instantiates, and anyone who can write to that Redis can write any class name that is on your classpath. That is why the builder has a method literally called enableUnsafeDefaultTyping() and why the safe one takes a validator. The allow-list above accepts only types under com.ankurm.redis., and here is a class outside it, and a class inside it that no longer exists (transcript 07):
$ redis-cli -p 6390 SET user:evil '{"@class":"com.ankurm.other.Outsider","name":"x"}'
jsonTemplate.opsForValue().get("user:evil")
org.springframework.data.redis.serializer.SerializationException
  message: Could not read JSON: Could not resolve type id 'com.ankurm.other.Outsider' as a subtype of `com.ankurm.other.Outsider`: Configured `PolymorphicTypeValidator` (of type `tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator`) denied resolution
$ redis-cli -p 6390 SET user:gone '{"@class":"com.ankurm.redis.model.Gone","id":9}'
  message: Could not read JSON: Failed to parse type 'com.ankurm.redis.model.Gone' (remaining: ''): Cannot locate class 'com.ankurm.redis.model.Gone', problem: com.ankurm.redis.model.Gone
Both failures are worth recognising in a log. The first is the allow-list doing its job. The second is what a refactor looks like when old entries are still in Redis: type names stored in Redis are a compatibility surface, exactly as the class name inside a JDK-serialised value was. If you would rather not have one, the typed serializer stores no class name at all. Jackson Security Best Practices: Defending Against Deserialization Gadget Attacks and Jackson Polymorphic Deserialisation explain the risk in general.
TypedGeneric
redis-cli outputplain JSONJSON with @class
Rename or move the classsafebreaks reads
Needs an allow-listnoyes
Templates you needone per value typeone

@RedisHash: a repository that creates six keys

If you would rather not call the template yourself, Spring Data Redis can map a class to a Redis hash and give you a repository. The entity needs @RedisHash and an @Id; @Indexed on a field makes it searchable. The files are Person.java and PersonRepository.java:
@RedisHash("person")
public class Person {

    @Id
    private String id;

    private String firstName;

    /** Indexed fields get a Redis set per value, e.g. {@code person:lastName:Mhatre}. */
    @Indexed
    private String lastName;

    private int age;
public interface PersonRepository extends CrudRepository<Person, String> {

    /** Answered from the index set {@code person:lastName:<value>}, which exists because lastName is @Indexed. */
    List<Person> findByLastName(String lastName);
}
Nothing else is configured: Boot enables Redis repositories on its own. Save two people and list what appeared (transcript 08):
people.save(new Person("1", "Ankur", "Mhatre", 40))
people.save(new Person("2", "Asha", "Mhatre", 38))
$ redis-cli -p 6390 KEYS '*' | sort
person
person:1
person:1:idx
person:2
person:2:idx
person:lastName:Mhatre
$ redis-cli -p 6390 --no-raw HGETALL person:1
 1) "_class"
 2) "com.ankurm.redis.hash.Person"
 3) "age"
 4) "40"
 5) "firstName"
 6) "Ankur"
 7) "id"
 8) "1"
 9) "lastName"
10) "Mhatre"
Saving one Person creates four kinds of key Person id = 1 lastName = Mhatre person:1 hash: the entity person set of every id person:lastName:Mhatre set: ids with that name person:1:idx set: which index sets hold 1 delete one and Spring Data Redis tidies all four
Two people produced six keys. Each person is a hash, person:1, and the hash records the Java type in _class. The other keys are bookkeeping that Spring Data Redis maintains for you: a set called person that holds every id (this is what count() reads), a set per indexed value (person:lastName:Mhatre, which is how findByLastName is answered), and a small set per entity, person:1:idx, that lists the index sets the entity is in, which the delete at the end of the transcript removes along with the rest:
$ redis-cli -p 6390 SMEMBERS person:1:idx | sort
person:lastName:Mhatre
people.findByLastName("Mhatre") = 2 results
people.deleteById("1")
It does not go through the template serializers described above: the transcript shows the fields stored as plain text values, so the JDK default is not the problem here. The catch is the bookkeeping. Spring Data Redis keeps those extra keys tidy only when it is the one doing the deleting.

Expiry on a @RedisHash: the leftovers, and the fix

A hash can carry a time to live: @RedisHash(value = "session", timeToLive = 2) gives every saved session two seconds. Redis deletes the hash when the time is up. The question is what happens to everything Spring Data Redis wrote around the hash. With Boot’s defaults, the answer is nothing, and the transcript shows what that costs (transcript 09):
sessions.save(new Session("s1", "ankur"))
$ redis-cli -p 6390 --no-raw TTL session:s1
(integer) 2
$ redis-cli -p 6390 --no-raw EXISTS session:s1
(integer) 0
$ redis-cli -p 6390 SMEMBERS session | sort
s1
sessions.findById("s1")   = Optional.empty
sessions.count()          = 1
The hash is gone and the index entries are not. findById is right (there is no hash to find), but count() still says 1 because the id is still in the session set, and session:user:ankur still lists an id that resolves to nothing. On a busy system these accumulate. The default is enableKeyspaceEvents = OFF, and I read that from the annotation’s bytecode rather than from prose (transcript 16).
What is left after a two-second @RedisHash expires events OFF (Boot default) t = 0 save session s1 t = 2 s Redis deletes hash left behind: session set, session:user:ankur, session:s1:idx. count() = 1 events ON_STARTUP t = 0 save + phantom t = 2 s Redis deletes hash expiry event published over Pub/Sub Spring cleans up DBSIZE = 0
The fix is to switch keyspace events on. That does three things, which the second timeline shows. This is the configuration and the listener from HashTtlEventsOnTest.java:
@TestConfiguration
@EnableRedisRepositories(basePackageClasses = SessionRepository.class, enableKeyspaceEvents = EnableKeyspaceEvents.ON_STARTUP)
@EventListener
void on(RedisKeyExpiredEvent<?> event) {
    events.add("keyspace=" + event.getKeyspace() + " id=" + new String(event.getId()) + " value=" + event.getValue());
}
First, Spring Data Redis changes a server setting. Redis only publishes expiry notifications when notify-keyspace-events is non-empty, and the transcript (transcript 10) reads it before the application context starts and after:
$ redis-cli -p 6390 --no-raw CONFIG GET notify-keyspace-events
1) "notify-keyspace-events"
2) ""
$ redis-cli -p 6390 --no-raw CONFIG GET notify-keyspace-events
1) "notify-keyspace-events"
2) "xE"
The annotation’s keyspaceNotificationsConfigParameter defaults to Ex, and Redis reports the same setting back as xE. Second, it keeps a phantom copy of the hash (session:s1:phantom) alive five minutes longer than the hash itself, 302 seconds for a two-second TTL, so that the entity still exists when the expiry announcement arrives. Third, the announcement becomes a Spring event (same transcript, 10-hash-ttl-events-on.txt):
sessions.save(new Session("s1", "ankur"))
$ redis-cli -p 6390 --no-raw TTL session:s1:phantom
(integer) 302
$ redis-cli -p 6390 KEYS '*' | sort
session
session:s1
session:s1:idx
session:s1:phantom
session:user:ankur
RedisKeyExpiredEvent: keyspace=session id=s1 value=Session[id=s1, user=ankur]
$ redis-cli -p 6390 --no-raw DBSIZE
(integer) 0
sessions.count() = 0
Redis expired the hash and announced it; Spring Data Redis then removed the index entries and the phantom key, and delivered a RedisKeyExpiredEvent carrying the entity, which the phantom copy is there to make possible. Afterwards the database is empty and count() is 0.
Do not build correctness on it. The announcement travels over Pub/Sub, which is fire-and-forget: a subscriber that is not connected when it is sent never sees it, as the Pub/Sub section below demonstrates with a stopped listener. If your application is down when a session expires, its cleanup is not guaranteed. This repository does not test a missed notification, so treat that as a design consequence rather than a measured result. Some managed Redis services also restrict CONFIG SET; I did not run against one, so check yours. Redis keyspace notifications describes the mechanism.

Pub/Sub: Boot 4.1 removes the boilerplate, and Redis keeps nothing

Pub/Sub is a broadcast. PUBLISH channel message sends the message to whoever is subscribed to the channel at that moment, and the reply is the number of subscribers that got it. Older articles start by declaring a RedisMessageListenerContainer bean. Boot 4.1 declares one for you, named redisMessageListenerContainer, and enables the @RedisListener annotation, so a listener is a method (both are in NewsListener.java):
@RedisListener(topic = "news")
void onNews(String body) {
    received.add("@RedisListener got '" + body + "'");
}
@RedisListener(topic = "alerts.*")
void onAlert(String body, @Header(PubSubHeaders.CHANNEL) String channel) {
    received.add("@RedisListener got '" + body + "' on " + channel);
}
A topic that contains a glob, like alerts.*, subscribes as a pattern, and the channel a message actually arrived on is available as a header. Sending is one call on the string template, which returns the receiver count (transcript 11):
beans of type RedisMessageListenerContainer: [redisMessageListenerContainer]
running=true listening=true
template.convertAndSend("quiet", "hello?") = 0 receivers
template.convertAndSend("news", "first") = 1 receiver
@RedisListener got 'first'
container.addMessageListener(late, new ChannelTopic("news"))
template.convertAndSend("news", "second") = 1 receiver
@RedisListener got 'second'
late listener got 'second' on news
$ redis-cli -p 6390 --no-raw PUBSUB NUMPAT
(integer) 1
template.convertAndSend("alerts.disk", "90% full") = 1 receiver
@RedisListener got '90% full' on alerts.disk
PUBLISH returns how many connections were listening at that moment publisher convertAndSend channel “news” listener container one subscription @RedisListener late listener result = 1 receiver publisher convertAndSend channel “quiet” nobody subscribed message is gone result = 0 receivers
Read the numbers, because they are the point. A message to a channel nobody listens to reaches zero receivers and is simply gone. A message to news reaches one. Add a second listener to the same channel and PUBLISH still says one, because the container subscribes once and fans the message out to its listeners inside your application: the number is receivers on the Redis side, not listeners on yours. And a pattern subscriber counts as a receiver of PUBLISH, though PUBSUB NUMSUB on the channel says zero, because NUMSUB counts only direct channel subscribers. Nothing is queued, which the next run makes concrete by stopping the container, publishing, and starting it again:
container.stop()
template.convertAndSend("news", "while you were away") = 0 receivers
container.start()
messages the listener has: []
template.convertAndSend("news", "welcome back") = 1 receiver
@RedisListener got 'welcome back'
The message sent while the listener was away is never delivered (transcript 12). That is the design, and it is why Pub/Sub suits hints and live updates and does not suit anything that must not be lost. Redis Streams exist for that; they are not covered here.
A parameter that did not work. A listener method declared with org.springframework.data.redis.connection.Message as its parameter failed in this test: the adapter tried to parse the payload as JSON and threw a StreamReadException on the text 90% full. A String body plus @Header(PubSubHeaders.CHANNEL), as above, worked. I did not investigate why.

TTL: -1, -2, and the plain set that erases it

A TTL is how long a key lives. getExpire(key) returns the seconds left, with two negative values that look like errors and are not: -1 means the key exists and never expires, and -2 means it does not exist. Setting a value and its TTL together in one command avoids a window in which the key exists without one (the run is 13-ttl.txt):
getExpire("plain")   = -1
getExpire("missing") = -2
set("session", "v1", Duration.ofMinutes(10))
getExpire("session")                       = 600
getExpire("session", TimeUnit.MINUTES)     = 9
set("session", "v2")
getExpire("session") = -1
set("session", "v3", Expiration.keepTtl())
getExpire("session") = 600, value = v3
The three states of a key, and what moves it between them no such key getExpire = -2 has a TTL getExpire = seconds left never expires getExpire = -1 set(k, v, Duration) time runs out set(k, v) expire(k, Duration) set(k, v, Expiration.keepTtl()) keeps the TTL
Read the run with the diagram. A fresh ten-minute TTL reads 600 seconds, and the same key asked in minutes reads 9, because the conversion truncates. Then the trap: a plain set on a key with a TTL removes the TTL, so refreshing a cached value without passing the TTL again makes it immortal. If you need to overwrite and keep the clock, use KEEPTTL:
template.opsForValue().set("session", "v3", Expiration.keepTtl());
That line is in TtlTest.java, and the transcript shows the TTL still at 600 afterwards. The same run shows that Duration.ofMillis(1500) is honoured, that persist removes a TTL, and that a 300-millisecond key really is gone when read back. One more use of a TTL is the smallest lock: setIfAbsent with a duration succeeds for the first caller, fails for the second, and disappears by itself if the holder dies.
setIfAbsent("lock:job", "worker-1", 30s) = true
setIfAbsent("lock:job", "worker-2", 30s) = false
$ redis-cli -p 6390 --no-raw TTL lock:job
(integer) 30
That is not a full distributed lock. There is no owner-checked release and no fencing here, and this repository does not test either. It shows what setIfAbsent with a TTL does, no more.
  • Every TTL call in one transcript: chapter 8.
  • EXPIRE and SET in the Redis command reference.

Redis as the Spring cache

Everything above applies when Redis backs @Cacheable, with two twists. The annotations themselves (keys, eviction, and the self-invocation trap that silently disables them) are covered in The Spring Cache Abstraction: @Cacheable, @CacheEvict, Key Generators and the Self-Invocation Trap; this section is only about what changes when the store is Redis. With the cache starter and @EnableCaching, Boot picks RedisCacheManager, and the two cached methods below return results that differ only in whether they are Serializable:
@Cacheable("legacy")
public SerializableUser findSerializable(long id) {
    calls.incrementAndGet();
    return new SerializableUser(id, "Ankur");
}

@Cacheable("users")
public User find(long id) {
    calls.incrementAndGet();
    return new User(id, "Ankur");
}
That is UserService.java. The keys are readable, cacheName::key, so the key half is not a problem. The value half is the same as in the first serializer section, because the cache’s default serializer for values is the JDK one (transcript 14):
org.springframework.data.redis.cache.RedisCacheManager
findSerializable(1) called twice, method body ran 1 time(s)
$ redis-cli -p 6390 --no-raw TTL legacy::1
(integer) 600
$ redis-cli -p 6390 --no-raw GET legacy::1
"\xac\xed\x00\x05sr\x00'com.ankurm.redis.model.SerializableUser\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x02J\x00\x02idL\x00\x04namet\x00\x12Ljava/lang/String;xp\x00\x00\x00\x00\x00\x00\x00\x01t\x00\x05Ankur"
users.find(1)
java.lang.IllegalStateException
  message: Cannot serialize value of type com.ankurm.redis.model.User without a serializer
method body ran 2 time(s) in total
One cached call, one readable key, and a value that depends on one bean @Cacheable(“users”) find(1) RedisCacheManager builds the key users::1 readable key JDK bytes (default) Serializable only JSON with @class with a bean; any record
Two consequences. The stored value is unreadable bytes with a class name inside. And a result that is not Serializable does not just fail to cache: the method call fails. The body ran, the write to Redis threw, and the caller got the exception, so a @Cacheable on a method that returns a plain record breaks that method. A spring.cache.redis.time-to-live=10m property did take effect on the Serializable case (TTL is 600). The fix is one bean, a RedisCacheConfiguration that stores JSON. Here it uses the same allow-listed serializer as in the JSON section:
RedisCacheConfiguration cacheConfiguration() {
    return RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(5))
            .serializeValuesWith(SerializationPair.fromSerializer(JsonRedisConfig.allowListSerializer()));
}
That is JsonCacheConfig.java, active under the json-cache profile so both behaviours stay reproducible. With it (transcript 15):
find(1) called twice, method body ran 1 time(s)
$ redis-cli -p 6390 GET users::1
{"@class":"com.ankurm.redis.model.User","id":1,"name":"Ankur"}
$ redis-cli -p 6390 --no-raw TTL users::1
(integer) 300
The plain record now caches, the method body ran once for two calls, and the value is JSON you can read.
The bean overrides the property. The test sets spring.cache.redis.time-to-live=10m and declares a bean with entryTtl(Duration.ofMinutes(5)). The key’s TTL is 300. When you define your own RedisCacheConfiguration, Boot used it and the property had no effect on the TTL, so put the TTL in the bean. I observed this for the TTL only; I did not check every spring.cache.redis.* property.

Which one should you use?

Most of the choices above come down to one question: what do you want to see when you open redis-cli?
You want to storeUseWatch out for
Text, counters, flagsStringRedisTemplateDo not mix it with the default template on the same keys
One object type as JSONRedisTemplate with JacksonJsonRedisSerializer<T>One template per type
Many object types as JSONGeneric Jackson 3 serializer with an allow-list@class is input; a rename breaks old entries
Entities you query by a field@RedisHash repositorySix keys for two entities; turn on keyspace events if you use a TTL
A cache@Cacheable with a RedisCacheConfiguration beanThe bean overrides spring.cache.redis.*
Notifications you can afford to lose@RedisListenerNothing is queued for a listener that is not there
Should you use Redis at all? If you run one instance and only need a cache, an in-process cache is simpler and has no network hop or second system to operate, and The Spring Cache Abstraction: @Cacheable, @CacheEvict, Key Generators and the Self-Invocation Trap shows the annotations work the same on it. Redis earns its place when several instances must share state: a cache they all see, sessions, counters, or a broadcast. The default serializer is the reason a first Redis integration often looks broken. It is not a reason to avoid Redis, and it is fixed with a few lines of configuration. One caution for the tests in the repository: they call FLUSHALL, which is why they start their own server on port 6390 and refuse to reuse one.

FAQs

What is the difference between RedisTemplate and StringRedisTemplate?

The serializer. StringRedisTemplate writes keys and values as UTF-8 text, so you can read them in redis-cli. The default RedisTemplate<Object, Object> Boot registers writes JDK-serialised bytes for both. Because the serializer is part of the key, the two templates do not see each other’s keys (transcript 05).

Why does redis-cli GET return (nil) for a key I just saved?

Almost certainly because the key in Redis is not the text you typed. With the default template the stored key is \xac\xed\x00\x05t\x00\x06user:1 (transcript 01). Use StringRedisTemplate, or set StringRedisSerializer as the key serializer.

Is Jackson2JsonRedisSerializer deprecated in Spring Boot 4?

Yes: in Spring Data Redis 4.1.1 both Jackson2JsonRedisSerializer and GenericJackson2JsonRedisSerializer carry @Deprecated(since="4.0"). The Jackson 3 replacements are JacksonJsonRedisSerializer and GenericJacksonJsonRedisSerializer (transcript 16).

How do I set a TTL with Spring Data Redis?

For a plain key, opsForValue().set(key, value, Duration), or expire(key, Duration) afterwards. For a @RedisHash entity, timeToLive on the annotation, with keyspace events on if you want the index entries cleaned up. A plain set on a key with a TTL clears it; use Expiration.keepTtl() to keep it (transcript 13).

Do I need to declare a RedisMessageListenerContainer in Boot 4.1?

No. Boot registers one named redisMessageListenerContainer and enables @RedisListener, and the listener in the transcript (transcript 11) uses nothing else. Declare your own bean of that name only if you want to replace it.

Conclusion

Redis with Spring Boot 4.1 is easy to connect and easy to misread. Nearly all the confusion comes from one default, the JDK serializer, applied to keys as well as values: fix that first, with StringRedisTemplate or a String key serializer plus a Jackson 3 value serializer, and the tools you use to look at Redis start telling the truth. After that the interesting parts are the ones that happen around your data rather than to it: the keys a repository creates on your behalf, the bookkeeping that expiry leaves behind unless keyspace events are on, the way a plain set erases a TTL, and the fact that Pub/Sub keeps nothing.

Further Reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.