Mastering Consistency in Redis Cache

The Java Trail
10 min readSep 21, 2023

--

In the fast-paced world of software development, cache consistency is a critical yet often overlooked aspect of building scalable and performant applications. Throughout my career, I’ve encountered a myriad of cache consistency challenges that have taught me valuable lessons. In this article, I will share some of these challenges and how I’ve tackled them, hoping to shed light on the importance of cache consistency in modern software systems.

What is Cache Consistency?

Cache consistency ensures that when a user requests data from a cache, they receive the most up-to-date and accurate information, regardless of the frequency and complexity of data updates in the backend.

Redis’s versatile data structures, such as strings, lists, sets, and sorted sets, enable developers to store and manipulate cached data with precision and efficiency. Redis’s ability to execute atomic operations on these data structures ensures that cache updates are seamless, reducing the chances of cache inconsistency.

Importance of consistency

Cache consistency is crucial in e-commerce servers where millions of customers rely on real-time product information, inventory accuracy, and dynamic pricing. Caching helps speed up responses and reduce backend load, but inconsistencies can lead to outdated data, jeopardizing customer trust and revenue. Inaccurate inventory status may result in overselling, dynamic pricing errors can confuse customers, and inconsistent recommendations can miss upselling opportunities. During the checkout process, pricing and availability inconsistencies can cause cart abandonment. Ensuring cache consistency is vital for delivering a seamless shopping experience, maintaining customer loyalty, and maximizing e-commerce success.

Obstacles to cache consistency:

1. When changes to the primary database aren’t reflected in the cache

Scenario: Imagine a high-traffic e-commerce website where product prices change frequently. A user views a product, but the cached price is outdated, leading to discrepancies in pricing.

Solution: Cache expiration policies and strategies to auto-refresh stale cache entries.

2.Delays in Updating Cache

The challenge of delayed cache updates arises when data in the primary database is modified, and an instruction is dispatched to the cache for an update or removal. This process typically occurs swiftly, maintaining cache consistency. Factors like the cache server’s existing workload and network latency can impact the speed of these updates. In unfortunate instances, users might encounter outdated data during this update window.

Scenario: E-commerce Product Price Updates

Imagine you’re running a popular e-commerce platform, and your system caches product prices to improve response times and reduce database load. Here’s how this challenge can manifest in this context:

1. Cache-Aside Pattern in E-commerce:

  • Your e-commerce application uses the cache-aside pattern, where product prices are fetched from the cache first.
  • When a user views a product, the system checks the cache for its price.
  • If the price is found in the cache (cache hit), it’s displayed to the user.
  • If not (cache miss), the system fetches the price from the database, caches it, and displays it to the user.

2. Price Update Process:

  • Your e-commerce platform frequently updates product prices, sometimes due to dynamic pricing algorithms or discounts.
  • Whenever a price is updated in the database, a message is sent to the cache layer to update or invalidate the cached price for that product.

3. The Challenge — Delayed Cache Updates:

  • Let’s say a product’s price was updated from $50 to $40 in the database due to a special promotion.
  • The database immediately triggers a message to the cache, instructing it to update the price for that product.

However, there’s a delay in processing this update message:

  • Processing Power: The cache server is currently handling numerous other requests and updates. It takes a bit of time to process this specific message.
  • Network Throughput: Network congestion or latency affects the transmission of the update message from the database to the cache.

4. User Experience Impact:

  • During this processing delay, a user decides to view the same product.
  • The cache still holds the old price of $50 because the update message hasn’t been processed yet.
  • The user sees the outdated price of $50 on the product page, which is both incorrect and misleading.

Solution: Cache invalidation and then use cache-aside/ lazy loading.

3. Delays in Cache Invalidation

Scenario: An event ticketing platform updates seat availability in real-time. However, cached seat information lags behind, causing overbooking issues.

Solution: Redis Pub/Sub mechanisms and cache eviction strategies to instantly invalidate and update cache entries upon data changes.

4: Inconsistency Across Cached Nodes

Use Case: E-Commerce Platform with Distributed Caching

Imagine a popular e-commerce platform that serves customers worldwide, utilizing a distributed cache across multiple geographically distributed nodes to enhance performance. When a product’s price changes, the central database is updated, and a message is propagated to all cache nodes to reflect the new price.

Now, consider a situation where a customer in Europe and another in North America simultaneously browse the same product just after the price update. Due to geographical differences and network latencies, there’s a delay in updating the cache across all nodes. Here’s what can occur:

1. Customer in Europe: They access a cache node in Europe where the price update is still pending.

2. Customer in North America: This user accesses a cache node in North America where the cache update has already occurred. They see the correct, updated price.

Solution: To address this, businesses often implement cache-coherency mechanisms, employ Content Delivery Networks (CDNs), and prioritize cache synchronization strategies.

Solution Approaches to Cache Inconsistency:

