8 种方案解决重复提交问题!你选择哪一种呀?
1.什么是冪等
在我們編程中常見冪等
-
select查詢天然冪等
-
delete刪除也是冪等,刪除同一個多次效果一樣
-
update直接更新某個值的,冪等
-
update更新累加操作的,非冪等
-
insert非冪等操作,每次新增一條
2.產生原因
由于重復點擊或者網絡重發 eg:
-
點擊提交按鈕兩次;
-
點擊刷新按鈕;
-
使用瀏覽器后退按鈕重復之前的操作,導致重復提交表單;
-
使用瀏覽器歷史記錄重復提交表單;
-
瀏覽器重復的HTTP請;
-
nginx重發等情況;
-
分布式RPC的try重發等;
3.解決方案
1)前端js提交禁止按鈕可以用一些js組件
2)使用Post/Redirect/Get模式
在提交后執行頁面重定向,這就是所謂的Post-Redirect-Get (PRG)模式。簡言之,當用戶提交了表單后,你去執行一個客戶端的重定向,轉到提交成功信息頁面。這能避免用戶按F5導致的重復提交,而其也不會出現瀏覽器表單重復提交的警告,也能消除按瀏覽器前進和后退按導致的同樣問題。
3)在session中存放一個特殊標志
在服務器端,生成一個唯一的標識符,將它存入session,同時將它寫入表單的隱藏字段中,然后將表單頁面發給瀏覽器,用戶錄入信息后點擊提交,在服務器端,獲取表單中隱藏字段的值,與session中的唯一標識符比較,相等說明是首次提交,就處理本次請求,然后將session中的唯一標識符移除;不相等說明是重復提交,就不再處理。
4)其他借助使用header頭設置緩存控制頭Cache-control等方式
比較復雜 不適合移動端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在一定范圍類認為是唯一的 近似唯一 當然在低并發的情況下足夠了
本地鎖只適用于單機部署的應用.
①配置注解
import java.lang.annotation.*;@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface Resubmit {/*** 延時時間 在延時多久后可以再次提交** @return Time unit is one second*/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;/*** @author lijing* 重復提交鎖*/ @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 static final Cache<String, Object> CACHES = CacheBuilder.newBuilder()// 最大緩存 100 個// .maximumSize(1000)// 設置寫緩存后 5 秒鐘過期// .expireAfterWrite(5, TimeUnit.SECONDS)// .build();private ResubmitLock() {}/*** 靜態內部類 單例模式** @return*/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);}/*** 加鎖 putIfAbsent 是原子操作保證線程安全** @param key 對應的key* @param value* @return*/public boolean lock(final String key, Object value) {return Objects.isNull(LOCK_CACHE.putIfAbsent(key, value));}/*** 延時釋放鎖 用以控制短時間內的重復提交** @param lock 是否需要解鎖* @param key 對應的key* @param delaySeconds 延時時間*/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 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;/*** @ClassName RequestDataAspect* @Description 數據重復提交校驗* @Author lijing* @Date 2019/05/16 17:05**/ @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);});//生成加密參數 使用了content_MD5的加密方式key = ResubmitLock.handleKey(sb.toString());}}//執行鎖boolean lock = false;try {//設置解鎖keylock = ResubmitLock.getInstance().lock(key, PRESENT);if (lock) {//放行return joinPoint.proceed();} else {//響應重復提交異常return new ResponseDTO<>(ResponseCode.REPEAT_SUBMIT_OPERATION_EXCEPTION);}} finally {//設置解鎖key和解鎖時間ResubmitLock.getInstance().unLock(lock, key, delaySeconds);}} }④注解使用案例
@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?的依賴即可
<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?相關的配置項
spring.redis.host=localhost spring.redis.port=6379 spring.redis.password=123456主要實現方式:
熟悉?Redis?的朋友都知道它是線程安全的,我們利用它的特性可以很輕松的實現一個分布式鎖,如 opsForValue().setIfAbsent(key,value)它的作用就是如果緩存中沒有當前 Key 則進行緩存同時返回 true 反之亦然;
當緩存后給 key 在設置個過期時間,防止因為系統崩潰而導致鎖遲遲不釋放形成死鎖;那么我們是不是可以這樣認為當返回 true 我們認為它獲取到鎖了,在鎖未釋放的時候我們進行異常的拋出…
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;/*** redis 方案** @author Levin* @since 2018/6/12 0012*/ @Aspect @Configuration public class LockMethodInterceptor {@Autowiredpublic 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 {// 假設上鎖成功,但是設置過期時間失效,以后拿到的都是 falsefinal 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 {// TODO 如果演示的話需要注釋該代碼;實際應該放開redisLockHelper.unlock(lockKey, value);}} }RedisLockHelper 通過封裝成 API 方式調用,靈活度更加高
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;/*** 需要定義成 Bean** @author Levin* @since 2018/6/15 0015*/ @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;}/*** 獲取鎖(存在死鎖風險)** @param lockKey lockKey* @param value value* @param time 超時時間* @param unit 過期單位* @return true or false*/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));}/*** 獲取鎖** @param lockKey lockKey* @param uuid UUID* @param timeout 超時時間* @param unit 過期單位* @return true or false*/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;}/*** @see <a href="http://redis.io/commands/set">Redis Documentation: SET</a>*/public void unlock(String lockKey, String value) {unlock(lockKey, value, 0, TimeUnit.MILLISECONDS);}/*** 延遲unlock** @param lockKey key* @param uuid client(最好是唯一鍵的)* @param delayTime 延遲時間* @param unit 時間單位*/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);}}/*** @param lockKey key* @param uuid client(最好是唯一鍵的)*/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);}}}redis的提交參照
https://blog.battcn.com/2018/06/13/springboot/v2-cache-redislock/
總結
以上是生活随笔為你收集整理的8 种方案解决重复提交问题!你选择哪一种呀?的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Spring 中的 bean 为什么默认
- 下一篇: 如何保障消息中间件 100% 消息投递成