多种方案解决重复提交问题

首先我们分几个步骤来进行讨论

  • 什么是幂等
  • 产生的原因
  • 解决方案

1.什么是幂等

幂等:F(F(x)) = F(x) 多次运算结果一致
编程种常见的幂等:

  • select查询天然幂等
  • delete删除也是幂等,删除同一个多次效果一样
  • update直接更新某个值的-幂等
  • update更新累加操作的-非幂等
  • insert非幂等操作,每次都会新增

2.产生原因

由于重复点击或者网络重发 例如:

  • 点击提交按钮多次
  • 点击刷新按钮
  • 浏览器后退按钮重复之前的操作,导致表单重复提交
  • 使用浏览器历史记录重复提交表单
  • 浏览器重复的TTTP请求
  • nginx重发
  • 分布式RPC的retry

3.解决方案

1)前端js提交禁止按钮可以用一些js组件
2)使用Post-Redirect-Get模式
在提交后执行页面重定向,就是所谓的Post-Redirect-Get(PRG)模式。简而言之,在用户提交了表单后去执行一个客户端的重定向,转到信息提交成功页面。能避免用户F5刷新导致的重复提交,也不会出现浏览器表单重新提交的警告,同样能消除浏览器前进后退模式导致的问题。
3)在session中存放一个特殊标志
在服务器端,生成一个标识符,将它存入session,同时写入表单的隐藏字段中,然后将表单发送给浏览器。用户点击提交,在服务器端获取隐藏的值,与session中的唯一标识作比较,相等说明是首次提交,就处理本次请求,然后将session中的唯一标识移除。不想等就说明是重复提交。
4)借助使用header头设置缓存控制头Cache-control等方式
5)借助数据库
insert使用唯一索引,update使用乐观锁version版本法,在大数据和高并发情况下非常依赖数据库硬件能力,可以针对核心业务。
6)借助悲观锁
使用select ---for update,这种和synchronized锁住先查看再insert for update一样,但要避免死锁,效率较差
7)借助本地锁
原理:
使用ConcurrentHashMap并发容器putIfAbsent方法和ScheduledThreadPoolExecutor定时任务,也可以使用guava cache机制,因为guava中配有缓存的有效时间。
Content-MD5 是指Body的MD5值,只有当body非Form表单提交时才计算MD5值,计算方式是直接将参数和参数名统一使用MD5加密。MD5在一定范围内可以认为唯一,本地锁只适用于单机部署应用。

  • 配置注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Resubmit {
  /**
  * 延时时间  在延时多久后可以进行再次提交
  */
  int delaySecond() default 20;
}
  • 实例化锁
/**
* 重复提交锁
*/
@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切面
/**
* @ClassName RequestDataAspect
* @Description 数据重复提交校验
**/
@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 {
    //设置解锁key
    lock = 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);
  }
  }
}
  • 注解使用案例
@PostMapping(/save)
@Resubmit(delaySeconds = 20)
public void saveSome(@RequestBody requestDto){
  return saveService.saveDto(requestDto);
}

以上就是用本地锁的方式进行的幂等提交,使用了Content-MD5进行加密,只要参数不变,参数加密后的密值不变,key存在就阻止提交。
也可以使用其他签名检验,后端将签名当成校验token去缓存处理。

8)借助分布式redis锁
在pom.xml上添加依赖;
在yml或者properties中配置redis相关信息
实现方式
熟悉redis的都知道redis是线程安全的,我们利用它的特性会轻松实现一个分布式锁,opsForValue().setIfAbsent(key,value),它的作用就是如果当缓存中没有当前key的时候,进行缓存同时返回true。
缓存后,设置过期时间,防止因为系统崩溃而导致锁迟迟没有释放。

@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 {
      // 假设上锁成功,但是设置过期时间失效,以后拿到的都是 false
      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 {
      // TODO 如果演示的话需要注释该代码;实际应该放开
      redisLockHelper.unlock(lockKey, value);
    }
  }
}

RedisLockHelper 封装成API方式调用,灵活度变高

/**
* 需要定义成 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=" ">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);
  }
}
}

(转)

©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容