Redis Caching Pattern in Java Applications: From Theory to Practice

Redis Caching Pattern in Java Applications: From Theory to Practice

Introduction

Picture this: your Java application is handling thousands of requests per second, but your database is struggling to keep up. Response times are climbing, and your team is debating whether to throw more hardware at the problem. Sound familiar? This is where Redis shines. Redis started as a tool for real-time analytics at Twitter (now X). Now, it’s a versatile caching solution. It manages everything from basic key-value storage to advanced message brokering.

I’ve used Redis in many Java projects over the years. I found that success doesn’t come from adding it to your stack. It’s about picking the right caching patterns for your needs. In this article, we’ll explore these patterns through practical examples you can start using today. When you create an e-commerce site for fast product searches or a financial system for reliable data, you’ll discover useful patterns. These patterns solve real problems.

Understanding caching fundamentals

Before exploring Redis patterns, it is important to grasp the basic ideas of caching.

Cache — aside (lazy loading)

The application first checks that cache for data. If not found (cache miss), it retrieves it from the database, stores it in the cache, and returns it to the client. This is the most common pattern as it only caches what’s actually needed.

Real-world example: E-commerce product catalog

Scenario: An online store offers millions of products, yet only a small percentage receive frequent views.

Implementation: When a customer views a product, the system:

  • Check Redis for product details.
  • If not found, it fetches from the database and caches in Redis.
  • Subsequent customers viewing the same product get faster response times.

Benefits: Efficient use of cache space, as only frequently viewed products are cached.

Used by: Amazon, eBay for product listing pages

Write — through

The system writes data to both the cache and the database in the same transaction. This ensures cache consistency but adds write latency.

Real-world example: banking transaction system

Scenario: A bank’s account balance tracking system where accuracy is critical.

Implementation:

  • Updates both the Redis cache and the database in a single transaction.
  • Either both succeed or both fail (maintaining consistency).
  • We guarantee that all balance reads are accurate.

Benefits: No risk of showing an incorrect balance to customers.

Used by: Financial institutions and payment processing systems.

Write-behind (Write-back)

The system first writes data to the cache. Then, it updates the database without needing simultaneous processing. This improves write performance but risks data loss if the cache fails before persisting to the database.

Real-world example: Social media post likes counter.

Scenario: Instagram/Facebook-style application handling millions of likes per minute.

Implementation: 

  • Immediately updates the likes count in Redis.
  • Update the database in batches without synchronizing.
  • Users see feedback right away while the system manages a high write load effectively.

Benefits: Can handle viral posts with a sudden spike in likes.

Used by: Social media platforms, gaming leaderboards

Refresh-ahead

The cache automatically refreshes data before it expires. This pattern is useful for frequently accessed data but can waste resources refresing unused items.

Real — world example: Weather forecasting application

Scenario: Weather app serving current conditions for major cities

Implementation:

  • System predicts high — traffic periods (morning commute times).
  • Proactively refreshes weather data 5–10 minutes before peak usage.
  • Ensure fresh data without causing user delays.

Benefits: Consistent response times during high — traffic periods.

Used by: Weather services, sports scores applications during game times

Redis caching patterns implementation

Let’s explore a more practical implementation of these patterns using Spring Boot and Redis.

Let’s begin with a Docker Compose file that sets up Redis for local development.

version: "3.8"

services:
  redis:
    image: redis:latest
    container_name: redis
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    command: redis-server --appendonly yes
    networks:
      - redis_network
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 3

volumes:
  redis_data:
    driver: local

networks:
  redis_network:
    driver: bridge

You may start your Redis instance with the following command.

docker compose up

or if you prefer detached, add -d flag in the above command.

docker compose up -d 

So recently I started using Spring CLI to start my Spring Boot projects; it is very useful for me. But if you still prefer to open a browser and create it through the interface of Spring Initializr, it is up to you.

