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.
| Item | PostgreSQL | Redis |
|---|---|---|
| Primary Responsibility | Persistence of authoritative data (Source of Truth) | Temporary storage of query results (cache) |
| Data Persistence | Persistent storage to disk | Volatile (evicted upon reaching TTL or under memory pressure) |
| Data Model | Relational structure (SQL) | Key-value structure |
| Consistency Guarantee | Adherence to ACID transactions | Improved 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.
- When receiving a request from a client, first check whether the specified key exists in Redis.
- Cache Hit: If the key exists, return the value from Redis without accessing PostgreSQL.
- 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.