Load Balancing Policy Interface Polymorphism Design
In high-performance load balancers, supporting multiple load balancing strategies is a common requirement. Different business scenarios may require different strategies: Round Robin for stateless services, Least Connections for long-lived connections, Hash for session persistence.
How to support flexible strategy switching while maintaining high performance? This article provides an in-depth analysis of the policy interface polymorphism design in industrial-grade code, with a clean-room reconstruction in Rust.
The Problem: Evolution of Strategies
In early load balancers, strategies were often hardcoded:
// Pseudo-code - hardcoded strategy
if (strategy == "round_robin") {
// Round robin logic
} else if (strategy == "least_connections") {
// Least connections logic
}
The drawbacks of this approach are obvious:
- Adding new strategies requires modifying core code
- Code branches become numerous and hard to maintain
- Code cannot be reused between strategies
Industrial Solution: Interface Abstraction + Factory Pattern
The original code uses the classic strategy pattern:
// Policy interface
class IPolicy {
public:
virtual ~IPolicy() = default;
virtual IBackend* Next(IAlgorithm* algorithm, bool fastAttempt) = 0;
virtual void RegisterSuccess() = 0;
virtual void RegisterFail() = 0;
};
// Policy factory
class IPolicyFactory {
public:
virtual THolder<IPolicy> ConstructPolicy(const TStepParams& params) noexcept = 0;
virtual void FillFeatures(TPolicyFeatures& features) const noexcept = 0;
};
The core ideas of this design:
- Interface abstraction: Define policy behavior through
IPolicyinterface - Runtime polymorphism: Factory pattern creates concrete strategy objects at runtime
- Feature detection:
FillFeaturesallows the framework to query strategy features and optimize behavior
Trade-off Analysis
Advantages
- Open/Closed Principle: Adding new strategies doesn't require modifying framework code
- Decoupling: Strategy implementation is isolated from framework code
- Flexible configuration: Strategies can be switched at runtime
Costs
- Virtual function overhead: Every strategy call has vtable lookup
- Heap allocation: Factory pattern typically involves heap allocation
- Indirect jumps: Code readability decreases slightly
Additional Design Wisdom: Feature Query
The FillFeatures method in the original code is a clever design:
struct TPolicyFeatures {
bool WantsHash = false;
};
The framework can query strategy features to decide whether to pass hash values, avoiding unnecessary computation.
Rust Clean-Room Demonstration
Below is a clean-room demonstration in Rust, reconstructing the above design philosophy:
#![allow(dead_code)]
#![allow(unused_imports)]
use std::sync::Mutex;
// Policy trait - corresponds to C++ IPolicy
trait Policy: Send + Sync {
fn select_backend(&self, backends: &[Backend], params: &StepParams) -> Option<usize>;
fn on_success(&self);
fn on_failure(&self);
fn requires_hash(&self) -> bool {
false
}
}
// Simulated backend server
#[derive(Clone)]
struct Backend {
id: usize,
weight: usize,
}
// Round Robin policy
struct RoundRobinPolicy {
counter: Mutex<usize>,
}
impl RoundRobinPolicy {
fn new() -> Self {
Self { counter: Mutex::new(0) }
}
}
impl Policy for RoundRobinPolicy {
fn select_backend(&self, backends: &[Backend], _: &StepParams) -> Option<usize> {
if backends.is_empty() { return None; }
let mut counter = self.counter.lock().unwrap();
let idx = *counter % backends.len();
*counter += 1;
Some(idx)
}
fn on_success(&self) {}
fn on_failure(&self) {}
}
// Least Connections policy
struct LeastConnectionsPolicy {
connections: Mutex<Vec<usize>>,
}
impl LeastConnectionsPolicy {
fn new(backend_count: usize) -> Self {
Self { connections: Mutex::new(vec![0; backend_count]) }
}
}
impl Policy for LeastConnectionsPolicy {
fn select_backend(&self, backends: &[Backend], _: &StepParams) -> Option<usize> {
if backends.is_empty() { return None; }
let conns = self.connections.lock().unwrap();
conns.iter().enumerate().min_by_key(|(_, &c)| c).map(|(i, _)| i)
}
fn on_success(&self) {}
fn on_failure(&self) {}
fn requires_hash(&self) -> bool { true }
}
// Policy factory
struct PolicyFactory;
impl PolicyFactory {
fn create_policy(policy_type: &str, backend_count: usize) -> Box<dyn Policy> {
match policy_type {
"round_robin" => Box::new(RoundRobinPolicy::new()),
"least_connections" => Box::new(LeastConnectionsPolicy::new(backend_count)),
_ => Box::new(RoundRobinPolicy::new()),
}
}
}
fn main() {
let backends = vec![
Backend { id: 1, weight: 100 },
Backend { id: 2, weight: 100 },
];
let params = StepParams { attempts: 1, hash: None };
// Use round robin policy
let policy = PolicyFactory::create_policy("round_robin", backends.len());
if let Some(idx) = policy.select_backend(&backends, ¶ms) {
println!("Selected backend: {}", backends[idx].id);
}
}
Summary
This article provides an in-depth analysis of load balancing policy interface polymorphism design, exploring the following core trade-offs:
- Interface abstraction vs Hardcoding: Trade flexibility for some performance cost
- Runtime polymorphism vs Compile-time polymorphism: Rust's trait object provides an alternative
- Feature query: Allows framework to optimize behavior without strategy implementation awareness
This design pattern is very common in large-scale systems. Understanding the trade-offs behind it is crucial for designing scalable systems.