This article discusses the 'Thundering Herd Problem', a critical issue in distributed caching where many clients concurrently request the same uncached data, overwhelming the backend database. It explores the causes and proposes various system design solutions to prevent database Distributed Denial of Service (DDoS) and ensure cache effectiveness.
Read original on Medium #system-designThe Thundering Herd Problem occurs when a cache miss for a popular item causes a large number of concurrent requests to hit the backend data store (e.g., a database). This sudden surge of requests can overwhelm the database, leading to performance degradation, timeouts, and potentially a cascading failure. It's often triggered during cache warm-up, cache invalidation, or when a popular item expires simultaneously across multiple cache nodes. This scenario effectively turns a cache, intended to protect the database, into a mechanism that inadvertently DDoS's it.
The Cache's Paradox
While caching is vital for scalability, incorrect handling of cache misses, especially for popular items, can create more problems than it solves. A poorly designed cache invalidation strategy or lack of protection against simultaneous cache misses can lead to a 'database DDoS'.
Several system design patterns and techniques can be employed to mitigate the Thundering Herd Problem. These strategies primarily focus on preventing multiple concurrent requests from reaching the backend data store for the same missing cache entry.
Implementing these solutions requires careful consideration of consistency requirements, cache eviction policies, and the potential for increased latency for the initial request that populates the cache. Distributed locks are often necessary for request coalescing in a distributed caching environment to ensure atomicity across multiple cache nodes.
type SingleFlightGroup struct {
mu sync.Mutex
m map[string]*call
}
type call struct {
wg sync.WaitGroup
val interface{}
err error
}
func (g *SingleFlightGroup) Do(key string, fn func() (interface{}, error)) (interface{}, error) {
g.mu.Lock()
if g.m == nil {
g.m = make(map[string]*call)
}
if c, ok := g.m[key]; ok {
g.mu.Unlock()
c.wg.Wait() // Wait for existing call to complete
return c.val, c.err
}
c := new(call)
c.wg.Add(1)
g.m[key] = c
g.mu.Unlock()
c.val, c.err = fn() // Execute the actual data fetch
c.wg.Done()
g.mu.Lock()
delete(g.m, key) // Clean up after call completes
g.mu.Unlock()
return c.val, c.err
}