4、负载均衡进阶:一致性哈希算法、会话保持策略、健康检查机制
各位同学,咱们接着聊负载均衡。上一章我们把基础轮询、加权轮询这些讲透了,但说实话,那些只是入门功夫。真正到了生产环境,你会发现光靠轮询根本扛不住。为什么?我举个例子你就明白了。
4.1 一致性哈希算法:不只是哈希
先说说哈希。普通的哈希算法,比如取模运算,你想想看——假设后端有4台服务器,请求的客户端IP取模4,结果0、1、2、3分别对应一台机器。这看起来很完美,对吧?
但问题来了:如果其中一台服务器挂了,或者你扩容到5台,那取模的基数就变了。原来落在机器0上的请求,现在可能落到机器1或机器2上。这意味着什么?缓存全部失效,数据库瞬间被打爆。我在项目中遇到过这种情况,扩容后数据库连接数直接飙到上限,差点酿成事故。
一致性哈希就是来解决这个问题的。它的核心思想是:把服务器和请求都映射到一个环上,然后顺时针查找最近的服务器。
一致性哈希的关键特性:
- 服务器增减时,只影响环上相邻的节点
- 大部分请求的映射关系保持不变
- 配合虚拟节点,可以解决数据倾斜问题
嗯,这里要注意虚拟节点的概念。我刚开始用一致性哈希时,发现有的服务器负载特别高,有的却闲着。后来才明白——物理节点太少,哈希环上的分布不均匀。解决办法就是给每个物理节点创建几十个虚拟节点,让它们在环上均匀散开。
看一段简单的伪代码实现:
// 一致性哈希环的简化实现
class ConsistentHash {
// 虚拟节点数量
private final int VIRTUAL_NODES = 150;
// 哈希环,key是哈希值,value是物理节点
private final TreeMap<Integer, String> ring = new TreeMap<>();
public void addNode(String node) {
for (int i = 0; i < VIRTUAL_NODES; i++) {
int hash = hash(node + "#" + i);
ring.put(hash, node);
}
}
public String getNode(String key) {
if (ring.isEmpty()) return null;
int hash = hash(key);
// 顺时针查找第一个大于等于hash的节点
Map.Entry<Integer, String> entry = ring.ceilingEntry(hash);
if (entry == null) {
// 如果没有,则取环的第一个节点
entry = ring.firstEntry();
}
return entry.getValue();
}
}
这段代码看着简单,但生产环境里坑不少。我曾经踩过一个坑——哈希函数的选择。用JDK自带的hashCode(),分布效果很差。后来改用MurmurHash,均匀度好了很多。你想想看,如果哈希碰撞太多,虚拟节点就白设了。
我的建议:一致性哈希适合缓存场景、分布式存储场景。但如果你的业务对数据一致性要求极高,比如金融交易,那还是老老实实用数据库分片吧。
4.2 会话保持策略:别让用户反复登录
聊完哈希,咱们说说会话保持。说白了,就是让同一个用户的请求,始终落到同一台后端服务器上。为什么需要这个?因为很多老系统,Session是存在本地内存里的。你第一次请求落到A机器,登录成功了;第二次请求被轮询到B机器,B机器说「你没登录啊」,用户就懵了。
常见的会话保持策略有这么几种:
| 策略 | 原理 | 优缺点 |
|---|---|---|
| 源地址哈希 | 根据客户端IP哈希,固定到一台服务器 | 简单,但NAT环境下会出问题 |
| Cookie插入 | 负载均衡给客户端种一个Cookie,记录后端服务器ID | 灵活,但客户端可能禁用Cookie |
| 会话复制 | Session在多个服务器之间同步 | 可靠,但网络开销大 |
| 集中式存储 | Session存到Redis等外部存储 | 扩展性好,但增加了一次网络调用 |
我个人习惯用Cookie插入的方式。为什么?因为它不需要修改应用代码,负载均衡器自己就能搞定。Nginx的配置大概是这样的:
upstream backend {
server 192.168.1.10:8080;
server 192.168.1.11:8080;
sticky cookie srv_id expires=1h domain=.example.com path=/;
}
但这里有个坑——如果后端服务器挂了,用户的Cookie还指向那台机器,请求就会失败。我曾经遇到过,某台服务器内存溢出挂了,但Cookie还没过期,那一批用户全都报500错误。后来我加了个fallback机制:如果指定的服务器不可用,就重新分配一台。
避坑指南:我曾经在电商大促时,因为会话保持策略没配好,导致用户加购的商品在跳转支付时丢失了。排查了半天,发现是负载均衡把支付请求转发到了另一台服务器,而购物车数据存在本地Session里。从那以后,我强制要求所有新项目必须用Redis存Session。
4.3 健康检查机制:别把请求发给死掉的机器
最后说说健康检查。这个听起来简单,但做扎实了不容易。健康检查的目的就一个——别把请求发给已经挂掉的机器。
健康检查分两种:
- 被动检查:转发请求时发现连接失败,就把这台机器标记为不可用。优点是实时,缺点是已经损失了一次请求。
- 主动检查:定期发送探测请求(比如HTTP GET /health),根据响应判断机器是否健康。优点是不会把请求发给坏机器,缺点是有延迟。
我建议两种都用。主动检查做基础保障,被动检查做快速响应。你看Nginx的配置:
upstream backend {
server 192.168.1.10:8080 max_fails=3 fail_timeout=30s;
server 192.168.1.11:8080 max_fails=3 fail_timeout=30s;
# 主动健康检查(需要nginx_upstream_check_module)
check interval=3000 rise=2 fall=5 timeout=1000 type=http;
check_http_send "GET /health HTTP/1.0\r\n\r\n";
check_http_expect_alive http_2xx http_3xx;
}
这里有个细节——健康检查的路径。我见过很多团队直接用根路径/做健康检查,结果静态资源返回200,但业务逻辑已经挂了。正确的做法是:单独写一个健康检查接口,不仅要检查应用是否存活,还要检查依赖的数据库、缓存是否正常。
健康检查接口的设计原则:
- 轻量级,不要做复杂计算
- 检查关键依赖(DB、Redis、MQ)
- 返回明确的HTTP状态码(200正常,503异常)
- 响应体包含详细的健康状态信息
嗯,这里还要注意检查频率。我见过有人把健康检查设成每秒钟一次,结果后端服务器被探测请求打满了。你想想看,100台服务器,每台每秒检查一次,那就是每秒100个请求,虽然不大,但积少成多。我一般设成3-5秒一次,配合被动检查,足够了。
还有一个容易被忽略的点——优雅下线。当你要升级服务器时,直接kill进程会导致正在处理的请求中断。正确的做法是:先通知负载均衡器这台机器要下线了,让它不再转发新请求,等现有请求处理完,再真正停止服务。这个机制在Kubernetes里叫Readiness Probe和PreStop Hook,在传统负载均衡里,需要手动操作或写脚本实现。
我的经验:健康检查不是越严格越好。有一次我把Redis连接检查加到了健康检查接口里,结果Redis主从切换时,所有服务器都返回503,负载均衡把全部机器摘掉了,整个服务直接瘫痪。后来我改成「降级检查」——Redis不可用时,接口返回200但附带警告信息,负载均衡只根据200/503做决策。
好了,这一章的内容就这些。一致性哈希解决了缓存失效的问题,会话保持让用户不用反复登录,健康检查保证了请求只发给健康的机器。这三者配合好了,你的负载均衡才算真正「进阶」了。