一、先把负载均衡这层窗户纸捅破
不管你是刚接触微服务,还是已经维护过不少接口,应该都遇到过这样的怪事:一个服务明明起了三个实例,结果流量基本都打在了其中一台上,另外两台像是来凑数的。今天咱们把客户端负载均衡和服务端负载均衡放在一起聊聊,看看它们各自适合干什么,再通过一个真实出现过的配置问题,把请求倾斜的原因和解决办法理清楚。
负载均衡这个词听起来很专业,其实可以想象成一个餐厅门口的排号员。餐厅里有五张桌子,客人来了,排号员按照顺序把人领到不同的桌子,避免某一桌忙不过来,其他桌却闲着。在微服务场景里,“客人”就是一个个请求,“桌子”就是同一个服务的多个实例,而排号员就是负载均衡器。它需要回答的核心问题只有一个:这次请求到底发给谁。
那客户端负载均衡和服务端负载均衡又有什么区别呢?关键看“排号员”站在哪里。
1.1 客户端负载均衡是什么
客户端负载均衡,站在“调用方”这一侧。比如订单服务要调用库存服务,订单服务先到注册中心拉一份库存服务的实例名单,名单上有三个地址。然后订单服务自己决定这次发到哪个地址,不需要额外请求某个中间组件。整个过程像是你手里有一张订餐电话表,想打给哪家店直接拨号。
这样做的好处是少一个中转环节,请求路径短,延迟自然会低一些。坏处是每个调用方都得自己维护这份名单和分配规则。如果订单服务写错了,它只会坑自己,不会影响别人,但也正因为这样,问题容易被各个服务“各藏一份”,排查起来很费劲。
1.2 服务端负载均衡是什么
服务端负载均衡,一般站在“被调用方”的前面。最常见的形式是独立网关或者专门的负载均衡器。所有请求先到达这个中间层,由它决定把请求转发给后面的哪一台机器。调用方根本不知道后面有几台机器,反正把请求交给中间层就行了。
这种模式对调用方特别友好,因为调用方只需要知道网关地址。而且统一在入口做负载均衡,后续想加鉴权、限流、灰度发布都比较方便。但代价也很明显:多了一个网络跳转,中间层本身容易成为瓶颈。如果中间层挂了,后面所有服务都会跟着“失联”。
二、两种负载均衡分别在什么场景更合适
微服务架构里很少有“绝对最好”的方案,更多是看场景。选错了场景,即使代码没有报错,也会在流量变化时露出马脚。
2.1 客户端负载均衡适合的场景
如果你的服务已经接入了注册中心,每个服务都能轻松拿到其他服务的实例列表,那么服务之间的内部调用用客户端负载均衡会比较顺手。特别是那些调用频率非常高的接口,省掉一次中转网络请求,对整体性能是有帮助的。
比如订单服务在创建订单时需要校验库存。库存服务部署了三个实例,订单服务用 Spring Cloud 的负载均衡组件,直接基于服务名从注册中心获取三个地址,然后自己挑一个发起调用。这个链路干净利落,不会因为多一跳增加额外耗时。
2.2 服务端负载均衡适合的场景
对外入口的流量,比如手机 App 的后端接口、网页端请求,最适合用服务端负载均衡。因为这类流量来源复杂,需要统一入口做权限校验、限流、协议转换,如果让每个业务服务自己处理就太散了。
另外,很多老系统没有能力直接对接注册中心,也适合在前面挂一个服务端负载均衡器,把请求按照权重、IP 哈希等方式分到后面几台老机器上。这样老系统不用改代码,也能享受多实例带来的高可用。
2.3 两者优缺点对照
用大白话总结一下:
客户端负载均衡:灵活、低延迟,适合频繁的内部调用。缺点是规则分散,各个服务可能使用不同的策略,出问题时很难一口咬定是哪一层出了问题。
服务端负载均衡:集中、好管理,适合做统一入口。缺点是网络链路长,网关需要额外做高可用,不然整个系统都会因为一个入口挂掉而崩溃。
还有一点很重要,这两者不是二选一。很多大型微服务系统是“两层都用”的。最外层用服务端负载均衡把流量分到几台网关实例,网关再通过服务发现把请求转发到下游服务。这时候,网关相对于下游服务又是一个“客户端”,所以它也会执行客户端负载均衡的逻辑。理解这一点,对后面的排查很有帮助。
三、导致请求倾斜的配置误区有哪些
请求倾斜是什么意思?简单说就是,明明有三台实例可以提供同样的服务,但实际流量却集中到了其中一台或者两台,其他实例的空闲率很高。这通常不是负载均衡策略不够高级,而是配置在某个不起眼的地方把负载均衡“短路”了。
3.1 误区一:网关路由把地址写死成固定 IP
Spring Cloud Gateway 是微服务里常用的网关组件。配置路由时,很多人会把地址写成 http://某台具体机器的IP和端口,而不是 lb://服务名。这样写,流量自然死死地钻进那台机器。
举个例子。下面是错误配置:
# 技术栈:Java + Spring Boot 2.7 + Spring Cloud 2021.0.5
spring:
cloud:
gateway:
routes:
- id: order-route
# 错误:把目标固定到了某一台订单服务实例
uri: http://192.168.1.31:8080
predicates:
- Path=/api/order/**
正确的写法是让网关从注册中心动态拿实例:
# 技术栈:Java + Spring Boot 2.7 + Spring Cloud 2021.0.5
spring:
cloud:
gateway:
routes:
- id: order-route
# 正确:以 lb:// 开头,网关会自己挑一个健康的实例
uri: lb://order-service
predicates:
- Path=/api/order/**
filters:
- StripPrefix=1
那个 lb:// 是负载均衡的意思。没有它,网关就不知道去注册中心找实例,只能傻乎乎地打固定地址。
3.2 误区二:服务间调用把服务名写成了 IP
服务间调用比网关问题隐藏得更深。有些开发者在调试阶段图省事,直接把服务提供方的 IP 写在代码里。联调完忘记改回来,结果一发上线,所有请求都打到那台机器上。
例如:
// 技术栈:Java + Spring Boot 2.7 + Spring Cloud 2021.0.5
// 错误写法:绕过了客户端负载均衡
String url = "http://192.168.1.72:8080/inventory/";
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
正确做法是使用服务名,并配合加了注解的 RestTemplate:
// 技术栈:Java + Spring Boot 2.7 + Spring Cloud 2021.0.5
// 正确写法:使用服务名,实例选择交给负载均衡器
String url = "http://inventory-service/inventory/";
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
但这里有个前提:RestTemplate 必须是经过特殊处理的。Spring Cloud 通过 @LoadBalanced 注解来标记这个 RestTemplate,让它遇到服务名时能自动去注册中心解析。
// 技术栈:Java + Spring Boot 2.7 + Spring Cloud 2021.0.5
// 这个配置类专门创建 RestTemplate
@Configuration
public class RestTemplateConfig {
/**
* 加上 @LoadBalanced 之后,
* RestTemplate 才能把服务名解析成具体实例地址。
*/
@Bean
@LoadBalanced
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
如果漏掉这个注解,直接写服务名会报错,问题很容易暴露。怕就怕有人图省事写 IP,或者用了别的方式绕过了负载均衡,导致流量倾斜得无声无息。
3.3 误区三:健康检查配置把实例都“查死”了
Spring Cloud LoadBalancer 提供了健康检查功能,可以在挑选实例之前过滤掉不健康的实例。但配置不当,也会让请求倾斜。
比如下面这段配置,健康检查请求会打到一个根本不存在的路径上:
# 技术栈:Java + Spring Boot 2.7 + Spring Cloud 2021.0.5
spring:
cloud:
loadbalancer:
configurations: health-check
health-check:
path: /inventory/healthcheck
interval: 5s
如果库存服务根本没有 /inventory/healthcheck 这个接口,三个实例都会被判定为不健康。某些版本的负载均衡器遇到这种情况,会退化成只挑其中一个实例发送请求,于是压力全部集中到那一个“幸存者”身上。注册中心看所有实例都是正常的,代码也编译通过,但流量就是歪得厉害。
正确做法是使用真正存在的健康检查接口。比如引入了 Spring Boot Actuator 后,可以直接检查 /actuator/health:
# 技术栈:Java + Spring Boot 2.7 + Spring Cloud 2021.0.5
spring:
cloud:
loadbalancer:
configurations: health-check
health-check:
path: /actuator/health
interval: 5s
这样,不健康的实例才会被真正过滤掉,剩下的健康实例轮流接客。
四、一次请求倾斜问题的深度排查
理论说了很多,下面用一个简化的电商场景走一遍排查过程。
这个场景里有三个环节:外部请求先到 Spring Cloud Gateway 网关,网关把请求分发给订单服务的两个实例,订单服务再通过 RestTemplate 调用库存服务的三个实例。短时间内监控显示,订单服务实例 2 的流量高达 95%,库存服务实例 3 的流量也非常高,其他实例几乎没干活。
4.1 观察现象
大促刚开始,监控系统持续报警。打开服务流量面板,订单服务两个实例的请求量对比非常悬殊,实例 2 快被打满了,实例 1 却没什么波动。再点进库存服务看,三个实例的流量比例大约是一边倒。
这种“一边倒”的流量曲线,通常不是用户行为导致的,而是底层调用关系出了问题。
4.2 排查网关到订单服务的链路
先看入口。登录网关所在服务器,查看网关配置。为了快速验证,可以在网关本机连续发起几次请求:
# 连续请求网关接口10次,观察订单服务两个实例收到的日志
for ((i=1;i<=10;i++)); do
curl -s http://localhost:8080/api/order/info
done
请求发完之后,去订单服务实例 1 和实例 2 的日志里分别搜索请求标记。结果所有标记都出现在实例 2 上。然后查看网关配置,发现了问题:
# 技术栈:Java + Spring Boot 2.7 + Spring Cloud 2021.0.5
spring:
cloud:
gateway:
routes:
- id: order-route
# 这里写成了固定地址,没有走负载均衡
uri: http://192.168.1.31:8080
predicates:
- Path=/api/order/**
问题很明显:网关把流量全部路由到了 192.168.1.31,也就是订单服务实例 2。实例 1 没有收到任何来自网关注入的流量。
4.3 排查订单服务到库存服务的链路
网关问题解决了,但订单服务内部调用库存服务的流量也可能有问题。打开订单服务的代码,发现调用库存服务时,URL 直接写死了 IP:
// 技术栈:Java + Spring Boot 2.7 + Spring Cloud 2021.0.5
// 这里写死了库存服务实例3的地址
String url = "http://192.168.1.72:8080/inventory/";
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
库存服务实例 3 的 IP 是 192.168.1.72,所以所有订单服务发出去的库存请求都砸到了这一台。两个问题叠加在一起,流量自然严重倾斜。
4.4 修复与验证
修复网关配置,把固定地址改成 lb://order-service:
# 技术栈:Java + Spring Boot 2.7 + Spring Cloud 2021.0.5
spring:
cloud:
gateway:
routes:
- id: order-route
uri: lb://order-service
predicates:
- Path=/api/order/**
filters:
- StripPrefix=1
修复订单服务的调用代码,改成服务名:
// 技术栈:Java + Spring Boot 2.7 + Spring Cloud 2021.0.5
// 使用服务名,由负载均衡器选择具体实例
String url = "http://inventory-service/inventory/";
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
同时确认 RestTemplate 已经加了 @LoadBalanced 注解。两步都改完之后,再重复调用接口,流量很快就分散到了不同实例上。
4.5 一次健康检查引发的“隐性倾斜”
后来又遇到过一种情况,网关和服务名都写对了,流量还是歪。查了一圈,发现是健康检查配置惹的祸。
配置里健康检查路径写成了 /inventory/healthcheck,但库存服务根本没有这个接口。负载均衡器每次检查都失败,只好把请求丢给最后一个“看起来还算能用”的实例,结果所有压力都堆到了这个实例上。把路径改成 /actuator/health,并且确认 Actuator 依赖已经添加后,问题就消失了。
这种问题特别迷惑人,因为服务实例都活着,注册中心也显示健康,但客户端负载均衡器内部的实例列表已经“名存实亡”。
五、注意事项和最佳实践
请求倾斜的坑很多,但大部分都可以通过规范和监控来避开。
5.1 配置检查清单
每次上线前,花几分钟核对三件事:第一,网关里所有路由地址是不是都用的 lb:// 开头;第二,服务间调用是不是都写了服务名,而不是 IP;第三,健康检查路径是不是与真实服务接口一致。这三项没问题,就已经避开了大部分低级的坑。
5.2 监控要看到实例维度
不要只看服务整体的平均指标,要把 QPS、耗时、错误率按照实例维度拆分。这样才能在流量开始倾斜的时候立刻发现,而不是等到某一台机器被拖垮才反应。
5.3 不要过度设计
如果某个服务只部署了一个实例,再配置复杂的负载均衡策略纯属浪费时间。负载均衡是为多实例服务的,实例数量少,简单方案就够用。等到实例多了,再逐步引入更复杂的策略。
5.4 注意负载均衡策略和注册中心的一致性
有些注册中心支持权重配置,但客户端负载均衡组件不一定会读取这个权重。如果两边理解不一致,就会出现“你以为会按照 2 比 1 分发,实际却平均发放”的情况。配置之前,最好先确认负载均衡器用的是哪套权重体系。
六、文章总结
客户端负载均衡和服务端负载均衡在微服务里各有擅长。服务端适合做对外统一入口,客户端适合做内部高频调用。两者可以共存,但配置都要谨慎。
请求倾斜不是玄学,它通常来自一些看起来很不起眼的配置,比如网关写了固定 IP、服务调用写了具体地址、健康检查路径错误。排查时先看入口,再看链路,最后检查负载均衡本身的配置,一步一步缩小范围。同时把实例维度的监控做好,让问题在爆发前就露出苗头,而不是等到用户开始抱怨才去救火。
配置负载均衡看起来不难,但每一个细节都可能影响最终效果。希望这次聊的这些坑,能帮你在下次遇到请求倾斜的时候少走几条弯路。
评论
围绕“客户端负载均衡与服务器端负载均衡在微服务中的适用场景差异及配置误区导致请求倾斜问题的深度排查与解决方案”参与讨论