SpringCloud 2024 + JDK 17 实战:构建面向未来的微服务架构核心
最近在重构一个老旧的单体应用,团队决定采用最新的技术栈来搭建一套全新的微服务系统。我们选择了 Spring Cloud 2024 和 JDK 17 的组合,这不仅仅是追逐版本号的新鲜感,更是因为这套组合在性能、安全性和开发体验上带来的实质性提升。如果你也正在规划或已经开始一个微服务项目,希望这篇从零开始的实战指南能帮你避开我们踩过的那些坑,特别是新版兼容性、组件选型和配置优化上的问题。
微服务架构的核心在于将复杂的单体应用拆分为一组小型、自治的服务,每个服务围绕特定的业务能力构建,并能够独立开发、部署和扩展。Spring Cloud 提供了一套完整的工具集,让开发者能够快速实现这些分布式系统中的常见模式。但面对 Spring Cloud 庞大的生态和快速迭代的版本,如何选择并正确集成核心组件,往往成为项目启动的第一个挑战。本文将聚焦于 服务注册与发现(Eureka) 、API网关(Gateway) 和 分布式缓存(Redis) 这三个最核心的模块,手把手带你搭建一个可运行、可扩展的微服务基础骨架。
1. 环境准备与项目骨架搭建
在动手写代码之前,确保你的开发环境已经就绪。我们选择 JDK 17 作为运行环境,这是目前 LTS(长期支持)版本中性能与特性平衡得较好的选择。Spring Boot 3.4.x 与 Spring Cloud 2024.0.x(代号 Moorgate)是官方推荐的兼容组合。
注意:Spring Cloud 的版本命名采用了伦敦地铁站名称,如 2024.0.x 对应 Moorgate。务必通过官方版本兼容性表格核对 Spring Boot 与 Spring Cloud 的对应关系,这是避免依赖冲突的第一步。
我习惯使用 IntelliJ IDEA 作为 IDE,它的 Spring Initializr 集成和智能提示能极大提升效率。当然,你也可以直接访问 start.spring.io 来生成项目基础代码。
1.1 创建聚合父工程(Maven Multi-Module)
微服务项目通常由多个独立模块组成,使用 Maven 的聚合工程(父POM)来统一管理依赖版本是最佳实践。这样能确保所有子模块使用相同版本的库,避免"依赖地狱"。
首先,创建一个空的 Maven 项目,并删除默认的 src 目录,因为它仅作为父模块存在。核心在于 pom.xml 的配置:
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>springcloud-2024-demo</artifactId>
<version>1.0.0-SNAPSHOT</version>
<!-- 关键:打包方式为 pom -->
<packaging>pom</packaging>
<!-- 模块列表,后续逐步添加 -->
<modules>
<module>eureka-server</module>
<module>api-gateway</module>
<module>cache-service</module>
<module>business-service</module>
<module>common-library</module>
</modules>
<properties>
<java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- 锁定 Spring 系列版本 -->
<spring-boot.version>3.4.3</spring-boot.version>
<spring-cloud.version>2024.0.0</spring-cloud.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
</properties>
<!-- 依赖管理:这是统一版本的灵魂 -->
<dependencyManagement>
<dependencies>
<!-- 导入 Spring Boot 的依赖管理 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- 导入 Spring Cloud 的依赖管理 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<!-- 编译器插件,确保使用正确的Java版本 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<parameters>true</parameters> <!-- 支持参数名反射,对Spring有用 -->
</configuration>
</plugin>
</plugins>
</build>
</project>
这个父POM有几个关键点:
packaging设置为pom。- 在
dependencyManagement中通过import方式引入 Spring Boot 和 Spring Cloud 的官方 BOM(物料清单)。这样,子模块在添加相关依赖时就无需指定版本号,版本由父POM统一控制。 - 编译器插件配置了
parameters=true,这能让 Spring MVC 在方法参数名映射时更准确,尤其在 JDK 8+ 的编译环境下。
1.2 创建公共模块(Common Library)
在微服务中,一些类会被多个服务共享,例如通用的数据传输对象(DTO)、工具类、常量定义等。将这些内容放在一个独立的 common-library 模块中,可以避免代码重复和潜在的序列化冲突。
创建这个模块,并在其 pom.xml 中声明它是一个普通的 Jar 包,不引入 Spring Boot 的启动器依赖:
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.example</groupId>
<artifactId>springcloud-2024-demo</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>common-library</artifactId>
<packaging>jar</packaging>
<dependencies>
<!-- 常用工具包 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<!-- 如果需要JSON处理 -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- 统一API响应格式 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
</dependencies>
</project>
在这个模块里,你可以定义如下的统一响应类:
java
package com.example.common.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ApiResponse<T> {
private Integer code;
private String message;
private T data;
private Long timestamp = System.currentTimeMillis();
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(200, "success", data, System.currentTimeMillis());
}
public static <T> ApiResponse<T> error(Integer code, String message) {
return new ApiResponse<>(code, message, null, System.currentTimeMillis());
}
}
2. 服务注册与发现中心:Eureka Server
在微服务架构中,服务实例的网络位置是动态变化的。服务注册与发现机制使得服务消费者无需硬编码服务提供者的地址,而是通过一个中心化的注册中心来查找。Spring Cloud Netflix Eureka 是一个经典的选择,虽然 Netflix 已不再积极开发,但在 Spring Cloud 2024 中它仍然被支持且稳定可靠。
2.1 搭建 Eureka 服务端
在父工程下创建 eureka-server 模块。其 pom.xml 需要引入两个核心依赖:
xml
<dependencies>
<!-- Spring Boot Web 支持 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Eureka 服务器依赖 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
</dependency>
</dependencies>
接下来是配置文件 application.yml。Eureka 服务器本身也是一个 Spring Boot 应用,我们需要配置它不向自己注册(单机模式下):
yaml
server:
port: 8761 # Eureka 默认端口
spring:
application:
name: eureka-server # 服务名称
eureka:
instance:
hostname: localhost
client:
# 是否从Eureka服务器获取注册信息(单机模式设为false)
fetch-registry: false
# 是否将自己注册到Eureka服务器(单机模式设为false)
register-with-eureka: false
service-url:
# Eureka服务器自身的地址
defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/
server:
# 关闭自我保护模式(开发环境建议关闭,便于及时剔除失效实例)
enable-self-preservation: false
# 清理无效实例的间隔(毫秒)
eviction-interval-timer-in-ms: 5000
提示 :
enable-self-preservation在生产环境通常建议开启,以防止因网络波动导致大量服务实例被误剔除。但在开发测试环境,关闭它可以让我们更快地看到服务上下线的效果。
最后,创建启动类。使用 @EnableEurekaServer 注解来激活 Eureka 服务器功能:
java
package com.example.eurekaserver;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;
@SpringBootApplication
@EnableEurekaServer // 核心注解
public class EurekaServerApplication {
public static void main(String[] args) {
SpringApplication.run(EurekaServerApplication.class, args);
}
}
启动这个应用,访问 http://localhost:8761,你应该能看到 Eureka 自带的监控仪表盘。目前"Instances currently registered with Eureka"列表应该是空的,因为我们还没有注册任何服务。
2.2 Eureka 的高可用与集群部署
单节点的 Eureka Server 存在单点故障风险。在生产环境中,我们需要部署至少两个 Eureka Server 实例,并让它们相互注册,形成集群。
假设我们规划两个节点:eureka-server-1 (8761端口) 和 eureka-server-2 (8762端口)。它们的配置文件需要相互指向对方:
节点1 (application-peer1.yml):
yaml
server:
port: 8761
spring:
application:
name: eureka-server
eureka:
instance:
hostname: peer1
client:
service-url:
defaultZone: http://peer2:8762/eureka/ # 注册到节点2
节点2 (application-peer2.yml):
yaml
server:
port: 8762
spring:
application:
name: eureka-server
eureka:
instance:
hostname: peer2
client:
service-url:
defaultZone: http://peer1:8761/eureka/ # 注册到节点1
然后通过 --spring.profiles.active=peer1 或 peer2 启动参数来分别启动两个实例。这样,即使一个节点宕机,另一个节点仍然能提供服务注册和发现的功能。服务客户端(如后面的 Gateway 和业务服务)在配置 defaultZone 时,需要包含所有 Eureka 服务器的地址,用逗号分隔:defaultZone: http://peer1:8761/eureka/,http://peer2:8762/eureka/。
3. 智能路由与网关:Spring Cloud Gateway
API 网关是微服务架构的入口,它负责请求路由、过滤、限流、安全认证等横切关注点。Spring Cloud Gateway 是基于 Spring WebFlux 的非阻塞式 API 网关,性能远超传统的 Zuul 1.x,是当前 Spring Cloud 生态中的首选网关组件。
3.1 基础网关搭建
创建 api-gateway 模块,并添加依赖。特别注意 :Gateway 依赖于 WebFlux 而非传统的 Servlet Web,所以不要引入 spring-boot-starter-web。
xml
<dependencies>
<!-- Gateway 核心依赖 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<!-- 服务发现客户端,用于从Eureka获取服务列表 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<!-- 健康检查与监控 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
Gateway 的配置是其强大功能的核心。我们可以在 application.yml 中定义路由规则:
yaml
server:
port: 8080
spring:
application:
name: api-gateway
cloud:
gateway:
discovery:
locator:
enabled: true # 开启通过服务发现自动创建路由
lower-case-service-id: true # 服务ID转为小写
routes:
- id: cache-service-route # 路由ID,唯一
uri: lb://CACHE-SERVICE # lb:// 表示使用负载均衡,后面是Eureka中的服务名
predicates:
- Path=/api/cache/** # 路径匹配断言
filters:
- StripPrefix=1 # 去掉路径前缀,/api/cache/xxx 转发到 cache-service 的 /xxx
- name: RequestRateLimiter # 请求限流过滤器
args:
redis-rate-limiter.replenishRate: 10 # 每秒允许的请求数
redis-rate-limiter.burstCapacity: 20 # 令牌桶容量
key-resolver: "#{@userKeyResolver}" # 限流键解析器(需自定义Bean)
- id: business-service-route
uri: lb://BUSINESS-SERVICE
predicates:
- Path=/api/biz/**
filters:
- StripPrefix=1
eureka:
client:
service-url:
defaultZone: http://localhost:8761/eureka/
instance:
prefer-ip-address: true # 注册时使用IP而非主机名
启动类非常简单,只需标准的 @SpringBootApplication 注解。Gateway 会自动配置,并通过 @EnableDiscoveryClient(已包含在 starter 中)向 Eureka 注册并获取服务列表。
java
package com.example.apigateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ApiGatewayApplication {
public static void main(String[] args) {
SpringApplication.run(ApiGatewayApplication.class, args);
}
}
3.2 实现自定义全局过滤器
Gateway 的过滤器(Filter)机制非常灵活,允许我们实现认证、日志、修改请求/响应等逻辑。下面实现一个简单的全局认证过滤器,检查请求头中是否包含有效的 Token:
java
package com.example.apigateway.filter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
@Component
@Slf4j
public class AuthGlobalFilter implements GlobalFilter, Ordered {
private static final String AUTH_HEADER = "X-Auth-Token";
private static final String VALID_TOKEN = "secret-token-2024";
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getURI().getPath();
log.info("Gateway processing request: {}", path);
// 对特定路径(如登录)放行
if (path.startsWith("/api/auth/login")) {
return chain.filter(exchange);
}
String token = exchange.getRequest().getHeaders().getFirst(AUTH_HEADER);
if (VALID_TOKEN.equals(token)) {
return chain.filter(exchange);
} else {
log.warn("Invalid or missing auth token for path: {}", path);
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
}
@Override
public int getOrder() {
// 执行顺序,数值越小优先级越高
return -1;
}
}
这个过滤器会拦截所有经过网关的请求,检查请求头 X-Auth-Token 的值。只有携带正确令牌或访问登录接口的请求才会被放行。通过实现 Ordered 接口,我们可以控制多个过滤器的执行顺序。
3.3 集成 Redis 实现限流
你可能会注意到前面配置中有一个 RequestRateLimiter 过滤器。它需要 Redis 来存储限流状态。我们需要在项目中添加 Redis 依赖,并配置一个 KeyResolver Bean 来定义如何区分不同的用户或客户端进行限流。
首先,在 pom.xml 中添加:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>
然后,在配置类中定义 KeyResolver。这里我们简单地根据请求的IP地址进行限流:
java
package com.example.apigateway.config;
import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import reactor.core.publisher.Mono;
@Configuration
public class RateLimitConfig {
@Bean
public KeyResolver userKeyResolver() {
return exchange -> Mono.just(
exchange.getRequest().getRemoteAddress().getAddress().getHostAddress()
);
}
}
同时,在 application.yml 中补充 Redis 连接配置:
yaml
spring:
data:
redis:
host: localhost
port: 6379
password: # 如果有密码则填写
这样,网关就能对来自同一IP的请求进行限流控制,防止恶意刷接口。
4. 分布式缓存服务:Spring Data Redis 集成
缓存是提升系统性能、减轻数据库压力的重要手段。Redis 作为高性能的内存键值数据库,在微服务中常被用作分布式缓存、会话存储和消息队列。我们将创建一个独立的 cache-service 来封装所有缓存操作,其他业务服务通过 REST API 或消息队列来调用它。
4.1 配置 Redis 客户端
创建 cache-service 模块,添加必要的依赖:
xml
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<!-- Spring Data Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 连接池 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!-- 引入我们之前创建的公共模块 -->
<dependency>
<groupId>com.example</groupId>
<artifactId>common-library</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
在 application.yml 中配置 Redis 连接和连接池参数。合理的连接池配置对性能至关重要:
yaml
server:
port: 8081
spring:
application:
name: cache-service
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
database: 0
lettuce:
pool:
max-active: 20 # 连接池最大连接数
max-idle: 10 # 最大空闲连接数
min-idle: 5 # 最小空闲连接数
max-wait: 2000ms # 获取连接最大等待时间
shutdown-timeout: 100ms
eureka:
client:
service-url:
defaultZone: http://localhost:8761/eureka/
4.2 自定义 RedisTemplate 配置
默认的 RedisTemplate 使用 JdkSerializationRedisSerializer,序列化后的可读性差且占用空间大。我们通常配置为使用 StringRedisSerializer 作为键的序列化器,使用 GenericJackson2JsonRedisSerializer 作为值的序列化器,以便存储 JSON 格式的数据。
java
package com.example.cacheservice.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// 创建并配置ObjectMapper,支持Java 8时间类型
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
// 使用String序列化器 for keys
StringRedisSerializer stringSerializer = new StringRedisSerializer();
template.setKeySerializer(stringSerializer);
template.setHashKeySerializer(stringSerializer);
// 使用Jackson JSON序列化器 for values
GenericJackson2JsonRedisSerializer jsonSerializer =
new GenericJackson2JsonRedisSerializer(objectMapper);
template.setValueSerializer(jsonSerializer);
template.setHashValueSerializer(jsonSerializer);
template.afterPropertiesSet();
return template;
}
}
4.3 实现缓存服务接口
现在,我们可以实现具体的缓存操作服务了。首先定义一个服务接口和它的实现:
java
package com.example.cacheservice.service;
import com.example.common.model.ApiResponse;
public interface CacheService {
ApiResponse<String> set(String key, String value, Long ttlSeconds);
ApiResponse<Object> get(String key);
ApiResponse<Boolean> delete(String key);
ApiResponse<Boolean> expire(String key, Long seconds);
ApiResponse<Boolean> hasKey(String key);
}
java
package com.example.cacheservice.service.impl;
import com.example.cacheservice.service.CacheService;
import com.example.common.model.ApiResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service
@Slf4j
@RequiredArgsConstructor
public class CacheServiceImpl implements CacheService {
private final RedisTemplate<String, Object> redisTemplate;
@Override
public ApiResponse<String> set(String key, String value, Long ttlSeconds) {
try {
if (ttlSeconds != null && ttlSeconds > 0) {
redisTemplate.opsForValue().set(key, value, ttlSeconds, TimeUnit.SECONDS);
} else {
redisTemplate.opsForValue().set(key, value);
}
log.debug("Cache set successfully: key={}, ttl={}s", key, ttlSeconds);
return ApiResponse.success("OK");
} catch (Exception e) {
log.error("Failed to set cache for key: {}", key, e);
return ApiResponse.error(500, "Cache operation failed: " + e.getMessage());
}
}
@Override
public ApiResponse<Object> get(String key) {
try {
Object value = redisTemplate.opsForValue().get(key);
if (value == null) {
return ApiResponse.error(404, "Key not found");
}
return ApiResponse.success(value);
} catch (Exception e) {
log.error("Failed to get cache for key: {}", key, e);
return ApiResponse.error(500, "Cache operation failed: " + e.getMessage());
}
}
// 其他方法实现类似...
}
然后,暴露一个 RESTful 控制器供其他服务调用:
java
package com.example.cacheservice.controller;
import com.example.cacheservice.service.CacheService;
import com.example.common.model.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/cache")
@RequiredArgsConstructor
public class CacheController {
private final CacheService cacheService;
@PostMapping("/set")
public ApiResponse<String> set(
@RequestParam String key,
@RequestParam String value,
@RequestParam(required = false) Long ttl) {
return cacheService.set(key, value, ttl);
}
@GetMapping("/get/{key}")
public ApiResponse<Object> get(@PathVariable String key) {
return cacheService.get(key);
}
@DeleteMapping("/delete/{key}")
public ApiResponse<Boolean> delete(@PathVariable String key) {
return cacheService.delete(key);
}
}
启动 cache-service 并确保它注册到了 Eureka。现在,你可以通过网关来访问这个缓存服务了。例如,通过 http://localhost:8080/api/cache/set?key=test&value=hello 来设置一个缓存值,再通过 http://localhost:8080/api/cache/get/test 来获取它。网关会自动将请求路由到 cache-service 实例。
4.4 缓存模式与实战技巧
在实际项目中,直接使用这种"裸"的 Redis 操作可能不够。我们通常会采用一些成熟的缓存模式:
- 缓存穿透:查询一个必然不存在的数据。解决方案:缓存空对象(null)并设置较短过期时间,或使用布隆过滤器(Bloom Filter)预先判断 key 是否存在。
- 缓存击穿:某个热点 key 过期瞬间,大量请求直接打到数据库。解决方案:使用互斥锁(mutex),只允许一个线程去查询数据库并重建缓存。
- 缓存雪崩:大量 key 在同一时间过期。解决方案:给缓存过期时间加上随机值,分散过期时间。
这里提供一个简单的"缓存空对象"和"互斥锁"结合的工具类示例:
java
package com.example.cacheservice.util;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
@Component
@Slf4j
@RequiredArgsConstructor
public class CacheHelper {
private final RedisTemplate<String, Object> redisTemplate;
private static final String NULL_PLACEHOLDER = "::NULL::";
private static final String LOCK_PREFIX = "LOCK:";
/**
* 安全获取缓存,防止缓存穿透和击穿
*/
public Object safeGet(String key, CacheLoader loader, long timeout, TimeUnit unit) {
// 1. 先查缓存
Object value = redisTemplate.opsForValue().get(key);
if (value != null) {
if (NULL_PLACEHOLDER.equals(value)) {
return null; // 缓存的是空对象
}
return value;
}
// 2. 尝试获取分布式锁
String lockKey = LOCK_PREFIX + key;
boolean locked = tryLock(lockKey, 10, TimeUnit.SECONDS);
if (!locked) {
// 没拿到锁,短暂休眠后重试(或直接返回旧数据/默认值)
try {
Thread.sleep(50);
return safeGet(key, loader, timeout, unit); // 简单重试
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
}
}
try {
// 3. 再次检查缓存(Double Check)
value = redisTemplate.opsForValue().get(key);
if (value != null) {
return NULL_PLACEHOLDER.equals(value) ? null : value;
}
// 4. 执行加载逻辑(如查询数据库)
value = loader.load();
if (value == null) {
// 防止缓存穿透:缓存空对象
redisTemplate.opsForValue().set(key, NULL_PLACEHOLDER, 60, TimeUnit.SECONDS);
} else {
redisTemplate.opsForValue().set(key, value, timeout, unit);
}
return value;
} finally {
// 5. 释放锁
releaseLock(lockKey);
}
}
private boolean tryLock(String lockKey, long waitTime, TimeUnit unit) {
// 使用Redis的SET NX EX命令实现简单分布式锁
return Boolean.TRUE.equals(
redisTemplate.opsForValue().setIfAbsent(
lockKey, "1", waitTime, unit
)
);
}
private void releaseLock(String lockKey) {
// 使用Lua脚本保证原子性删除
String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) " +
"else return 0 end";
DefaultRedisScript<Long> script = new DefaultRedisScript<>(luaScript, Long.class);
redisTemplate.execute(script, Collections.singletonList(lockKey), "1");
}
@FunctionalInterface
public interface CacheLoader {
Object load();
}
}
这个 CacheHelper 提供了一个 safeGet 方法,业务方可以传入一个 CacheLoader(通常是数据库查询逻辑)来安全地获取缓存数据。它内部处理了缓存空值、分布式锁和双重检查,是一个相对健壮的缓存工具。
5. 业务服务集成与全链路测试
最后,我们创建一个简单的 business-service 来演示如何消费其他服务(如缓存服务),并完成一个从网关到业务服务再到缓存服务的完整调用链。
5.1 创建业务服务并集成 OpenFeign
业务服务需要调用缓存服务的接口。在微服务中,服务间调用推荐使用声明式的 HTTP 客户端 OpenFeign,它能让远程调用像本地方法调用一样简单。
首先,在 business-service 的 pom.xml 中添加 OpenFeign 依赖:
xml
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
然后,创建一个 Feign 客户端接口,用于调用 cache-service:
java
package com.example.businessservice.client;
import com.example.common.model.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
@FeignClient(name = "cache-service", path = "/cache")
public interface CacheServiceClient {
@PostMapping("/set")
ApiResponse<String> setCache(
@RequestParam("key") String key,
@RequestParam("value") String value,
@RequestParam(value = "ttl", required = false) Long ttl);
@GetMapping("/get/{key}")
ApiResponse<Object> getCache(@PathVariable("key") String key);
}
注意 @FeignClient 注解中的 name 属性必须与 cache-service 在 Eureka 中注册的服务名称一致。path 属性指定了请求路径的前缀。
在启动类上添加 @EnableFeignClients 注解以启用 Feign 客户端扫描:
java
package com.example.businessservice;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
@SpringBootApplication
@EnableFeignClients
public class BusinessServiceApplication {
public static void main(String[] args) {
SpringApplication.run(BusinessServiceApplication.class, args);
}
}
5.2 实现业务逻辑与控制器
现在,我们可以在业务服务中实现一个简单的功能:用户访问一个接口,该接口会先尝试从缓存中获取数据,如果缓存不存在,则模拟一个耗时的数据库查询,并将结果存入缓存。
java
package com.example.businessservice.service;
import com.example.businessservice.client.CacheServiceClient;
import com.example.common.model.ApiResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.UUID;
@Service
@Slf4j
@RequiredArgsConstructor
public class BusinessService {
private final CacheServiceClient cacheServiceClient;
public ApiResponse<String> getProductInfo(String productId) {
String cacheKey = "product:" + productId;
// 1. 尝试从缓存获取
ApiResponse<Object> cacheResp = cacheServiceClient.getCache(cacheKey);
if (cacheResp.getCode() == 200 && cacheResp.getData() != null) {
log.info("Cache hit for product: {}", productId);
return ApiResponse.success("From Cache: " + cacheResp.getData());
}
// 2. 缓存未命中,模拟数据库查询(耗时操作)
log.info("Cache miss for product: {}, querying database...", productId);
try {
Thread.sleep(1000); // 模拟1秒的数据库查询
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 3. 生成模拟数据
String productInfo = String.format("Product-%s details: %s", productId, UUID.randomUUID());
// 4. 写入缓存,有效期60秒
cacheServiceClient.setCache(cacheKey, productInfo, 60L);
return ApiResponse.success("From DB: " + productInfo);
}
}
最后,暴露一个 REST 接口:
java
package com.example.businessservice.controller;
import com.example.businessservice.service.BusinessService;
import com.example.common.model.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/biz")
@RequiredArgsConstructor
public class BusinessController {
private final BusinessService businessService;
@GetMapping("/product/{id}")
public ApiResponse<String> getProduct(@PathVariable String id) {
return businessService.getProductInfo(id);
}
}
5.3 全链路启动与测试
现在,让我们按顺序启动所有服务,并进行一次完整的调用测试:
- 启动 Eureka Server (
eureka-server): 访问http://localhost:8761确认服务已启动。 - 启动 Cache Service (
cache-service): 在 Eureka 页面应能看到CACHE-SERVICE实例。 - 启动 Business Service (
business-service): 在 Eureka 页面应能看到BUSINESS-SERVICE实例。 - 启动 API Gateway (
api-gateway): 在 Eureka 页面应能看到API-GATEWAY实例。
所有服务启动后,Eureka 仪表盘应类似下图:
| Application | AMIs | Availability Zones | Instances |
|---|---|---|---|
| API-GATEWAY | n/a | defaultZone | 1 |
| BUSINESS-SERVICE | n/a | defaultZone | 1 |
| CACHE-SERVICE | n/a | defaultZone | 1 |
| EUREKA-SERVER | n/a | defaultZone | 1 |
现在,通过网关访问业务接口。由于我们之前在网关配置了全局过滤器,需要添加认证头:
bash
# 第一次调用,会触发数据库查询并写入缓存
curl -H "X-Auth-Token: secret-token-2024" http://localhost:8080/api/biz/product/123
# 响应可能为:{"code":200,"message":"success","data":"From DB: Product-123 details: xxxxx","timestamp":...}
# 立即第二次调用,应该从缓存获取
curl -H "X-Auth-Token: secret-token-2024" http://localhost:8080/api/biz/product/123
# 响应可能为:{"code":200,"message":"success","data":"From Cache: Product-123 details: xxxxx","timestamp":...}
观察业务服务和缓存服务的日志,你可以清晰地看到第一次调用时的"Cache miss"和第二次调用时的"Cache hit"。这验证了从网关路由、服务发现、到服务间调用(Feign)、再到缓存集成的整个链路是通畅的。
通过这个从零搭建的过程,我们不仅实现了 Spring Cloud 2024 + JDK 17 的核心组件集成,更关键的是理解了每个组件在微服务架构中的角色和它们之间的协作方式。这套"全家桶"为你构建更复杂、更健壮的分布式系统打下了坚实的基础。在实际项目中,你还可以在此基础上集成配置中心(如 Spring Cloud Config 或 Nacos)、链路追踪(如 Sleuth + Zipkin)、熔断降级(如 Sentinel)等更多组件,以应对不同的业务场景和挑战。