Caching Demystified - Part 2: Strategies and Patterns
Table of Contents
- Why Choosing the Right Strategy Matters
- Cache-Aside (Lazy Loading)
- Read-Through Cache
- Write-Through Cache
- Write-Behind (Write-Back) Cache
- Write-Around Cache
- Refresh-Ahead (Prefetching)
- Comprehensive Strategy Comparison
- Multi-Level (Tiered) Caching
- Consistent Hashing for Distributed Caches
1. Why Choosing the Right Strategy Matters
There is no one-size-fits-all caching strategy. The wrong strategy can cause:
- Data inconsistency: Users see stale or incorrect data.
- Data loss: Writes acknowledged to users are lost before persisting.
- Performance degradation: Stampedes or avalanches under load.
- Unnecessary complexity: Over-engineering for a simple use case.
The right strategy depends on:
- Read/write ratio: Is the workload read-heavy or write-heavy?
- Consistency requirements: Must the cache always match the database?
- Latency tolerance: Can writes wait for background persistence?
- Failure tolerance: What happens if the cache fails?
2. Cache-Aside (Lazy Loading)
The most commonly used caching pattern in production. The application code is responsible for managing all interactions with both the cache and the database.
How It Works
The cache sits "aside" from the main data flow. The application checks the cache, and only goes to the database on a miss.
Read Flow
Application Cache Database
| | |
| 1. GET user:123 | |
|------------------------->| |
| | |
| (Cache MISS) | |
|<-------------------------| |
| | |
| 2. SELECT * FROM users WHERE id=123 |
|---------------------------------------------------->|
| | |
| 3. Return user data | |
|<----------------------------------------------------|
| | |
| 4. SET user:123, TTL=300| |
|------------------------->| |
| | |
| 5. Return user to caller| |
(Next request for user:123)
| 1. GET user:123 | |
|------------------------->| |
| (Cache HIT) | |
|<-------------------------| |
| 2. Return user to caller (Database NOT contacted)
Write Flow
Application Cache Database
| | |
| 1. UPDATE user data | |
|---------------------------------------------------->|
| 2. DB write succeeds | |
|<----------------------------------------------------|
| | |
| 3. DELETE user:123 | |
| (Invalidate cache) | |
|------------------------->| |
| | |
| 4. Confirm to caller |
Java Implementation
@Service
public class UserService {
@Autowired
private RedisTemplate<String, User> redisTemplate;
@Autowired
private UserRepository userRepository;
private static final Duration TTL = Duration.ofMinutes(5);
// READ: Cache-Aside
public User getUser(Long userId) {
String cacheKey = "user:" + userId;
// Step 1: Check cache
User cached = (User) redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return cached; // Cache HIT
}
// Step 2: Cache MISS - fetch from database
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException(userId));
// Step 3: Populate cache
redisTemplate.opsForValue().set(cacheKey, user, TTL);
return user;
}
// WRITE: Update database, then invalidate cache
public User updateUser(Long userId, UserUpdateRequest request) {
// Step 1: Update in database
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException(userId));
user.setName(request.getName());
user.setEmail(request.getEmail());
userRepository.save(user);
// Step 2: Invalidate cache (delete, not update)
redisTemplate.delete("user:" + userId);
return user;
}
}Pros
- Most flexible: Application has full control over caching logic.
- Resilient to cache failures: If the cache is down, the application still works (just slower). It reads directly from the database.
- Cache only what you need: Not all data types have to be cached. The application decides per query.
- Avoids cold data in cache: Only data that has been requested at least once ends up in cache (truly lazy).
- Works with any database: No special cache-aware database drivers required.
Cons
- First request is always slow: Cache miss on first access means database latency for that request.
- Potential for stale data: If the write path forgets to invalidate the cache, the stale entry persists until TTL expires.
- Cache stampede risk: On popular data expiry, many concurrent requests all miss simultaneously (see Part 4).
- Boilerplate code: Every data access method must implement the read-check-miss-load-store pattern. This can be reduced with AOP (Spring @Cacheable).
When to Use Cache-Aside
- Read-heavy workloads where cache resilience is important.
- When different data types need different caching logic.
- When using Spring Boot (use @Cacheable which implements Cache-Aside internally).
- When writes are infrequent relative to reads.
- As the default strategy when you are not sure which strategy to pick.
3. Read-Through Cache
The application always talks to the cache. The cache itself is responsible for fetching from the database on a miss. The application never directly queries the database.
How It Works
Application Cache (with DB connector) Database
| | |
| 1. GET user:123 | |
|------------------------->| |
| | 2. (Cache miss) |
| | SELECT * FROM users WHERE id=123
| |------------------------------>|
| | 3. Return data |
| |<------------------------------|
| | 4. Store in cache |
| 5. Return user data | |
|<-------------------------| |
The key difference from Cache-Aside: the application issues a single GET to the cache. The cache transparently handles the miss by querying the database.
Java Implementation (using a Cache-Loader abstraction)
@Configuration
public class CacheConfig {
@Bean
public LoadingCache<Long, User> userCache(UserRepository repository) {
return Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.recordStats()
.build(userId -> {
// This is the CacheLoader - called automatically on miss
return repository.findById(userId)
.orElseThrow(() -> new UserNotFoundException(userId));
});
}
}
@Service
public class UserService {
@Autowired
private LoadingCache<Long, User> userCache;
public User getUser(Long userId) {
// Application only talks to cache - no explicit DB logic here
return userCache.get(userId); // Cache handles miss transparently
}
}Pros
- Clean application code: Application has no knowledge of the database. Single responsibility.
- Consistent caching behavior: Cache loader logic is centralized, not duplicated across multiple service methods.
- Automatic population: Cache is self-healing on miss.
Cons
- Cache failure = application failure: If the cache is down, the application cannot function (no direct database fallback in the application layer).
- First request still slow: Same as Cache-Aside, the first access is a cache miss.
- Tighter coupling: Cache and database become coupled through the cache loader.
- Less flexibility: Harder to implement per-method caching variations.
Cache-Aside vs Read-Through - Key Difference
Cache-Aside:
Application ----reads----> Cache
Application ----reads----> Database (on miss)
Application ----writes---> Cache (on miss)
Application manages both cache AND database interactions.
Read-Through:
Application ----reads----> Cache (always, only)
Cache --------reads------> Database (on miss, transparently)
Application only manages cache. Cache manages database interaction.
When to Use Read-Through
- When you want to fully separate data access logic from caching logic.
- Useful with frameworks that support cache loaders (Caffeine LoadingCache, Guava CacheLoader, JCache).
- When the same data is accessed identically from many places in the code.
4. Write-Through Cache
Every write goes to the cache AND the database synchronously. The write is not acknowledged to the caller until BOTH the cache and the database have been updated.
How It Works
Application Cache Database
| | |
| 1. Write user:123 | |
|--------------------->| |
| | 2. Write to database |
| |------------------------->|
| | 3. DB write confirmed |
| |<-------------------------|
| 4. Write confirmed | |
|<---------------------| |
Java Implementation
@Service
public class UserService {
@Autowired
private RedisTemplate<String, User> redisTemplate;
@Autowired
private UserRepository userRepository;
public User updateUser(Long userId, UserUpdateRequest request) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException(userId));
user.setName(request.getName());
user.setEmail(request.getEmail());
// Step 1: Write to database (synchronous)
userRepository.save(user);
// Step 2: Write to cache (synchronous, in same transaction scope)
redisTemplate.opsForValue().set("user:" + userId, user, Duration.ofMinutes(5));
return user;
}
}Spring @CachePut implements Write-Through:
@Service
public class UserService {
@CachePut(value = "users", key = "#user.id")
public User updateUser(User user) {
// @CachePut: always executes the method AND updates the cache
return userRepository.save(user);
}
@Cacheable(value = "users", key = "#userId")
public User getUser(Long userId) {
return userRepository.findById(userId).orElseThrow();
}
}Pros
- Consistency: Cache and database are always in sync after every write.
- No stale data for write paths: If data is in cache, it is guaranteed to be the latest version.
- High read performance after first write: Data is always in cache after a write, so subsequent reads are always cache hits.
Cons
- Write latency: Every write incurs the latency of TWO operations: cache write + database write. The total write latency is roughly: max(cache_latency, db_latency).
- Write penalty: Write operations are slower than with write-around or cache-aside. This matters for write-heavy workloads.
- Cache pollution: Data may be written and never read again (written once, read never). This wastes cache space.
- Does not help write-heavy workloads: If writes are far more common than reads, the write overhead dominates with little read benefit.
When to Use Write-Through
- Strong consistency is required between cache and database.
- Write-heavy workloads are NOT the primary concern.
- Financial data, inventory, or any data where even momentary staleness is unacceptable.
- Typically used together with Read-Through cache for a clean abstraction layer.
5. Write-Behind (Write-Back) Cache
The application writes to the cache immediately. The write is acknowledged to the caller as soon as the cache accepts it. The cache asynchronously persists the data to the database in the background.
How It Works
Application Cache Database
| | |
| 1. Write user:123 | |
|--------------------->| |
| 2. ACK (fast!) | |
|<---------------------| |
| | |
| | 3. Async write to database |
| | (happens later, in batch) |
| |----------------------------->|
| | 4. DB confirms (async) |
| |<-----------------------------|
The Risk of Write-Behind
Timeline:
T+0s: User updates profile. Cache accepts write. ACK sent to user.
T+2s: Cache prepares to batch-write to DB.
T+3s: CACHE SERVER CRASHES.
T+3s: Data in cache is LOST. Database was never updated.
T+4s: Application restarts. User's profile update is gone.
Write-behind trades durability for write performance.
Java Conceptual Implementation
@Service
public class UserService {
@Autowired
private RedisTemplate<String, User> redisTemplate;
@Autowired
private UserRepository userRepository;
// Async writer (in background)
@Scheduled(fixedDelay = 1000) // Flush to DB every 1 second
public void flushDirtyEntriesToDatabase() {
Set<String> dirtyKeys = redisTemplate.keys("dirty:user:*");
if (dirtyKeys == null) return;
for (String dirtyKey : dirtyKeys) {
String userKey = dirtyKey.replace("dirty:", "");
User user = (User) redisTemplate.opsForValue().get(userKey);
if (user != null) {
userRepository.save(user); // Async write to DB
redisTemplate.delete(dirtyKey); // Clear dirty flag
}
}
}
public User updateUser(Long userId, UserUpdateRequest request) {
User user = buildUpdatedUser(userId, request);
// Step 1: Write to cache (fast, synchronous return to caller)
String cacheKey = "user:" + userId;
redisTemplate.opsForValue().set(cacheKey, user, Duration.ofMinutes(30));
// Step 2: Mark as dirty for async flush
redisTemplate.opsForValue().set("dirty:" + cacheKey, "1", Duration.ofMinutes(30));
return user; // Return immediately, DB write happens asynchronously
}
}Real-World Write-Behind: Redis + Spring Data (Conceptual)
Redis itself does not natively implement write-behind. This pattern is typically implemented by:
- JCache (JSR-107) with Write-Behind Loader
- Hazelcast MapStore with write-delay
- Custom implementation with a dirty tracking map and a background flush thread
// Hazelcast Write-Behind example
@Configuration
public class HazelcastConfig {
@Bean
public Config hazelcastConfig() {
Config config = new Config();
MapConfig userMapConfig = new MapConfig("users");
MapStoreConfig mapStoreConfig = new MapStoreConfig();
mapStoreConfig.setImplementation(new UserMapStore());
mapStoreConfig.setWriteDelaySeconds(5); // Write-behind: flush to DB after 5 seconds
mapStoreConfig.setWriteBatchSize(100); // Batch 100 writes together
userMapConfig.setMapStoreConfig(mapStoreConfig);
config.addMapConfig(userMapConfig);
return config;
}
}Pros
- Lowest write latency: Write is acknowledged as soon as cache accepts it (no DB latency).
- Batching opportunities: Multiple writes to the same key can be coalesced into a single DB write.
- Write throughput: Can handle extremely high write rates that would overwhelm the database.
Cons
- Risk of data loss: If cache crashes before the async write, data is lost.
- Complexity: Requires dirty tracking, error handling for failed DB writes, retry logic.
- Eventual consistency: There is always a window where cache and DB are out of sync.
- Ordering challenges: With concurrent writes, ensuring DB writes happen in the correct order is complex.
When to Use Write-Behind
- Extremely write-heavy workloads where write latency is critical.
- Data that is regenerable or where some loss is acceptable (metrics, analytics counters, draft auto-saves).
- Gaming leaderboards, real-time analytics where high ingestion rate matters more than perfect durability.
- NOT for: Financial transactions, user authentication, any data where loss is unacceptable.
6. Write-Around Cache
Writes go directly to the database, completely bypassing the cache. Reads may still populate the cache on a miss (combined with Cache-Aside).
How It Works
Write Operation:
Application Cache Database
| | |
| 1. Write user:789 | (Cache is BYPASSED |
| (skip cache!) | for writes) |
|------------------------------------------------->|
| 2. DB write confirmed |
|<-------------------------------------------------|
(Cache is NOT updated or invalidated)
Read Operation (Cache-Aside on subsequent read):
| 3. GET user:789 | |
|--------------------->| |
| (Cache MISS) | |
|<---------------------| |
| 4. Read from DB | |
|------------------------------------------------->|
| 5. Return data | |
|<-------------------------------------------------|
| 6. Store in cache | |
|--------------------->| |
Pros
- Best for write-once, read-rarely data: Cache is not polluted with data that will never be re-read.
- Simpler write path: No cache management on writes.
- Consistent database as source of truth: No risk of cache-database inconsistency from write path.
Cons
- First read is always a cache miss: Data is only in cache after it has been read at least once.
- Higher read latency for new data: Recently written data is never cached until explicitly read.
When to Use Write-Around
- Log data, audit trails, time-series data: written once, read rarely (or never).
- Large file uploads and media storage.
- Batch processing results that are unlikely to be requested immediately.
- Combined with Cache-Aside for reads: writes bypass cache, but if the data is read later, it gets cached.
7. Refresh-Ahead (Prefetching)
The cache proactively refreshes entries BEFORE they expire, so that a reader never encounters a cache miss due to expiration.
How It Works
TTL = 300 seconds
Refresh threshold = 270 seconds (90% of TTL)
Timeline:
T=0s: Entry added to cache. TTL starts.
T=270s: A request arrives. Cache sees entry is "near expiry" (270s > threshold).
- Returns current (still valid) cached value to the request immediately.
- Triggers background refresh (async fetch from DB, update cache).
T=300s: TTL expires. But cache was already refreshed at T=270s.
Cache never goes stale. No user ever hits a miss for this hot key.
Java Implementation with Caffeine
@Bean
public LoadingCache<Long, Product> productCache(ProductRepository repository) {
return Caffeine.newBuilder()
.maximumSize(50_000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.refreshAfterWrite(4, TimeUnit.MINUTES) // Refresh asynchronously after 4 min
.buildAsync(productId -> repository.findById(productId).orElseThrow())
.synchronous();
}With refreshAfterWrite(4, TimeUnit.MINUTES):
- After 4 minutes, the first request that touches this key triggers an async background refresh.
- The current (slightly stale) value is returned immediately.
- The background thread fetches fresh data and updates the cache.
- Future requests get the fresh value.
XFetch Algorithm (Probabilistic Early Expiration)
A probabilistic approach to refresh-ahead. Instead of refreshing at a fixed threshold, requests have a probability of triggering a refresh that increases as the TTL approaches zero.
public User getUserWithXFetch(String userId) {
CacheEntry entry = cache.get(userId);
if (entry == null) {
return fetchAndCache(userId); // Normal miss
}
double remainingTTL = entry.getExpiryTime() - System.currentTimeMillis();
double beta = 1.0; // Tuning parameter (higher = earlier refresh)
double delta = entry.getFetchDuration(); // How long DB fetch takes in ms
// Probability of early refresh increases as TTL decreases
double threshold = -delta * beta * Math.log(Math.random());
if (remainingTTL <= threshold) {
// Probabilistically decide to refresh early (even though entry is still valid)
triggerBackgroundRefresh(userId);
}
return entry.getValue();
}The XFetch algorithm elegantly distributes refreshes over time, preventing multiple concurrent refreshes (stampede).
Pros
- Zero-latency expiry: Users never wait for a cache miss due to TTL expiration.
- Stampede prevention: Background refresh happens before expiry, not after.
- Smooth performance: No spikes in database load due to expiration.
Cons
- Stale data window: When Refresh-Ahead returns the old value while refreshing in the background, the response is slightly stale.
- Wasted refreshes: If a key is refreshed proactively but nobody ever reads it again, the background fetch was wasted work.
- Complexity: Requires async background processing and handling of background refresh failures.
When to Use Refresh-Ahead
- Hot keys that are accessed very frequently and must have consistently low latency.
- Data that you KNOW will be accessed again (high confidence in future reads).
- Latency-sensitive real-time features (game state, live scores, top-N leaderboards).
8. Comprehensive Strategy Comparison
Decision Table
Strategy Read Source Write Source Consistency Write Latency Data Loss Risk Complexity
-------------- ----------- ------------ ----------- ------------- --------------- ----------
Cache-Aside Cache/DB DB (invalidate) High* Normal None Low
Read-Through Cache only DB (via cache) High* Normal None Medium
Write-Through Cache Cache+DB (sync) High Higher None Medium
Write-Behind Cache Cache (async) Eventual Lowest Medium High
Write-Around DB DB only High Normal None Low
* High consistency assuming cache invalidation is correct.
When to Use Each Strategy
Strategy Best When...
-------------- -----------------------------------------------
Cache-Aside Default choice, read-heavy, cache resilience needed
Read-Through Clean code separation desired, framework support available
Write-Through Strong consistency + read performance after writes
Write-Behind High write throughput, some data loss acceptable
Write-Around Write-once data, large objects, audit/log data
Refresh-Ahead Hot keys, zero-miss tolerance, predictable access
Combining Strategies
Real production systems combine strategies:
Common combination: Cache-Aside reads + Write-Invalidate writes
- Reads use Cache-Aside (lazy loading)
- Writes update the database and DELETE the cache entry
- Clean, simple, and consistent (most widely used pattern)
Common combination: Write-Through + Read-Through
- All reads and writes go through a cache abstraction layer
- Cache transparently talks to the database
- Application has a single data access point
Common combination: Write-Around + Cache-Aside
- Writes bypass the cache (write directly to DB)
- Reads use Cache-Aside to lazy-load into cache
- Good for data that is written frequently but read infrequently
Production example: User profile service
Read: Cache-Aside (5-minute TTL)
Write: Write to DB first, then DELETE cache entry
Heavy updates (bulk): Write-Around (bypass cache entirely)
High-traffic fields: Refresh-Ahead for hot user IDs
9. Multi-Level (Tiered) Caching
Multi-level caching uses multiple cache layers at different speeds and sizes to optimize both latency and hit rates.
Architecture
Request Flow:
User Request
|
v
[L1: In-Process Cache] -- Fastest (sub-millisecond), per-instance, ~100 MB
| Miss
v
[L2: Distributed Cache] -- Fast (1-5ms), shared, ~10-100 GB
| Miss
v
[L3: Database Cache/Query] -- Slow (5-50ms), source of truth
| Miss
v
[L4: Origin Store] -- Slowest (disk I/O, aggregations)
Example: Product Catalog Service
@Service
public class ProductService {
// L1: In-process cache (Caffeine) - per instance, extremely fast
private final Cache<Long, Product> localCache = Caffeine.newBuilder()
.maximumSize(1_000)
.expireAfterWrite(30, TimeUnit.SECONDS)
.build();
// L2: Distributed cache (Redis) - shared across all instances
@Autowired
private RedisTemplate<String, Product> redisTemplate;
// L3: Database
@Autowired
private ProductRepository productRepository;
public Product getProduct(Long productId) {
// Check L1 (in-process, ~0.01ms)
Product product = localCache.getIfPresent(productId);
if (product != null) {
return product;
}
// Check L2 (Redis, ~1-2ms)
String cacheKey = "product:" + productId;
product = (Product) redisTemplate.opsForValue().get(cacheKey);
if (product != null) {
localCache.put(productId, product); // Backfill L1
return product;
}
// L3: Database (~20ms)
product = productRepository.findById(productId).orElseThrow();
// Backfill L2 and L1
redisTemplate.opsForValue().set(cacheKey, product, Duration.ofMinutes(5));
localCache.put(productId, product);
return product;
}
}Benefits of Multi-Level Caching
- L1 eliminates network overhead for the hottest data (e.g., home page products, top 100 users).
- L2 provides shared state across all instances for a larger working set.
- Hit rate is additive: If L1 hits 70% and L2 hits 80% of L1 misses, the combined hit rate is: 70% + (0.3 x 80%) = 94%.
Challenges
Coherence across L1 instances:
If one application instance updates a product and invalidates L2 (Redis), the other instances' L1 caches still have the stale value.
Solutions:
- Keep L1 TTL very short (30 seconds). Accept 30 seconds of staleness in L1.
- Use Redis Pub/Sub to broadcast L1 invalidation:
// Publisher (on update)
redisTemplate.convertAndSend("cache:invalidate", "product:" + productId);
// Subscriber (on all app instances)
@Component
public class CacheInvalidationListener {
@Autowired
private Cache<Long, Product> localCache;
@RedisListener(topic = "cache:invalidate")
public void onInvalidation(String cacheKey) {
if (cacheKey.startsWith("product:")) {
Long productId = Long.parseLong(cacheKey.split(":")[1]);
localCache.invalidate(productId);
}
}
}10. Consistent Hashing for Distributed Caches
When you have multiple cache nodes in a distributed cache cluster, you need a way to decide which node stores a given key. This is the partitioning or sharding problem.
Why Naive Hashing Fails
Naive approach: node = hash(key) % number_of_nodes
3 nodes: node = hash("user:123") % 3 = node 1
4 nodes: node = hash("user:123") % 4 = node 0 <-- Different node!
When you add or remove a cache node, the modulo changes, and every single key maps to a different node. This means virtually all cache entries are immediately on the "wrong" node, causing a complete cache miss storm. All traffic hits the database simultaneously during the rebalancing. This is catastrophic for a live system.
Consistent Hashing
Consistent hashing solves this by arranging nodes on a virtual "ring" (a circle of hash values from 0 to 2^32).
Building the Ring
- Hash each node's identifier (IP address or name) to a position on the ring.
- To find which node owns a key, hash the key to a ring position, then walk clockwise to find the first node.
Hash space: 0 to 2^32, arranged as a circle
0
+----+----+
270 / \ 90
/ \
Node C * * Node A
(hash: 250) (hash: 100)
\ /
180 \ / 90
+----+----+
180
* Node B
(hash: 175)
Key "user:123" hashes to position 140:
Walk clockwise from 140... next node is C at 250.
"user:123" is stored on Node C.
Key "product:456" hashes to position 80:
Walk clockwise from 80... next node is A at 100.
"product:456" is stored on Node A.
Adding a Node
When Node D is added at position 210:
- Only keys between 180 (Node B) and 210 (new Node D) are affected.
- These keys move from Node C to Node D.
- All other keys are unaffected.
- Impact: Only ~K/N keys are remapped (K = total keys, N = number of nodes), versus K keys with naive hashing.
The Problem: Non-Uniform Distribution
With few physical nodes, nodes may not be evenly spaced on the ring, causing some nodes to hold much more data than others (hot spots).
Solution: Virtual Nodes (Vnodes)
Each physical node is assigned multiple positions on the ring (typically 100-200 virtual nodes per physical node).
// Conceptual consistent hash with virtual nodes
public class ConsistentHash<T> {
private final int replicationFactor; // Number of virtual nodes per physical node
private final TreeMap<Integer, T> ring = new TreeMap<>();
private final HashFunction hashFunction = Hashing.murmur3_32();
public void addNode(T node) {
for (int i = 0; i < replicationFactor; i++) {
int hash = hashFunction.hashString(node.toString() + "-vnode-" + i,
StandardCharsets.UTF_8).asInt();
ring.put(hash, node);
}
}
public void removeNode(T node) {
for (int i = 0; i < replicationFactor; i++) {
int hash = hashFunction.hashString(node.toString() + "-vnode-" + i,
StandardCharsets.UTF_8).asInt();
ring.remove(hash);
}
}
public T getNode(String key) {
if (ring.isEmpty()) return null;
int hash = hashFunction.hashString(key, StandardCharsets.UTF_8).asInt();
// Find the first node clockwise from the hash position
Map.Entry<Integer, T> entry = ring.ceilingEntry(hash);
if (entry == null) {
entry = ring.firstEntry(); // Wrap around the ring
}
return entry.getValue();
}
}With 150 virtual nodes per physical node and 5 physical nodes, the ring has 750 points. Keys are distributed with much more statistical uniformity. Adding or removing a node still affects only ~1/N of keys.
Consistent Hashing in Practice
Redis Cluster uses a variant called hash slots:
- 16,384 total hash slots
- Hash function: CRC16(key) % 16384
- Each master node owns a range of hash slots
- Adding/removing nodes means moving hash slot ownership, not rehashing keys
3 nodes example:
Node A: hash slots 0 - 5460
Node B: hash slots 5461 - 10922
Node C: hash slots 10923 - 16383
Key "user:123":
CRC16("user:123") % 16384 = 9423
9423 is in range 5461-10922 -> Node B handles this key
Summary
Choosing the right caching strategy is one of the most impactful architectural decisions you can make:
- Cache-Aside is your default. It is flexible, resilient to cache failure, and easy to reason about.
- Read-Through cleans up application code by centralizing the cache miss logic.
- Write-Through ensures cache-database consistency at the cost of write latency.
- Write-Behind maximizes write throughput at the cost of durability. Use only when data loss is acceptable.
- Write-Around is ideal for write-once, read-rarely data that would pollute the cache.
- Refresh-Ahead eliminates latency spikes for predictably hot keys.
In production, you will rarely use a single strategy. Design your caching tier as a combination of these strategies, each applied to the data access pattern it suits best.
Previous: Part 1 - Fundamentals
Next: Part 3 - Technologies and Tools