数学中幂等就是多次运算结果一致,对应到实际工作的软件或者网络环境中就是同一个操作不管你操作多少次结果是一样的。
我们在编程过程中会看到一些幂等是天然存在的,比如:
幂等问题之所以产生无外乎重复点击或者网络重发,比如:
1)点击提交按钮两次
2)操作进行的时候点击了刷新按钮
3)在浏览器中后退后重复之前的操作,导致重复提交表单
4)Nginx重发
5)分布式RPC环境的try重发
6)消息重复消费,使用MQ消息中间件的时候,消息中间件错误没及时提交,导致重复消费。
为了保证幂等性,主要有以下一些方法:
这个方法是调用方在调用接口时候先向后端请求一个全局ID(Token),请求的时候携带全局ID一起请求,后端需要用这个Token作为Key,用户信息作为Value到Redis中进行键值内容校验,如果Key存在且Value匹配就执行删除命令,然后执行后面的业务逻辑。如果不存在对应的Key或者Value不匹配就返回执行错误的信息。
使用流程如下图所示:
①服务端提供一个接口,用于获取Token,这个Token可以是一个序列号、分布式ID或者UUID。客户端调用接口获取Token,这时服务端会生成一个Token串。
②将这个Token串存入到Redis中,以该Token作为Redis的键(需要设置过期时间)。
③将Token返回到客户端,客户端拿到后存储到表单隐藏域中。
④客户端在执行提交表单时,在Header中带上Token。
⑤服务端接收请求,从Header中拿到Token,然后在Redis中查找是否存在对应的Key,如果存在就将Key删除,如果不存在抛出重复提交的异常。这里要注意查找和删除操作都要保证原子性,否则在并发情况下可能无法保证幂等。至于原子性可以通过分布式锁或者Lua脚本来注销查询与删除操作。
⑥返回结果,执行正常的业务逻辑或者提示错误信息。
这种方法可以适用于插入、更新和删除操作。限制就是需要生成全局唯一Token串,而且需要用Redis进行数据校验。
这里我们具体看一下他的实现方法:
pom实现
引入springboot、Redis、lombok等相关依赖
org.springframework.boot
spring-boot-starter-web
2.3.5.RELEASE
org.springframework.boot
spring-boot-starter-data-redis
2.2.2.RELEASE
org.apache.commons
commons-pool2
2.6.1
org.projectlombok
lombok
1.18.16
application实现
一个Redis连接相关参数配置文件
spring:
redis:
ssl: false
host: 127.0.0.1
port: 6379
database: 0
timeout: 1000
password:
lettuce:
pool:
max-active: 100
max-wait: -1
min-idle: 0
max-idle: 20
创建Token验证Token工具类
@Slf4j
@Service
public class TokenUtilService {
@Autowired
private StringRedisTemplate redisTemplate;
/*
* 存入Redis的Token的前缀
*/
private static final String IDEMPOTENT_TOKEN_PREFIX = "idempotent_token:";
public String generateToken(String value) {
String token = UUID.randomUUID().toString();
//设置存入Redis的key
String key = IDEMPOTENT_TOKEN_PREFIX + token;
//存储Token到Redis并设置过期时间为5分钟
redisTemplate.opsForValue().set(key, value, 5, TimeUnit.MiNUTES);
return token;
}
public boolean validToken(String token, String value) {
//设置Lus脚本,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 redisScript= new DefaultRedisScript<>(script, Long.class);
String key = IDEMPOTENT_TOKEN_PREFIX + token;
//执行Lua脚本
Long result = redisTemplate.execute(redisScript, Arrays.asList(key, value));
//根据返回结果判断是否成功匹配并删除,结果不为空或0,验证通过
if (result != null && result != 0L) {
log.info("验证 token={}, key={}, value={}, 成功", token, key, value);
return true;
}
log.info("验证 token={}, key={}, value={}, 失败", token, key, value);
return false;
}
}
测试类(Controller层模拟)
@Slf4j
@RestController
public class TokenContoller {
@Autowired
private TokenUtilService tokenService;
/*
* 获取Token接口,返回Token串
*/
@GetMapping("/token")
public String getToken() {
//模拟数据,使用token验证是否存在对应的key
String userInfo = "myInfo";
return tokenService.generateToken(userInfo);
}
/*
* 幂等性测试接口
*/
@PostMapping("/test")
public String test(@RequestHeader(value = "token") String token) {
String userInfo = "myInfo";
boolean result = tokenService.validToken(tolen, userInfo);
return result ? "正常调用":"重复调用";
}
}
最后,这个方案还有一个改进版本,就是引入关系库,利用关系库事务的特性来保证操作的原子性,就是把处理过的数据插入到关系库中,最后再把幂等Key插入到Redis 上,在并发情况下仍然可以保证幂等。
每次向服务端请求时附带一个短时间唯一不重复的序列号,这个序列号一般由下游生成,在调用上游服务端接口时附加序列号和用于认证的ID。上游服务器拿这个序列号和下游认证ID组合,形成用于操作Redis的Key,然后到Redis中查询是否存在对应的key。如果存在,说明已经对下游的序列号的请求做了处理,直接返回重复请求的错误信息;如果不存在,就以这个Key作为Redis的键,以下游关键信息做为存储的值,将该键值对存储到Redis中,然后再执行正常的业务逻辑。
使用的流程如下图所示:
需要注意的是插入数据到Redis一定要设置过期时间。这样保证在时间范围内,重复调用接口可以识别,不然可能导致数据无限量存入Redis。
这个方法适用于插入、更新和删除操作,代价是需要第三方传递唯一序列号,而且需要使用Redis进行数据校验。
这里使用数据库唯一主键的约束特性,这种方法适用于插入时的幂等,能保证一张表值存一个带该主键的记录,这里使用的主键一般来说指的是分布式ID,这样可以保证分布式环境下ID的全局唯一性。
使用流程如下图所示:
①客户端执行创建请求,调用服务端接口。
②服务端执行业务逻辑并生成一个分布式ID,将该ID作为插入数据的主键执行插入操作,这里的ID生成算法可以使用雪花算法,也可以使用数据库号段模式或者Redis自增的方法生成分布式唯一ID。
③服务端执行数据库的插入,如果插入成功代表没有重复调用接口。如果抛出主键重复异常,就返回错误信息到客户端。
这种方法适用于插入操作和删除操作,限制是需要生成一个主键。
数据库乐观锁一般用于更新操作的,方法是在对应的数据库表中多加一个版本标识的字段,这样每次更新都会检查这个版本标识值。
他的使用流程很简单如下图:
唯一需要注意的就是执行update语句时多一个判断当前版本的条件,例如:
update my_table set price=price+50, version=version+1 where id = 3 and version = 5;
这样每执行一次version会变,如果重复执行还是原来的版本号执行不会生效,保证了幂等。
这种方法只能用于更新操作,而且还需要在对应的数据库表中多加一个字段。
最后,我们总结一下常用的四种后端的处理幂等性问题的方法,如下所示:
除了以上说的主要的方法,还有一些方法也可以采用:
使用了ConcurrentHashMap并发容器putIfAbsent方法和ScheduledThreadPoolExecutor 定时任务,也可以使用guava cache的机制,guava中有配有缓存的有效时间也是可以,key的生成通过Content-MD5,Content-MD5在一定范围内是唯一的,用的时候可以认为近似唯一,在低并发的环境下可以当做key 用。
当然本地锁也只适用于单机部署的应用,我们看一下他的简单实现:
配置注解:
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Resubmit {
/*
* 延时时间,在延时多久后可以再次提交,单位为秒
* */
int delaySeconds() default 20;
}
实例化锁:
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.digest.DigestUtils;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
@Slf4j
public final class ResubmitLock {
private static final ConcurrentHashMap LOCK_CACHE = new ConcurrentHashMap(200);
private static final ScheduledThreadPoolExecutor EXECUTOR = new ScheduledThreadPoolExecutor(5, new ThreadPoolExecutor.DiscardPolicy());
private ResubmitLock() {
}
/*
* 静态内部类的单例模式
* */
private static class SingletonInstance {
private static final ResubmitLock Instance = new ResubmitLock();
}
public static ResubmitLock getInstance() {
return SingletonInstance.Instance;
}
public static String handleKey(String param) {
return DigestUtils.md5Hex(param == null ? "" : param);
}
public boolean lock(final String key, Object value) {
return Objects.isNull(LOCK_CACHE.putIfAbsent(key, value));
}
public void unlock(final boolean lock, final String key, final int delaySeconds) {
if (lock) {
EXECUTOR.schedule(() -> {
LOCK_CACHE.remove(key);
}, delaySeconds, TimeUnit.SECONDS);
}
}
}
AOP切面:
import java.lang.reflect.Method;
@Log4j
@Aspect
@Component
public class ResubmitDataAspect {
private final static String DATA = "data";
private final static Object PRESENT = new Object();
@Around("@annotation(com.cn.xxx.common.annotation.Resubmit)")
public Object handleResubmit(ProceedingJoinPoint joinPoint) {
Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
//获取注解信息
Resubmit annotation = method.getAnnotation(Resubmit.class);
int delaySeconds = annotation.delaySeconds();
Object[] pointArgs = joinPoint.getArgs();
String key = "";
//获取第一个参数
Object firstParam = pointArgs[0];
if (firstParam instanceof RequestDTO) {
//解析参数
JSONObject requestDTO = JSONObject.parseObject(firstParam.toString());
JSONObject data = JSONObject.parseObject(requestDTO.getString(DATA));
if (data != null) {
StringBuffer sb = new StringBuffer();
data.forEach((k, v) -> {
sb.apperd(v);
});
key = ResubmitLock.handleKey(sb.toString());
}
}
boolean lock = false;
try {
//设置解锁key
lock = ResubmitLock.getInstance().lock(key, PRESENT);
if (lock) {
//放行
return joinPoint.proceed();
} else {
//响应重复提交异常
return new ResponseDTO<>(RespoinseCode.REPEAT_SUBMIT_OPERATION_EXCEPTION);
}
} finally {
//设置解锁key和解锁时间
ResubmitLock.getInstance().unLock(lock, key, delaySeconds);
}
}
}
使用注解:
public class ResponseToSavaPosts {
@ApiOperation(value = "保存我的帖子接口", notes = "保存我的帖子接口")
@PostMapping("/posts/save")
@Resubmit(delaySeconds = 10)
public void ResponseToSava(@RequestBody @Validated RequestDTOrequestDto) {
return bbsPostsBizService.saveBbsPosts(requestDto);
}
}
熟悉Redis的都知道它是线程安全的,我们利用它的特性可以很轻松的实现一个分布式锁,比如opsForValue().setIfAbsent(key)它的作用就是如果缓存中没有当前key则进行缓存同时返回true,当缓存后给key再设置一个过期时间,防止因为系统崩溃而导致锁不释放形成死锁,我们可以认为当返回true的时候他取到锁了,在锁没有释放的时候我们进行异常的抛出。
使用select … for update,这样和synchronized的原理是一样的,先锁住再查再执行update或insert操作。这样做的问题是要考虑如何避免死锁,而且效率也比较差,这种方法针对单体应用并发量小的情况下可以用。
通常是在提交后,设置提交按钮禁止点击(一般会设定一个定长的时间段)。
这种方式就是提交后执行页面重定向,PRG(Post-Redirect-Get)模式。
也就是说用户提交表单后,去执行一个客户端的重定向,转到提交成功的信息页面。这样能够避免页面刷新导致的重复提交,也不会出现浏览器表单重复提交的警告,也能消除按浏览器前进后退按钮导致的问题。