# scaffold 项目之幂等性

# 简介

幂等性 (防重复提交) 是指一个操作(或接口)无论执行多少次,其对系统状态的影响都与第一次执行的结果一致。

  • 数学中的幂等性:例如, f(f(x)) = f(x) ,如绝对值函数 abs(abs(x)) = abs(x)
  • 计算机领域:重复调用接口不会产生副作用(如重复扣款、重复生成订单)。

# 核心特点

  • 结果一致性:多次执行结果与一次执行相同。
  • 副作用可控:重复调用不会导致数据错误或逻辑混乱。
  • 适用场景:网络请求重试、分布式事务、接口调用等需要容错的场景。

# 典型应用场景

场景说明
HTTP 请求例如: GET (查询)、 PUT (全量更新)、 DELETE (删除)是幂等的,而 POST (新增)通常不是。
支付系统防止用户重复点击导致多次扣款。
订单创建避免网络重试时生成多个相同订单。
数据库操作UPDATE table SET col=1 (多次执行结果相同)是幂等的,而 UPDATE col=col+1 不是。

# 幂等性与并发控制的区别

特性幂等性并发控制
目标确保重复操作的结果一致防止多个请求同时修改同一资源
典型场景网络重试、客户端重复提交高并发下单、库存扣减
实现手段Token 机制、唯一索引、状态机分布式锁、乐观锁、悲观锁

# 实际例子

  • 支付场景
    用户点击支付按钮时,生成唯一订单号。服务端校验订单号是否存在,若存在则直接返回结果,否则创建新订单。
  • 文件上传
    客户端上传文件前先请求服务端生成唯一上传 ID,后续分片上传携带该 ID,服务端校验 ID 是否有效。

# 开始使用

# 说明

本项目封装了 scaffold-spring-boot-starter-protection 组件, 由它的 idempotent 包来做幂等性,它提供了 声明式 幂等性特性,可以防止重复请求。列如说,前端某个按钮,前端如果没有做限制,用户快速点击多词,导致发送了多次重复请求。

声明式注解代码如下:

package com.tz.scaffold.framework.idempotent.core.annotation;
import com.tz.scaffold.framework.idempotent.core.keyresolver.impl.DefaultIdempotentKeyResolver;
import com.tz.scaffold.framework.idempotent.core.keyresolver.IdempotentKeyResolver;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;
/**
 * <p> Project: scaffold - Idempotent </p>
 *
 * 幂等注解
 * @author Tz
 * @date 2024/01/09 23:45
 * @version 1.0.0
 * @since 1.0.0
 */
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
    /**
     * 幂等的超时时间,默认为 1 秒
     *
     * 注意,如果执行时间超过它,请求还是会进来
     */
    int timeout() default 1;
    /**
     * 时间单位,默认为 SECONDS 秒
     */
    TimeUnit timeUnit() default TimeUnit.SECONDS;
    /**
     * 提示信息,正在执行中的提示
     */
    String message() default "重复请求,请稍后重试";
    /**
     * 使用的 Key 解析器
     */
    Class<? extends IdempotentKeyResolver> keyResolver() default DefaultIdempotentKeyResolver.class;
    /**
     * 使用的 Key 参数
     */
    String keyArg() default "";
}

实际使用

@Idempotent(timeout = 10, timeUnit = TimeUnit.SECONDS, message = "正在添加用户,请稍后尝试")
@Post("/user/add")
public String createUser(User user) {
    userService.add(user);
    return "添加成功";
}

上面注释的解释,10 秒钟内,所有用户只能操作一次

问题:如何指定用户或者 IP 在指定时间内只能操作一次呢?

自定义规则:
可设置该注解的 keyResolver 属性,可选择的有:

  • DefaultldempotentKeyResolver: 全局级别
  • UserldempotentKeyResolver: 用户级别
  • ExpressionldempotentKeyResover: 自定义级别,通过 keyArg 属性指定 Spring EL 表达式

# 实现原理

设计幂等性关键的几个点

  • 唯一标识符:客户端生成唯一请求 ID(如 UUID),服务端校验 ID 是否已处理。
  • 业务状态机:定义操作的状态流转规则(如订单从 “未支付” 到 “已支付” 不可逆)。
  • 版本号控制:为数据添加版本号,更新时校验版本号是否匹配。
  • 数据库约束:利用唯一索引、主键冲突防止重复数据插入。

实现原理是针对相同的参数,一段时间内,有且仅能执行一次,这里能想到的就算用 redis,执行流程如下:

  1. 在方法执行前,根据参数对应的 key 查询是否存在:

    • 如果存在,说明正在执行中,则进行报错返回

    • 如果不在,根据对应的参数,生成对应的 key,将 key 存储在 redis 中,并设置过期时长,就是标记正在执行。

      默认的 key 计算是 MD5 (方法名 + 方法参数),

  2. 方法执行完成,不会主动删除 reids 中存储的 key

    严格来说本项目提供的幂等性其实和前面用 redis实现的分布式锁 类似

  3. 如果方法执行时间较长,超过密钥的过期时间,则 Redis 会自动删除对应的密钥。因此,需要大概评估下,避免方法的执行时间超过过期时间。

  4. 如果方法执行发生异常 (Exception) 异常时,默认会删除密钥,避免下次请求无法正常执行,此处参考《美团 GTIS》。

