Spring Cloud 微服务实战:构建高可用的服务注册与 API 网关系统
1. 从单体到微服务:为什么我们需要服务注册与网关?
我记得几年前,我还在维护一个庞大的单体应用。每次上线新功能,哪怕只是改一行代码,都得把整个几百兆的“巨无霸”重新打包、部署、重启。测试团队更是苦不堪言,一个模块的改动,需要把整个应用的所有功能都回归一遍。最要命的是,有一次数据库连接池出了问题,直接导致整个系统瘫痪,所有业务线都停了。那时候我就想,有没有一种架构,能让我们的系统像乐高积木一样,每个模块独立开发、独立部署、独立伸缩,一个模块挂了不影响其他模块?
这就是微服务架构要解决的核心问题。简单来说,微服务就是把一个大型的单体应用,按照业务功能拆分成一系列小而自治的服务。每个服务都围绕一个具体的业务能力(比如用户管理、订单处理、库存查询)来构建,可以独立开发、测试、部署和扩展。它们之间通过轻量级的通信机制(通常是 HTTP/RESTful API 或消息队列)来协作。
听起来很美,对吧?但拆开之后,新的麻烦就来了。以前在单体应用里,服务A调用服务B,直接写个本地方法调用就行了。现在服务A和服务B可能部署在不同的机器、甚至不同的数据中心,它们怎么找到对方?这就是服务注册与发现要解决的问题。想象一下,你搬到一个新城市,你得去派出所登记你的住址(注册),你的朋友想找你,也得去查这个登记信息(发现)。在微服务世界里,这个“派出所”就是服务注册中心,比如 Spring Cloud 里的 Eureka。
另一个大问题是入口管理。以前只有一个单体应用,所有请求都打到同一个入口(比如 www.yourapp.com)。现在有几十个甚至上百个微服务,每个服务都有自己的 IP 和端口。难道要让客户端记住所有服务的地址吗?这显然不现实,而且涉及到安全、限流、监控等通用功能,难道要在每个服务里都写一遍?这时候,API 网关就登场了。它就像公司大楼的前台,所有外部请求都先到这里,由前台根据你要找的部门(具体服务)进行路由、验证身份、记录访客信息,然后再把你引导到正确的办公室。Spring Cloud 提供了 Spring Cloud Gateway 来扮演这个“智能前台”的角色。
所以,构建一个高可用的微服务系统,服务注册与发现和API网关是两个最基础、最核心的基石。前者解决了服务间“如何找到彼此”的问题,后者解决了外部“如何统一、安全地访问内部服务”的问题。接下来,我们就用 Spring Cloud 这套“全家桶”,手把手搭建一个既稳固又灵活的系统。
2. 搭建高可用的服务注册中心:Eureka 集群实战
单点的服务注册中心是微服务架构里最可怕的“单点故障”。如果唯一的 Eureka Server 宕机了,那么所有服务实例的注册信息都将无法更新,新的服务无法被发现,整个系统的服务发现机制就瘫痪了。因此,生产环境必须部署 Eureka 集群。
Eureka 的高可用设计非常巧妙,它采用了“互相注册、互相复制”的 Peer-to-Peer 对等架构。简单说,就是让多个 Eureka Server 实例互相把自己当成客户端,注册到对方那里去。这样,任何一个实例上注册的服务信息,都会被同步到集群中的其他实例。
2.1 构建双节点 Eureka Server 集群
我们来搭建一个最简单的双节点集群。假设我们有两台机器(或者用两个端口模拟),eureka-server-1 和 eureka-server-2。
首先,创建 Eureka Server 项目,pom.xml 依赖和之前一样:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
</dependency>
关键就在于配置文件。我们需要为两个实例准备不同的配置文件,比如 application-peer1.yml 和 application-peer2.yml。
application-peer1.yml 配置:
server:
port: 8761 # 第一个实例的端口
spring:
application:
name: eureka-server # 服务名一致
eureka:
instance:
hostname: peer1 # 实例的主机名,可以是机器名或IP
client:
# 作为客户端,向另一个对等节点注册自己
service-url:
defaultZone: http://peer2:8762/eureka/
# 因为自己也是Server,所以也需要获取注册表(从对等节点)
fetch-registry: true
register-with-eureka: true # 关键!集群模式下需要相互注册
server:
enable-self-preservation: true # 生产环境建议开启自我保护
application-peer2.yml 配置:
server:
port: 8762 # 第二个实例的端口
spring:
application:
name: eureka-server
eureka:
instance:
hostname: peer2
client:
service-url:
defaultZone: http://peer1:8761/eureka/ # 指向对等节点1
fetch-registry: true
register-with-eureka: true
server:
enable-self-preservation: true
注意看,peer1 的 defaultZone 指向 peer2:8762,而 peer2 的 defaultZone 指向 peer1:8761。这样就形成了一个互相注册的环。为了让本地测试能解析 peer1 和 peer2 主机名,你需要在你的 hosts 文件(C:\Windows\System32\drivers\etc\hosts 或 /etc/hosts)里添加两行:
127.0.0.1 peer1
127.0.0.1 peer2
启动类加上 @EnableEurekaServer 注解不变。然后我们分别用不同的配置文件启动两个实例。在 IDEA 里,你可以复制一个启动配置,在 Program arguments 里分别指定 --spring.profiles.active=peer1 和 --spring.profiles.active=peer2。
启动后,分别访问 http://peer1:8761 和 http://peer2:8762。你会看到两个 Eureka 的控制台。在 DS Replicas(副本)部分,你会看到对方节点的地址。在 Instances currently registered with Eureka 列表里,你会看到除了自己这个 EUREKA-SERVER 实例,还有来自对等节点的实例。这就说明集群搭建成功了!
2.2 微服务客户端如何连接集群
对于像订单服务这样的客户端,配置就简单了。你不需要列出所有 Eureka Server 节点,只需要把 defaultZone 指向集群中的所有节点(用逗号分隔)即可。Eureka Client 会自动尝试连接这些节点,只要连上一个,就能获取完整的注册表信息。
# order-service 的 application.yml
spring:
application:
name: order-service
eureka:
client:
service-url:
defaultZone: http://peer1:8761/eureka/,http://peer2:8762/eureka/
这样配置后,即使 peer1 宕机了,订单服务在启动或心跳时,依然可以连接到 peer2 进行注册和获取服务列表,保证了高可用性。我实际项目中通常会把至少三个 Eureka 节点部署在不同的物理机或可用区,defaultZone 里就把所有节点地址都写上,这样可靠性就非常高了。
3. 构建智能路由与过滤器:Spring Cloud Gateway 深度配置
Spring Cloud Gateway 是 Spring 官方推出的第二代网关,基于 Reactor 响应式编程模型,性能比第一代的 Zuul 1.x 要好很多。它核心就三个概念:路由(Route)、断言(Predicate) 和过滤器(Filter)。你可以把路由想象成一张快递配送单,断言就是判断这个包裹是不是发往某个区域的(比如上海),过滤器就是对包裹进行加工(比如贴个“易碎品”标签,或者拆开检查一下)。
3.1 动态路由与服务发现集成
最常用的路由配置就是根据请求路径,转发到相应的微服务。并且,我们希望网关能自动从 Eureka 发现服务,而不是写死服务的 IP 和端口。这需要引入 spring-cloud-starter-gateway 和 spring-cloud-starter-netflix-eureka-client 依赖。
一个典型的动态路由配置如下:
spring:
cloud:
gateway:
discovery:
locator:
enabled: true # 开启从服务发现组件(如Eureka)自动创建路由的功能
lower-case-service-id: true # 服务ID使用小写(Eureka默认是大写)
routes:
- id: order-service-route # 路由ID,唯一即可
uri: lb://ORDER-SERVICE # lb:// 表示启用负载均衡,后面是Eureka中的服务名
predicates:
- Path=/api/order/** # 断言:匹配 /api/order/ 开头的所有请求
filters:
- StripPrefix=1 # 过滤器:去掉路径中的第一段(/api),再转发给后端服务
- AddRequestHeader=X-Request-From, gateway # 添加一个请求头
这个配置做了几件事:
uri: lb://ORDER-SERVICE:告诉网关,这个路由的目标是 Eureka 里名为ORDER-SERVICE的服务,并使用负载均衡。predicates: - Path=/api/order/**:只有请求路径以/api/order/开头的,才会走这条路由。filters: - StripPrefix=1:这是一个非常实用的过滤器。假设客户端请求的是/api/order/list,网关在转发给order-service时,会去掉第一段路径(/api),变成/order/list。这样后端服务就不需要感知网关添加的前缀。filters: - AddRequestHeader=...:在转发前,给请求添加一个自定义头,后端服务可以通过这个头知道请求来自网关。
启动网关和订单服务后,你访问 http://网关地址:8080/api/order/list,网关就会自动将请求负载均衡地转发到 ORDER-SERVICE 的某个实例上,并且请求路径变成了 /order/list。
3.2 常用断言与过滤器实战
除了 Path,Gateway 还提供了很多强大的断言,让你能更精细地控制路由规则:
After/Before/Between:基于时间路由。比如只在工作日早9点到晚6点开放某个接口。predicates: - Between=2024-01-01T00:00:00.000+08:00, 2024-12-31T23:59:59.999+08:00Cookie/Header:基于请求的 Cookie 或 Header 路由。比如只有携带特定 Token 的请求才能访问内部管理接口。predicates: - Header=X-Request-Id, \d+ # 要求请求头 X-Request-Id 的值是数字Method:基于 HTTP 方法路由。比如只允许 GET 请求访问查询接口。predicates: - Method=GET,POSTQuery:基于请求参数路由。predicates: - Query=source, app # 要求请求必须包含 source=app 的参数
过滤器链是 Gateway 的精华所在,可以在请求转发前后执行各种操作:
AddRequestParameter:添加请求参数。PrefixPath:与StripPrefix相反,为路径添加前缀。Retry:失败重试。这是提高系统韧性的重要手段。filters: - name: Retry args: retries: 3 # 重试次数 statuses: BAD_GATEWAY, SERVICE_UNAVAILABLE # 针对哪些HTTP状态码重试 methods: GET # 只对GET方法重试RequestRateLimiter:请求限流。结合 Redis 使用,防止某个接口被刷。- 自定义过滤器:你可以实现
GlobalFilter或GatewayFilter接口,实现鉴权、日志记录、请求耗时统计等通用逻辑。我经常写一个全局过滤器,把所有进入网关的请求和响应日志记录下来,并给每个请求生成一个唯一的traceId放入 Header,方便后续全链路追踪。
4. 保障系统韧性:客户端负载均衡与熔断降级
服务注册和网关解决了“找路”和“门卫”的问题,但微服务之间的调用依然可能出问题。比如,订单服务调用用户服务,用户服务响应特别慢或者直接挂掉了,如果订单服务傻傻地一直等,它的线程池很快就会被占满,导致订单服务自己也瘫痪。这就是所谓的“雪崩效应”。我们需要两件武器来应对:负载均衡和熔断降级。
4.1 Spring Cloud LoadBalancer:更现代的负载均衡器
早期 Spring Cloud 使用 Netflix Ribbon 做客户端负载均衡,但现在 Ribbon 已进入维护模式。Spring Cloud 官方推荐使用 Spring Cloud LoadBalancer。对于 Spring Cloud Gateway 和 OpenFeign,它已经默认集成。
当你使用 lb://SERVICE-ID 这种格式的 URI 时,Gateway 底层就是通过 LoadBalancer 来选择一个健康的服务实例。它的默认策略是轮询(Round Robin)。你完全可以自定义负载均衡规则。比如,我想实现一个“同机房优先”的规则:
- 首先,给每个服务实例打上标签(比如
zone: zone1),可以通过 Eureka 的元数据(metadata)来设置。# 在用户服务的配置中 eureka: instance: metadata-map: zone: zone1 - 然后,自定义一个
ServiceInstanceListSupplierBean,让它优先返回同 zone 的实例列表。 - 最后,在网关或 Feign 客户端配置中使用这个自定义的 Supplier。
虽然配置稍复杂,但在跨机房部署时,这种策略能显著降低调用延迟,提升稳定性。
4.2 Resilience4j:强大的熔断、限流与重试库
熔断器的概念很像家里的电闸。当电路短路(服务异常)时,电闸(熔断器)会立刻跳闸(打开),切断电路,保护电器(上游服务)。过一段时间,它会半开,尝试放一个请求过去,如果成功了,就闭合闸门,恢复正常;如果还是失败,就继续保持打开状态。Spring Cloud 2020 版本后,默认的熔断器实现从 Hystrix 换成了 Resilience4j,它更轻量、更模块化。
在 Spring Cloud Gateway 中集成熔断: Gateway 可以很方便地对路由配置熔断,保护后端服务。
spring:
cloud:
gateway:
routes:
- id: user-service
uri: lb://USER-SERVICE
predicates:
- Path=/api/user/**
filters:
- name: CircuitBreaker # 配置熔断过滤器
args:
name: userServiceCB # 熔断器名称
fallbackUri: forward:/fallback/user # 降级后转发的URI
statusCodes: # 触发熔断的HTTP状态码
- 500
- 502
- 503
- 504
同时,你需要在网关应用里定义一个 Controller 来处理 fallbackUri:
@RestController
public class FallbackController {
@GetMapping("/fallback/user")
public ResponseEntity<String> userServiceFallback() {
return ResponseEntity.status(503)
.body("用户服务暂时不可用,请稍后再试。");
}
}
这样,当 USER-SERVICE 连续失败达到阈值后,熔断器打开,后续请求在短时间内会直接由网关返回友好的降级信息,而不会把压力传递到已经出问题的用户服务。
在微服务内部使用 Resilience4j:
对于服务间的调用(比如用 RestTemplate 或 OpenFeign),你可以在调用方集成 Resilience4j。首先添加依赖:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-circuitbreaker-resilience4j</artifactId>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot2</artifactId>
</dependency>
然后,你可以使用注解式或编程式的方式来使用熔断、限流、舱壁隔离等功能。注解式非常简洁:
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
@Service
public class OrderService {
@Autowired
private RestTemplate restTemplate;
@CircuitBreaker(name = "userService", fallbackMethod = "getUserFallback")
public UserDTO getUserById(Long userId) {
// 调用用户服务
return restTemplate.getForObject("http://USER-SERVICE/users/" + userId, UserDTO.class);
}
// 降级方法,签名必须与原方法一致,最后多一个 Throwable 参数
private UserDTO getUserFallback(Long userId, Throwable t) {
log.warn("调用用户服务失败, userId: {}, 启用降级数据", userId, t);
// 返回一个默认用户,或者缓存中的数据
return new UserDTO(userId, "默认用户");
}
}
通过 @CircuitBreaker 注解,当对 userService 的调用失败率超过配置阈值时,熔断器会打开,直接执行 getUserFallback 方法,返回一个可接受的默认值,保证了订单服务的主流程不被一个非核心的依赖拖垮。你还可以在 application.yml 里详细配置这个熔断器的参数,比如失败率阈值、熔断打开后的等待时间、半开状态下的请求数等,非常灵活。在实际项目中,合理配置这些参数,是保障系统高可用的关键。
更多推荐
所有评论(0)