负载均衡:不只是平均分配

20 阅读 2283 字 · 约 8 分钟

三台服务器跑同一个服务,请求来了给谁?

轮询。一人一个,公平。这个答案对,但只对了一小部分。负载均衡要回答的问题比"怎么分"多得多:怎么知道谁忙谁闲?某个节点挂了怎么办?新加的节点怎么接流量?会话状态怎么处理?

几种分法

轮询(Round Robin)。 1号请求给A,2号给B,3号给C,4号回A。Nginx 的默认策略。简单,不需要知道节点的状态。简单也是它的局限:所有节点被当作一样的,不管有的机器4核8G、有的8核32G,不管有的满负荷了、有的还在空转。

加权轮询。 给每个节点一个权重,权重大的多分。4核机器权重1,8核机器权重2。比纯轮询好一步,但权重是静态配的。一台机器白天正常、晚上跑批处理,权重该怎么定?静态权重跟不上动态变化。

最少连接(Least Connections)。 把请求发给当前连接数最少的节点。Nginx 的 least_conn 指令。比轮询聪明了一步:开始考虑节点的实时负载。但"连接数少"不代表"最闲"。一个节点连接少,可能因为它处理慢,每个请求占的时间长,连接积不起来。

一致性哈希(Consistent Hashing)。 前三种分法适合无状态服务。有些场景需要同一个用户的请求落到同一个节点。比如缓存:用户A的数据缓存在节点B上,下次请求落到节点C,缓存没命中,又得去数据库取。一致性哈希把节点和请求 key 都映射到哈希环上,顺时针找最近的节点。加节点时只影响相邻的一段,不全量洗牌。Memcached 和 Redis Cluster 都用一致性哈希。Kafka 的 partition 分配也用了类似思路。

有状态怎么办

无状态服务,请求打到哪个节点都一样。有状态服务不行。用户的购物车在节点A上,请求落到节点B,B看不到购物车。

会话亲和(Session Affinity)。 让同一个用户的请求落同一个节点。Nginx 的 ip_hash 按客户端 IP 哈希,同一个 IP 落同一台。简单,但 IP 变了(手机切 WiFi、运营商换出口)就失效。某个节点挂了,粘在上面的用户全受影响。

集中存储。 会话不放在节点本地,放 Redis 或数据库里。任何节点都能读。解决了亲和性问题,多了一次网络 IO。Redis 挂了,所有人掉线。

无状态化。 把状态从服务里挪出去,服务本身不存会话信息。每次请求带完整上下文(JWT 是典型做法)。任何节点都能处理任何请求。代价是请求变大了,每次都要传完整状态。

实际系统里三种方式混着用。核心链路无状态化,非核心用会话亲和,缓存用一致性哈希。

健康检查

负载均衡器要知道哪些节点活着。不知道,就把请求发给死节点,用户看到超时。

被动检查。 不主动探测,靠正常请求的结果判断。连续几次请求超时或报错,标记为不可用。Nginx 的被动健康检查。好处是不增加额外流量。坏处是发现慢,要等真实请求去撞,前几个请求白白超时。

主动检查。 定期发探测请求,不等真实请求。HTTP 健康检查端点 /health,TCP 探活。发现快,多了一份探测流量。探测间隔太短浪费资源,太长发现晚了。

两种方式一起用。主动检查负责快速发现故障,被动检查负责实时感知服务质量下降。

加节点和摘节点

集群是动态的。加机器、下机器、机器挂了,负载均衡器要及时感知。

加节点:新节点上线,注册到负载均衡器。新节点可能还没 ready,JVM 没预热、缓存没填满,灌全量流量会拖慢响应。好的做法是先给小流量,逐步加码。Nginx Plus 的 slow start、Kubernetes 的 readiness probe 都在做这件事。

摘节点:节点要下线,先从负载均衡器摘掉,等存量请求处理完再关。kill 掉的话,存量请求中断,用户看到错误。Kubernetes 的 preStop hook 和 graceful shutdown 配合,给应用一个"先摘流量、等请求跑完、再退出"的窗口。

窗口期的长短是个问题。长连接(WebSocket、gRPC stream)可能撑很久。等,部署时间拉长。不等,长连接被强切。具体多久,看连接类型和业务容忍度。

反面案例:均衡了但没均匀

某系统三台应用服务器,Nginx 轮询。流量看着分得均匀,每台 QPS 差不多。其中一台 CPU 经常飙到 90%,另外两台只有 40%。

轮询分的是请求数量,不是请求开销。三台机器各分到 1000 QPS,但其中一台接到的请求里有大量复杂查询(商品详情页带多表关联),另外两台接到的多是简单查询(静态页)。请求数量均匀了,工作量没均匀。

换成最少连接策略后,CPU 利用率拉平了一些,没根治。"连接数"只是负载的一个维度,CPU 密集型和 IO 密集型的请求,连接数一样,消耗差几倍。

最终做法:在应用层把请求按类型分类,重请求和轻请求分到不同的 upstream pool。Nginx 层做粗粒度分流,应用层做细粒度调度。负载均衡不是一个层能搞定的事。

和熔断的配合

负载均衡器知道哪些节点健康,熔断器知道哪些服务在报错。两个信息源不总是一致。

一个节点没挂,健康检查通过,但它的下游数据库慢了。请求发到这个节点,节点等数据库超时,返回错误。负载均衡器看到节点活着,继续发请求。熔断器看到失败率升高,触发熔断。

负载均衡器管"节点在不在",熔断器管"服务好不好"。在,不代表好。熔断打开时,即使节点健康检查通过,请求也不该发过去。

小结

负载均衡分请求,听起来像切蛋糕一人一份。实际要处理的维度远多于"怎么分":节点能力不同要加权,实时负载不同要动态选,有状态要保亲和,缓存要一致性哈希,节点上下线要平滑过渡,健康检查要主动加被动,还得和熔断配合。选什么策略,看服务有没有状态、节点是否同质、对故障发现的实时性要求多高。多层配合比单层完美更靠谱。