1、什么是幂等
在我们编程中常见幂等
select
查询天然幂等
delete
删除也是幂等,删除同一个多次效果一样
update
直接更新某个值的,幂等
update
更新累加操作的,非幂等
insert
非幂等操作,每次新增一条
2、产生原因 由于重复点击或者网络重发:
点击提交按钮两次;
点击刷新按钮;
使用浏览器后退按钮重复之前的操作,导致重复提交表单;
使用浏览器历史记录重复提交表单;
浏览器重复的HTTP请;
nginx重发等情况;
分布式RPC的try重发等;
3、解决方案 1. 前端js提交禁止按钮可以用一些js组件 2. 使用Post/Redirect/Get模式 在提交后执行页面重定向,这就是所谓的Post-Redirect-Get (PRG)模式。简言之,当用户提交了表单后,你去执行一个客户端的重定向,转到提交成功信息页面。这能避免用户按F5导致的重复提交,而其也不会出现浏览器表单重复提交的警告,也能消除按浏览器前进和后退按导致的同样问题。
3. 在session中存放一个特殊标志 在服务器端,生成一个唯一的标识符,将它存入session
,同时将它写入表单的隐藏字段中,然后将表单页面发给浏览器,用户录入信息后点击提交,在服务器端,获取表单中隐藏字段的值,与session
中的唯一标识符比较,相等说明是首次提交,就处理本次请求,然后将session
中的唯一标识符移除;不相等说明是重复提交,就不再处理。
比较复杂 不适合移动端APP的应用 这里不详解
5. 借助数据库 insert使用唯一索引 update使用 乐观锁 version版本法
这种在大数据量和高并发下效率依赖数据库硬件能力,可针对非核心业务
6. 借助悲观锁 使用select … for update
,这种和 synchronized
锁住先查再insert or update一样,但要避免死锁,效率也较差
针对单体 请求并发不大 可以推荐使用
7. 借助本地锁(本文重点) 原理 : 使用了 ConcurrentHashMap 并发容器 putIfAbsent 方法,和 ScheduledThreadPoolExecutor 定时任务,也可以使用guava cache的机制, gauva中有配有缓存的有效时间也是可以的key的生成Content-MD5
Content-MD5
是指 Body 的 MD5 值,只有当 Body 非Form表单时才计算MD5,计算方式直接将参数和参数名称统一加密MD5
MD5
在一定范围类认为是唯一的 近似唯一 当然在低并发的情况下足够了
本地锁只适用于单机部署的应用
1. 配置注解
1 2 3 4 5 6 7 8 9 10 11 12 13 14 import java.lang.annotation.*;@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface Resubmit { int delaySeconds () default 20 ; }
2. 实例化锁
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 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<String, Object> 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); } } }
3. AOP 切面
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 import com.alibaba.fastjson.JSONObject;import com.cn.xxx.common.annotation.Resubmit;import com.cn.xxx.common.annotation.impl.ResubmitLock;import com.cn.xxx.common.dto.RequestDTO;import com.cn.xxx.common.dto.ResponseDTO;import com.cn.xxx.common.enums.ResponseCode;import lombok.extern.log4j.Log4j;import org.aspectj.lang.ProceedingJoinPoint;import org.aspectj.lang.annotation.Around;import org.aspectj.lang.annotation.Aspect;import org.aspectj.lang.reflect.MethodSignature;import org.springframework.stereotype.Component;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) throws Throwable { 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.append(v); }); key = ResubmitLock.handleKey(sb.toString()); } } boolean lock = false ; try { lock = ResubmitLock.getInstance().lock(key, PRESENT); if (lock) { return joinPoint.proceed(); } else { return new ResponseDTO<>(ResponseCode.REPEAT_SUBMIT_OPERATION_EXCEPTION); } } finally { ResubmitLock.getInstance().unLock(lock, key, delaySeconds); } } }
4. 注解使用案例
1 2 3 4 5 6 7 @ApiOperation(value = "保存我的帖子接口", notes = "保存我的帖子接口") @PostMapping("/posts/save") @Resubmit(delaySeconds = 10) public ResponseDTO<BaseResponseDataDTO> saveBbsPosts (@RequestBody @Validated RequestDTO<BbsPostsRequestDTO> requestDto) { return bbsPostsBizService.saveBbsPosts(requestDto); }
以上就是本地锁的方式进行的幂等提交 使用了Content-MD5 进行加密 只要参数不变,参数加密 密值不变,key存在就阻止提交
当然也可以使用 一些其他签名校验 在某一次提交时先 生成固定签名 提交到后端 根据后端解析统一的签名作为 每次提交的验证token 去缓存中处理即可.
8. 借助分布式redis锁 (参考其他) 在 pom.xml 中添加上 starter-web、starter-aop、starter-data-redis 的依赖即可
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <dependencies > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-web</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-aop</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-data-redis</artifactId > </dependency > </dependencies >
属性配置 在 application.properites 资源文件中添加 redis 相关的配置项
1 2 3 spring.redis.host =localhost spring.redis.port =6379 spring.redis.password =123456
主要实现方式:
熟悉 Redis 的朋友都知道它是线程安全的,我们利用它的特性可以很轻松的实现一个分布式锁,如 opsForValue().setIfAbsent(key,value)它的作用就是如果缓存中没有当前 Key 则进行缓存同时返回 true 反之亦然;
当缓存后给 key 在设置个过期时间,防止因为系统崩溃而导致锁迟迟不释放形成死锁;那么我们是不是可以这样认为当返回 true 我们认为它获取到锁了,在锁未释放的时候我们进行异常的抛出…
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 package com.battcn.interceptor;import com.battcn.annotation.CacheLock;import com.battcn.utils.RedisLockHelper;import org.aspectj.lang.ProceedingJoinPoint;import org.aspectj.lang.annotation.Around;import org.aspectj.lang.annotation.Aspect;import org.aspectj.lang.reflect.MethodSignature;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.context.annotation.Configuration;import org.springframework.util.StringUtils;import java.lang.reflect.Method;import java.util.UUID;@Aspect @Configuration public class LockMethodInterceptor { @Autowired public LockMethodInterceptor (RedisLockHelper redisLockHelper, CacheKeyGenerator cacheKeyGenerator) { this .redisLockHelper = redisLockHelper; this .cacheKeyGenerator = cacheKeyGenerator; } private final RedisLockHelper redisLockHelper; private final CacheKeyGenerator cacheKeyGenerator; @Around("execution(public * *(..)) && @annotation(com.battcn.annotation.CacheLock)") public Object interceptor (ProceedingJoinPoint pjp) { MethodSignature signature = (MethodSignature) pjp.getSignature(); Method method = signature.getMethod(); CacheLock lock = method.getAnnotation(CacheLock.class); if (StringUtils.isEmpty(lock.prefix())) { throw new RuntimeException("lock key don't null..." ); } final String lockKey = cacheKeyGenerator.getLockKey(pjp); String value = UUID.randomUUID().toString(); try { final boolean success = redisLockHelper.lock(lockKey, value, lock.expire(), lock.timeUnit()); if (!success) { throw new RuntimeException("重复提交" ); } try { return pjp.proceed(); } catch (Throwable throwable) { throw new RuntimeException("系统异常" ); } } finally { redisLockHelper.unlock(lockKey, value); } } }
RedisLockHelper 通过封装成 API 方式调用,灵活度更加高
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 package com.battcn.utils;import org.springframework.boot.autoconfigure.AutoConfigureAfter;import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;import org.springframework.context.annotation.Configuration;import org.springframework.data.redis.connection.RedisStringCommands;import org.springframework.data.redis.core.RedisCallback;import org.springframework.data.redis.core.StringRedisTemplate;import org.springframework.data.redis.core.types.Expiration;import org.springframework.util.StringUtils;import java.util.concurrent.Executors;import java.util.concurrent.ScheduledExecutorService;import java.util.concurrent.TimeUnit;import java.util.regex.Pattern;@Configuration @AutoConfigureAfter(RedisAutoConfiguration.class) public class RedisLockHelper { private static final String DELIMITER = "|" ; private static final ScheduledExecutorService EXECUTOR_SERVICE = Executors.newScheduledThreadPool(10 ); private final StringRedisTemplate stringRedisTemplate; public RedisLockHelper (StringRedisTemplate stringRedisTemplate) { this .stringRedisTemplate = stringRedisTemplate; } public boolean tryLock (final String lockKey, final String value, final long time, final TimeUnit unit) { return stringRedisTemplate.execute((RedisCallback<Boolean>) connection -> connection.set(lockKey.getBytes(), value.getBytes(), Expiration.from(time, unit), RedisStringCommands.SetOption.SET_IF_ABSENT)); } public boolean lock (String lockKey, final String uuid, long timeout, final TimeUnit unit) { final long milliseconds = Expiration.from(timeout, unit).getExpirationTimeInMilliseconds(); boolean success = 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); final String[] oldValues = oldVal.split(Pattern.quote(DELIMITER)); if (Long.parseLong(oldValues[0 ]) + 1 <= System.currentTimeMillis()) { return true ; } } return success; } public void unlock (String lockKey, String value) { unlock(lockKey, value, 0 , TimeUnit.MILLISECONDS); } public void unlock (final String lockKey, final String uuid, long delayTime, TimeUnit unit) { if (StringUtils.isEmpty(lockKey)) { return ; } if (delayTime <= 0 ) { doUnlock(lockKey, uuid); } else { EXECUTOR_SERVICE.schedule(() -> doUnlock(lockKey, uuid), delayTime, unit); } } private void doUnlock (final String lockKey, final String uuid) { String val = stringRedisTemplate.opsForValue().get(lockKey); final String[] values = val.split(Pattern.quote(DELIMITER)); if (values.length <= 0 ) { return ; } if (uuid.equals(values[1 ])) { stringRedisTemplate.delete(lockKey); } } }