Spring Data Redis
Spring Data Redis connects a Spring Boot application to Redis, an in-memory data store that answers in well under a millisecond. Redis is more than a cache: it has hashes, sorted sets, counters and keys that delete themselves after a set time. That makes it a natural home for shopping carts, leaderboards, rate limits and short-lived offers. Spring Data Redis gives you templates for every Redis command and repositories for storing objects, without hand-written connection code.
This tutorial explains how Spring Data Redis works, the difference between StringRedisTemplate and RedisTemplate, the main Redis data types, expiry with TTL, JSON serialization, and repositories with @RedisHash. It then builds a shop backend on Spring Boot 4.1 with a cart, trending products, flash deals and delivery tracking. It also shows the real problems behind the most common pitfalls, and tests everything with @DataRedisTest and Testcontainers.
1. What Is Spring Data Redis?
Redis keeps all its data in memory and stores it under keys, such as cart:C-42. Each key holds one value of a specific data type: a string, a hash (a small map of fields), a list, a set or a sorted set. Every key can have a TTL (time to live), after which Redis deletes it automatically.
Spring Data Redis is the Spring module for Redis. It gives you:
- Templates (
StringRedisTemplateandRedisTemplate) with one method for each Redis command, grouped by data type. - Serializers that turn your Java keys and values into bytes, and back.
- Repositories that save objects annotated with
@RedisHash, like Spring Data JPA does for tables. - The cache support behind
@Cacheable, covered separately in the Spring Boot caching tutorial.
2. How Spring Data Redis Works
Spring Boot auto-configures a RedisConnectionFactory backed by Lettuce, a non-blocking Redis client whose connections are thread-safe and shared. On top of it, Boot creates two templates, stringRedisTemplate and redisTemplate, and enables Redis repositories. Every template call or repository method turns into Redis commands such as HINCRBY, ZINCRBY or SET ... EX.
3. Setup: Connecting Spring Boot to Redis
Add the Redis starter. The test dependencies are used in section 8:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</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-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
spring.application.name=shop
spring.data.redis.host=localhost
spring.data.redis.port=6379
# Fail fast instead of hanging when Redis is slow or down
spring.data.redis.timeout=2s
For a managed Redis with a password and TLS, use a single URL such as spring.data.redis.url=rediss://default:${REDIS_PASSWORD}@cache.example.net:6380. The rediss scheme turns on TLS.
spring.data.redis.* since Spring Boot 3.0. The old spring.redis.* keys are deprecated. Spring Boot 4 uses Spring Data Redis 4 and Jackson 3, so the JSON serializers are now JacksonJsonRedisSerializer and GenericJacksonJsonRedisSerializer. The Jackson 2 versions, Jackson2JsonRedisSerializer and GenericJackson2JsonRedisSerializer, are deprecated.4. Core Concepts
4.1 StringRedisTemplate vs RedisTemplate
StringRedisTemplate | RedisTemplate<K, V> | |
|---|---|---|
| Key and value types | String only | Any types you configure |
| Serializer | UTF-8 strings | Boot's default bean uses JDK serialization (binary). Configure your own, e.g. JSON |
Readable in redis-cli | Yes | Only with string or JSON serializers |
| Use for | Counters, hashes of simple values, sorted sets, flags | Storing whole objects as values |
Start with StringRedisTemplate. Reach for a typed RedisTemplate only when a value is a whole object, and then give it a JSON serializer (section 4.4).
4.2 Redis Data Types and Their Operations
Each template groups its commands by data type through an opsFor...() method:
| Redis type | Template method | Typical commands | Shop use case |
|---|---|---|---|
| String | opsForValue() | SET, GET, INCR | A flash deal, a page-view counter |
| Hash | opsForHash() | HSET, HINCRBY, HGETALL | A cart: SKU to quantity |
| List | opsForList() | LPUSH, LRANGE, LTRIM | Recently viewed products |
| Set | opsForSet() | SADD, SISMEMBER | Customers who used a coupon |
| Sorted set | opsForZSet() | ZINCRBY, ZREVRANGE | Trending products by views |
Commands such as HINCRBY and ZINCRBY are atomic. Redis runs them one at a time, so two customers clicking "add to cart" at the same moment both count, without any locking in your code:
// HINCRBY is atomic, so two "add" clicks at once both count
Long quantity = redis.opsForHash().increment(key, request.sku(), request.quantity());
redis.expire(key, CART_TTL);
4.3 Expiring Keys with TTL
Give every key that should not live forever a TTL. Set it when you write the value, or afterwards with expire, and read the remaining time with getExpire:
// Reset the TTL after every change to the cart
redis.expire("cart:" + customerId, Duration.ofMinutes(30));
// Seconds left: -1 = no TTL, -2 = key does not exist
Long ttl = redis.getExpire("cart:" + customerId);
// Write the value and its TTL in one command (SET ... EX)
flashDealTemplate.opsForValue().set("deal:" + sku, deal, Duration.ofSeconds(request.durationSeconds()));
The cart resets its 30-minute TTL on every change, so only abandoned carts expire. The flash deal is written with its duration in a single SET ... EX command, so it can never exist without an expiry.
4.4 Storing Objects as JSON
For whole objects, define a typed RedisTemplate with a string key serializer and a JSON value serializer. JacksonJsonRedisSerializer writes the record as plain JSON and reads it back into the same type:
package com.javabytechie.shop.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.JacksonJsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import com.javabytechie.shop.deal.FlashDeal;
@Configuration
public class RedisConfig {
// Keys as plain strings, values as JSON: both stay readable in redis-cli
@Bean
public RedisTemplate<String, FlashDeal> flashDealTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, FlashDeal> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(StringRedisSerializer.UTF_8);
template.setValueSerializer(new JacksonJsonRedisSerializer<>(FlashDeal.class));
return template;
}
}
GenericJacksonJsonRedisSerializer is the alternative when one template stores many types. It adds type information to the JSON so it knows which class to create when reading.
4.5 Redis Repositories with @RedisHash
A Redis repository stores each object as a hash and lets you query it like a Spring Data repository. @RedisHash("deliveries") sets the key prefix, @Indexed makes a field searchable, and @TimeToLive sets a per-object TTL:
package com.javabytechie.shop.delivery;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.TimeToLive;
import org.springframework.data.redis.core.index.Indexed;
// Stored as the hash "deliveries:<orderId>", plus an index set per customer
@RedisHash("deliveries")
public record DeliveryTracking(@Id String orderId,
@Indexed String customerId,
String status,
String courier,
@TimeToLive long ttlSeconds) {
}
package com.javabytechie.shop.delivery;
import java.util.List;
import org.springframework.data.repository.CrudRepository;
public interface DeliveryTrackingRepository extends CrudRepository<DeliveryTracking, String> {
// Works only because customerId is @Indexed
List<DeliveryTracking> findByCustomerId(String customerId);
}
package com.javabytechie.shop.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
import org.springframework.data.redis.core.RedisKeyValueAdapter.EnableKeyspaceEvents;
// Listen for Redis "expired" events so Spring Data can clean up index entries of expired hashes
@Configuration
@EnableRedisRepositories(basePackages = "com.javabytechie.shop", enableKeyspaceEvents = EnableKeyspaceEvents.ON_STARTUP)
public class RedisRepositoryConfig {
}
Saving two deliveries for customer C-42 creates several keys. Redis has no secondary indexes of its own, so Spring Data maintains them as sets:
> SCAN 0
deliveries:ORD-1001
deliveries:ORD-1002
deliveries
deliveries:customerId:C-42
deliveries:ORD-1001:idx
deliveries:ORD-1002:idx
deliveries:ORD-1001:phantom
deliveries:ORD-1002:phantom
> HGETALL deliveries:ORD-1001
_class com.javabytechie.shop.delivery.DeliveryTracking
courier SwiftShip
customerId C-42
orderId ORD-1001
status OUT_FOR_DELIVERY
ttlSeconds 3600
> SMEMBERS deliveries:customerId:C-42
ORD-1001
ORD-1002
Redis repositories support only simple queries: equality on @Indexed fields, combined with And or Or. There are no range queries or sorting on values. For anything more, use the templates directly.
5. A Complete Example: Cart, Trending Products and Flash Deals
The shop backend stores carts as hashes, ranks products by views in a sorted set and publishes flash deals as JSON values that expire. Errors follow one pattern: 404 for a deal that doesn't exist or has expired, and 400 for an invalid quantity, returned as ProblemDetail responses as described in the exception handling tutorial.
5.1 Cart
package com.javabytechie.shop.cart;
import java.time.Duration;
import java.util.Map;
import java.util.TreeMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
@Service
public class CartService {
private static final Logger log = LoggerFactory.getLogger(CartService.class);
// An abandoned cart disappears 30 minutes after its last change
static final Duration CART_TTL = Duration.ofMinutes(30);
private final StringRedisTemplate redis;
public CartService(StringRedisTemplate redis) {
this.redis = redis;
}
private static String key(String customerId) {
return "cart:" + customerId;
}
public CartResponse addItem(String customerId, AddItemRequest request) {
if (request.quantity() < 1) {
throw new InvalidQuantityException(request.quantity());
}
String key = key(customerId);
// HINCRBY is atomic, so two "add" clicks at once both count
Long quantity = redis.opsForHash().increment(key, request.sku(), request.quantity());
redis.expire(key, CART_TTL);
log.info("Cart {}: {} x {}", customerId, quantity, request.sku());
return getCart(customerId);
}
public CartResponse removeItem(String customerId, String sku) {
String key = key(customerId);
redis.opsForHash().delete(key, sku);
redis.expire(key, CART_TTL);
return getCart(customerId);
}
public CartResponse getCart(String customerId) {
String key = key(customerId);
HashOperations<String, String, String> hash = redis.opsForHash();
Map<String, Integer> items = new TreeMap<>();
hash.entries(key).forEach((sku, quantity) -> items.put(sku, Integer.parseInt(quantity)));
Long ttl = redis.getExpire(key);
return new CartResponse(customerId, items, ttl == null || ttl < 0 ? 0 : ttl);
}
}
package com.javabytechie.shop.cart;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/carts/{customerId}")
public class CartController {
private final CartService cartService;
public CartController(CartService cartService) {
this.cartService = cartService;
}
@GetMapping
public CartResponse get(@PathVariable String customerId) {
return cartService.getCart(customerId);
}
@PostMapping("/items")
public CartResponse add(@PathVariable String customerId, @RequestBody AddItemRequest request) {
return cartService.addItem(customerId, request);
}
@DeleteMapping("/items/{sku}")
public CartResponse remove(@PathVariable String customerId, @PathVariable String sku) {
return cartService.removeItem(customerId, sku);
}
}
package com.javabytechie.shop.cart;
public record AddItemRequest(String sku, int quantity) {
}
package com.javabytechie.shop.cart;
import java.util.Map;
public record CartResponse(String customerId, Map<String, Integer> items, long expiresInSeconds) {
}
package com.javabytechie.shop.cart;
public class InvalidQuantityException extends RuntimeException {
public InvalidQuantityException(int quantity) {
super("Quantity must be at least 1, but was " + quantity);
}
}
5.2 Trending Products and Flash Deals
package com.javabytechie.shop.trending;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ZSetOperations.TypedTuple;
import org.springframework.stereotype.Service;
@Service
public class TrendingService {
static final String VIEWS_KEY = "products:views";
private final StringRedisTemplate redis;
public TrendingService(StringRedisTemplate redis) {
this.redis = redis;
}
// ZINCRBY: the sorted set keeps products ordered by their view count
public void recordView(String sku) {
redis.opsForZSet().incrementScore(VIEWS_KEY, sku, 1);
}
public List<TrendingProduct> top(int limit) {
Set<TypedTuple<String>> tuples = redis.opsForZSet().reverseRangeWithScores(VIEWS_KEY, 0, limit - 1);
return Objects.requireNonNull(tuples).stream()
.map(t -> new TrendingProduct(t.getValue(), Objects.requireNonNull(t.getScore()).longValue()))
.toList();
}
}
package com.javabytechie.shop.trending;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/products")
public class TrendingController {
private final TrendingService trendingService;
public TrendingController(TrendingService trendingService) {
this.trendingService = trendingService;
}
@PostMapping("/{sku}/views")
public ResponseEntity<Void> view(@PathVariable String sku) {
trendingService.recordView(sku);
return ResponseEntity.noContent().build();
}
@GetMapping("/trending")
public List<TrendingProduct> trending(@RequestParam(defaultValue = "3") int limit) {
return trendingService.top(limit);
}
}
package com.javabytechie.shop.trending;
public record TrendingProduct(String sku, long views) {
}
package com.javabytechie.shop.deal;
import java.time.Duration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
@Service
public class FlashDealService {
private static final Logger log = LoggerFactory.getLogger(FlashDealService.class);
private final RedisTemplate<String, FlashDeal> flashDealTemplate;
public FlashDealService(RedisTemplate<String, FlashDeal> flashDealTemplate) {
this.flashDealTemplate = flashDealTemplate;
}
// SET with EX: Redis deletes the deal by itself when the time is up
public FlashDeal publish(String sku, PublishDealRequest request) {
FlashDeal deal = new FlashDeal(sku, request.title(), request.price(), request.discountPercent());
flashDealTemplate.opsForValue().set("deal:" + sku, deal, Duration.ofSeconds(request.durationSeconds()));
log.info("Published deal for {} for {} seconds", sku, request.durationSeconds());
return deal;
}
public FlashDeal find(String sku) {
FlashDeal deal = flashDealTemplate.opsForValue().get("deal:" + sku);
if (deal == null) {
throw new DealNotFoundException(sku);
}
return deal;
}
}
package com.javabytechie.shop.deal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/deals/{sku}")
public class FlashDealController {
private final FlashDealService flashDealService;
public FlashDealController(FlashDealService flashDealService) {
this.flashDealService = flashDealService;
}
@PutMapping
public FlashDeal publish(@PathVariable String sku, @RequestBody PublishDealRequest request) {
return flashDealService.publish(sku, request);
}
@GetMapping
public FlashDeal find(@PathVariable String sku) {
return flashDealService.find(sku);
}
}
package com.javabytechie.shop.deal;
import java.math.BigDecimal;
public record FlashDeal(String sku, String title, BigDecimal price, int discountPercent) {
}
package com.javabytechie.shop.deal;
import java.math.BigDecimal;
public record PublishDealRequest(String title, BigDecimal price, int discountPercent, long durationSeconds) {
}
package com.javabytechie.shop.deal;
public class DealNotFoundException extends RuntimeException {
public DealNotFoundException(String sku) {
super("No active deal for product " + sku);
}
}
5.3 Error Handling and Application
package com.javabytechie.shop.config;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import com.javabytechie.shop.cart.InvalidQuantityException;
import com.javabytechie.shop.deal.DealNotFoundException;
@RestControllerAdvice
public class ShopExceptionHandler {
@ExceptionHandler(DealNotFoundException.class)
public ProblemDetail notFound(DealNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
@ExceptionHandler(InvalidQuantityException.class)
public ProblemDetail badQuantity(InvalidQuantityException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
}
}
package com.javabytechie.shop;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ShopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}
5.4 Run It
Start Redis, for example with docker run -d -p 6379:6379 redis:8.2, then run the app with ./mvnw spring-boot:run. Add items to a cart. The second add of KB-100 increases its quantity from 2 to 3:
$ curl -s -X POST localhost:8080/api/carts/C-42/items -H 'Content-Type: application/json' -d '{"sku":"KB-100","quantity":2}'
{"customerId":"C-42","items":{"KB-100":2},"expiresInSeconds":1800}
$ curl -s -X POST localhost:8080/api/carts/C-42/items -H 'Content-Type: application/json' -d '{"sku":"MS-200","quantity":1}'
{"customerId":"C-42","items":{"KB-100":2,"MS-200":1},"expiresInSeconds":1800}
$ curl -s -X POST localhost:8080/api/carts/C-42/items -H 'Content-Type: application/json' -d '{"sku":"KB-100","quantity":1}'
{"customerId":"C-42","items":{"KB-100":3,"MS-200":1},"expiresInSeconds":1800}
$ curl -s -X DELETE localhost:8080/api/carts/C-42/items/MS-200
{"customerId":"C-42","items":{"KB-100":3},"expiresInSeconds":1800}
$ curl -s -i -X POST localhost:8080/api/carts/C-42/items -H 'Content-Type: application/json' -d '{"sku":"KB-100","quantity":0}'
HTTP/1.1 400
{"detail":"Quantity must be at least 1, but was 0","instance":"/api/carts/C-42/items","status":400,"title":"Bad Request"}
Record some product views, publish a deal and ask for one that doesn't exist:
# MS-200 viewed 3 times, HD-300 twice, KB-100 once
$ curl -s -X POST localhost:8080/api/products/MS-200/views
...
$ curl -s 'localhost:8080/api/products/trending?limit=2'
[{"sku":"MS-200","views":3},{"sku":"HD-300","views":2}]
$ curl -s -X PUT localhost:8080/api/deals/KB-100 -H 'Content-Type: application/json' \
-d '{"title":"Keyboard flash sale","price":69.00,"discountPercent":20,"durationSeconds":600}'
{"sku":"KB-100","title":"Keyboard flash sale","price":69.00,"discountPercent":20}
$ curl -s -i localhost:8080/api/deals/MS-200
HTTP/1.1 404
{"detail":"No active deal for product MS-200","instance":"/api/deals/MS-200","status":404,"title":"Not Found"}
Look at the raw data with redis-cli. The keys are readable, the cart is a hash with a TTL counting down, and the deal is plain JSON:
> SCAN 0
deal:KB-100
products:views
cart:C-42
> HGETALL cart:C-42
KB-100
3
> TTL cart:C-42
1793
> ZREVRANGE products:views 0 -1 WITHSCORES
MS-200
3
HD-300
2
KB-100
1
> GET deal:KB-100
{"sku":"KB-100","title":"Keyboard flash sale","price":69.00,"discountPercent":20}
> TTL deal:KB-100
593
6. Configuration Options
| Property | Default | Purpose |
|---|---|---|
spring.data.redis.host / port | localhost / 6379 | Where Redis runs |
spring.data.redis.url | None | Host, port, credentials and TLS in one value; overrides the separate settings |
spring.data.redis.username / password | None | Authentication |
spring.data.redis.database | 0 | Logical database number |
spring.data.redis.timeout | Lettuce's default (60s) | How long a command may take before it fails |
spring.data.redis.connect-timeout | Lettuce's default | How long to wait for a connection |
spring.data.redis.lettuce.pool.enabled | Enabled if commons-pool2 is on the classpath | Connection pool; rarely needed, because Lettuce shares one connection |
spring.data.redis.repositories.enabled | true | Turns Redis repositories on or off |
7. Common Pitfalls and How to Fix Them
7.1 Keys Look Like "\xac\xed\x00\x05t\x00..."
Spring Boot's default redisTemplate bean uses JdkSerializationRedisSerializer for keys and values. Writing "cart:C-99" through it stores a binary key that other tools, and even StringRedisTemplate, can't find:
redisTemplate.opsForValue().set("cart:C-99", "KB-100"); // Boot's default RedisTemplate<Object, Object>
value serializer: JdkSerializationRedisSerializer
stringRedisTemplate.hasKey("cart:C-99") -> false
> SCAN 0
"\xac\xed\x00\x05t\x00\tcart:C-99"
How to fix it: use StringRedisTemplate, or a typed RedisTemplate with StringRedisSerializer keys and a JSON value serializer, as in section 4.4.
7.2 Expired Entities Still Counted by the Repository
When a @RedisHash object's TTL runs out, Redis deletes its hash but not the index sets Spring Data created. findById correctly returns nothing, but count() and the index sets still remember the object:
before expiry: findByCustomerId("C-55") = 1 count() = 1
after expiry: findByCustomerId("C-55") = 0 count() = 1 findById("ORD-2001") = empty
> SCAN 0
deliveries:customerId:C-55
deliveries
deliveries:ORD-2001:idx
How to fix it: enable keyspace events with @EnableRedisRepositories(enableKeyspaceEvents = ON_STARTUP), as RedisRepositoryConfig does. Spring Data then listens for Redis "expired" events and removes the index entries. It keeps a :phantom copy of each object for 5 minutes longer than its TTL, so the data is still available when the event arrives. It also turns on Redis's notify-keyspace-events setting itself. Some managed Redis services block that command, so set it in the provider's configuration instead.
7.3 RedisConnectionFailureException: Unable to Connect to Redis
When Redis is down, the first command fails. Without keyspace events the app starts, and each request returns 500 with this cause. With keyspace events enabled, the listener connects at startup, so the app refuses to start at all:
ERROR o.s.boot.SpringApplication : Application run failed
Caused by: org.springframework.data.redis.RedisConnectionFailureException: Unable to connect to Redis
Caused by: io.lettuce.core.RedisConnectionException: Unable to connect to localhost/<unresolved>:6379
Caused by: java.net.ConnectException: Connection refused
How to fix it: check the host, port and password, and make sure Redis is reachable from where the app runs. Inside Docker, localhost is the container itself. Set spring.data.redis.timeout so a slow Redis fails quickly instead of blocking request threads.
7.4 Using KEYS in Production
KEYS cart:* walks through every key in one blocking step. On a large database it stalls Redis for everyone. How to fix it: use SCAN, which returns keys in small batches (redisTemplate.scan(ScanOptions.scanOptions().match("cart:*").build())), or keep your own set of the keys you need.
7.5 Keys That Never Expire
Redis holds everything in memory. Carts, sessions and counters written without a TTL accumulate until Redis runs out of memory and, depending on its maxmemory-policy, starts rejecting writes or evicting other keys. How to fix it: give every temporary key a TTL, and check with TTL key; -1 means the key never expires.
7.6 Treating Redis as the Only Copy of Important Data
Redis can save to disk, but depending on its persistence settings a crash can lose recent writes. How to fix it: keep orders, payments and other records that must not be lost in a durable database, such as PostgreSQL or MongoDB with Spring Data. Use Redis for data you can rebuild or afford to lose.
8. Testing with @DataRedisTest and Testcontainers
@DataRedisTest starts only the Redis part of the application: the connection factory, the templates and Redis repositories. It does not load your own @Configuration classes or services, so import them. A Redis container from Testcontainers provides the server. Testcontainers has no Redis-specific class here, so a GenericContainer is used, and @ServiceConnection(name = "redis") tells Spring Boot what it is. The Testcontainers tutorial explains this setup in detail:
package com.javabytechie.shop;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.utility.DockerImageName;
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfiguration {
// GenericContainer has no Redis-specific type, so name tells Spring Boot what it is
@Bean
@ServiceConnection(name = "redis")
GenericContainer<?> redisContainer() {
return new GenericContainer<>(DockerImageName.parse("redis:8.2")).withExposedPorts(6379);
}
}
The service test empties Redis before each test and checks state before and after each action. That covers the cart quantity going from 2 to 3, an invalid quantity leaving no key behind, the trending order, and a one-second deal that really disappears:
package com.javabytechie.shop.cart;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.math.BigDecimal;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.redis.test.autoconfigure.DataRedisTest;
import org.springframework.context.annotation.Import;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;
import com.javabytechie.shop.TestcontainersConfiguration;
import com.javabytechie.shop.config.RedisConfig;
import com.javabytechie.shop.deal.DealNotFoundException;
import com.javabytechie.shop.deal.FlashDeal;
import com.javabytechie.shop.deal.FlashDealService;
import com.javabytechie.shop.deal.PublishDealRequest;
import com.javabytechie.shop.trending.TrendingProduct;
import com.javabytechie.shop.trending.TrendingService;
@DataRedisTest
@Import({TestcontainersConfiguration.class, RedisConfig.class,
CartService.class, TrendingService.class, FlashDealService.class})
class ShopRedisServicesTest {
@Autowired
StringRedisTemplate redis;
@Autowired
CartService cartService;
@Autowired
TrendingService trendingService;
@Autowired
FlashDealService flashDealService;
@BeforeEach
void emptyRedis() {
redis.execute((RedisCallback<Void>) connection -> {
connection.serverCommands().flushDb();
return null;
});
}
@Test
void addingTheSameItemTwiceIncreasesQuantityAndSetsTtl() {
assertThat(cartService.getCart("C-42").items()).isEmpty();
cartService.addItem("C-42", new AddItemRequest("KB-100", 2));
CartResponse cart = cartService.addItem("C-42", new AddItemRequest("KB-100", 1));
assertThat(cart.items()).isEqualTo(Map.of("KB-100", 3));
assertThat(cart.expiresInSeconds()).isBetween(1L, CartService.CART_TTL.toSeconds());
}
@Test
void removingAnItemKeepsTheRest() {
cartService.addItem("C-42", new AddItemRequest("KB-100", 1));
cartService.addItem("C-42", new AddItemRequest("MS-200", 2));
assertThat(cartService.getCart("C-42").items()).hasSize(2);
assertThat(cartService.removeItem("C-42", "KB-100").items()).isEqualTo(Map.of("MS-200", 2));
}
@Test
void invalidQuantityChangesNothing() {
assertThatThrownBy(() -> cartService.addItem("C-42", new AddItemRequest("KB-100", 0)))
.isInstanceOf(InvalidQuantityException.class);
assertThat(redis.hasKey("cart:C-42")).isFalse();
}
@Test
void trendingOrdersProductsByViews() {
assertThat(trendingService.top(3)).isEmpty();
for (int i = 0; i < 3; i++) trendingService.recordView("MS-200");
trendingService.recordView("KB-100");
for (int i = 0; i < 2; i++) trendingService.recordView("HD-300");
assertThat(trendingService.top(2)).containsExactly(
new TrendingProduct("MS-200", 3), new TrendingProduct("HD-300", 2));
}
@Test
void dealIsStoredAsJsonAndExpires() throws InterruptedException {
flashDealService.publish("KB-100",
new PublishDealRequest("Keyboard flash sale", new BigDecimal("69.00"), 20, 1));
assertThat(flashDealService.find("KB-100"))
.isEqualTo(new FlashDeal("KB-100", "Keyboard flash sale", new BigDecimal("69.00"), 20));
assertThat(redis.opsForValue().get("deal:KB-100")).startsWith("{\"sku\":\"KB-100\"");
Thread.sleep(1500);
assertThatThrownBy(() -> flashDealService.find("KB-100")).isInstanceOf(DealNotFoundException.class);
}
}
The repository test checks the stored hash, the index query and the clean-up after expiry from section 7.2:
package com.javabytechie.shop.delivery;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.redis.test.autoconfigure.DataRedisTest;
import org.springframework.context.annotation.Import;
import org.springframework.data.redis.core.StringRedisTemplate;
import com.javabytechie.shop.TestcontainersConfiguration;
import com.javabytechie.shop.config.RedisRepositoryConfig;
@DataRedisTest
@Import({TestcontainersConfiguration.class, RedisRepositoryConfig.class})
class DeliveryTrackingRepositoryTest {
@Autowired
DeliveryTrackingRepository repository;
@Autowired
StringRedisTemplate redis;
@BeforeEach
void clean() {
repository.deleteAll();
}
@Test
void savesAsHashAndFindsByIndexedField() {
assertThat(repository.count()).isZero();
repository.save(new DeliveryTracking("ORD-1001", "C-42", "OUT_FOR_DELIVERY", "SwiftShip", 3600));
repository.save(new DeliveryTracking("ORD-1002", "C-42", "PACKED", "SwiftShip", 3600));
repository.save(new DeliveryTracking("ORD-1003", "C-77", "DELIVERED", "RoadRunner", 3600));
assertThat(repository.count()).isEqualTo(3);
assertThat(repository.findByCustomerId("C-42"))
.extracting(DeliveryTracking::orderId)
.containsExactlyInAnyOrder("ORD-1001", "ORD-1002");
assertThat(redis.opsForHash().get("deliveries:ORD-1001", "status")).isEqualTo("OUT_FOR_DELIVERY");
assertThat(redis.getExpire("deliveries:ORD-1001")).isBetween(1L, 3600L);
}
@Test
void expiredDeliveryDisappearsFromIndexesToo() throws InterruptedException {
repository.save(new DeliveryTracking("ORD-2001", "C-55", "PACKED", "SwiftShip", 1));
assertThat(repository.findByCustomerId("C-55")).hasSize(1);
assertThat(redis.hasKey("deliveries:customerId:C-55")).isTrue();
Thread.sleep(2500);
assertThat(repository.findById("ORD-2001")).isEmpty();
assertThat(repository.count()).isZero();
assertThat(redis.hasKey("deliveries:customerId:C-55")).isFalse();
}
}
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0 -- in com.javabytechie.shop.cart.ShopRedisServicesTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 -- in com.javabytechie.shop.delivery.DeliveryTrackingRepositoryTest
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
9. When to Use Redis (and When Not To)
| Good fit | Poor fit |
|---|---|
| Short-lived data with a natural expiry: carts, sessions, one-time codes, deals | Records that must never be lost, such as orders and payments |
| Counters, rate limits and leaderboards that change constantly | Complex queries, joins, ranges and reports |
| Caching slow database or API results | Large datasets that don't fit in memory affordably |
| Sharing state between several instances of your app | Data used by only one instance, where a local in-memory cache is simpler |
10. Best Practices
- Prefer StringRedisTemplate, and give any typed
RedisTemplatestring keys and JSON values. - Name keys with a prefix and an id, such as
cart:C-42, so they are easy to find and scan. - Set a TTL on every temporary key, ideally in the same command that writes it.
- Use atomic commands such as
HINCRBY,INCRandZINCRBYinstead of read-modify-write. - Pick the data type that fits: hashes for objects with fields, sorted sets for rankings, sets for membership.
- Enable keyspace events when repositories combine
@TimeToLivewith@Indexed. - Never use KEYS in production; use
SCAN. - Set spring.data.redis.timeout so a slow Redis can't block request threads.
- Keep the system of record elsewhere and treat Redis data as rebuildable.
- Test against real Redis with Testcontainers, emptying the database before each test.
11. Summary
Spring Data Redis gives a Spring Boot application fast access to Redis through templates and repositories. Add the starter and set spring.data.redis.*. Use StringRedisTemplate for counters, hashes and sorted sets, and a typed RedisTemplate with JacksonJsonRedisSerializer for whole objects. Give temporary keys a TTL and rely on atomic commands for concurrent updates. Use @RedisHash repositories for simple objects with indexed lookups, with keyspace events enabled when entities expire. Test it all against a real Redis container with @DataRedisTest. The main recommendation: model each feature with the Redis data type that fits it, keep keys readable, and make every temporary key expire.
Frequently Asked Questions (FAQ)
Q1. What is the difference between RedisTemplate and StringRedisTemplate?
StringRedisTemplate is a RedisTemplate<String, String> that stores keys and values as UTF-8 text, so everything stays readable. A RedisTemplate<K, V> can store any types, but Spring Boot's default one uses JDK serialization, which writes binary data. Use StringRedisTemplate for simple values and a typed RedisTemplate with a JSON serializer for objects.
Q2. Why do my Redis keys start with \xac\xed\x00\x05?
Those bytes are the header of Java serialization. The key was written through Spring Boot's default redisTemplate, which uses JdkSerializationRedisSerializer. Switch to StringRedisTemplate, or set StringRedisSerializer as the key serializer of your own RedisTemplate.
Q3. Is Spring Data Redis the same as Spring Boot Redis caching?
No, but they build on each other. Spring Data Redis is the library for working with Redis directly through templates and repositories. Spring's cache abstraction (@Cacheable) can use Redis as its store through RedisCacheManager, which itself comes from Spring Data Redis.
Q4. Lettuce or Jedis: which Redis client should I use?
Spring Boot uses Lettuce by default, and it is the right choice for most applications. Its connections are thread-safe, so one connection is shared by the whole app. Jedis is a simpler blocking client that needs a connection pool; switch only if a library you depend on requires it.
Q5. How do I set an expiry time on a Redis key in Spring Boot?
Pass a Duration when writing a value, for example opsForValue().set(key, value, Duration.ofMinutes(10)), or call expire(key, duration) afterwards. For repositories, annotate a field with @TimeToLive or set timeToLive on @RedisHash.
Q6. Why does my Redis repository still count expired objects?
Redis deletes an expired hash but not the index sets Spring Data created for it. Enable keyspace events with @EnableRedisRepositories(enableKeyspaceEvents = EnableKeyspaceEvents.ON_STARTUP) so Spring Data removes those entries when the key expires. On managed Redis services that block CONFIG, also enable notify-keyspace-events in the provider's settings.
Q7. Can Redis transactions be used with Spring Data Redis?
Yes, through MULTI and EXEC, using SessionCallback or setEnableTransactionSupport(true) on a template. They group commands but don't roll back on errors the way database transactions do. For a single atomic change, a single command such as HINCRBY or a Lua script is usually simpler.
Q8. How do I connect to Redis with a password and TLS?
Use spring.data.redis.url=rediss://username:password@host:port, where rediss enables TLS, or set spring.data.redis.password and spring.data.redis.ssl.enabled=true. Keep the password in an environment variable, not in a committed properties file.