接口防止重复点击后端处理

场景:用户对于新增保存/修改保存,往往会进行点击。但是部分接口后端逻辑处理时间稍长,那么就会出现当前数据已经在处理了,但是没有返回执行完成数据或者页面跳转时候,依然停留在原页面上,用户不由自主的进行反复点击。

处理方案:

A、前端约束,前端在用户点击之后设置悬浮页/遮罩层等,放置用户再次点击,但是这些对于客户在页面按了F5,约束重置取消。

B、部分人 进行接口调用,也就是该接口对外提供,供第三方调用,这种无前端,你没法凭空变出一个前端控制第三方调用次数以及时间。所以接口防止重复点击的本质应该是后端控制。

这里的接口防止重复点击后端处理跟接口限流是二码事。

接口限流是某一接口被调用多次,次数频率过高,系统消耗严重,所以约束时间段内的调用次数。这里用到的也是redis限流。

接口防止重复点击是针对一些接口调用之后会变更数据。查询这种幂等性返回数据的,一般不会去设置接口防止重复点击,因为单次跟多次点击,数据返回没有变动。新增会导致数据变更;修改如果是全量修改的话,多次点击变更看具体业务;修改属于加某些数量或者减些数量,按照你点击操作次数,进行数量*次数的数值变化。 这就是为什么需要部分接口进行放置重复点击操作,原因就是为了保持数据的正确性以及幂等接口的稳定性。重复提交会导致非幂等性。

通常的解决方案。常见的是加锁。java单体应用可以用语言本身的synchronized锁机制。分布式系统,一般是借助redis或zk等分布式锁。

img

代码实操。

1、编写对应 redis的防重复点击锁。

配置对应导入jar包

对应pom文件导入
 <properties>
        <redisson.version>3.9.1</redisson.version>
 </properties>      
        
        
          <dependency>
                <groupId>org.redisson</groupId>
                <artifactId>redisson-spring-boot-starter</artifactId>
                <version>${redisson.version}</version>
          </dependency>


通用方法类建立。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Rp8zNDL8-1640306869467)(C:\Users\qijian\AppData\Roaming\Typora\typora-user-images\image-20211223172716721.png)]

通用方法建立。 暂时会去掉作者信息。尽量方法公共。

/**
 * 提供运行防重点击锁接口
 * @desc
 **/
public interface LockCallbackWithoutResult {
    void doWithLock();
}
/**
 *
 * @desc 防止并发控制
 **/
public interface LockTemplateService {

    /**
     * 加锁控制
     * @param  bizNo 业务单号
     * @param  service 业务层相关接口
     * @return
     */
    void execute(String bizNo, LockCallbackWithoutResult service);

}
// 包名省略,大致就是那里红了,自动导包就好

/**
 * @desc 防止并发控制实现
 **/
@Slf4j
@Service
@Primary
public class LockTemplateServiceImpl implements LockTemplateService {

    @Autowired
    private RedissonClient redissonClient;

    private final static String pre_lock = "claim_lock:";
    /** 对应时间为5,默认单位是TimeUnit.SECONDS 秒; 该方法作为基础的接口防重点击接口,默认是5S内进行防止重复调用
    后续 可以提供额外接口,调用方自定义 锁定时间,也就是 time值作为 传参写入到方法体内。目前默认5S内满足现有逻辑运行。
    */
    private final static Integer time = 5;

    @Override
    public void execute(String bizNo, LockCallbackWithoutResult service) throws InterruptedException {
        if (StringUtils.isEmpty(bizNo) || service == null) {
            throw new IllegalArgumentException("bizNo or service is empty");
        }
        RLock lock = redissonClient.getLock(pre_lock + bizNo);
        try {
            if (lock.tryLock(time, TimeUnit.SECONDS)) {
                //执行相关业务层代码
                service.doWithLock();
            } else {
                LoggerUtils.error(log, "concurrent biz work error,bizNo={0}", pre_lock + bizNo);
                throw new BizException(BizErrorCodeEnum.OPERATE_FREQUENTLY);
            }
        } finally {
            if(lock.isHeldByCurrentThread()){
                lock.unlock();
            }
        }
    }
}

实际调用

// 原先方法调用。

    /**
     * @param no
     */
    public RestResponse claimSubmit(@PathVariable("no") String no,
                                    @RequestBody Info info) {
        Assert.hasText(no, "XXX不能为空");
        CaseInfoChecker.checkClaimInfo(info, false);
        caseService.saveClaimInfo(info);
        return RestResponse.succ();
    }

// 现在方法加防重锁。

    /**
     *  悲观锁组件
     */
    @Autowired
    private LockTemplateService lockTemplateService;
    
        /**
     * @param no
     */
    public RestResponse claimSubmit(@PathVariable("no") String no,
                                    @RequestBody Info info) {
        Assert.hasText(no, "XXX不能为空");
        CaseInfoChecker.checkClaimInfo(info, false);
        lockTemplateService.execute(reportNo,  () -> caseService.saveClaimInfo(info));
        return RestResponse.succ();
    }
    

