Keeping Cache and Database Consistent with Redis
1. Overview
Placing Redis in front of the database reduces load and speeds up reads. In return, you have two places storing the same data, and they can drift apart. A user changes the product price, but the detail page still shows the old price for 10 minutes. That’s a typical consistency error.
This article uses the cache-aside pattern: for reads, fetch from cache first; for writes, update the database then delete the cache after transaction commit. We’ll examine remaining race conditions and ways to narrow them down. At the end is an integration test with real Redis running via Testcontainers.
2. Why Cache and Database Drift Apart
Database and Redis don’t share a transaction. There’s no way to write to both atomically. The order of operations and timing determine whether data drifts.
The three most common mistakes:
- Updating cache instead of deleting it. Two concurrent write requests might write to the database in order A→B, but write to cache in order B→A. Cache holds A’s value indefinitely, or until TTL expires.
- Deleting cache before commit. A read request sneaks in between the cache delete and the commit. That request reads the old value from the database and loads it back into cache.
- Cache deletion fails silently. Redis times out, an exception gets swallowed, and cache keeps the old data.
The rest of the article handles each error in turn.
3. Project Setup
Add dependencies for Redis, JPA, and a database to run tests:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
Configure Redis connection:
spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.data.redis.timeout=500ms
The short timeout is intentional. If Redis is slow, you want requests to fall back to the database quickly, not hang for seconds.
Entity and repository:
@Entity
public class Product {
@Id @GeneratedValue
private Long id;
private String name;
private BigDecimal price;
protected Product() {}
public Product(String name, BigDecimal price) { this.name = name; this.price = price; }
public Long getId() { return id; }
public String getName() { return name; }
public BigDecimal getPrice() { return price; }
public void setPrice(BigDecimal price) { this.price = price; }
}
public interface ProductRepository extends JpaRepository<Product, Long> {}
We don’t cache the entity directly; instead we cache an immutable record:
public record ProductView(Long id, String name, BigDecimal price) {
static ProductView from(Product p) {
return new ProductView(p.getId(), p.getName(), p.getPrice());
}
}
This avoids Hibernate proxy serialization errors. It also keeps the cache format independent of the table structure.
4. Reading with Cache-Aside
@Service
public class ProductService {
private static final Duration TTL = Duration.ofMinutes(10);
private final ProductRepository repository;
private final StringRedisTemplate redis;
private final ObjectMapper mapper;
private final ApplicationEventPublisher events;
public ProductService(ProductRepository repository, StringRedisTemplate redis,
ObjectMapper mapper, ApplicationEventPublisher events) {
this.repository = repository;
this.redis = redis;
this.mapper = mapper;
this.events = events;
}
static String key(Long id) { return "product:" + id; }
public ProductView findById(Long id) {
String cached = redis.opsForValue().get(key(id));
if (cached != null) {
return read(cached);
}
ProductView view = repository.findById(id)
.map(ProductView::from)
.orElseThrow(() -> new NoSuchElementException("Product " + id));
redis.opsForValue().set(key(id), write(view), TTL);
return view;
}
private ProductView read(String json) {
try { return mapper.readValue(json, ProductView.class); }
catch (JsonProcessingException e) { throw new IllegalStateException(e); }
}
private String write(ProductView view) {
try { return mapper.writeValueAsString(view); }
catch (JsonProcessingException e) { throw new IllegalStateException(e); }
}
}
The flow is as follows:
redis.opsForValue().get(...)tries to fetch from cache. If it exists, return it immediately. This is a cache hit.- If it doesn’t exist (cache miss), the service reads from the database and writes to Redis with a 10-minute TTL.
- TTL is a safety net. Every mechanism in later sections can fail. TTL ensures incorrect data doesn’t persist longer than 10 minutes.
After the first call with id = 1, Redis will contain:
127.0.0.1:6379> GET product:1
"{\"id\":1,\"name\":\"Mechanical keyboard\",\"price\":1200000}"
127.0.0.1:6379> TTL product:1
(integer) 598
5. Writing: Update Database, Then Delete Cache After Commit
5.1. Delete, Don’t Update
When the price changes, we delete the key instead of writing the new price to cache. The next read will reload the data from the database, which is the source of truth. Deletion is idempotent: deleting twice yields the same result. The order between delete commands doesn’t matter either. This eliminates the “out-of-order overwrite” error from section 2.
5.2. Wait for Transaction Commit
If you call redis.delete(...) inside an @Transactional method, the delete command runs before the commit. Here’s a scenario that can happen:
T1 (write) T2 (read)
UPDATE price = 900 (not yet committed)
DEL product:1
GET product:1 -> miss
SELECT -> price = 1200 (stale)
SET product:1 = 1200
COMMIT
=> cache holds 1200 until TTL expires
The solution is to publish an event and handle it in the AFTER_COMMIT phase:
public record ProductChangedEvent(Long productId) {}
Add a write method to ProductService:
@Transactional
public void updatePrice(Long id, BigDecimal newPrice) {
Product product = repository.findById(id).orElseThrow();
product.setPrice(newPrice);
events.publishEvent(new ProductChangedEvent(id));
}
The listener is responsible for evicting the cache:
@Component
public class ProductCacheEvictor {
private static final Logger log = LoggerFactory.getLogger(ProductCacheEvictor.class);
private final StringRedisTemplate redis;
public ProductCacheEvictor(StringRedisTemplate redis) {
this.redis = redis;
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onProductChanged(ProductChangedEvent event) {
evict(ProductService.key(event.productId()));
}
void evict(String key) {
try {
redis.delete(key);
} catch (RuntimeException e) {
log.warn("Failed to evict cache key {}, relying on TTL", key, e);
}
}
}
A few points to note:
@TransactionalEventListener(phase = AFTER_COMMIT)only runs when the transaction commits successfully. If the transaction rolls back, the cache is not evicted. That’s correct, because the data hasn’t changed.- Errors during cache eviction don’t break the write request. The database has already committed; throwing an exception here only misleads the client.
- Warning logs help you monitor error frequency. Meanwhile, TTL limits how long stale data can persist.
Note: If
publishEventis called outside a transaction,@TransactionalEventListenerby default ignores the event and the cache never gets evicted. Ensure your write method has@Transactional. If needed, setfallbackExecution = true.
6. Remaining Race Conditions and Delayed Double Delete
Even with deletion after commit, there’s a rare scenario:
T2 (read) T1 (write)
GET product:1 -> miss
SELECT -> price = 1200
UPDATE price = 900, COMMIT
DEL product:1
SET product:1 = 1200 <- late write
T2 reads the stale value before T1 commits, but writes to cache after T1 has deleted. This scenario requires T2 to be delayed at just the right moment, for example due to GC pause or network hiccup. It’s rare but real.
A simple mitigation is delayed double delete: delete again after a short delay.
@Component
public class DelayedCacheEvictor {
private final ProductCacheEvictor evictor;
private final TaskScheduler scheduler;
public DelayedCacheEvictor(ProductCacheEvictor evictor, TaskScheduler scheduler) {
this.evictor = evictor;
this.scheduler = scheduler;
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void scheduleSecondDelete(ProductChangedEvent event) {
String key = ProductService.key(event.productId());
scheduler.schedule(() -> evictor.evict(key),
Instant.now().plusMillis(500));
}
}
Spring Boot auto-configures a TaskScheduler when you enable @EnableScheduling on a configuration class. The second delete 500ms later sweeps away the stale value that T2 wrote late. Choose a delay longer than your typical database read time.
Note: Delayed double delete only narrows the error window, it doesn’t eliminate it completely. Scheduled tasks live in memory and are lost if the application restarts. Don’t treat it as an absolute guarantee.
7. When Cache Deletion Must Absolutely Succeed
For data like account balances or inventory, “relying on TTL” may be unacceptable. Two common approaches emerge:
- Transactional outbox: write a
cache_invalidationrow to the database itself, in the same transaction as the business change. A periodic job reads this table, deletes keys from Redis, and only marks rows as done when deletion succeeds. Because the outbox row commits with the data, the cache eviction event is never lost. - Change Data Capture (CDC): a process that reads the database’s binlog/WAL and evicts the corresponding cache. This decouples cache logic from business code, at the cost of more complex infrastructure.
Both achieve eventual consistency. They guarantee the cache will eventually be correct, but not immediately.
8. Testing with Real Redis
Mocking StringRedisTemplate won’t catch timing issues with commits. Test with real Redis via Testcontainers:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class ProductServiceIT {
@Container
@ServiceConnection(name = "redis")
static GenericContainer<?> redisContainer =
new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
@Autowired ProductService productService;
@Autowired ProductRepository repository;
@Autowired StringRedisTemplate redis;
@Test
void updatePrice_evictsCache_andNextReadReturnsNewPrice() {
Long id = repository.save(new Product("Mechanical keyboard", new BigDecimal("1200000"))).getId();
productService.findById(id); // load cache
assertThat(redis.hasKey(ProductService.key(id))).isTrue();
productService.updatePrice(id, new BigDecimal("900000"));
assertThat(redis.hasKey(ProductService.key(id))).isFalse();
assertThat(productService.findById(id).price())
.isEqualByComparingTo("900000");
}
}
This test checks three things:
@ServiceConnection(name = "redis")automatically pointsspring.data.redis.*to the container, so no extra configuration is needed.- After
updatePrice, the key is no longer in Redis. This proves theAFTER_COMMITlistener ran. - The next read returns the new price from the database.
Note: Do not mark this test method as
@Transactional. Spring will roll back the test’s transaction at the end, soAFTER_COMMITnever fires and the assertion fails.
Output when running:
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
9. When Not to Use This Approach
- Data requiring strong consistency, like account balances used to approve transactions. Don’t read them from cache. Read directly from the database with appropriate locks.
- Write-heavy data. The cache is constantly evicted, so hit rate is low. You pay the cost of complexity with little benefit.
- Keys composed of multiple entities, like a “top 10 products” list. It’s hard to know which keys to evict when a product changes. For such keys, short TTLs are usually simpler and safer than precise invalidation.
- Multiple services writing the same table. Events in your application don’t see changes from other services. In that case, CDC makes more sense.
10. Conclusion
Keeping Redis and database consistent doesn’t mean always being perfectly in sync. The goal is to make the drift window small and bounded. The basic formula has four steps:
- Read with cache-aside, always set TTL.
- Write to database, then delete the key, don’t update it.
- Only delete after commit, using
@TransactionalEventListener(phase = AFTER_COMMIT). - Add delayed double delete, outbox, or CDC depending on data sensitivity.
Finally, test with real Redis and don’t let @Transactional on tests mask post-commit behavior.