幂等有很多方案,但是个人觉得这个最好用,来一个示例方案先

我们使用Redis保存Token令牌,引入SpringBootRedisULID相关的依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    <version>2.7.0</version>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <version>2.7.0</version>
</dependency>

<dependency>
    <groupId>com.github.f4b6a3</groupId>
    <artifactId>ulid-creator</artifactId>
    <version>5.2.0</version>
</dependency>

Redis相关配置

spring.redis.database=0  
spring.redis.host=127.0.0.1  
spring.redis.port=6379  
spring.redis.password=  
spring.redis.pool.max-active=8  
spring.redis.pool.max-wait=-1  
spring.redis.pool.max-idle=8  
spring.redis.pool.min-idle=0  
spring.redis.timeout=60  
server.port=8080  
server.servlet.context-path=/coderacademy

Token令牌的生成

使用ULID生成随机串,存Redis.这里以idempotent_token+账户+请求操作类型+token作为key。

private StringRedisTemplate stringRedisTemplate;

/**
 * 存入Redis的Token键的前缀
 */
private static final String IDEMPOTENT_TOKEN_PREFIX = "idempotent_token:%s:$s:%s";

/**
 * 生成token令牌
 *
 * @param accountSecret 账户令牌
 * @param operatorType 接口请求类型,可以是接口url或者其他可以区分接口服务类型的值
 * @return token令牌
 */
@Override
public String generateToken(String accountSecret, String operatorType) {
    // 创建或获取ULID生成器实例
    long timestampInMillis = LocalDateTime.now().atZone(ZoneOffset.systemDefault()).toInstant().toEpochMilli();
    Ulid ulid = UlidCreator.getUlid(timestampInMillis);
    String token = ulid.toString();
    // 设置存入 Redis 的 Key
    String key = String.format(IDEMPOTENT_TOKEN_PREFIX, accountSecret, operatorType, token);
    // 存储 Token 到 Redis,且设置过期时间为5分钟
    stringRedisTemplate.opsForValue().set(key, accountSecret, 5, TimeUnit.MINUTES);
    // 返回 Token
    return token;
}

校验Token

这里我们使用Redis执行Lua命令去查找以及删除key,Lua 表达式能保证命令执行的原子性。

/**
     * 验证 Token 正确性
     *
     * @param token token 字符串
     * @param operatorType 接口请求类型,可以是接口url或者其他可以区分接口服务类型的值
     * @return 验证结果
     */
private boolean validToken(String token, String accountSecret, String operatorType) {
    // 设置 Lua 脚本,其中 KEYS[1] 是 key,KEYS[2] 是 value
    String script = "if redis.call('get', KEYS[1]) == KEYS[2] then return redis.call('del', KEYS[1]) else return 0 end";
    RedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
    // 根据 Key 前缀拼接 Key
    String key = String.format(IDEMPOTENT_TOKEN_PREFIX, accountSecret, operatorType, token);
    // 执行 Lua 脚本
    Long result = stringRedisTemplate.execute(redisScript, Arrays.asList(key, operatorType));
    // 根据返回结果判断是否成功成功匹配并删除 Redis 键值对,若果结果不为空和0,则验证通过
    if (result != null && result != 0L) {
        System.out.println(String.format("验证 token=%s,key=%s,value=%s 成功", token, key, operatorType));
        return true;
    }
    System.err.println(String.format("验证 token=%s,key=%s,value=%s 失败", token, key, operatorType));
    return false;
}

业务代码以及接口

我们在实现模拟创建订单的服务,在创建订单之前,首先校验token令牌

/**
 * 创建订单接口
 *
 * @param requestVO     创建订单参数
 * @param accountSecret 账户令牌
 * @param token         token令牌
 * @return 生成的订单号
 */
@Override
public String createOrder(OrderCreateRequestVO requestVO, String accountSecret, String token) {
    // 根据 Token 和与用户相关的信息到 Redis 验证是否存在对应的信息
    boolean result = validToken(token, accountSecret, "createOrder");
    if (!result){
        // 这里需要自定义异常,统一处理异常,再统一响应返回
        throw new RuntimeException("重复的请求");
    }
    // 根据验证结果响应不同信息
    return "Success";
}

校验如果不存在token,则说明请求时重复请求,直接抛出异常,由统一异常管理,直接返回客户端请求失败的错误信息。关于SpringBoot中统一异常处理,统一结果响应,请查看:

我们在定义获取Token令牌的接口,以及创建订单的接口

@RestController
@RequestMapping("order")
public class OrderController {

    private IOrderService orderService;

    /**
     * 获取token接口
     * @param secret 账户令牌
     * @return
     */
    @GetMapping("getToken")
    public String getToken(@RequestHeader("secret") String secret){
        return orderService.generateToken(secret, "createOrder");
    }

    /**
     * 创建订单接口
     * @param requestVO 参数
     * @param token token令牌
     * @param secret 账户令牌
     * @return 响应信息
     */
    @PostMapping("create")
    public OrderCreateResponseVO createOrder(@RequestBody OrderCreateRequestVO requestVO,
                                             @RequestHeader("token") String token,
                                             @RequestHeader("secret") String secret){
        OrderCreateResponseVO responseVO = new OrderCreateResponseVO();
        String result = orderService.createOrder(requestVO, secret, token);
        responseVO.setSuccess(Boolean.TRUE);
        responseVO.setMsg(result);
        return responseVO;
    }

    @Autowired
    public void setOrderService(IOrderService orderService) {
        this.orderService = orderService;
    }
}