16- [数据深化] 事务、缓存与外部调用

4. [数据深化] 事务、缓存与外部调用

摘要: CRUD 功能的实现只是数据操作的起点。本章我们将深入持久层的两大核心——事务管理性能优化。我们将学习如何通过 @Transactional 注解优雅地保证数据一致性,并引入 Spring Cache 与 Redis,为我们的应用插上缓存的翅膀,最后探讨在生产环境中配置与监控多数据源的最佳实践。

4.1. [核心] 声明式事务管理 (@Transactional)

承前启后: 在上一章,我们学习了 AOP 如何将通用逻辑(横切关注点)从业务代码中分离。Spring 的声明式事务管理正是 AOP 最经典、最成功的应用之一。您将亲眼见证,一个简单的 @Transactional 注解背后,蕴含着多么强大的切面技术。

4.1.1. 痛点:为什么需要事务?

想象一个简化的银行转账业务:账户 A 向账户 B 转账 100 元。这个操作至少需要两个数据库步骤:

  1. UPDATE: 将账户 A 的余额减去 100。
  2. UPDATE: 将账户 B 的余额增加 100。

现在,设想一种极端情况:在成功执行第一步后,数据库突然宕机或应用崩溃,导致第二步未能执行。结果将是灾难性的:A 的钱被扣了,B 却没有收到,凭空蒸发了 100 元。

事务 (Transaction) 正是为了解决这类问题而生。它将一组数据库操作捆绑成一个不可分割的原子单元。在这个单元内,所有操作要么全部成功,要么全部失败回滚到操作前的状态,从而确保数据的绝对一致性。

在 Spring 中,我们无需手动编写 try-catch-finallycommit/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) {
// 1. 扣减转出方余额
accountMapper.decrease(fromUserId, amount);

// 2. 模拟一个异常
if (true) {
throw new RuntimeException("模拟转账过程中发生意外...");
}

// 3. 增加转入方余额
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. 验证事务效果

  1. 重启 demo-admin 应用。
  2. 调用转账接口: 使用 API 工具调用 POST http://localhost:8080/account/transfer?from=1&to=2&amount=100
  3. 观察结果: 您会收到一个由全局异常处理器捕获并返回的 500 错误(因为我们抛出了 RuntimeException)。
  4. 检查数据库: 查询 t_account 表,您会发现用户 12 的余额都还是 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, privatedefault 可见性的方法上,事务将不会生效。

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(); // this 调用,绕过了代理,事务失效!
}

@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 默认只在遇到 RuntimeExceptionError 时才会触发事务回滚。如果方法抛出的是一个受检异常(Checked Exception,如 IOException),事务默认不会回滚。

解决方案: 使用 rollbackFor 属性,我们将在下一节详细讲解。


4.1.4. rollbackForisolation 属性详解

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
// ... imports ...
import org.springframework.cache.annotation.EnableCaching;

@SpringBootApplication(scanBasePackages = "com.example")
@MapperScan("com.example.*.mapper")
@EnableCaching // 启用 Spring 缓存功能
public class SpringBootDemoApplication {
// ... main method ...
}

第二步:配置 Redis 连接
demo-admin 的配置文件中,添加 Redis 的连接信息。

文件路径: demo-admin/src/main/resources/application.yml (修改)

1
2
3
4
5
6
7
8
spring:
# ... datasource config ...
data:
redis:
host: localhost
port: 6379
# password: your_password
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;

/**
* Spring Cache 配置类,用于将缓存存储到 Redis。
*/
@Configuration
public class CacheConfig {

/**
* 配置并创建 CacheManager Bean,Spring 将使用它来管理缓存。
*
* @param factory Spring 自动注入的 Redis 连接工厂。
* @return 配置好的 RedisCacheManager。
*/
@Bean
public CacheManager cacheManager(RedisConnectionFactory factory) {
// 1. 创建 Redis Key 和 Value 的序列化器
// Key 使用字符串序列化器
StringRedisSerializer redisSerializer = new StringRedisSerializer();
// Value 使用自定义的 Jackson JSON 序列化器
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = createJacksonSerializer();

// 2. 配置默认的缓存规则
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
// 设置缓存默认过期时间为 1 小时
.entryTtl(Duration.ofHours(1))
// 设置 Key 的序列化方式
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer))
// 设置 Value 的序列化方式
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jackson2JsonRedisSerializer))
// 禁止缓存 null 值
.disableCachingNullValues();

// 3. 根据以上配置创建 CacheManager
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.build();
}

