设计模式与负载均衡:策略模式选算法、代理模式转请求、观察者监控节点

负载均衡这东西,说白了就是「把活儿分给兄弟们干」。我做了十几年后端系统,几乎每个高并发项目都离不开它。但很多人只停留在配配 Nginx 或者用用 Ribbon 的层面,一旦要自己实现一套轻量级的负载均衡器,就抓瞎了。

今天咱们就用三个经典设计模式,把负载均衡的核心逻辑拆开揉碎。你想想看,一个负载均衡器无非就三件事:选谁干活、怎么转发、如何知道谁还活着。正好对应策略模式、代理模式和观察者模式。

核心思路:策略模式负责「选」、代理模式负责「转」、观察者模式负责「看」。三个模式各司其职,组合起来就是一个健壮的负载均衡框架。

1. 策略模式:负载算法怎么选?

我记得刚入行那会儿,公司有个老系统用的轮询算法。结果有一台机器配置高、一台配置低,轮询下去低配机经常被打满。后来我改成加权轮询,问题才解决。你看,算法选不对,系统就遭罪。

策略模式在这里的用法很自然:把「选择服务器」这个行为抽象成接口,每种算法一个实现类。调用方只需要传入策略对象,不用关心具体怎么选的。

// 策略接口
public interface LoadBalanceStrategy {
    Server select(List<Server> servers, String clientIp);
}

// 具体策略:轮询
public class RoundRobinStrategy implements LoadBalanceStrategy {
    private int index = 0;
    
    @Override
    public Server select(List<Server> servers, String clientIp) {
        if (servers == null || servers.isEmpty()) return null;
        index = (index + 1) % servers.size();
        return servers.get(index);
    }
}

// 具体策略:加权随机
public class WeightedRandomStrategy implements LoadBalanceStrategy {
    @Override
    public Server select(List<Server> servers, String clientIp) {
        int totalWeight = servers.stream().mapToInt(Server::getWeight).sum();
        int random = new Random().nextInt(totalWeight);
        int cumulative = 0;
        for (Server server : servers) {
            cumulative += server.getWeight();
            if (random < cumulative) return server;
        }
        return servers.get(0);
    }
}

// 具体策略:一致性哈希
public class ConsistentHashStrategy implements LoadBalanceStrategy {
    private final TreeMap<Integer, Server> hashRing = new TreeMap<>();
    private static final int VIRTUAL_NODES = 160;
    
    public ConsistentHashStrategy(List<Server> servers) {
        for (Server server : servers) {
            for (int i = 0; i < VIRTUAL_NODES; i++) {
                int hash = (server.getIp() + "#" + i).hashCode();
                hashRing.put(hash, server);
            }
        }
    }
    
    @Override
    public Server select(List<Server> servers, String clientIp) {
        int hash = clientIp.hashCode();
        Map.Entry<Integer, Server> entry = hashRing.ceilingEntry(hash);
        if (entry == null) entry = hashRing.firstEntry();
        return entry.getValue();
    }
}
我的经验:一致性哈希在缓存场景特别好用,能减少节点变动时的缓存失效。但虚拟节点数别设太少,我见过设 40 个导致分布不均的案例,后来改成 160 才稳定。

2. 代理模式:请求怎么转发?

代理模式在负载均衡里扮演的角色,就是「中间人」。客户端不直接访问后端服务器,而是通过代理对象转发。这样做的好处是:可以在转发前后做统一处理,比如记录日志、限流、熔断。

我个人习惯把代理模式分为静态代理和动态代理。静态代理就是手写一个转发类,动态代理可以用 Java 的 InvocationHandler 或者 C++ 的虚函数表拦截。不过在实际项目中,我更推荐用静态代理,因为逻辑清晰,出了问题好排查。

// 真实主题:后端服务
public interface BackendService {
    Response handleRequest(Request request);
}

public class RealBackendService implements BackendService {
    private final String serverId;
    
    public RealBackendService(String serverId) {
        this.serverId = serverId;
    }
    
    @Override
    public Response handleRequest(Request request) {
        // 实际处理业务逻辑
        return new Response(200, "Handled by " + serverId);
    }
}

// 代理类:负责转发请求
public class LoadBalanceProxy implements BackendService {
    private final List<Server> servers;
    private final LoadBalanceStrategy strategy;
    
    public LoadBalanceProxy(List<Server> servers, LoadBalanceStrategy strategy) {
        this.servers = servers;
        this.strategy = strategy;
    }
    
