1.什么是幂等
在我们编程中常见幂等
1)select查询天然幂等
2)delete删除也是幂等,删除同一个多次效果一样
3)update直接更新某个值的,幂等
4)update更新累加操作的,非幂等
5)insert非幂等操作,每次新增一条
2.产生原因
由于重复点击或者网络重发 eg:
1)点击提交按钮两次;
2)点击刷新按钮;
3)使用浏览器后退按钮重复之前的操作,导致重复提交表单;
4)使用浏览器历史记录重复提交表单;
5)浏览器重复的HTTP请;
6)nginx重发等情况;
7)分布式RPC的try重发等;
3.解决方案
在提交后执行页面重定向,这就是所谓的Post-Redirect-Get (PRG)模式。
简言之,当用户提交了表单后,你去执行一个客户端的重定向,转到提交成功信息页面。
这能避免用户按F5导致的重复提交,而其也不会出现浏览器表单重复提交的警告,也能消除按浏览器前进和后退按导致的同样问题。
在服务器端,生成一个唯一的标识符,将它存入session,同时将它写入表单的隐藏字段中,然后将表单页面发给浏览器,用户录入信息后点击提交,在服务器端,获取表单中隐藏字段的值,与session中的唯一标识符比较,相等说明是首次提交,就处理本次请求,然后将session中的唯一标识符移除;不相等说明是重复提交,就不再处理。
比较复杂 不适合移动端APP的应用 这里不详解
insert使用唯一索引 update使用 乐观锁 version版本法
这种在大数据量和高并发下效率依赖数据库硬件能力,可针对非核心业务
使用select ... for update ,这种和 synchronized
锁住先查再insert or update一样,但要避免死锁,效率也较差
针对单体 请求并发不大 可以推荐使用
原理:使用了 ConcurrentHashMap 并发容器 putIfAbsent 方法,和 ScheduledThreadPoolExecutor 定时任务,也可以使用guava cache的机制, gauva中有配有缓存的有效时间 也是可以的key的生成 Content-MD5 Content-MD5 是指 Body 的 MD5 值,只有当 Body 非Form表单时才计算MD5,计算方式直接将参数和参数名称统一加密MD5。
MD5在一定范围类认为是唯一的,近似唯一,当然在低并发的情况下足够了 。
当然本地锁只适用于单机部署的应用。
①配置注解
importjava.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public@interfaceResubmit {
/**
* 延时时间 在延时多久后可以再次提交
*
*@returnTime unit is one second
*/
intdelaySeconds()default20;
}
②实例化锁
importcom.google.common.cache.Cache;
importcom.google.common.cache.CacheBuilder;
importlombok.extern.slf4j.Slf4j;
importorg.apache.commons.codec.digest.DigestUtils;
importjava.util.Objects;
importjava.util.concurrent.ConcurrentHashMap;
importjava.util.concurrent.ScheduledThreadPoolExecutor;
importjava.util.concurrent.ThreadPoolExecutor;
importjava.util.concurrent.TimeUnit;
/**
*@authorlijing
* 重复提交锁
*/
@Slf4j
publicfinalclassResubmitLock{
privatestaticfinalConcurrentHashMapLOCK_CACHE =newConcurrentHashMap<>(200);
privatestaticfinalScheduledThreadPoolExecutor EXECUTOR =newScheduledThreadPoolExecutor(5,newThreadPoolExecutor.DiscardPolicy());
// private static final CacheCACHES = CacheBuilder.newBuilder()
// 最大缓存 100 个
// .maximumSize(1000)
// 设置写缓存后 5 秒钟过期
// .expireAfterWrite(5, TimeUnit.SECONDS)
// .build();
privateResubmitLock(){
}
/**
* 静态内部类 单例模式
*
*@return
*/
privatestaticclassSingletonInstance{
privatestaticfinalResubmitLock INSTANCE =newResubmitLock();
}
publicstaticResubmitLockgetInstance(){
returnSingletonInstance.INSTANCE;
}
publicstaticStringhandleKey(String param){
returnDigestUtils.md5Hex(param ==null?"": param);
}
/**
* 加锁 putIfAbsent 是原子操作保证线程安全
*
*@paramkey 对应的key
*@paramvalue
*@return
*/
publicbooleanlock(finalString key, Object value){
returnObjects.isNull(LOCK_CACHE.putIfAbsent(key, value));
}
/**
* 延时释放锁 用以控制短时间内的重复提交
*
*@paramlock 是否需要解锁
*@paramkey 对应的key
*@paramdelaySeconds 延时时间
*/
publicvoidunLock(finalbooleanlock,finalString key,finalintdelaySeconds){
if(lock) {
EXECUTOR.schedule(() -> {
LOCK_CACHE.remove(key);
}, delaySeconds, TimeUnit.SECONDS);
}
}
}
③AOP 切面
importcom.alibaba.fastjson.JSONObject;
importcom.cn.xxx.common.annotation.Resubmit;
importcom.cn.xxx.common.annotation.impl.ResubmitLock;
importcom.cn.xxx.common.dto.RequestDTO;
importcom.cn.xxx.common.dto.ResponseDTO;
importcom.cn.xxx.common.enums.ResponseCode;
importlombok.extern.log4j.Log4j;
importorg.aspectj.lang.ProceedingJoinPoint;
importorg.aspectj.lang.annotation.Around;
importorg.aspectj.lang.annotation.Aspect;
importorg.aspectj.lang.reflect.MethodSignature;
importorg.springframework.stereotype.Component;
importjava.lang.reflect.Method;
/**
*@ClassNameRequestDataAspect
*@Description数据重复提交校验
*@Authorlijing
*@Date2019/05/16 17:05
**/
@Log4j
@Aspect
@Component
publicclassResubmitDataAspect{
privatefinalstaticString DATA ="data";
privatefinalstaticObject PRESENT =newObject();
@Around("@annotation(com.cn.xxx.common.annotation.Resubmit)")
publicObjecthandleResubmit(ProceedingJoinPoint joinPoint)throwsThrowable{
Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
//获取注解信息
Resubmit annotation = method.getAnnotation(Resubmit.class);
intdelaySeconds = annotation.delaySeconds();
Object[] pointArgs = joinPoint.getArgs();
String key ="";
//获取第一个参数
Object firstParam = pointArgs[0];
if(firstParaminstanceofRequestDTO) {
//解析参数
JSONObject requestDTO = JSONObject.parseObject(firstParam.toString());
JSONObject data = JSONObject.parseObject(requestDTO.getString(DATA));
if(data !=null) {
StringBuffer sb =newStringBuffer();
data.forEach((k, v) -> {
sb.append(v);
});
//生成加密参数 使用了content_MD5的加密方式
key = ResubmitLock.handleKey(sb.toString());
}
}
//执行锁
booleanlock =false;
try{
//设置解锁key
lock = ResubmitLock.getInstance().lock(key, PRESENT);
if(lock) {
//放行
returnjoinPoint.proceed();
}else{
//响应重复提交异常
returnnewResponseDTO<>(ResponseCode.REPEAT_SUBMIT_OPERATION_EXCEPTION);
}
}finally{
//设置解锁key和解锁时间
ResubmitLock.getInstance().unLock(lock, key, delaySeconds);
}
}
}
④注解使用案例
@ApiOperation(value ="保存我的帖子接口", notes ="保存我的帖子接口")
@PostMapping("/posts/save")
@Resubmit(delaySeconds =10)
public ResponseDTOsaveBbsPosts(@RequestBody@ValidatedRequestDTOrequestDto) {
returnbbsPostsBizService.saveBbsPosts(requestDto);
}
以上就是本地锁的方式进行的幂等提交 使用了Content-MD5 进行加密 只要参数不变,参数加密 密值不变,key存在就阻止提交。
当然也可以使用 一些其他签名校验 在某一次提交时先 生成固定签名 提交到后端 根据后端解析统一的签名作为 每次提交的验证token 去缓存中处理即可。
在 pom.xml 中添加上 starter-web、starter-aop、starter-data-redis 的依赖即可
org.springframework.bootgroupId>
spring-boot-starter-webartifactId>
dependency>
org.springframework.bootgroupId>
spring-boot-starter-aopartifactId>
dependency>
org.springframework.bootgroupId>
spring-boot-starter-data-redisartifactId>
dependency>
dependencies>
属性配置 在 application.properites 资源文件中添加 redis 相关的配置项:
spring.redis.host=localhost
spring.redis.port=6379
spring.redis.password=123456
主要实现方式: 熟悉 Redis 的朋友都知道它是线程安全的,我们利用它的特性可以很轻松的实现一个分布式锁,如 opsForValue().setIfAbsent(key,value)它的作用就是如果缓存中没有当前 Key 则进行缓存同时返回 true 反之亦然;
当缓存后给 key 在设置个过期时间,防止因为系统崩溃而导致锁迟迟不释放形成死锁;那么我们是不是可以这样认为当返回 true 我们认为它获取到锁了,在锁未释放的时候我们进行异常的抛出…
packagecom.battcn.interceptor;
importcom.battcn.annotation.CacheLock;
importcom.battcn.utils.RedisLockHelper;
importorg.aspectj.lang.ProceedingJoinPoint;
importorg.aspectj.lang.annotation.Around;
importorg.aspectj.lang.annotation.Aspect;
importorg.aspectj.lang.reflect.MethodSignature;
importorg.springframework.beans.factory.annotation.Autowired;
importorg.springframework.context.annotation.Configuration;
importorg.springframework.util.StringUtils;
importjava.lang.reflect.Method;
importjava.util.UUID;
/**
* redis 方案
*
*@authorLevin
*@since2018/6/12 0012
*/
@Aspect
@Configuration
publicclassLockMethodInterceptor{
@Autowired
publicLockMethodInterceptor(RedisLockHelper redisLockHelper, CacheKeyGenerator cacheKeyGenerator){
this.redisLockHelper = redisLockHelper;
this.cacheKeyGenerator = cacheKeyGenerator;
}
privatefinalRedisLockHelper redisLockHelper;
privatefinalCacheKeyGenerator cacheKeyGenerator;
@Around("execution(public * *(..)) && @annotation(com.battcn.annotation.CacheLock)")
publicObjectinterceptor(ProceedingJoinPoint pjp){
MethodSignature signature = (MethodSignature) pjp.getSignature();
Method method = signature.getMethod();
CacheLock lock = method.getAnnotation(CacheLock.class);
if(StringUtils.isEmpty(lock.prefix())) {
thrownewRuntimeException("lock key don't null...");
}
finalString lockKey = cacheKeyGenerator.getLockKey(pjp);
String value = UUID.randomUUID().toString();
try{
// 假设上锁成功,但是设置过期时间失效,以后拿到的都是 false
finalbooleansuccess = redisLockHelper.lock(lockKey, value, lock.expire(), lock.timeUnit());
if(!success) {
thrownewRuntimeException("重复提交");
}
try{
returnpjp.proceed();
}catch(Throwable throwable) {
thrownewRuntimeException("系统异常");
}
}finally{
// TODO 如果演示的话需要注释该代码;实际应该放开
redisLockHelper.unlock(lockKey, value);
}
}
}
RedisLockHelper 通过封装成 API 方式调用,灵活度更加高
packagecom.battcn.utils;
importorg.springframework.boot.autoconfigure.AutoConfigureAfter;
importorg.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
importorg.springframework.context.annotation.Configuration;
importorg.springframework.data.redis.connection.RedisStringCommands;
importorg.springframework.data.redis.core.RedisCallback;
importorg.springframework.data.redis.core.StringRedisTemplate;
importorg.springframework.data.redis.core.types.Expiration;
importorg.springframework.util.StringUtils;
importjava.util.concurrent.Executors;
importjava.util.concurrent.ScheduledExecutorService;
importjava.util.concurrent.TimeUnit;
importjava.util.regex.Pattern;
/**
* 需要定义成 Bean
*
*@authorLevin
*@since2018/6/15 0015
*/
@Configuration
@AutoConfigureAfter(RedisAutoConfiguration.class)
publicclassRedisLockHelper{
privatestaticfinalString DELIMITER ="|";
/**
* 如果要求比较高可以通过注入的方式分配
*/
privatestaticfinalScheduledExecutorService EXECUTOR_SERVICE = Executors.newScheduledThreadPool(10);
privatefinalStringRedisTemplate stringRedisTemplate;
publicRedisLockHelper(StringRedisTemplate stringRedisTemplate){
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 获取锁(存在死锁风险)
*
*@paramlockKey lockKey
*@paramvalue value
*@paramtime 超时时间
*@paramunit 过期单位
*@returntrue or false
*/
publicbooleantryLock(finalString lockKey,finalString value,finallongtime,finalTimeUnit unit){
returnstringRedisTemplate.execute((RedisCallback) connection -> connection.set(lockKey.getBytes(), value.getBytes(), Expiration.from(time, unit), RedisStringCommands.SetOption.SET_IF_ABSENT));
}
/**
* 获取锁
*
*@paramlockKey lockKey
*@paramuuid UUID
*@paramtimeout 超时时间
*@paramunit 过期单位
*@returntrue or false
*/
publicbooleanlock(String lockKey,finalString uuid,longtimeout,finalTimeUnit unit){
finallongmilliseconds = Expiration.from(timeout, unit).getExpirationTimeInMilliseconds();
booleansuccess = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, (System.currentTimeMillis() + milliseconds) + DELIMITER + uuid);
if(success) {
stringRedisTemplate.expire(lockKey, timeout, TimeUnit.SECONDS);
}else{
String oldVal = stringRedisTemplate.opsForValue().getAndSet(lockKey, (System.currentTimeMillis() + milliseconds) + DELIMITER + uuid);
finalString[] oldValues = oldVal.split(Pattern.quote(DELIMITER));
if(Long.parseLong(oldValues[0]) +1<= System.currentTimeMillis()) {
returntrue;
}
}
returnsuccess;
}
/**
*@seeRedis Documentation: SET
*/
publicvoidunlock(String lockKey, String value){
unlock(lockKey, value,0, TimeUnit.MILLISECONDS);
}
/**
* 延迟unlock
*
*@paramlockKey key
*@paramuuid client(最好是唯一键的)
*@paramdelayTime 延迟时间
*@paramunit 时间单位
*/
publicvoidunlock(finalString lockKey,finalString uuid,longdelayTime, TimeUnit unit){
if(StringUtils.isEmpty(lockKey)) {
return;
}
if(delayTime <=0) {
doUnlock(lockKey, uuid);
}else{
EXECUTOR_SERVICE.schedule(() -> doUnlock(lockKey, uuid), delayTime, unit);
}
}
/**
*@paramlockKey key
*@paramuuid client(最好是唯一键的)
*/
privatevoiddoUnlock(finalString lockKey,finalString uuid){
String val = stringRedisTemplate.opsForValue().get(lockKey);
finalString[] values = val.split(Pattern.quote(DELIMITER));
if(values.length <=0) {
return;
}
if(uuid.equals(values[1])) {
stringRedisTemplate.delete(lockKey);
}
}
}
redis的提交参照博客:
https://blog.battcn.com/2018/06/13/springboot/v2-cache-redislock/
END
本文发于 微星公众号「程序员的成长之路」,回复「1024」你懂得,给个赞呗。
回复 [ 256 ] Java 程序员成长规划
回复 [ 777 ] 接私活的七大平台利器
回复 [ 2048 ] 免费领取C/C++,Linux,Python,Java,PHP,人工智能,单片机,树莓派,等 5T 学习资料