# @Idempotent 注解

package com.tz.scaffold.framework.idempotent.core.annotation;
import com.tz.scaffold.framework.idempotent.core.keyresolver.impl.DefaultIdempotentKeyResolver;
import com.tz.scaffold.framework.idempotent.core.keyresolver.IdempotentKeyResolver;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;
/**
 * <p> Project: scaffold - Idempotent </p>
 *
 * 幂等注解
 * @author Tz
 * @date 2024/01/09 23:45
 * @version 1.0.0
 * @since 1.0.0
 */
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
    /**
     * 幂等的超时时间,默认为 1 秒
     *
     * 注意,如果执行时间超过它,请求还是会进来
     */
    int timeout() default 1;
    /**
     * 时间单位,默认为 SECONDS 秒
     */
    TimeUnit timeUnit() default TimeUnit.SECONDS;
    /**
     * 提示信息,正在执行中的提示
     */
    String message() default "重复请求,请稍后重试";
    /**
     * 使用的 Key 解析器
     */
    Class<? extends IdempotentKeyResolver> keyResolver() default DefaultIdempotentKeyResolver.class;
    /**
     * 使用的 Key 参数
     */
    String keyArg() default "";
}

对应的切面处理:

package com.tz.scaffold.framework.idempotent.core.aop;
/**
 * <p> Project: scaffold - IdempotentAspect </p>
 *
 * 拦截声明了 {@link Idempotent} 注解的方法,实现幂等操作
 * @author Tz
 * @date 2024/01/09 23:45
 * @version 1.0.0
 * @since 1.0.0
 */
@Aspect
@Slf4j
public class IdempotentAspect {
    /**
     * IdempotentKeyResolver 集合
     */
    private final Map<Class<? extends IdempotentKeyResolver>, IdempotentKeyResolver> keyResolvers;
    private final IdempotentRedisDAO idempotentRedisDAO;
    public IdempotentAspect(List<IdempotentKeyResolver> keyResolvers, IdempotentRedisDAO idempotentRedisDAO) {
        this.keyResolvers = CollectionUtils.convertMap(keyResolvers, IdempotentKeyResolver::getClass);
        this.idempotentRedisDAO = idempotentRedisDAO;
    }
    @Before("@annotation(idempotent)")
    public Object beforePointCut(JoinPoint joinPoint, Idempotent idempotent) {
        // 获得 IdempotentKeyResolver
        IdempotentKeyResolver keyResolver = keyResolvers.get(idempotent.keyResolver());
        Assert.notNull(keyResolver, "找不到对应的 IdempotentKeyResolver");
        // 解析 Key
        String key = keyResolver.resolver(joinPoint, idempotent);
        // 锁定 Key。
        boolean success = idempotentRedisDAO.setIfAbsent(key, idempotent.timeout(), idempotent.timeUnit());
        // 锁定失败,抛出异常
        if (!success) {
            log.info("[beforePointCut][方法({}) 参数({}) 存在重复请求]", joinPoint.getSignature().toString(), joinPoint.getArgs());
            throw new ServiceException(GlobalErrorCodeConstants.REPEATED_REQUESTS.getCode(), idempotent.message());
        }
    }
}

生成 key 和对应的写入 redis 操作:

package com.tz.scaffold.framework.idempotent.core.redis;
import lombok.AllArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;
/**
 * <p> Project: scaffold - IdempotentRedisDAO </p>
 *
 * 幂等 Redis DAO
 * @author Tz
 * @date 2024/01/09 23:45
 * @version 1.0.0
 * @since 1.0.0
 */
@AllArgsConstructor
public class IdempotentRedisDAO {
    /**
     * 幂等操作
     *
     * KEY 格式:idempotent:% s // 参数为 uuid
     * VALUE 格式:String
     * 过期时间:不固定
     */
    private static final String IDEMPOTENT = "idempotent:%s";
    private final StringRedisTemplate redisTemplate;
    public Boolean setIfAbsent(String key, long timeout, TimeUnit timeUnit) {
        String redisKey = formatKey(key);
        return redisTemplate.opsForValue().setIfAbsent(redisKey, "", timeout, timeUnit);
    }
    private static String formatKey(String key) {
        return String.format(IDEMPOTENT, key);
    }
}

# 使用示例

在需要使用的地方引入组件

<dependency>
    <groupId>com.tz.boot</groupId>
    <artifactId>scaffold-spring-boot-starter-protection</artifactId>
</dependency>

声明式注解

@Idempotent(timeout = 10, timeUnit = TimeUnit.SECONDS, message = "正在添加用户,请稍后尝试")

# 总结

幂等性是分布式系统中保障数据一致性和服务可靠性的核心机制,尤其在网络不稳定、客户端可能重试的场景下至关重要。设计时应结合具体业务需求,选择 Token 校验、唯一索引、状态机等合适方案,确保系统在重复请求时仍能正确响应。