    @Override
    public Response handleRequest(Request request) {
        // 前置处理:记录请求开始时间
        long start = System.currentTimeMillis();
        
        // 策略模式选择目标服务器
        Server target = strategy.select(servers, request.getClientIp());
        if (target == null) {
            return new Response(503, "No available server");
        }
        
        // 转发请求到真实服务
        RealBackendService realService = new RealBackendService(target.getId());
        Response response = realService.handleRequest(request);
        
        // 后置处理:记录耗时
        long elapsed = System.currentTimeMillis() - start;
        System.out.println("Request to " + target.getId() + " took " + elapsed + "ms");
        
        return response;
    }
}
注意:代理模式不要滥用。我曾经在一个项目里嵌套了三层代理,结果排查问题时根本不知道请求到底被谁拦截了。保持代理层数不超过两层,这是血的教训。

3. 观察者模式:节点状态怎么监控?

负载均衡最怕什么?怕把请求转发给已经挂掉的节点。观察者模式正好解决这个问题:让每个服务器节点作为被观察者,负载均衡器作为观察者。节点状态变化时主动通知,负载均衡器及时更新可用列表。

我见过很多团队用定时轮询来检测节点健康,其实效率很低。观察者模式能做到「有变化才通知」,比轮询省资源得多。

// 观察者接口
public interface ServerObserver {
    void onServerStatusChanged(Server server, ServerStatus newStatus);
}

// 被观察者:服务器节点
public class Server {
    private String id;
    private String ip;
    private int port;
    private int weight;
    private ServerStatus status;
    private List<ServerObserver> observers = new ArrayList<>();
    
    public void addObserver(ServerObserver observer) {
        observers.add(observer);
    }
    
    public void removeObserver(ServerObserver observer) {
        observers.remove(observer);
    }
    
    public void setStatus(ServerStatus newStatus) {
        if (this.status != newStatus) {
            this.status = newStatus;
            // 状态变化时通知所有观察者
            for (ServerObserver observer : observers) {
                observer.onServerStatusChanged(this, newStatus);
            }
        }
    }
}

// 具体观察者:负载均衡器
public class LoadBalancer implements ServerObserver {
    private List<Server> availableServers = new CopyOnWriteArrayList<>();
    
    @Override
    public void onServerStatusChanged(Server server, ServerStatus newStatus) {
        if (newStatus == ServerStatus.UP) {
            if (!availableServers.contains(server)) {
                availableServers.add(server);
                System.out.println("Server " + server.getId() + " is UP, added to pool");
            }
        } else if (newStatus == ServerStatus.DOWN) {
            availableServers.remove(server);
            System.out.println("Server " + server.getId() + " is DOWN, removed from pool");
        }
    }
    
    public List<Server> getAvailableServers() {
        return availableServers;
    }
}

// 状态枚举
public enum ServerStatus {
    UP, DOWN, DEGRADED
}
避坑指南:我曾经在观察者通知里直接操作共享列表,结果并发环境下出现了 ConcurrentModificationException。后来改用 CopyOnWriteArrayList 或者加读写锁才解决。另外,通知顺序也很重要——先更新状态再通知,别搞反了。

4. 三者如何协同工作?

光讲单个模式没意思,咱们看看它们怎么配合。下面这张图展示了完整的请求处理流程:

负载均衡三大模式协同工作流程 客户端 代理模式 请求转发 / 前置后置处理 策略模式 选择负载算法 观察者模式 节点状态监控 服务器A 服务器B 服务器C 发送请求 调用策略选择 返回目标服务器 转发请求 状态变化通知 更新可用列表 图例: 请求流 策略调用 状态通知 更新列表

流程其实很简单:

  1. 客户端请求到达代理(负载均衡器)
  2. 代理调用策略模式选择目标服务器
  3. 代理将请求转发给选中的服务器
  4. 服务器状态变化时,通过观察者模式通知代理
  5. 代理更新可用服务器列表,下次选择时跳过不可用节点

5. 实战中的组合技巧

这三个模式在实际项目中不是孤立的。我分享几个组合使用的经验:

场景 模式组合 说明
灰度发布 策略 + 代理 策略模式根据用户ID或IP选择灰度版本,代理模式负责转发到对应集群
动态扩缩容 观察者 + 策略 观察者检测到新节点加入,通知策略模式重新计算权重或哈希环
熔断降级 观察者 + 代理 观察者发现节点响应变慢,代理模式自动降级到备用节点
总结一下:策略模式让算法可插拔,代理模式让转发可控制,观察者模式让状态可知晓。三个模式组合起来,就是一个灵活、健壮、可扩展的负载均衡系统。下次你再设计类似系统时,不妨试试这个组合拳。
公众号:蓝海资料掘金营,微信deep3321