JAVA 微服务接口幂等性保障:防重 Token + Redis 方案详解
大家好,今天我们来聊聊微服务架构下接口幂等性的实现。在分布式系统中,由于网络抖动、服务超时等原因,导致请求重复发送的情况屡见不鲜。如果不加以控制,这些重复请求可能会对数据造成不可预期的影响,例如重复扣款、重复下单等。因此,保证接口的幂等性至关重要。
幂等性是指一个操作,无论执行多少次,产生的结果都与执行一次的结果相同。 简单来说,就是用户发起一次请求,服务器端只执行一次操作。
今天,我们重点介绍一种常用的且相对简单的幂等性解决方案:防重 Token + Redis 方案。 这种方案结合了客户端的 Token 生成和服务器端的 Redis 存储,能够在大部分场景下有效地防止重复请求。
1. 为什么需要幂等性?
在深入讨论解决方案之前,我们先来明确一下为什么我们需要关注幂等性。考虑以下场景:
- 网络抖动: 客户端发起一个支付请求,服务端处理成功后,返回结果给客户端的过程中发生网络抖动,客户端未收到响应,因此认为请求失败,重新发起支付请求。
- 服务超时: 客户端发起一个创建订单请求,服务端处理时间超过客户端设置的超时时间,客户端认为请求失败,重新发起创建订单请求。
- 消息队列重复消费: 消息队列在某些情况下可能会出现消息重复投递的情况,导致消费者重复消费消息。
在上述场景中,如果不保证接口的幂等性,可能会导致用户重复支付、重复创建订单等问题,造成严重的经济损失。
2. 防重 Token + Redis 方案原理
防重 Token + Redis 方案的核心思想是:
- 客户端生成 Token: 客户端在发起请求之前,向服务端申请一个唯一的 Token。这个 Token 可以是 UUID 或者其他唯一标识符。
- 服务端验证 Token: 服务端接收到请求后,首先验证 Token 是否存在于 Redis 中。
- 如果 Token 不存在,则认为这是一个新的请求,将 Token 存入 Redis,并执行业务逻辑。
- 如果 Token 已经存在,则认为这是一个重复请求,直接返回上次的执行结果,不再执行业务逻辑。
- Token 的过期机制: 为了防止 Redis 中存储过多的 Token,需要设置 Token 的过期时间。过期时间应该根据业务场景进行合理设置,确保在正常情况下,Token 不会过期。
用一张表格可以更清晰的展示:
| 步骤 | 客户端 | 服务端 | Redis |
|---|---|---|---|
| 1 | 请求 Token 生成接口,获取 Token | 生成唯一 Token (UUID),返回给客户端 | 无 |
| 2 | 携带 Token 发起业务请求 | 接收请求,提取 Token。 | 无 |
| 3 | 检查 Redis 中是否存在该 Token。 | 检查 Token 是否存在。 | |
| 4 | Token 不存在: 将 Token 存入 Redis,设置过期时间,执行业务逻辑,返回结果。 | 存储 Token,设置过期时间。 | |
| 5 | Token 存在: 认为重复请求,返回上次结果(如果需要记录重复请求日志,可以记录)。 | 无 | |
| 6 | [Token 过期的情况] 业务逻辑执行失败(比如写入数据库失败),但是已经将Token写入redis, 建议采用柔性幂等,考虑补偿机制。可以重新生成Token重试,或者人工介入解决。 | 如果 Token 因为过期被删除,则视为新的请求(需要根据业务场景判断是否允许),可能导致重复执行。因此Token过期时间需要合理设置。 |
3. 代码示例
下面我们通过代码示例来演示如何实现防重 Token + Redis 方案。
3.1 客户端代码
客户端需要负责生成 Token,并在发起请求时将 Token 携带到服务端。
import java.util.UUID;
import org.springframework.web.client.RestTemplate;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
public class Client {
private static final String TOKEN_URL = "http://localhost:8080/token"; // 获取 Token 的接口
private static final String BUSINESS_URL = "http://localhost:8080/business"; // 业务接口
private static final String TOKEN_HEADER = "Idempotent-Token"; // 自定义 Header,用于传递 Token
private final RestTemplate restTemplate = new RestTemplate();
public String executeBusinessLogic() {
// 1. 获取 Token
String token = getToken();
// 2. 构造请求头,携带 Token
HttpHeaders headers = new HttpHeaders();
headers.set(TOKEN_HEADER, token);
HttpEntity<String> requestEntity = new HttpEntity<>(headers);
// 3. 发起业务请求
ResponseEntity<String> response = restTemplate.exchange(BUSINESS_URL, HttpMethod.POST, requestEntity, String.class);
return response.getBody();
}
private String getToken() {
return restTemplate.getForObject(TOKEN_URL, String.class);
}
public static void main(String[] args) {
Client client = new Client();
for (int i = 0; i < 5; i++) {
String result = client.executeBusinessLogic();
System.out.println("Result: " + result);
try {
Thread.sleep(100); // 模拟网络延迟
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
3.2 服务端代码
服务端需要提供两个接口:
- 获取 Token 接口:用于客户端获取 Token。
- 业务接口:用于处理业务逻辑,并验证 Token。
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@RestController
public class ServerController {
private static final String TOKEN_PREFIX = "idempotent:token:"; // Redis Key 前缀
private static final long TOKEN_EXPIRATION = 60; // Token 过期时间,单位:秒
private static final String TOKEN_HEADER = "Idempotent-Token"; // 自定义 Header,用于传递 Token
@Autowired
private StringRedisTemplate redisTemplate;
private int counter = 0;
@GetMapping("/token")
public String generateToken() {
String token = UUID.randomUUID().toString();
return token;
}
@PostMapping("/business")
public String handleBusiness(@RequestHeader(TOKEN_HEADER) String token) {
// 1. 验证 Token 是否存在
String key = TOKEN_PREFIX + token;
Boolean exists = redisTemplate.hasKey(key);
if (exists != null && exists) {
// 2. Token 存在,说明是重复请求
return "Duplicate request";
} else {
// 3. Token 不存在,是新的请求
// 3.1 将 Token 存入 Redis,并设置过期时间
redisTemplate.opsForValue().set(key, "1", TOKEN_EXPIRATION, TimeUnit.SECONDS);
// 3.2 执行业务逻辑
counter++;
//模拟写入数据库操作,可能存在失败情况
try{
Thread.sleep(10);
}catch (InterruptedException e){
e.printStackTrace();
}
return "Business logic executed. Counter: " + counter;
}
}
}
3.3 Redis 配置
确保你的 Spring Boot 项目已经配置了 Redis。 如果没有,添加以下依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
并在 application.properties 或 application.yml 中配置 Redis 连接信息:
spring.redis.host=localhost
spring.redis.port=6379
#spring.redis.password=your_password
3.4 运行示例
- 启动 Redis 服务。
- 启动 Spring Boot 服务。
- 运行客户端代码。
观察控制台输出,可以看到只有第一次请求会执行业务逻辑,后续的请求都会被认为是重复请求。
4. 关键点和注意事项
- Token 的唯一性: 必须保证 Token 的唯一性,可以使用 UUID 或者其他唯一标识符生成算法。
- Token 的过期时间: 需要根据业务场景合理设置 Token 的过期时间。 过期时间过短可能会导致正常请求被误判为重复请求,过期时间过长可能会导致 Redis 中存储过多的 Token。
- Redis 的高可用: 建议使用 Redis 集群或者哨兵模式,保证 Redis 的高可用,避免 Redis 故障导致幂等性失效。
- 异常处理: 在服务端处理业务逻辑时,需要考虑各种异常情况,例如数据库连接失败、业务逻辑执行失败等。 如果出现异常,需要保证 Redis 中的 Token 不会被删除,防止请求被重复执行。 或者采用补偿机制,保证最终一致性。
-
并发问题: 虽然Redis本身是单线程的,但是高并发场景下仍然存在并发问题。例如,在token不存在时,多个请求同时尝试写入Redis,可能会导致只有一个请求写入成功,其他请求失败。 建议使用 Redis 的
SETNX命令,保证只有一个请求能够成功写入 Token。Boolean success = redisTemplate.opsForValue().setIfAbsent(key, "1", TOKEN_EXPIRATION, TimeUnit.SECONDS); if (success != null && success) { // Token 写入成功,执行业务逻辑 } else { // Token 写入失败,说明是重复请求 } - 柔性幂等: 考虑最终一致性,允许短时间内的数据不一致,通过后续的补偿机制达到最终一致。 例如,如果业务逻辑执行失败,可以记录失败的请求,然后通过定时任务或者人工介入进行补偿。
- 请求参数校验: 在实际应用中,除了token防重之外,还需要对请求参数进行校验,防止恶意请求。
- 数据版本号 (乐观锁): 适用于更新操作,在更新数据时,比较数据的版本号,如果版本号一致,则更新数据,并将版本号加1;如果版本号不一致,则说明数据已经被修改,放弃更新。
- 数据库唯一索引: 在数据库表上建立唯一索引,保证数据的唯一性。 适用于插入操作,如果插入的数据违反了唯一索引约束,则插入失败。
5. 适用场景和局限性
适用场景:
- 大部分需要保证幂等性的接口,例如支付接口、创建订单接口等。
- 对性能要求较高的场景,Redis 的读写速度很快,可以有效地提高接口的性能。
- 简单的分布式系统,不需要复杂的事务管理。
局限性:
- 需要引入 Redis 作为依赖,增加了系统的复杂度。
- Token 的过期时间需要根据业务场景进行合理设置,有一定的维护成本。
- 不适用于需要强事务一致性的场景,例如银行转账等。 对于这些场景,需要使用分布式事务解决方案。
- token如果被盗用,会导致重复请求绕过幂等性校验。需要考虑token的安全性,例如使用HTTPS协议传输token,或者对token进行加密。
6. 如何选择合适的幂等性方案
选择合适的幂等性方案需要根据具体的业务场景进行综合考虑。
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 防重 Token + Redis | 实现简单,性能较高,适用于大部分场景。 | 需要引入 Redis 作为依赖,Token 的过期时间需要合理设置,不适用于需要强事务一致性的场景。 | 大部分需要保证幂等性的接口,例如支付接口、创建订单接口等。对性能要求较高的场景,简单的分布式系统,不需要复杂的事务管理。 |
| 数据库唯一索引 | 实现简单,可以保证数据的唯一性。 | 只能用于插入操作,无法解决更新操作的幂等性问题。 | 适用于插入操作,如果插入的数据违反了唯一索引约束,则插入失败。 |
| 乐观锁(版本号) | 适用于更新操作,可以避免并发更新导致的数据不一致问题。 | 需要在数据库表中添加版本号字段,如果并发更新冲突严重,可能会导致大量的更新失败。 | 适用于更新操作,在更新数据时,比较数据的版本号,如果版本号一致,则更新数据,并将版本号加1;如果版本号不一致,则说明数据已经被修改,放弃更新。 |
| 悲观锁 | 可以保证强事务一致性。 | 性能较低,可能会导致死锁。 | 适用于需要强事务一致性的场景,例如银行转账等。 |
| 分布式事务 | 可以保证跨多个服务的事务一致性。 | 实现复杂,性能较低。 | 适用于需要跨多个服务的事务一致性的场景。 |
在实际应用中,可以根据不同的场景选择不同的幂等性方案,或者将多种方案结合使用,以达到最佳的效果。
7. 总结
今天我们详细介绍了在 JAVA 微服务架构下如何使用防重 Token + Redis 方案实现接口幂等性。 这种方案实现简单、性能较高,适用于大部分需要保证幂等性的接口。 同时也讨论了该方案的局限性和注意事项,以及如何选择合适的幂等性方案。
希望今天的分享能够帮助大家更好地理解和应用接口幂等性技术,构建更加稳定可靠的分布式系统。
关于幂等性方案的一些补充说明
- 理解幂等性是构建可靠分布式系统的关键。
- 防重 Token + Redis 方案是一个实用且常见的选择。
- 根据业务场景和系统需求选择合适的方案至关重要。