JAVA 微服务如何优雅实现接口幂等?防重 Token + Redis 方案详解

JAVA 微服务接口幂等性保障:防重 Token + Redis 方案详解

大家好,今天我们来聊聊微服务架构下接口幂等性的实现。在分布式系统中,由于网络抖动、服务超时等原因,导致请求重复发送的情况屡见不鲜。如果不加以控制,这些重复请求可能会对数据造成不可预期的影响,例如重复扣款、重复下单等。因此,保证接口的幂等性至关重要。

幂等性是指一个操作,无论执行多少次,产生的结果都与执行一次的结果相同。 简单来说,就是用户发起一次请求,服务器端只执行一次操作。

今天,我们重点介绍一种常用的且相对简单的幂等性解决方案:防重 Token + Redis 方案。 这种方案结合了客户端的 Token 生成和服务器端的 Redis 存储,能够在大部分场景下有效地防止重复请求。

1. 为什么需要幂等性?

在深入讨论解决方案之前,我们先来明确一下为什么我们需要关注幂等性。考虑以下场景:

  • 网络抖动: 客户端发起一个支付请求,服务端处理成功后,返回结果给客户端的过程中发生网络抖动,客户端未收到响应,因此认为请求失败,重新发起支付请求。
  • 服务超时: 客户端发起一个创建订单请求,服务端处理时间超过客户端设置的超时时间,客户端认为请求失败,重新发起创建订单请求。
  • 消息队列重复消费: 消息队列在某些情况下可能会出现消息重复投递的情况,导致消费者重复消费消息。

在上述场景中,如果不保证接口的幂等性,可能会导致用户重复支付、重复创建订单等问题,造成严重的经济损失。

2. 防重 Token + Redis 方案原理

防重 Token + Redis 方案的核心思想是:

  1. 客户端生成 Token: 客户端在发起请求之前,向服务端申请一个唯一的 Token。这个 Token 可以是 UUID 或者其他唯一标识符。
  2. 服务端验证 Token: 服务端接收到请求后,首先验证 Token 是否存在于 Redis 中。
    • 如果 Token 不存在,则认为这是一个新的请求,将 Token 存入 Redis,并执行业务逻辑。
    • 如果 Token 已经存在,则认为这是一个重复请求,直接返回上次的执行结果,不再执行业务逻辑。
  3. 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.propertiesapplication.yml 中配置 Redis 连接信息:

spring.redis.host=localhost
spring.redis.port=6379
#spring.redis.password=your_password

3.4 运行示例

  1. 启动 Redis 服务。
  2. 启动 Spring Boot 服务。
  3. 运行客户端代码。

观察控制台输出,可以看到只有第一次请求会执行业务逻辑,后续的请求都会被认为是重复请求。

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 方案是一个实用且常见的选择。
  • 根据业务场景和系统需求选择合适的方案至关重要。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注