这是目前 某一项目中关于接口防重复点击的示例应用。

这里需要注意的是,怎么来判断是同一次调用的多次点击。

目前使用的还是比较简单的 通过某一个关键的 报案号字段 作为约束本次代码执行的关键值。

其实 目前数据库比较缺乏的是 版本号 version。 如果对应主键 + 版本号,同一版本号的修改 默认只有一次,下次再修改时候,版本已经发生改变。从这个逻辑来看,如果数据库存在版本号数据,前端在修改时候版本号也作为影响接口防重复点击方法对应redis key值拼接因素之一。

然后是本次接口点击修改时间戳,类似版本号作用。本次参数以时间戳 + 对应关键字段 作为约束。

后续也存在,将表单或者数据用md5加密,然后该md5 作为约束条件之一。

然后就是令牌方法,用户进行接口调用,都是会按某一规则申请一个令牌,然后携带令牌进行接口调用,多次调用申请令牌,回去判断对应参数是否被锁住了,如果被锁住了就不返回令牌,不知道哪里看的,可行性不高,主要是技术难度可能较大,不易实现。

对应部分存在问题进行优化更改。 主要是 LockTemplateServiceImpl


/**
 * @desc 防止并发控制实现
 **/
@Slf4j
@Service
@Primary
public class LockTemplateServiceImpl implements LockTemplateService {

    @Autowired
    private RedissonClient redissonClient;

    private final static String pre_lock = "claim_lock:";
    /** 对应时间为5,默认单位是TimeUnit.SECONDS 秒; 该方法作为基础的接口防重点击接口,默认是5S内进行防止重复调用
    后续 可以提供额外接口,调用方自定义 锁定时间,也就是 time值作为 传参写入到方法体内。目前默认5S内满足现有逻辑运行。
    */
    private final static Integer time = 5;

    @Override
    public void execute(String bizNo, LockCallbackWithoutResult service) throws InterruptedException {
        if (StringUtils.isEmpty(bizNo) || service == null) {
            throw new IllegalArgumentException("bizNo or service is empty");
        }
        RLock lock = redissonClient.getLock(pre_lock + bizNo);
        try {
       // 1. 最常见的使用方法
		//lock.lock();
		// 2. 支持过期解锁功能,10秒钟以后自动解锁, 无需调用unlock方法手动解锁
		//lock.lock(10, TimeUnit.SECONDS);
		// 3. 尝试加锁,最多等待3秒,上锁以后10秒自动解锁 (time 秒后自动解锁)
            if (lock.tryLock(3, time, TimeUnit.SECONDS)) {
                //执行相关业务层代码
                service.doWithLock();
            } else {
                LoggerUtils.error(log, "concurrent biz work error,bizNo={0}", pre_lock + bizNo);
                throw new BizException(BizErrorCodeEnum.OPERATE_FREQUENTLY);
            }
        } finally {
        //此处代码 需要进行讨论。  lock.isHeldByCurrentThread() 跟 lock.isLocked() 二选一。 
        // lock.isHeldByCurrentThread()的作用是查询当前线程是否保持此锁定,和lock.hasQueueThread()不同的地方是:lock.hasQueueThread(Thread thread)的作用是判断当前线程是否处于等待lock的状态。
        // lock.isLocked()的作用是查询此锁定是否由任意线程保持。
        
            if(lock.isHeldByCurrentThread()){
                lock.unlock();
            }
            
            //可能代码如下
             if (lock.isLocked()) {
       			 lock.unlock();
    		}
        }
    }
}

这里可以考虑公平锁概念

公平锁(Fair Lock)

Redisson分布式可重入公平锁也是实现了java.util.concurrent.locks.Lock接口的一种RLock对象。在提供了自动过期解锁功能的同时,保证了当多个Redisson客户端线程同时请求加锁时,优先分配给先发出请求的线程。

public void testFairLock(RedissonClient redisson){
	RLock fairLock = redisson.getFairLock("anyLock");
	try{
		// 最常见的使用方法
		fairLock.lock();
		// 支持过期解锁功能, 10秒钟以后自动解锁,无需调用unlock方法手动解锁
		fairLock.lock(10, TimeUnit.SECONDS);
		// 尝试加锁,最多等待100秒,上锁以后10秒自动解锁
		boolean res = fairLock.tryLock(100, 10, TimeUnit.SECONDS);
	} catch (InterruptedException e) {
		e.printStackTrace();
	} finally {
		fairLock.unlock();
	}

参考网址

https://blog.csdn.net/weixin_42129270/article/details/114086185 分布式锁的isFair()、isHeldByCurrentThread()和isLocked()的用法和区别

https://blog.csdn.net/l1028386804/article/details/73523810 Java之——redis并发读写锁,使用Redisson实现分布式锁


漫漫长路,一个小周跟他一个小陈朋友一起努力奔跑。


Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