4. [数据深化] 事务、缓存与外部调用 摘要 : CRUD 功能的实现只是数据操作的起点。本章我们将深入持久层的两大核心——事务管理 与性能优化 。我们将学习如何通过 @Transactional
注解优雅地保证数据一致性,并引入 Spring Cache 与 Redis,为我们的应用插上缓存的翅膀,最后探讨在生产环境中配置与监控多数据源的最佳实践。
4.1. [核心] 声明式事务管理 (@Transactional
) 承前启后 : 在上一章,我们学习了 AOP 如何将通用逻辑(横切关注点)从业务代码中分离。Spring 的声明式事务管理正是 AOP 最经典、最成功的应用之一。您将亲眼见证,一个简单的 @Transactional
注解背后,蕴含着多么强大的切面技术。
4.1.1. 痛点:为什么需要事务? 想象一个简化的银行转账业务:账户 A 向账户 B 转账 100 元。这个操作至少需要两个数据库步骤:
UPDATE : 将账户 A 的余额减去 100。UPDATE : 将账户 B 的余额增加 100。现在,设想一种极端情况:在成功执行第一步后,数据库突然宕机或应用崩溃,导致第二步未能执行。结果将是灾难性的:A 的钱被扣了,B 却没有收到,凭空蒸发了 100 元。
事务 (Transaction) 正是为了解决这类问题而生。它将一组数据库操作捆绑成一个不可分割的原子单元 。在这个单元内,所有操作要么全部成功,要么全部失败回滚 到操作前的状态,从而确保数据的绝对一致性。
在 Spring 中,我们无需手动编写 try-catch-finally
和 commit/rollback
代码,只需一个注解就能实现这一切。
1. 实践准备:构建转账业务场景 为了演示事务,我们首先需要创建一个 t_account
表,并为其构建相应的业务代码。
请在您的数据库中执行以下 SQL :
1 2 3 4 5 6 7 8 9 10 CREATE TABLE `t_account` ( `id` int NOT NULL AUTO_INCREMENT COMMENT '主键ID' , `user_id` int DEFAULT NULL COMMENT '用户ID' , `balance` decimal (10 ,2 ) DEFAULT NULL COMMENT '账户余额' , PRIMARY KEY (`id`) ) ENGINE= InnoDB; INSERT INTO `t_account` (user_id, balance) VALUES (1 , 1000.00 );INSERT INTO `t_account` (user_id, balance) VALUES (2 , 1000.00 );
接下来,创建相应的 Entity, Mapper 和 Service :
文件路径 : demo-system/src/main/java/com/example/demosystem/entity/Account.java
(新增)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 package com.example.demosystem.entity;import com.baomidou.mybatisplus.annotation.IdType;import com.baomidou.mybatisplus.annotation.TableId;import com.baomidou.mybatisplus.annotation.TableName;import lombok.Data;import java.math.BigDecimal;@Data @TableName("t_account") public class Account { @TableId(value = "id", type = IdType.AUTO) private Integer id; private Integer userId; private BigDecimal balance; }
文件路径 : demo-system/src/main/java/com/example/demosystem/mapper/AccountMapper.java
(新增)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 package com.example.demosystem.mapper;import com.baomidou.mybatisplus.core.mapper.BaseMapper;import com.example.demosystem.entity.Account;import org.apache.ibatis.annotations.Param;import org.apache.ibatis.annotations.Update;import java.math.BigDecimal;public interface AccountMapper extends BaseMapper <Account> { @Update("update t_account set balance = balance - #{amount} where user_id = #{userId}") void decrease (@Param("userId") Integer userId, @Param("amount") BigDecimal amount) ; @Update("update t_account set balance = balance + #{amount} where user_id = #{userId}") void increase (@Param("userId") Integer userId, @Param("amount") BigDecimal amount) ; }
文件路径 : demo-system/src/main/java/com/example/demosystem/service/AccountService.java
(新增)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 package com.example.demosystem.service;import com.example.demosystem.mapper.AccountMapper;import lombok.RequiredArgsConstructor;import org.springframework.stereotype.Service;import org.springframework.transaction.annotation.Transactional;import java.math.BigDecimal;@Service @RequiredArgsConstructor public class AccountService { private final AccountMapper accountMapper; @Transactional public void transfer (Integer fromUserId, Integer toUserId, BigDecimal amount) { accountMapper.decrease(fromUserId, amount); if (true ) { throw new RuntimeException ("模拟转账过程中发生意外..." ); } accountMapper.increase(toUserId, amount); } }
文件路径 : demo-system/src/main/java/com/example/demosystem/controller/AccountController.java
(新增)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 package com.example.demosystem.controller;import com.example.democommon.common.Result;import com.example.demosystem.service.AccountService;import io.swagger.v3.oas.annotations.Operation;import io.swagger.v3.oas.annotations.tags.Tag;import lombok.RequiredArgsConstructor;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.PostMapping;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RequestParam;import org.springframework.web.bind.annotation.RestController;import java.math.BigDecimal;@Tag(name = "账户管理", description = "演示事务") @RestController @RequestMapping("/account") @RequiredArgsConstructor public class AccountController { private final AccountService accountService; @Operation(summary = "模拟转账") @PostMapping("/transfer") public ResponseEntity<Result<Void>> transfer (@RequestParam Integer from, @RequestParam Integer to, @RequestParam BigDecimal amount) { accountService.transfer(from, to, amount); return ResponseEntity.ok(Result.success()); } }
2. 验证事务效果 重启 demo-admin
应用。调用转账接口 : 使用 API 工具调用 POST http://localhost:8080/account/transfer?from=1&to=2&amount=100
。观察结果 : 您会收到一个由全局异常处理器捕获并返回的 500
错误(因为我们抛出了 RuntimeException
)。检查数据库 : 查询 t_account
表,您会发现用户 1
和 2
的余额都还是 1000.00 ,没有任何变化。结论 : 因为 transfer
方法被 @Transactional
注解标记,当方法内抛出异常时,Spring AOP 切面捕获了它,并自动触发了事务回滚 (Rollback) 。已经执行的第一步 decrease
操作被撤销,从而保证了数据的绝对一致性。
4.1.2. 事务的传播行为 “痛点” : 当一个已经开启了事务的方法(外部方法)去调用另一个同样需要事务的方法(内部方法)时,内部方法的事务应该如何表现?是加入外部方法的现有事务,还是自己开启一个全新的事务?
这就是事务传播行为 要解决的问题。它定义了事务在方法调用链中的传递规则。
传播行为 核心作用 场景说明 — 最常用 — REQUIRED
(默认)加入或新建事务 如果当前存在事务,就加入;如果不存在,就创建一个新的。这是保证业务完整性的首选。 REQUIRES_NEW
总是新建事务 无论是否存在事务,都创建一个独立的新事务。常用于日志记录等需要与主业务隔离的操作。 — 其他 — SUPPORTS
支持当前事务 有事务就用,没有就以非事务方式执行。 NOT_SUPPORTED
以非事务方式执行 总是挂起当前事务(如果存在),以非事务方式运行。 MANDATORY
强制要求有事务 必须在已有事务中执行,否则抛出异常。 NEVER
强制要求无事务 必须在非事务环境中执行,否则抛出异常。 NESTED
嵌套事务 在已有事务中创建一个保存点(Savepoint),可以独立回滚而不影响外部事务。
4.1.3. @Transactional
失效的典型场景 @Transactional
虽好,但它依赖于 Spring AOP 的代理机制,在某些情况下会“悄无声息”地失效,这是面试高频题和生产环境中的大坑。
核心原因 : Spring AOP 是通过代理对象 来织入事务切面的。只有当调用是通过代理对象发起的,事务才会生效。任何绕过代理的调用都会导致事务失效。
1. 方法非 public
@Transactional
只能用于 public
方法。如果用在 protected
, private
或 default
可见性的方法上,事务将不会生效。
2. 方法内部调用 (最常见) 在一个类中,一个未被 @Transactional
注解的方法 a()
去调用同一个类中被注解的方法 b()
,b()
的事务会失效。
1 2 3 4 5 6 7 8 9 10 11 @Service public class MyService { public void methodA () { this .methodB(); } @Transactional public void methodB () { } }
原因 : methodA()
是通过 this
关键字直接调用的 methodB()
,它调用的是原始对象的方法,而不是 Spring 创建的代理对象。
解决方案 : 将 methodB()
移到另一个独立的 Service 类中,通过注入的代理对象来调用。
3. 异常被 catch
如果在事务方法内部 catch
了异常并且没有重新抛出,Spring 的事务切面将无法感知到异常的发生,从而不会触发回滚。
1 2 3 4 5 6 7 8 9 10 @Transactional public void methodC () { try { throw new RuntimeException (); } catch (Exception e) { log.error("发生异常" , e); } }
4. 默认只对 RuntimeException
回滚 Spring 默认只在遇到 RuntimeException
或 Error
时才会触发事务回滚。如果方法抛出的是一个受检异常(Checked Exception,如 IOException
),事务默认不会 回滚。
解决方案 : 使用 rollbackFor
属性,我们将在下一节详细讲解。
4.1.4. rollbackFor
和 isolation
属性详解 1. rollbackFor
: 精确控制回滚时机 rollbackFor
属性允许我们指定一个或多个异常类,当方法抛出这些类型的异常时,即使它们是受检异常,事务也依然会回滚。
语法 :@Transactional(rollbackFor = Exception.class)
最佳实践 : 通常建议将 rollbackFor
设置为 Exception.class
,这表示任何类型的异常都会触发回滚,更符合大多数业务场景的预期。
2. isolation
: 定义事务的隔离级别 isolation
属性用于定义事务的隔离级别,以解决并发访问时可能出现的脏读、不可重复读、幻读 等问题。
隔离级别 脏读 不可重复读 幻读 READ_UNCOMMITTED
可能 可能 可能 READ_COMMITTED
不会 可能 可能 REPEATABLE_READ
(MySQL默认)不会 不会 可能 SERIALIZABLE
不会 不会 不会
语法 :@Transactional(isolation = Isolation.REPEATABLE_READ)
最佳实践 : 绝大多数情况下,我们无需 手动设置隔离级别,直接使用数据库的默认级别(如 MySQL 的 REPEATABLE_READ
)即可。只有在特定业务场景需要解决并发问题时,才考虑调整它。
4.2. [性能] 整合 Redis 实现分布式缓存 “痛点” :在我们的项目中,findUserById
这样的查询操作可能会被频繁调用。每次调用都去查询数据库,不仅响应速度受限于磁盘 I/O,而且当并发量巨大时,会给数据库带来沉重的压力。
解决方案 :引入缓存 。对于那些不经常变化但频繁访问的数据(“读多写少”),我们可以将其第一次查询的结果存储在一个读取速度更快的介质中(如内存)。后续的请求将直接从缓存中获取数据,避免了对数据库的访问,从而极大地提升了应用的性能和吞吐量。
Spring 框架提供了一套名为 Spring Cache 的缓存抽象,它允许我们通过几个简单的注解,以一种“无侵入”的方式为方法增加缓存功能,而无需在业务代码中编写任何缓存相关的逻辑。
4.2.1. Spring Cache 核心注解 Spring Cache 的核心就是它的“注解三剑客”,它们分别对应了缓存的读、写、删操作。
注解 核心作用 适用场景 @Cacheable
读/写缓存 :方法执行前,先检查缓存。如果命中,直接返回缓存数据,不执行 方法体。如果未命中,则执行方法体,并将返回值放入缓存。查询 操作 (SELECT)@CachePut
更新缓存 :总是执行 方法体,然后将方法的返回值更新到缓存中。修改 操作 (UPDATE)@CacheEvict
删除缓存 :方法执行后,从缓存中移除指定的条目。删除 操作 (DELETE)
这些注解都共享一些通用属性,最核心的是:
cacheNames
(或 value
): 指定缓存的名称(可以理解为缓存的分组或命名空间)。key
: 指定缓存条目的键 (Key)。这是缓存的唯一标识,支持强大的 SpEL 表达式。4.2.2. 配置 Spring Boot Data Redis 作为缓存后端 Spring Cache 只是一个标准接口,我们需要为它提供一个具体的缓存实现。我们将使用 Redis 这个业界最主流的分布式缓存方案。
1. 添加依赖 我们需要在 demo-framework
模块中添加 spring-boot-starter-data-redis
依赖,它已经包含了 Spring Cache 所需的依赖。
首先,在父 pom.xml
的 <properties>
中统一版本 :
文件路径 : pom.xml
(根目录)
1 2 3 <properties > <redis.version > 3.3.0</redis.version > </properties >
然后,在父 pom.xml
的 <dependencyManagement>
中声明依赖 :
文件路径 : pom.xml
(根目录)
1 2 3 4 5 6 7 8 9 <dependencyManagement > <dependencies > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-data-redis</artifactId > <version > ${redis.version}</version > </dependency > </dependencies > </dependencyManagement >
最后,在 demo-framework
模块中引入依赖 :
文件路径 : demo-framework/pom.xml
1 2 3 4 <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-data-redis</artifactId > </dependency >
2. 启用缓存与配置序列化 第一步:启用缓存 我们需要在 demo-admin
启动模块的一个配置类上添加 @EnableCaching
注解,来正式开启 Spring 的缓存功能。
文件路径 : demo-admin/src/main/java/com/example/demoadmin/SpringBootDemoApplication.java
(修改)
1 2 3 4 5 6 7 8 9 import org.springframework.cache.annotation.EnableCaching;@SpringBootApplication(scanBasePackages = "com.example") @MapperScan("com.example.*.mapper") @EnableCaching public class SpringBootDemoApplication { }
第二步:配置 Redis 连接 在 demo-admin
的配置文件中,添加 Redis 的连接信息。
文件路径 : demo-admin/src/main/resources/application.yml
(修改)
1 2 3 4 5 6 7 8 spring: data: redis: host: localhost port: 6379 database: 0
第三步:配置 JSON 序列化 (关键步骤) Spring Boot 默认使用 JDK 序列化将 Java 对象存入 Redis,这会产生难以阅读的二进制乱码,且存在兼容性问题。最佳实践是配置使用 JSON 格式进行序列化。
我们在 demo-framework
模块下创建 CacheConfig
。
文件路径 : demo-framework/src/main/java/com/example/demoframework/config/CacheConfig.java
(新增)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 package com.example.demoframework.config;import com.fasterxml.jackson.annotation.JsonAutoDetect;import com.fasterxml.jackson.annotation.PropertyAccessor;import com.fasterxml.jackson.databind.ObjectMapper;import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;import org.springframework.cache.CacheManager;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.data.redis.cache.RedisCacheConfiguration;import org.springframework.data.redis.cache.RedisCacheManager;import org.springframework.data.redis.connection.RedisConnectionFactory;import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;import org.springframework.data.redis.serializer.RedisSerializationContext;import org.springframework.data.redis.serializer.StringRedisSerializer;import java.time.Duration;@Configuration public class CacheConfig { @Bean public CacheManager cacheManager (RedisConnectionFactory factory) { StringRedisSerializer redisSerializer = new StringRedisSerializer (); Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = createJacksonSerializer(); RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofHours(1 )) .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer)) .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jackson2JsonRedisSerializer)) .disableCachingNullValues(); return RedisCacheManager.builder(factory) .cacheDefaults(config) .build(); } private Jackson2JsonRedisSerializer<Object> createJacksonSerializer () { ObjectMapper objectMapper = new ObjectMapper (); objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL); return new Jackson2JsonRedisSerializer <>(objectMapper, Object.class); } }
4.2.3. 实战:为 UserService 添加缓存 现在,我们为 UserServiceImpl
中的 CRUD 方法添加缓存注解。
文件路径 : demo-system/src/main/java/com/example/demosystem/service/impl/UserServiceImpl.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 import org.springframework.cache.annotation.CacheEvict;import org.springframework.cache.annotation.CachePut;import org.springframework.cache.annotation.Cacheable;@Service @RequiredArgsConstructor @Slf4j public class UserServiceImpl implements UserService { private final UserMapper userMapper; @Override @Cacheable(cacheNames = "users", key = "#id") public UserVO findUserById (Long id) { log.info("执行数据库查询: findUserById, ID: {}" , id); User user = userMapper.selectById(id); if (user == null ) { return null ; } return convertToVO(user); } @Override @CachePut(cacheNames = "users", key = "#dto.id") public UserVO updateUser (UserEditDTO dto) { User user = Convert.convert(User.class, dto); userMapper.updateById(user); return findUserById(dto.getId()); } @Override @CacheEvict(cacheNames = "users", key = "#id") public void deleteUserById (Long id) { log.info("从数据库删除用户, ID: {}" , id); userMapper.deleteById(id); } }
4.2.4. 验证缓存效果 重启 demo-admin
应用。
进行查询 : 调用 GET /users/1
。
应用日志 : 您会看到 "执行数据库查询: findUserById, ID: 1"
。
Redis : 使用 redis-cli
执行 GET "users::1"
(双冒号是默认分隔符),您会看到 UserVO
的 JSON 字符串。
1 2 > GET "users::1" "{\"@class\":\"com.example.demosystem.vo.UserVO\",\"id\":1,\"userName\":\"张三\",...}"
4.3. [新特性] 现代 HTTP 调用:RestClient “痛点” :我们的应用(demo-system
)不会是一个孤岛。在真实的业务场景中,它经常需要与外部的、第三方的 HTTP API 或内部的其他微服务进行通信。例如:
调用支付网关完成支付。 请求天气服务获取实时天气。 在微服务架构中,调用订单服务查询订单详情。 Spring 长期以来提供了 RestTemplate
来满足这一需求,但它的 API 设计已略显陈旧。从 Spring Boot 3.2 开始,官方推荐使用一个全新的、更现代化的同步 HTTP 客户端——RestClient
。
4.3.1. 对比 RestTemplate,理解 RestClient 的优势 在深入实践之前,我们先通过一个简单的对比,来理解为什么 RestClient
是未来的方向。
场景 :我们需要调用一个 API GET /posts/1
,并将返回的 JSON 转换为 PostDTO
对象。
传统 RestTemplate 现代 RestClient 1 2 3 4 5 6 7 8 9 10 11 12 13 RestTemplate restTemplate = new RestTemplate ();String url = "https://jsonplaceholder.typicode.com/posts/1" ;try { ResponseEntity<PostDTO> response = restTemplate.getForEntity(url, PostDTO.class); PostDTO post = response.getBody(); } catch (HttpClientErrorException e) { log.error("API 调用失败: {}" , e.getStatusCode()); }
痛点
API 较为笨重,URL 和参数拼接繁琐。 错误处理依赖于传统的 try-catch
异常机制,不够优雅。 官方已将其置于 维护模式 ,不再推荐用于新项目。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 RestClient restClient = RestClient.builder() .baseUrl("https://jsonplaceholder.typicode.com" ) .build(); PostDTO post = restClient.get() .uri("/posts/{id}" , 1 ) .retrieve() .onStatus(HttpStatusCode::is4xxClientError, (request, response) -> { log.error("客户端错误: {}" , response.getStatusCode()); throw new MyCustomException ("API Client Error" ); }) .body(PostDTO.class);
优势
流式 API :代码如行云流水,可读性极高。优雅的错误处理 :通过 .onStatus()
方法,可以链式地、精准地处理不同类型的 HTTP 错误,代码更整洁。官方推荐 :作为 RestTemplate
的继任者,是 Spring Boot 3.2+ 中同步 HTTP 调用的 首选 。
4.3.2. 实战:使用 RestClient 调用公开 API 现在,我们为项目增加一个新功能:通过用户 ID,调用一个公开的 JSONPlaceholder API,获取该用户发布的第一篇文章。
1. 准备工作:创建 DTO 与配置 Bean 第一步:创建 PostDTO 并添加 SpringDoc 注解 这个 DTO 用于承载从外部 API 返回的文章数据。我们为其属性添加 @Schema
注解,以便在 Swagger UI 中清晰展示。
文件路径 : demo-system/src/main/java/com/example/demosystem/dto/external/PostDTO.java
(新增包和文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 package com.example.demosystem.dto.external;import io.swagger.v3.oas.annotations.media.Schema;import lombok.Data;@Data @Schema(description = "外部文章数据传输对象") public class PostDTO { @Schema(description = "关联的用户ID", example = "1") private Integer userId; @Schema(description = "文章ID", example = "1") private Integer id; @Schema(description = "文章标题") private String title; @Schema(description = "文章内容") private String body; }
第二步:配置 RestClient Bean 最佳实践是在配置类中预先创建一个 RestClient
Bean。
文件路径 : demo-framework/src/main/java/com/example/demoframework/config/WebConfig.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 package com.example.demoframework.config;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.web.client.RestClient;@Configuration @RequiredArgsConstructor public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors (InterceptorRegistry registry) { registry.addInterceptor(logInterceptor) .addPathPatterns("/**" ); registry.addInterceptor(authInterceptor) .addPathPatterns("/**" ) .excludePathPatterns( "/auth/login" , "/external/**" , "/files/**" , "/springboot-uploads/**" , "/swagger-ui/**" , "/v3/api-docs/**" , "/error" ); } @Bean public RestClient restClient () { return RestClient.builder() .baseUrl("https://jsonplaceholder.typicode.com" ) .build(); } }
2. 实现 Service 与 Controller 文件路径 : demo-system/src/main/java/com/example/demosystem/service/ExternalApiService.java
(新增)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 package com.example.demosystem.service;import cn.hutool.core.lang.Assert;import com.example.democommon.common.ResultCode;import com.example.democommon.exception.BusinessException;import com.example.demosystem.dto.external.PostDTO;import lombok.RequiredArgsConstructor;import lombok.extern.slf4j.Slf4j;import org.springframework.core.ParameterizedTypeReference;import org.springframework.http.HttpStatusCode;import org.springframework.stereotype.Service;import org.springframework.web.client.RestClient;import java.util.List;@Service @RequiredArgsConstructor @Slf4j public class ExternalApiService { private final RestClient restClient; public PostDTO getUserFirstPost (Integer userId) { List<PostDTO> posts = restClient.get() .uri("/posts?userId={userId}" , userId) .retrieve() .onStatus(HttpStatusCode::is4xxClientError, (req, resp) -> { log.error("调用外部 API 客户端错误: 状态码={}, 响应体={}" , resp.getStatusCode(), resp.getBody().toString()); throw new BusinessException (ResultCode.BAD_REQUEST); }) .body(new ParameterizedTypeReference <>() { }); Assert.notEmpty(posts, "该用户尚未发布任何文章" ); return posts.get(0 ); } }
文件路径 : demo-system/src/main/java/com/example/demosystem/controller/ExternalApiController.java
(新增)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 package com.example.demosystem.controller;import com.example.democommon.common.Result;import com.example.demosystem.dto.external.PostDTO;import com.example.demosystem.service.ExternalApiService;import io.swagger.v3.oas.annotations.Operation;import io.swagger.v3.oas.annotations.Parameter;import io.swagger.v3.oas.annotations.tags.Tag;import lombok.RequiredArgsConstructor;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.*;@Tag(name = "外部API调用", description = "演示RestClient") @RestController @RequestMapping("/external") @RequiredArgsConstructor public class ExternalApiController { private final ExternalApiService externalApiService; @Operation(summary = "获取用户的首篇文章", description = "调用JSONPlaceholder API") @GetMapping("/users/{userId}/first-post") public ResponseEntity<Result<PostDTO>> getUserFirstPost ( @Parameter(description = "目标用户的ID", required = true, example = "1") @PathVariable Integer userId) { PostDTO post = externalApiService.getUserFirstPost(userId); return ResponseEntity.ok(Result.success(post)); } }
3. 回归测试 重启 demo-admin
应用。访问 Swagger UI (http://localhost:8080/swagger-ui.html
)。预期响应 : 您将成功获取到用户 ID 为 1 的第一篇文章的 JSON 数据,并被包装在我们的标准 Result
结构中。
1 2 3 4 5 6 7 8 9 10 { "code" : 200 , "message" : "操作成功" , "data" : { "userId" : 1 , "id" : 1 , "title" : "sunt aut facere repellat provident occaecati excepturi optio reprehenderit" , "body" : "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto" } }
通过重构,我们不仅学习了 RestClient
的现代化用法,还将其完美地融入了我们现有的技术体系:使用 SpringDoc 为新 API 生成了专业的文档,并利用 Hutool Assert 增强了业务代码的健壮性。在下一节,我们将学习如何为这种可能失败的外部调用增加自动重试 的能力。
4.4. [弹性设计] 使用 Spring Retry 实现精准重试 在 4.3
节,我们掌握了 RestClient
这一强大的工具。但一个残酷的现实是:任何网络调用都可能失败。外部服务可能暂时过载、正在重启,或者网络出现瞬时抖动。如果我们的应用在第一次调用失败后就直接放弃,那么系统的健壮性(即弹性)将非常脆弱。
Spring Retry 提供了一套优雅的、声明式的解决方案,让我们可以通过注解,为可能失败的操作自动增加重试能力。
4.4.1. 弹性设计的核心:区分瞬时与永久性故障 在引入代码之前,我们必须先建立一个由您提出的、至关重要的心智模型:并非所有失败都值得重试 。
将失败分为两类,是构建专业重试策略的基石:
永久性故障 : 这类失败表明请求本身存在根本性问题,无论重试多少次,结果都将是相同的。例如,请求了一个不存在的资源 (404
) 或因参数错误导致请求无效 (400
)。瞬时故障 : 这类失败通常是由服务端临时性问题或网络波动引起的。服务可能只是暂时不可用或过载,稍后重试有可能 会成功。例如,服务不可用 (503
) 或网关超时 (504
)。我们的目标就是:只对瞬时故障进行重试,而对永久性故障执行快速失败 。
故障类型 HTTP 状态码 / 异常 我们的策略 原因 永久性故障 400
, 401
, 403
, 404
快速失败 (Fail Fast) 请求本身存在问题,重试是浪费资源。 瞬时故障 502
, 503
, 504
, TimeoutException
重试 (Retry) 服务端或网络临时问题,重试可能成功。
4.4.2. 准备工作:引入依赖与启用重试 1. 添加 Maven 依赖 Spring Retry 是一个独立的 Spring 项目,需要我们手动添加依赖。
首先,在父 pom.xml
的 <dependencyManagement>
中声明依赖 :
文件路径 : pom.xml
(根目录)
1 2 3 4 5 6 7 8 <dependencyManagement > <dependencies > <dependency > <groupId > org.springframework.retry</groupId > <artifactId > spring-retry</artifactId > <version > 2.0.8</version > </dependency > </dependencies > </dependencyManagement >
然后,在 demo-framework
模块中引入依赖 :
Spring Retry 依赖于 AOP。由于我们的 demo-framework
已经引入了 spring-boot-starter-aop
,所以此处无需重复添加。
文件路径 : demo-framework/pom.xml
1 2 3 4 5 <dependency > <groupId > org.springframework.retry</groupId > <artifactId > spring-retry</artifactId > </dependency >
2. 启用重试功能 我们在 demo-admin
的主启动类上添加 @EnableRetry
注解,全局激活重试能力。
文件路径 : demo-admin/src/main/java/com/example/demoadmin/SpringBootDemoApplication.java
(修改)
1 2 3 4 5 6 7 8 9 10 import org.springframework.retry.annotation.EnableRetry;@SpringBootApplication(scanBasePackages = "com.example") @MapperScan("com.example.*.mapper") @EnableCaching @EnableRetry public class SpringBootDemoApplication { }
4.4.3. 实战:实现对 5xx 错误码的精准重试 现在,我们将改造 ExternalApiService
,使其能够智能地区分 4xx
和 5xx
错误,并只对后者进行重试。
1. 创建自定义的可重试异常 为了让 @Retryable
注解能够精准识别目标,我们先创建一个专门用于标识瞬时故障的异常。
文件路径 : demo-common/src/main/java/com/example/democommon/exception/RetryableException.java
(新增)
1 2 3 4 5 6 7 8 9 package com.example.democommon.exception;public class RetryableException extends RuntimeException { public RetryableException (String message) { super (message); } }
2. 改造 ExternalApiService 文件路径 : demo-system/src/main/java/com/example/demosystem/service/ExternalApiService.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 package com.example.demosystem.service;import cn.hutool.core.lang.Assert;import com.example.democommon.common.ResultCode;import com.example.democommon.exception.BusinessException;import com.example.democommon.exception.RetryableException; import com.example.demosystem.dto.external.PostDTO;import lombok.RequiredArgsConstructor;import lombok.extern.slf4j.Slf4j;import org.springframework.core.ParameterizedTypeReference;import org.springframework.http.HttpStatusCode;import org.springframework.retry.annotation.Backoff;import org.springframework.retry.annotation.Recover;import org.springframework.retry.annotation.Retryable;import org.springframework.stereotype.Service;import org.springframework.web.client.RestClient;import java.util.Collections;import java.util.List;@Service @RequiredArgsConstructor @Slf4j public class ExternalApiService { private final RestClient restClient; @Retryable( include = RetryableException.class, // 只对我们自定义的 RetryableException 异常进行重试 maxAttempts = 3, // 最多重试3次(包括第一次调用) backoff = @Backoff(delay = 2000, multiplier = 2) // 退避策略:第一次重试等2秒,第二次等4秒 ) public PostDTO getUserFirstPost (Integer userId) { log.info("正在尝试调用外部 API, userId: {}" , userId); List<PostDTO> posts = restClient.get() .uri("/posts?userId={userId}" , userId) .retrieve() .onStatus(HttpStatusCode::is4xxClientError, (req, resp) -> { throw new BusinessException (ResultCode.BAD_REQUEST); }) .onStatus(HttpStatusCode::is5xxServerError, (req, resp) -> { throw new RetryableException ("外部服务暂时不可用, 状态码: " + resp.getStatusCode()); }) .body(new ParameterizedTypeReference <>() {}); Assert.notEmpty(posts, "该用户尚未发布任何文章" ); return posts.get(0 ); } @Recover public PostDTO recover (RetryableException e, Integer userId) { log.error("调用外部 API 达到最大重试次数后仍然失败, userId: {}. 异常信息: {}" , userId, e.getMessage()); return null ; } }
当然!使用像 httpbin.org
这样的专业测试服务是验证重试和熔断逻辑的最佳实践。您的建议非常好,这比我之前提出的修改 URI 的方式要严谨得多。
我将完全按照您的思路,重写测试小节,引导用户修改 RestClient
的 baseUrl
,并调用 httpbin.org
来精准地模拟 5xx
和 4xx
失败场景。
要完美地测试我们的精准重试逻辑,我们需要一个能按需返回指定 HTTP 状态码的 API 端点。为此,我们将使用 httpbin.org
这个强大的公开测试服务。
第一步:临时修改 RestClient
配置
为了让我们的 ExternalApiService
调用 httpbin.org
,我们需要暂时修改 RestClient
Bean 的 baseUrl
。
文件路径 : demo-framework/src/main/java/com/example/demoframework/config/WebConfig.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @Configuration @RequiredArgsConstructor public class WebConfig implements WebMvcConfigurer { @Bean public RestClient restClient () { return RestClient.builder() .baseUrl("https://httpbin.org" ) .build(); } }
重要提示 : 请记住,这只是为了测试目的。测试完成后,我们需要将 baseUrl
改回 https://jsonplaceholder.typicode.com
。
第二步:模拟 5xx 瞬时故障 (验证重试)
修改代码 : 暂时将 ExternalApiService
中的 .uri(...)
调用修改为指向 httpbin.org
的 503
状态码端点。
文件路径 : demo-system/src/main/java/com/example/demosystem/service/ExternalApiService.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 @Retryable(...) public PostDTO getUserFirstPost (Integer userId) { log.info("正在尝试调用外部 API, userId: {}" , userId); List<PostDTO> posts = restClient.get() .uri("/status/503" ) .retrieve()
重启 demo-admin
应用。
调用接口 : GET /external/users/1
。
观察日志 :
您会看到日志 “正在尝试调用外部 API…” 打印了 3 次 。 第一次和第二次调用之间,有 2 秒 的延迟。 第二次和第三次调用之间,有 4 秒 的延迟。 最终,您会看到 @Recover
方法中的错误日志 “调用外部 API 达到最大重试次数后仍然失败…”。 前端收到的最终响应是 data: null
。 第三步:模拟 4xx 永久性故障 (验证快速失败)
修改代码 : 再次修改 ExternalApiService
中的 .uri(...)
调用,这次指向 httpbin.org
的 404
状态码端点。
文件路径 : demo-system/src/main/java/com/example/demosystem/service/ExternalApiService.java
(修改)
1 2 3 4 5 6 7 8 9 10 11 @Retryable(...) public PostDTO getUserFirstPost (Integer userId) { log.info("正在尝试调用外部 API, userId: {}" , userId); List<PostDTO> posts = restClient.get() .uri("/status/404" ) .retrieve()
重启 demo-admin
应用。
调用接口 : GET /external/users/1
。
观察日志 :
您会看到日志 “正在尝试调用外部 API…” 只打印了 1 次 。 没有任何重试相关的日志 。全局异常处理器捕获了 BusinessException
,并立即向前端返回了 400 Bad Request
的 Result
错误响应。 测试完成后,请务必 将 WebConfig.java
中的 baseUrl
和 ExternalApiService.java
中的 .uri()
调用恢复原状 !通过本次重构和精准测试,我们的应用在面对外部服务故障时,变得无比“聪明”和“坚韧”,这正是企业级弹性设计的核心体现。