/**
* 创建一个自定义配置的 Jackson 序列化器。
* @return Jackson2JsonRedisSerializer 实例
*/
private Jackson2JsonRedisSerializer<Object> createJacksonSerializer() {
ObjectMapper objectMapper = new ObjectMapper();
// 允许 Jackson 序列化所有字段(包括 private)
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
// 在序列化的 JSON 中存储对象的类型信息,以便在反序列化时能恢复为原始类型
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
// ... imports ...
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
// updateUser 方法需要返回更新后的对象,才能被 @CachePut 放入缓存
@CachePut(cacheNames = "users", key = "#dto.id")
public UserVO updateUser(UserEditDTO dto) {
// ... (省略校验逻辑) ...
User user = Convert.convert(User.class, dto);
userMapper.updateById(user);
// 返回更新后的VO,以便@CachePut更新缓存
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. 验证缓存效果

  1. 重启 demo-admin 应用。

  2. 进行查询: 调用 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\":\"张三\",...}"

image-20250819194150849


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 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 1. 实例化 RestTemplate
RestTemplate restTemplate = new RestTemplate();
String url = "https://jsonplaceholder.typicode.com/posts/1";

try {
// 2. 发起调用,手动处理可能抛出的异常
ResponseEntity<PostDTO> response = restTemplate.getForEntity(url, PostDTO.class);
PostDTO post = response.getBody();
// ... 处理 post ...
} catch (HttpClientErrorException e) {
// 3. 需要用 try-catch 块来处理 4xx, 5xx 等错误
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
// 1. 通过构建器创建 RestClient
RestClient restClient = RestClient.builder()
.baseUrl("https://jsonplaceholder.typicode.com")
.build();

// 2. 使用流式 (Fluent) API 构建和执行请求
PostDTO post = restClient.get()
.uri("/posts/{id}", 1) // URI 模板和参数,更安全清晰
.retrieve() // 发起请求并获取响应体
// 3. 链式处理特定状态码,无需 try-catch
.onStatus(HttpStatusCode::is4xxClientError, (request, response) -> {
log.error("客户端错误: {}", response.getStatusCode());
// 可以抛出自定义异常
throw new MyCustomException("API Client Error");
})
.body(PostDTO.class); // 将响应体自动转换为 DTO

优势

  • 流式 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;
// ... 其他 import ...

@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/**", // !!!注意这里 我们添加了外部接口无需验证我们的TOken
"/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) {
// 1. 调用外部 API 获取指定用户的所有文章列表
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<>() {
});

// 2. 使用 Hutool Assert 进行业务断言
Assert.notEmpty(posts, "该用户尚未发布任何文章");

// 3. 业务处理:返回第一篇文章
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. 回归测试

  1. 重启 demo-admin 应用。
  2. 访问 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
<!--Spring Boot 重试-->
<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
// ... imports ...
import org.springframework.retry.annotation.EnableRetry;

@SpringBootApplication(scanBasePackages = "com.example")
@MapperScan("com.example.*.mapper")
@EnableCaching
@EnableRetry // 启用 Spring Retry 功能
public class SpringBootDemoApplication {
// ... main method ...
}

4.4.3. 实战:实现对 5xx 错误码的精准重试

现在,我们将改造 ExternalApiService,使其能够智能地区分 4xx5xx 错误,并只对后者进行重试。

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;

// 这是一个简单的标记异常,继承自 RuntimeException
// 它的唯一作用就是告诉 Spring Retry:“嘿,看到我这个类型的异常时,你应该发起重试!”
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) -> {
// 遇到 4xx 错误,抛出非重试的 BusinessException
throw new BusinessException(ResultCode.BAD_REQUEST);
})
.onStatus(HttpStatusCode::is5xxServerError, (req, resp) -> {
// 遇到 5xx 错误,抛出可重试的 RetryableException
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());
// 可以在此执行降级逻辑,例如返回一个默认的 PostDTO 对象或 null
// throw new BusinessException("外部服务持续不可用,请稍后再试");
return null; // 此处我们选择返回 null
}
}

当然!使用像 httpbin.org 这样的专业测试服务是验证重试和熔断逻辑的最佳实践。您的建议非常好,这比我之前提出的修改 URI 的方式要严谨得多。

我将完全按照您的思路,重写测试小节,引导用户修改 RestClientbaseUrl,并调用 httpbin.org 来精准地模拟 5xx4xx 失败场景。


4.4.4. 回归测试:使用 httpbin.org 模拟故障

要完美地测试我们的精准重试逻辑,我们需要一个能按需返回指定 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
// ... imports ...

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

// ... 已有的拦截器等配置 ...

@Bean
public RestClient restClient() {
return RestClient.builder()
// 暂时将 baseUrl 修改为 httpbin.org
.baseUrl("https://httpbin.org")
.build();
}
}

重要提示: 请记住,这只是为了测试目的。测试完成后,我们需要将 baseUrl 改回 https://jsonplaceholder.typicode.com

第二步:模拟 5xx 瞬时故障 (验证重试)

  1. 修改代码: 暂时将 ExternalApiService 中的 .uri(...) 调用修改为指向 httpbin.org503 状态码端点。

    文件路径: 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);

    // 暂时修改 URI 来触发 503 错误
    List<PostDTO> posts = restClient.get()
    .uri("/status/503") // 硬编码到 503 错误端点
    .retrieve()
    // ... onStatus 处理器保持不变 ...
    // ...
  2. 重启 demo-admin 应用。

  3. 调用接口: GET /external/users/1

  4. 观察日志:

    • 您会看到日志 “正在尝试调用外部 API…” 打印了 3 次
    • 第一次和第二次调用之间,有 2 秒 的延迟。
    • 第二次和第三次调用之间,有 4 秒 的延迟。
    • 最终,您会看到 @Recover 方法中的错误日志 “调用外部 API 达到最大重试次数后仍然失败…”。
    • 前端收到的最终响应是 data: null

第三步:模拟 4xx 永久性故障 (验证快速失败)

  1. 修改代码: 再次修改 ExternalApiService 中的 .uri(...) 调用,这次指向 httpbin.org404 状态码端点。

    文件路径: 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);

    // 暂时修改 URI 来触发 404 错误
    List<PostDTO> posts = restClient.get()
    .uri("/status/404") // 硬编码到 404 错误端点
    .retrieve()
    // ... onStatus 处理器保持不变 ...
    // ...
  2. 重启 demo-admin 应用。

  3. 调用接口: GET /external/users/1

  4. 观察日志:

    • 您会看到日志 “正在尝试调用外部 API…” 只打印了 1 次
    • 没有任何重试相关的日志
    • 全局异常处理器捕获了 BusinessException,并立即向前端返回了 400 Bad RequestResult 错误响应。

测试完成后,请务必WebConfig.java 中的 baseUrlExternalApiService.java 中的 .uri() 调用恢复原状!通过本次重构和精准测试,我们的应用在面对外部服务故障时,变得无比“聪明”和“坚韧”,这正是企业级弹性设计的核心体现。