接口防重複提交的技術解決方案

【本文完善中...】前端

 

不管是http接口,仍是rpc接口,防重複提交(接口防重)都是繞不過的話題。vue

重複提交與冪等,既有區別,又有聯繫。冪等的意思是,對資源的一次請求與屢次請求,做用是相同的。例如,HTTP的POST方法是非冪等的。若是程序處理很差,重複提交會致使非冪等,引發系統數據故障。防重複提交,當屬於冪等的範疇,首先經過技術手段來實現,其次,又要有對業務數據的惟一性驗證。java

 

常見的B/S場景的重複提交,用戶手抖或由於網絡問題,服務端在極短期內兩次甚至更屢次收到一樣的http請求。node

rpc接口的重複提交,一種是不恰當的程序調用,即程序漏洞致使重複提交。在一種,好比拿dubbo來講,由於網絡傳輸問題,會觸發重試調用。git

 

防重提交的方案,常見的是加鎖。分佈式系統,通常是藉助redis或zk等分佈式鎖。對於java單體應用,有網友說能夠用語言自己的synchronized鎖機制,嚴格來講,這樣是不恰當的,由於synchronized是多線程下的同步鎖,只會阻塞線程執行,而不會阻斷線程的執行。github

 

【說明幾點】web

  1.  lockKey的設置
  2. 鎖的有效期
  3. 上鎖的原子性
  4. 關於釋放鎖

 

redis分佈式鎖的實現

類圖:ajax

 

RedisDistributedLockredis

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;
    }
}
    

 

AbstractDistributedLockspring

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:

相關文章
相關標籤/搜索