Approach 1: Cache Refresh & Cache Expiration Polices:

Cache Refresh: Preload frequently accessed or popular product prices into the cache during low-traffic periods. This can help mitigate the impact of delayed cache updates for frequently accessed products.

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.util.List;
import java.util.concurrent.TimeUnit;

@Service
public class ProductService {
@Autowired
private RedisTemplate<String, BigDecimal> redisTemplate;

// Simulate fetching product prices from the database
public BigDecimal getProductPriceFromDatabase(Long productId) {
// Replace this with actual database query
// For simplicity, we'll use random prices
return BigDecimal.valueOf(Math.random() * 100);
}

// Schedule cache refresh every hour
@Scheduled(fixedDelay = 3600000) // Refresh every hour
public void refreshProductPrices() {
List<Product> products = getAllProducts();
for (Product product : products) {
String cacheKey = "product:price:" + product.getId();
BigDecimal price = getProductPriceFromDatabase(product.getId());
redisTemplate.opsForValue().set(cacheKey, price, 1, TimeUnit.HOURS);
}
}

// Simulate fetching all products from the database
public List<Product> getAllProducts() {
// Replace this with actual database query
// For simplicity, we'll return a list of dummy products
return List.of(
new Product(1L, "Product A"),
new Product(2L, "Product B"),
new Product(3L, "Product C")
);
}

public BigDecimal getProductPrice(Long productId) {
String cacheKey = "product:price:" + productId;
BigDecimal price = redisTemplate.opsForValue().get(cacheKey);
if (price == null) {
// Fetch price from the database
price = getProductPriceFromDatabase(productId);
// Update cache with the fetched price for 1 hour
redisTemplate.opsForValue().set(cacheKey, price, 1, TimeUnit.HOURS);
}
return price;
}
}
  • Here we ensure cache consistency by setting appropriate expiration times (time-to-live (TTL)) for cache keys, for cached data.
  • Scheduled tasks or listeners periodically refresh stale cache entries, minimizing the chances of users encountering outdated information.
  • When a cache miss occurs, the application seamlessly retrieves the data from the primary database, updates the cache, and returns the latest information to the user.

Approach 2. Write-Through Caching

In write-through caching, the application updates the cache and the primary database synchronously. This ensures that data consistency is maintained between the cache and the database in real-time.

Write Path

  • Data only written for caching
  • Updated database by cache
@Service
public class ProductService {
@Autowired
private ProductRepository productRepository;

@Autowired
private StringRedisTemplate redisTemplate;

public Product updateProductPrice(Long id, double newPrice) {
// Update the cache and the primary database synchronously
Product product = productRepository.findById(id).orElse(null);

if (product != null) {
product.setPrice(newPrice);
productRepository.save(product);

// Update the cache immediately
String cacheKey = "product:" + id;
redisTemplate.opsForValue().set(cacheKey, serializeProduct(product));
}

return product;
}
}

In this example, when you update a product’s price, the service updates both the cache and the primary database immediately, ensuring data consistency. The cache and database are always in sync.

Approach 3. Write-Behind Caching

Write-behind caching, on the other hand, updates the cache first and then asynchronously updates the primary database.

This approach reduces the immediate load on the system during write operations.

@Service
public class ProductService {
@Autowired
private ProductRepository productRepository;

@Autowired
private StringRedisTemplate redisTemplate;

@Async
public void updateProductPrice(Long id, double newPrice) {
// Update the cache immediately
Product product = productRepository.findById(id).orElse(null);

if (product != null) {
product.setPrice(newPrice);
String cacheKey = "product:" + id;
redisTemplate.opsForValue().set(cacheKey, serializeProduct(product));
}

// Update the primary database asynchronously
CompletableFuture.runAsync(() -> {
// Simulate a delay for the primary database update
try {
Thread.sleep(1000); // 1 second delay
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}

// Update the primary database
productRepository.save(product);
});
}
}

Approach 4: Async Cache Invalidation After DB Update & Cache-Aside (Lazy-Loading) Caching

Cache invalidation and cache read are not tightly coupled operation in tis approach

  • When a product’s price is updated in the database, your system doesn’t wait for the cache update to complete before responding to the user request.
  • It immediately serves the user request with the old price, while the cache update happens in the background.
  1. Asynchronous Invalidate on Each DB Update: To avoid delaying the user experience while processing cache updates, you can use background processing or asynchronous tasks. Spring Boot provides tools like the @Async annotation and @EnableAsync to run methods asynchronously.
  2. Update By Cache-Aside: Before fetching data after fetching from database, update the cache.
