Caching is a fundamental technique used in software systems to improve performance by temporarily storing frequently accessed data. However, maintaining the consistency between the cache and the underlying data source can be challenging, especially when updates occur. This tutorial will explore various techniques for cache invalidation, ensuring that your cached data remains up-to-date.
Cache invalidation refers to the process of removing or updating entries in a cache so that they reflect the current state of the underlying data source. There are several strategies to handle cache invalidation:
Time-based expiration is one of the simplest and most common strategies for cache invalidation. Each entry in the cache has a timestamp, and if the current time exceeds this timestamp, the entry is considered stale and should be invalidated.
class Cache {
constructor() {
this.store = {};
}
set(key, value, ttl) { // ttl in milliseconds
const expiryTime = Date.now() + ttl;
this.store[key] = { value, expiryTime };
}
get(key) {
const entry = this.store[key];
if (!entry || entry.expiryTime < Date.now()) {
return null; // Cache miss or expired
}
return entry.value;
}
}
// Usage
const cache = new Cache();
cache.set('user:1', { name: 'Alice' }, 5000); // Set with a TTL of 5 seconds
console.log(cache.get('user:1')); // Output: { name: 'Alice' }
setTimeout(() => {
console.log(cache.get('user:1')); // Output: null (expired)
}, 6000);
Event-driven invalidation involves listening for specific events that indicate changes in the data source and then invalidating the relevant cache entries. This approach ensures that the cache is always consistent with the underlying data.
class Cache {
constructor() {
this.store = {};
}
set(key, value) {
this.store[key] = value;
}
get(key) {
return this.store[key];
}
invalidate(key) {
delete this.store[key];
}
}
const cache = new Cache();
cache.set('user:1', { name: 'Alice' });
// Simulate a data change event
function onDataChange(key) {
console.log(`Invalidating cache for key: ${key}`);
cache.invalidate(key);
}
onDataChange('user:1');
console.log(cache.get('user:1')); // Output: null (invalidated)
Cache stampedes occur when multiple requests simultaneously try to regenerate the same data after a cache entry expires. To prevent this, you can use locking mechanisms such as mutexes.
class Cache {
constructor() {
this.store = {};
this.locks = new Set();
}
async getWithLock(key, fetchData) {
if (this.locks.has(key)) {
await new Promise(resolve => setTimeout(resolve, 100)); // Wait and retry
return this.getWithLock(key, fetchData);
}
this.locks.add(key);
try {
const value = this.store[key];
if (!value) {
const newValue = await fetchData();
this.store[key] = newValue;
return newValue;
}
return value;
} finally {
this.locks.delete(key);
}
}
}
const cache = new Cache();
async function fetchData() {
console.log('Fetching data...');
return { name: 'Alice' };
}
cache.getWithLock('user:1', fetchData).then(data => {
console.log(data); // Output: { name: 'Alice' }
});
In this section, we explored various techniques for cache invalidation, including time-based expiration, event-driven invalidation, and cache stampede prevention. Understanding these strategies is crucial for building efficient and reliable caching systems.
Next, you might want to dive deeper into database design, which plays a critical role in managing data consistency and performance. Learning how to design databases effectively will help you build robust applications that can handle large volumes of data efficiently.
Happy coding!