Implementation and Failure Design of the Redis Cache-Aside Pattern in a Spring Boot and PostgreSQL Environment

Explains how to build a Cache-Aside pattern using Spring Boot and Redis to reduce PostgreSQL read load, key considerations for AOP proxies, and failure design using error handlers.

In architectures with increasing database access frequency, concentrating direct queries on relational databases such as PostgreSQL causes latency degradation and resource exhaustion. Especially in systems with a high proportion of read requests, placing an in-memory data store in front of the persistence layer to temporarily cache query results is an effective design.

This article outlines the implementation steps for integrating Redis as a secondary cache in a Spring Boot application using the Cache-Aside pattern, as well as fallback processing during infrastructure failures.

Architecture Configuration and Role Allocation

To balance data reliability and search performance, the responsibilities of PostgreSQL and Redis are clearly separated.

ItemPostgreSQLRedis
Primary ResponsibilityPersistence of authoritative data (Source of Truth)Temporary storage of query results (cache)
Data PersistencePersistent storage to diskVolatile (evicted upon reaching TTL or under memory pressure)
Data ModelRelational structure (SQL)Key-value structure
Consistency GuaranteeAdherence to ACID transactionsImproved response speed via low-latency processing

⚠️ Even if all Redis instances completely stop, the application must maintain a state where it can retrieve canonical data from PostgreSQL and continue processing.

Processing Flow of the Cache-Aside Pattern

In the Cache-Aside (Lazy-Loading) pattern, the application primarily controls reading from and writing to the cache.

  1. When receiving a request from a client, first check whether the specified key exists in Redis.
  2. Cache Hit: If the key exists, return the value from Redis without accessing PostgreSQL.
  3. Cache Miss: If the key does not exist, execute a query against PostgreSQL, write the retrieved result to Redis along with the specified TTL (Time-To-Live), and then return it to the client.

Dependencies and Application Configuration

Add the starters for Spring Cache abstraction and Redis integration to build.gradle.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-cache'
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}

Define the basic cache properties in application.properties. In production environments, connection information should be injected via environment variables.

spring.cache.type=redis
spring.data.redis.host=${REDIS_HOST:localhost}
spring.data.redis.port=${REDIS_PORT:6379}
spring.data.redis.password=${REDIS_PASSWORD:}
spring.cache.redis.time-to-live=10m
spring.cache.redis.cache-null-values=false
spring.cache.redis.key-prefix=my-service:

💡 spring.cache.redis.cache-null-values=false: Setting to prevent caching null values when the database query result is null.

💡 spring.cache.redis.key-prefix: Adds a prefix to prevent key collisions in multi-service environments.

Enabling Cache Abstraction and Annotation Implementation

Create a configuration class annotated with @EnableCaching to enable cache processing via AOP proxies.

import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Configuration;

@Configuration
@EnableCaching
public class CacheConfig {
}

Annotate service methods performing read operations with @Cacheable.

import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class PlaceService {

    private final PlaceRepository placeRepository;

    @Cacheable(
        cacheNames = "placeDetail",
        key = "#placeId"
    )
    public PlaceDetail getDetail(Long placeId) {
        return placeRepository.findDetail(placeId);
    }
}

The generated Redis key format will be similar to my-service:placeDetail::100. Note that the Redis TTL countdown is not extended by GET operations; it counts uniformly from the time a write (SET) is performed.

During updates or deletions, use @CacheEvict to explicitly remove stale data.

import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class PlaceService {

    private final PlaceRepository placeRepository;

    @Transactional
    @CacheEvict(
        cacheNames = "placeDetail",
        key = "#place.placeId"
    )
    public void update(Place place) {
        placeRepository.update(place);
    }

    @Transactional
    @CacheEvict(
        cacheNames = "placeDetail",
        key = "#placeId"
    )
    public void delete(Long placeId) {
        placeRepository.delete(placeId);
    }
}

Fault Tolerance Design (CacheErrorHandler)

By default, Spring Cache rethrows exceptions when connection failures or timeouts occur with Redis, causing the entire request to error out. To fall back to PostgreSQL and continue processing even when Redis is down, implement a custom CacheErrorHandler.

import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.Cache;
import org.springframework.cache.annotation.CachingConfigurer;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.interceptor.CacheErrorHandler;
import org.springframework.context.annotation.Configuration;

@Slf4j
@Configuration
@EnableCaching
public class CacheConfig implements CachingConfigurer {

    @Override
    public CacheErrorHandler errorHandler() {
        return new CacheErrorHandler() {
            @Override
            public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) {
                log.warn("Cache GET failed. Bypassing to DB. Cache={}, Key={}", cache.getName(), key);
            }

            @Override
            public void handleCachePutError(RuntimeException exception, Cache cache, Object key, Object value) {
                log.warn("Cache PUT failed. Proceeding without caching. Cache={}, Key={}", cache.getName(), key);
            }

            @Override
            public void handleCacheEvictError(RuntimeException exception, Cache cache, Object key) {
                log.warn("Cache EVICT failed. Cache={}, Key={}", cache.getName(), key);
            }

            @Override
            public void handleCacheClearError(RuntimeException exception, Cache cache) {
                log.warn("Cache CLEAR failed. Cache={}", cache.getName());
            }
        };
    }
}

Troubleshooting

1. Cache Invalidation Due to Spring AOP Self-Invocation

When calling a method annotated with @Cacheable from another method within the same class (e.g., this.getDetail(id)), the Spring AOP proxy is bypassed. As a result, cache processing is not executed, and the database is always accessed.

🛠️ Mitigation: Separate the logic so that the target method for caching is always called from an external component/service.

2. Key Bloat When Applying Cache to Multivariate Search Queries

Generating cache keys by combining multiple search conditions (such as keyword, category, page, etc.) increases cardinality and lowers the cache hit rate. Furthermore, it unnecessarily consumes Redis memory space.

🛠️ Mitigation: Avoid applying cache to dynamic composite search queries and design the caching strategy primarily around single-entity primary key lookups (PK Lookups).

3. Cache Inconsistency During Network Partitions

If @CacheEvict to Redis fails due to network disruption after a successful update in PostgreSQL, the error handler prevents the application from crashing, but stale data remains in Redis.

🛠️ Mitigation: In domains requiring strict data consistency, consider setting a shorter TTL or implementing asynchronous retry/event-driven data invalidation mechanisms.

Verification Command Logs

Steps for starting the Redis container in a local environment and checking key status.

# docker-compose.yml
services:
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

Terminal verification example:

$ docker compose up -d redis
[+] Running 1/1
 Container app-redis-1  Started

$ docker compose exec redis redis-cli PING
PONG

$ docker compose exec redis redis-cli --scan --pattern "*placeDetail*"
my-service:placeDetail::100

$ docker compose exec redis redis-cli TTL "my-service:placeDetail::100"
(integer) 582

$ docker compose exec redis redis-cli DEL "my-service:placeDetail::100"
(integer) 1

$ docker compose exec redis redis-cli TTL "my-service:placeDetail::100"
(integer) -2

If the return value of the TTL command is -2, the key does not exist (expired or evicted); -1 means no expiration; a positive integer represents the remaining time in seconds.

Operational Notes

Introducing a caching layer using Redis contributes to reducing PostgreSQL load. However, because in-memory data stores are subject to memory limits (maxmemory) and eviction policies, systems must be designed under the assumption that data may be unexpectedly lost.

Prioritizing cache application for data with low update frequencies and high read frequencies, combined with fault fallback mechanisms via CacheErrorHandler, is essential for stable system operations.

Built with Hugo
Theme Stack designed by Jimmy
Privacy Policy Disclaimer Contact