@Service
public class ProductService {
@Autowired
private ProductRepository productRepository;

@Autowired
private StringRedisTemplate redisTemplate;

@Async
public void updateProductPriceInCache(Long productId, BigDecimal newPrice) {
// Update the product price in the database
Product product = productRepository.findById(productId).orElse(null);
if (product != null) {
product.setPrice(newPrice);
productRepository.save(product);
}

// Invalidate the cache for this product
String cacheKey = "product:price:" + productId;
redisTemplate.delete(cacheKey);
}


public BigDecimal getProductPrice(Long productId) {
String cacheKey = "product:price:" + productId;
String cachedPrice = redisTemplate.opsForValue().get(cacheKey);

if (cachedPrice != null) {
return new BigDecimal(cachedPrice);
}

// If not found in cache, fetch the price from the database
Product product = productRepository.findById(productId).orElse(null);

if (product != null) {
// Cache the product price with a short expiration (e.g., 1 minute)
redisTemplate.opsForValue().set(cacheKey, product.getPrice().toString(), Duration.ofMinutes(1));
return product.getPrice();
}

return BigDecimal.ZERO; // Handle if the product doesn't exist
}
}

Updating Product Price and Invalidating Cache:

@Autowired
private ProductService productService;

// Update the price of a product with ID 123 to $40 and invalidate the cache
productService.updateProductPriceInCache(123L, new BigDecimal("40.00"));

Fetching Product Price Efficiently:

@Autowired
private ProductService productService;

// Get the price of a product with ID 123
BigDecimal productPrice = productService.getProductPrice(123L);

// Display the product price to the user

In this approach, when you update the product price, you immediately invalidate the cache for that product. Though the Update and Read are not dependent on each other they are mutually asynchronous operations. but the cache invalidation and update is a synchronous operation, meaning that the cache invalidation process will block the update operation until the cache is invalidated. It ensures that the cache is always up to date, but it can introduce delays if cache invalidation takes a significant amount of time.

Approach 4: Redis Pub-Sub Cache Invalidation After DB Update & Cache-Aside Caching

Redis with its Pub/Sub feature, allow you to subscribe to data change events.

  1. Setup one or more pub-sub channels in Redis.
  2. Database Update Trigger: When a significant database change occurs (e.g., a new record is added, an existing record is updated, or a record is deleted), your application triggers an event
  3. Publish the Update: The event handler or service responsible for the database change publishes a message to the relevant Redis channel(s).
  4. Subscribers Listen: subscribers (which could be cache servers or other components of your application) are listening for updates.
  5. Update Caches: the cache subscriber can invalidate or refresh specific cache entries related to the database change.

CacheInvalidationListener.java (Redis message listener for cache invalidation):

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

@Component
public class CacheInvalidationListener implements MessageListener {

@Autowired
private StringRedisTemplate redisTemplate;

@Override
public void onMessage(Message message, byte[] pattern) {
String productId = message.toString();

// Invalidate the cache entry for the given product
String cacheKey = "product:price:" + productId;
redisTemplate.delete(cacheKey);
}
}

ProductService.java (Service for managing product data and cache)

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;

@Service
public class ProductService {

@Autowired
private ProductRepository productRepository;

@Autowired
private StringRedisTemplate redisTemplate;

private final String inventoryChannel = "inventory-channel";

@Async
@Transactional
public void updateProductPriceInCache(Long productId, BigDecimal newPrice) {
// Update the product price in the database
Product product = productRepository.findById(productId).orElse(null);
if (product != null) {
product.setPrice(newPrice);
productRepository.save(product);
}

// Publish a message to Redis for cache invalidation
String message = productId.toString();
redisTemplate.convertAndSend(inventoryChannel, message);
}

public BigDecimal getProductPrice(Long productId) {
String cacheKey = "product:price:" + productId;
String cachedPrice = redisTemplate.opsForValue().get(cacheKey);

if (cachedPrice != null) {
return new BigDecimal(cachedPrice);
}

// If not found in cache, fetch the price from the database
Product product = productRepository.findById(productId).orElse(null);

if (product != null) {
// Cache the product price with a short expiration (e.g., 1 minute)
redisTemplate.opsForValue().set(cacheKey, product.getPrice().toString());
return product.getPrice();
}

return BigDecimal.ZERO; // Handle if the product doesn't exist
}
}

RedisConfig.java (Configuration for Redis and message listener container)

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.core.StringRedisTemplate;

@Configuration
public class RedisConfig {

@Autowired
private CacheInvalidationListener cacheInvalidationListener;

@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(
RedisConnectionFactory connectionFactory) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
container.addMessageListener(cacheInvalidationListener, new ChannelTopic("inventory-channel"));
return container;
}

@Bean
public StringRedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) {
return new StringRedisTemplate(connectionFactory);
}
}

Cache consistency challenges are ubiquitous in modern software development, and they can have a profound impact on user experience, system performance, and data integrity. Throughout my career, I’ve learned that addressing these challenges requires a combination of careful planning, robust architectural choices, and creative problem-solving.

--

--

The Java Trail

Scalable Distributed System, Backend Performance Optimization, Java Enthusiast. (mazumder.dip.auvi@gmail.com Or, +8801741240520)