This article explains how the "power of two choices" load balancing algorithm can significantly improve request distribution compared to simple round-robin, especially with heterogeneous workloads. It highlights a common problem with traditional methods where nodes can become overloaded due to varying request sizes, and demonstrates a more adaptive solution. The core insight is that by selecting two random backends and picking the less loaded one, the system achieves a much more even distribution of load.
Read original on Dev.to #systemdesignTraditional load balancing strategies, such as round-robin, distribute requests sequentially or randomly across available backend servers. While simple and effective for uniform workloads, these methods can lead to significant performance bottlenecks when requests have varying processing times or resource requirements. For instance, a server handling a computationally intensive video transcoding job will be considered "available" by a basic load balancer just as quickly as one handling a light health check, leading to an uneven distribution of actual work and potential overloading of specific nodes. This scenario often results in increased latency and reduced system throughput, as some nodes are idle while others are struggling.
The "power of two choices" algorithm offers a remarkably simple yet powerful solution to this problem. Instead of picking a single backend randomly or sequentially, the algorithm selects two random backends. It then compares their current load (e.g., active connection count, CPU utilization, or request queue length) and dispatches the incoming request to the one with the *lower* load. This seemingly minor change drastically improves load distribution.
Mathematical Advantage
Mathematically, the worst-case maximum load on a server drops significantly from O(log N / log log N) for pure random distribution to O(log log N) with just two choices. This means hotspots become exponentially rarer, leading to a much more balanced system even with highly varied workloads and a large number of servers.
type PowerOfTwoLB struct {
backends []string
loads []int64 // approximate active request count
mu sync.Mutex
randSrc *rand.Rand
}
func NewPowerOfTwoLB(backs []string) *PowerOfTwoLB {
return &PowerOfTwoLB{
backends: backs,
loads: make([]int64, len(backs)),
randSrc: rand.New(rand.NewSource(time.Now().UnixNano())),
}
}
// call this when a request starts
func (lb *PowerOfTwoLB) Pick() string {
lb.mu.Lock()
defer lb.mu.Unlock()
n := len(lb.backends)
i1 := lb.randSrc.Intn(n)
i2 := lb.randSrc.Intn(n)
for i2 == i1 {
i2 = lb.randSrc.Intn(n)
}
if lb.loads[i1] <= lb.loads[i2] {
lb.loads[i1]++
return lb.backends[i1]
}
lb.loads[i2]++
return lb.backends[i2]
}
// call this when a request finishes
func (lb *PowerOfTwoLB) Done(backend string) {
lb.mu.Lock()
defer lb.mu.Unlock()
for i, b := range lb.backends {
if b == backend {
lb.loads[i]--
break
}
}
}This pattern is highly valuable in distributed systems where even distribution of work is critical for performance and stability, particularly in microservices architectures and API gateways.