【本文完善中...】
无论是http接口,还是rpc接口,防重复提交(接口防重)都是绕不过的话题。
重复提交与幂等,既有区别,又有联系。幂等的意思是,对资源的一次请求与多次请求,作用是相同的。例如,HTTP的POST方法是非幂等的。如果程序处理不好,重复提交会导致非幂等,引起系统数据故障。防重复提交,当属于幂等的范畴,首先通过技术手段来实现,其次,又要有对业务数据的唯一性验证。
常见的B/S场景的重复提交,用户手抖或因为网络问题,服务端在极短时间内两次甚至更多次收到同样的http请求。
rpc接口的重复提交,一种是不恰当的程序调用,即程序漏洞导致重复提交。在一种,比如拿dubbo来说,因为网络传输问题,会触发重试调用。
防重提交的方案,常见的是加锁。分布式系统,一般是借助redis或zk等分布式锁。对于java单体应用,有网友说可以用语言本身的synchronized锁机制,严格来说,这样是不恰当的,因为synchronized是多线程下的同步锁,只会阻塞线程执行,而不会阻断线程的执行。
【说明几点】
- lockKey的设置
- 锁的有效期
- 上锁的原子性
- 关于释放锁
redis分布式锁的实现
类图:
RedisDistributedLock
package com.emax.zhenghe.rpcapi.provider.config.distributeRedisLock; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.stereotype.Component; import javax.annotation.Resource; import java.util.Arrays; import java.util.UUID; import java.util.concurrent.TimeUnit; @Component @Slf4j public class RedisDistributedLock extends AbstractDistributedLock { @Autowired @Resource private RedisTemplate<Object, Object> redisTemplate; private ThreadLocal<String> lockFlag = new ThreadLocal<String>(); public static final String UNLOCK_LUA; static { StringBuilder sb = new StringBuilder(); sb.append("if redis.call("get",KEYS[1]) == ARGV[1] "); sb.append("then "); sb.append(" return redis.call("del",KEYS[1]) "); sb.append("else "); sb.append(" return 0 "); sb.append("end "); UNLOCK_LUA = sb.toString(); } public RedisDistributedLock() { super(); } @Override public boolean lock(String key, long expire, int retryTimes, long sleepMillis) { boolean result = setRedis(key, expire); // 如果获取锁失败,按照传入的重试次数进行重试 while ((!result) && retryTimes-- > 0) { try { log.debug("lock failed, retrying..." + retryTimes); Thread.sleep(sleepMillis); } catch (InterruptedException e) { return false; } result = setRedis(key, expire); } return result; } /** * * @param key * @param expire MILLISECONDS * @return */ private boolean setRedis(final String key, final long expire) { try { String uuid = UUID.randomUUID().toString(); lockFlag.set(uuid); return redisTemplate.opsForValue().setIfAbsent(key,uuid,expire,TimeUnit.MILLISECONDS); } catch (Exception e) { log.info("redis lock error.", e); } return false; } @Override public boolean releaseLock(String key) { // 释放锁的时候,有可能因为持锁之后方法执行时间大于锁的有效期,此时有可能已经被另外一个线程持有锁,所以不能直接删除 try { DefaultRedisScript<Boolean> defaultRedisScript = new DefaultRedisScript<Boolean>(UNLOCK_LUA,Boolean.class); return redisTemplate.execute(defaultRedisScript,Arrays.asList(key),lockFlag.get()); } catch (Exception e) { log.error("release lock occured an exception", e); } finally { // 清除掉ThreadLocal中的数据,避免内存溢出 lockFlag.remove(); } return false; } }
AbstractDistributedLock
package com.emax.zhenghe.rpcapi.provider.config.distributeRedisLock; public abstract class AbstractDistributedLock implements DistributedLock { @Override public boolean lock(String key) { return lock(key , TIMEOUT_MILLIS, RETRY_TIMES, SLEEP_MILLIS); } @Override public boolean lock(String key, int retryTimes) { return lock(key, TIMEOUT_MILLIS, retryTimes, SLEEP_MILLIS); } @Override public boolean lock(String key, int retryTimes, long sleepMillis) { return lock(key, TIMEOUT_MILLIS, retryTimes, sleepMillis); } @Override public boolean lock(String key, long expire) { return lock(key, expire, RETRY_TIMES, SLEEP_MILLIS); } @Override public boolean lock(String key, long expire, int retryTimes) { return lock(key, expire, retryTimes, SLEEP_MILLIS); } }
DistributedLock
package com.emax.zhenghe.rpcapi.provider.config.distributeRedisLock; public interface DistributedLock { long TIMEOUT_MILLIS = 30000; int RETRY_TIMES = 2; long SLEEP_MILLIS = 500; boolean lock(String key); boolean lock(String key, int retryTimes); boolean lock(String key, int retryTimes, long sleepMillis); boolean lock(String key, long expire); boolean lock(String key, long expire, int retryTimes); boolean lock(String key, long expire, int retryTimes, long sleepMillis); boolean releaseLock(String key); }
进一步封装,实现代码解耦
上面的加锁和释放锁都暴露在了业务调用方,增加了业务调用方的职责,同时,如果使用不当,还会产生bug。
接下来,我们稍作重构。看看下面的RedisLockTemplate
package com.emax.zhenghe.rpcapi.provider.config.distributeRedisLock; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; /** * redis分布式锁并发控制模板类 * * @author zhangguozhan */ @Slf4j @Component public class RedisLockTemplate { @Autowired private RedisDistributedLock redisDistributedLock; /** * redis分布式锁控制 * * @param key 锁名 * @param expireMS 锁的生命周期,单位:毫秒 * @param redisLockCallback callback方法 * @return */ public Object execute(String key, long expireMS, RedisLockCallback redisLockCallback) { return execute(key, expireMS, redisLockCallback, false, 2); } /** * redis分布式锁控制 * * @param key * @param expireMS * @param redisLockCallback * @param isAutoReleaseLock callback方法执行完成后自动释放锁 * @return */ public Object execute(String key, long expireMS, RedisLockCallback redisLockCallback, boolean isAutoReleaseLock, int retryTimes) { log.info("redis分布式锁控制 key={}", key); if (StringUtils.isBlank(key)) { log.info("try lock failure:key is null"); return null; } boolean lock = redisDistributedLock.lock(key, expireMS, retryTimes); if (lock) { try { Object o = redisLockCallback.doInRedisLock(); return o; } finally { if (isAutoReleaseLock) { redisDistributedLock.releaseLock(key); } } } else { log.info("###key已存在,终止 key={}", key); return null; } } }
RedisLockCallback是一个函数式接口
package com.emax.zhenghe.rpcapi.provider.config.distributeRedisLock; public interface RedisLockCallback { Object doInRedisLock(); }
这样,业务的调用就变得很easy了。
关于ajax异步请求
现在的web项目一般都是采用前后端分离的开发模式了,前端的程序框架也百花齐放,常见的有vue、nodejs等等。
对于用户手抖导致的重复提交,服务端的做法就是利用上面的分布式控制,非首次的请求因为上锁失败而中断处理,前端收到的是“请勿重复提交”这样的提示。我原以为这样可能会影响用户体验。后来咨询前端同事,原来事实并非如此。
自己写了一个demo,模拟重复提交。页面异步重复发起相同的请求,服务端重复处理。第一次是加锁,正常处理请求,第二次是发现锁已存在,上锁失败,直接返回“请勿重复提交”的提示。页面会收到两次的响应结果。不过,因为第二次的请求上锁失败直接返回错误提示,所以响应早于第一次的响应。ajax判断响应的逻辑是如果是成功(正常响应,视为成功),就触发相应的后续处理,如果是失败(“请勿重复提交”视为失败),就toast提示。 因此,虽然toast了一下,只是一瞬间,第一次请求的响应来了之后,就会正常处理页面逻辑。
所以,上面的防重机制,也是比较合适的方案。
当然,应该校验的业务逻辑还是要有的,尤其是数据校验。这属于业务范畴了。
本文代码已放到github: