# 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,执行流程如下:
在方法执行前,根据参数对应的 key 查询是否存在:
如果存在,说明正在执行中,则进行报错返回
如果不在,根据对应的参数,生成对应的 key,将 key 存储在 redis 中,并设置过期时长,就是标记正在执行。
默认的 key 计算是 MD5 (方法名 + 方法参数),
方法执行完成,不会主动删除 reids 中存储的 key
严格来说本项目提供的幂等性其实和前面用
redis实现的分布式锁类似如果方法执行时间较长,超过密钥的过期时间,则 Redis 会自动删除对应的密钥。因此,需要大概评估下,避免方法的执行时间超过过期时间。
如果方法执行发生异常 (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 校验、唯一索引、状态机等合适方案,确保系统在重复请求时仍能正确响应。