spring init \
--build=maven \
--java-version=17 \
--package-name={{project.basePackageName} \
--groupId={{groupId}} \
--name={{projectName}} \
--description={{Description}} \
--dependencies=web,actuator \
--extract \
{{projectName}}

First, let’s define a model of data that we are going to store in our cache. To keep it simple, we won’t use relational databases like PostgreSQL, MySQL, or others.

package io.vrnsky.redis.model;

import java.io.Serializable;

public class User implements Serializable {

    private Long id;
    private String username;
    private boolean fromCache;

    public User() {}

    public User(Long id, String username) {
        this.id = id;
        this.username = username;
    }

    public Long getId() {
        return this.id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return this.username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public boolean isFromCache() {
        return this.fromCache;
    }

    public void setFromCache(boolean fromCache) {
        this.fromCache = fromCache;
    }
}

Let’s create the service and data access layers. There’s nothing special here. As mentioned earlier, we will not use any relational databases.

package io.vrnsky.redis.dao;

import io.vrnsky.redis.model.User;
import java.util.HashMap;
import java.util.Map;
import org.springframework.stereotype.Service;

@Service
public class UserRepository {

    private static long ID_COUNTER = 0L;

    private final Map<Long, User> users;

    public UserRepository() {
        this.users = new HashMap<>();
    }

    public long persistUser(User user) {
        long userId = ID_COUNTER++;
        user.setId(userId);
        this.users.put(userId, user);
        return userId;
    }

    public User fetchUser(Long id) {
        return this.users.get(id);
    }
}

Let’s proceed to the service layer.

package io.vrnsky.redis.service;

import io.vrnsky.redis.dao.UserRepository;
import io.vrnsky.redis.model.User;
import java.time.Duration;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

@Service
public class UserService {

    private static final String CACHE_KEY_PREFIX = "user:";
    private static final Duration CACHE_TTL = Duration.ofMinutes(30);

    private final UserRepository repository;
    private final RedisTemplate<Long, User> redisTemplate;

    public UserService(
        UserRepository repository,
        RedisTemplate<Long, User> redisTemplate
    ) {
        this.repository = repository;
        this.redisTemplate = redisTemplate;
    }

    public User getUser(Long id) {
        String cacheKey = CACHE_KEY_PREFIX + String.valueOf(id);

        User cachedUser = redisTemplate.opsForValue().get(cacheKey);
        if (cachedUser != null) {
            System.out.println(cachedUser);
            cachedUser.setFromCache(true);
            return cachedUser;
        }

        User user = repository.fetchUser(id);
        redisTemplate.opsForValue().set(id, user, CACHE_TTL);
        user.setFromCache(true);
        return user;
    }

    public User saveUser(User user) {
        long userId = repository.persistUser(user);
        if (user.isFromCache()) {
            redisTemplate.opsForValue().set(userId, user, CACHE_TTL);
            user.setFromCache(true);
            return user;
        }

        return repository.fetchUser(userId);
    }
}

We are almost done with our very first usage of cache, but we still need some entry point — controller or rest controller. It depends on your needs. I will stick with rest controller since it solves almost all my needs.

package io.vrnsky.redis.controller;

import io.vrnsky.redis.model.User;
import io.vrnsky.redis.service.UserService;
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;

@RequestMapping("/user")
@RestController
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @PostMapping("/create")
    public User createUser(@RequestBody User user) {
        return this.userService.saveUser(user);
    }

    @GetMapping("/{id}")
    public User fetchUser(@PathVariable Long id) {
        return this.userService.getUser(id);
    }
}

Writing — through pattern implementation

@Service
public class ProductService {
    private final RedisTemplate<String, Product> redisTemplate;
    private final ProductRepository productRepository;
    
    private static final String CACHE_KEY_PREFIX = "product:";
    private static final Duration CACHE_TTL = Duration.ofHours(1);

    @Transactional
    public Product saveProduct(Product product) {
        Product savedProduct = productRepository.save(product);
        String cacheKey = CACHE_KEY_PREFIX + savedProduct.getId();
        redisTemplate.opsForValue().set(cacheKey, savedProduct, CACHE_TTL);
        
        return savedProduct;
    }
}

Write-behind pattern implementation

public class OrderService {
    private final RedisTemplate<String, Order> redisTemplate;
    private final OrderRepository orderRepository;
    private final ExecutorService executorService;
    
    private static final String CACHE_KEY_PREFIX = "order:";
    private static final Duration CACHE_TTL = Duration.ofHours(24);

    public Order createOrder(Order order) {
        String orderId = UUID.randomUUID().toString();
        order.setId(orderId);
        
        String cacheKey = CACHE_KEY_PREFIX + orderId;
        redisTemplate.opsForValue().set(cacheKey, order, CACHE_TTL);
        
        executorService.submit(() -> {
            try {
                orderRepository.save(order);
            } catch (Exception e) {
                log.error("Failed to persist order to database", e);
            }
        });
        
        return order;
    }
}

Refresh — ahead pattern implementation

@Service
public class ProductCatalogService {
    private final RedisTemplate<String, List<Product>> redisTemplate;
    private final ProductRepository productRepository;
    
    private static final String CATALOG_CACHE_KEY = "product:catalog";
    private static final Duration CACHE_TTL = Duration.ofHours(1);
    private static final Duration REFRESH_THRESHOLD = Duration.ofMinutes(45);

    @Scheduled(fixedRate = 300000)
    public void refreshCatalogIfNeeded() {
        Long ttl = redisTemplate.getExpire(CATALOG_CACHE_KEY);
        
        if (ttl != null && ttl < REFRESH_THRESHOLD.toSeconds()) {
            List<Product> products = productRepository.findAll();
            redisTemplate.opsForValue().set(CATALOG_CACHE_KEY, products, CACHE_TTL);
        }
    }
}

Conclusion

Redis caching patterns can boost the performance of Java applications. Each pattern has its own use cases, strengths, and drawbacks. Cache-Aside is typically a safe starting point, while Write-Through offers better consistency. Write-Behind enhances write performance but needs careful error management. Refresh-Ahead can improve read performance for predictable access, but it requires setup to avoid unnecessary cache updates.

To use these patterns well, understand your application’s needs. Think about data consistency and set up reliable monitoring and maintenance. Remember, caching isn’t one-size-fits-all. Choose a pattern that matches your specific use case and requirements.

Reference

Subscribe to Egor Voronianskii | Java Development and whatsoever

Sign up now to get access to the library of members-only issues.
Jamie Larson
Subscribe