Thundering Herd Problem: Preventing the Stampede

elephants on road

public Product getProductById(UUID id) throws ProductNotFoundException {
    String cacheKey = PRODUCT_CACHE_KEY_PREFIX + id;

    // Check if product is in cache
    Product productFromCache = redisTemplate.opsForValue().get(cacheKey);

    // If product is in cache, return it
    if (productFromCache != null) {
        return productFromCache;
    }

    // If the product is not in cache, fetch it from DB
    Product product = productRepository.findById(id)
            .orElseThrow(() -> new ProductNotFoundException(id));

    // Backfill the cache
    redisTemplate.opsForValue().set(cacheKey, product, CACHE_TTL);

    return product;
}
public Product getProductById(UUID id) throws ProductNotFoundException {
    String cacheKey = PRODUCT_CACHE_KEY_PREFIX + id;
    // Check if product is in cache
    Product productFromCache = redisTemplate.opsForValue().get(cacheKey);

    // If product is in cache, return it
    if (productFromCache != null) {
        return productFromCache;
    }

    // If the product is not in the cache, then acquire a lock over
    // the cache key
    String lockKey = cacheKey + ":lock";
    String lockValue = UUID.randomUUID().toString();
    Duration lockTtl = Duration.ofSeconds(10);
    Boolean lockAcquired = stringRedisTemplate.opsForValue()
        .setIfAbsent(lockKey, lockValue, lockTtl);
    if (Boolean.TRUE.equals(lockAcquired)) {
        // This is required to avoid a race condition where another thread
        // could acquire the lock and backfills the cache in between the
        // current thread checks the cache and acquires the lock.
        Product doubleCacheLookup = redisTemplate.opsForValue().get(cacheKey);
        if (doubleCacheLookup != null) {
            return doubleCacheLookup;
        }
        try {
            // Look up the product from the database
            Product product = productRepository.findById(id)
                    .orElseThrow(() -> new ProductNotFoundException(id));

            // Backfill the cache
            redisTemplate.opsForValue().set(cacheKey, product, CACHE_TTL);

            return product;
        } finally {
            releaseLock(lockKey, lockValue);
        }
    } else {
        // If the lock was not acquired, wait for the cache to be populated and
        // then return the product from the cache
        return waitAndRetryFromCache(cacheKey, id);
    }
}

In-process synchronization

public Product getProductById(UUID id) throws ProductNotFoundException {
    String cacheKey = PRODUCT_CACHE_KEY_PREFIX + id;
    // Check if product is in cache
    Product productFromCache = redisTemplate.opsForValue().get(cacheKey);

    // If product is in cache, return it
    if (productFromCache != null) {
        return productFromCache;
    }

    // If not found in the cache, perform a database lookup and backfill the cache
    CompletableFuture<Product> future = ongoingRequests.computeIfAbsent(id,
        productId -> CompletableFuture.supplyAsync(() -> {
            try {
                // Look up the product from the database
                Product product = productRepository.findById(id)
                        .orElseThrow(() -> new ProductNotFoundException(id));

                // Backfill the cache
                redisTemplate.opsForValue().set(cacheKey, product, CACHE_TTL);
                return product;
            } finally {
                ongoingRequests.remove(productId);
            }
        }));
    try {
        return future.get();
    } catch (ExecutionException e) {
      // Handle exception
    }
}