Java使用Redis實現分佈式鎖來防止重複提交問題(Redis鎖)

前言:

在系統中,有些接口如果重複提交,可能會造成髒數據或者其他的嚴重的問題,所以我們一般會對與數據庫有交互的接口進行重複處理。我們首先會想到在前端做一層控制。當前端觸發操作時,或彈出確認界面,或disable入口並倒計時等等,但是這並不能徹底限制,因此我們這裏使用Redis來對某些操作加鎖

場景:

  1. 場景一:在網絡延遲的情況下讓用戶有時間點擊多次submit按鈕導致表單重複提交
  2. 場景二:表單提交後用戶點擊【刷新】按鈕導致表單重複提交
  3. 場景三:用戶提交表單後,點擊瀏覽器的【後退】按鈕回退到表單頁面後進行再次提交

應用:這裏我們用到Redis的SETNX key value命令,對於該命令的解釋是

將 key 的值設爲 value ,當且僅當 key 不存在。

若給定的 key 已經存在,則 SETNX 不做任何動作。

SETNX 是『SET if Not eXists』(如果不存在,則 SET)的簡寫。

思路(如果不想每一個請求都單獨處理,以下行爲,可以在自定義攔截器裏面統一處理,1,2步在preHandle中處理,第3步再afterCompletion中處理):

  1. 把參數組裝好,進行MD5加密作爲key,這樣如果重複提交的話,這個請求生成的key就是一樣的
  2. 在請求之前,改action先去拿鎖,拿到鎖再繼續進行下去
  3. 請求結束之後,必須釋放鎖,雖然我們已經對鎖做了過期處理,防止死鎖,但是不建議只靠這樣的操作解鎖

 

代碼實現:

@Component
public class RedisLock {

    public static final int LOCK_EXPIRE = 3000; // ms

    @Autowired
    private StringRedisTemplate redisTemplate;


    /**
     *  分佈式鎖
     *
     * @param key key值
     * @return 是否獲取到
     */
    public boolean lock(String key) {
        String lock = key;
        try {
            return (Boolean) redisTemplate.execute((RedisCallback) connection -> {
                long expireAt = System.currentTimeMillis() + LOCK_EXPIRE;
                Boolean acquire = connection.setNX(lock.getBytes(), String.valueOf(expireAt).getBytes());
                if (acquire) {
                    return true;
                } else {
                    //判斷該key上的值是否過期了
                    byte[] value = connection.get(lock.getBytes());
                    if (Objects.nonNull(value) && value.length > 0) {
                        long expireTime = Long.parseLong(new String(value));
                        if (expireTime < System.currentTimeMillis()) {
                            // 如果鎖已經過期
                            byte[] oldValue = connection.getSet(lock.getBytes(), String.valueOf(System.currentTimeMillis() + LOCK_EXPIRE).getBytes());
                            // 防止死鎖
                            return Long.parseLong(new String(oldValue)) < System.currentTimeMillis();
                        }
                    }
                }
                return false;
            });
        } finally {
            RedisConnectionUtils.unbindConnection(redisTemplate.getConnectionFactory());
        }
    }


    @Autowired
    private RedisService redisService;

    /**
     * 刪除鎖
     *
     * @param key
     */
    public void delete(String key) {
        try {
            redisTemplate.delete(key);
        } finally {
            RedisConnectionUtils.unbindConnection(redisTemplate.getConnectionFactory());
        }
    }

}

測試Controller,如果拿不到鎖,則等待0.5秒後繼續拿,重複5次

@RestController
public class RedisLockTestController {

    @Autowired
    private RedisLock redisLock;

    @PostMapping("createOrder")
    public String createOrder(HttpServletRequest request){
        String lockKey = MapUtil.getRedisKeyByParam(request.getParameterMap());
        if (redisLock.lock(lockKey)){
            //處理邏輯
            redisLock.delete(lockKey);
            return "success";
        }else {
            // 設置失敗次數計數器, 當到達5次時, 返回失敗
            int failCount = 1;
            while(failCount <= 5){
                // 等待100ms重試
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                if (redisLock.lock(lockKey)){
                    // 執行邏輯操作
                    //處理邏輯
                    redisLock.delete(lockKey);
                    return "success";
                }else{
                    failCount ++;
                }
            }
            return "請勿重複提交請求";
        }

    }

}

請求參數工具類

public class MapUtil {

    public static String getRedisKeyByParam(Map<String, String[]> requestParams) {
        //除去數組中的空值
        Map<String, String> sPara = paraFilter(toVerifyMap(requestParams,false));
        //把數組所有元素,按照“參數=參數值”的模式用“&”字符拼接成字符串
        String prestr = createLinkString(sPara);
        //生成簽名結果
        String mysign = DigestUtils.md5Hex(getContentBytes(prestr, "UTF-8"));
        return mysign;
    }

    /**
     * 除去數組中的空值
     * @param sArray 參數組
     * @return 去掉空值後新的參數組
     */
    public static Map<String, String> paraFilter(Map<String, String> sArray) {
        Map<String, String> result = new HashMap<>();
        if (sArray == null || sArray.size() <= 0) {
            return result;
        }
        for (String key : sArray.keySet()) {
            String value = sArray.get(key);
            if (value == null || value.equals("")) {
                continue;
            }
            result.put(key, value);
        }
        return result;
    }

    /**
     * 把數組所有元素排序,並按照“參數=參數值”的模式用“&”字符拼接成字符串
     * @param params 需要排序並參與字符拼接的參數組
     * @return 拼接後字符串
     */
    public static String createLinkString(Map<String, String> params) {
        List<String> keys = new ArrayList<>(params.keySet());
        Collections.sort(keys);
        String prestr = "";
        for (int i = 0; i < keys.size(); i++) {
            String key = keys.get(i);
            String value = params.get(key);
            if (i == keys.size() - 1) {//拼接時,不包括最後一個&字符
                prestr = prestr + key + "=" + value;
            } else {
                prestr = prestr + key + "=" + value + "&";
            }
        }
        return prestr;
    }

    private static byte[] getContentBytes(String content, String charset) {
        if (charset == null || "".equals(charset)) {
            return content.getBytes();
        }
        try {
            return content.getBytes(charset);
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException("MD5簽名過程中出現錯誤,指定的編碼集不對,您目前指定的編碼集是:" + charset);
        }
    }


    /**
     * 請求參數Map轉換驗證Map
     * @param requestParams 請求參數Map
     * @param charset 是否要轉utf8編碼
     * @return
     * @throws UnsupportedEncodingException
     */
    public static Map<String,String> toVerifyMap(Map<String, String[]> requestParams, boolean charset) {
        Map<String,String> params = new HashMap<>();
        for (Iterator iter = requestParams.keySet().iterator(); iter.hasNext();) {
            String name = (String) iter.next();
            String[] values = requestParams.get(name);
            String valueStr = "";
            for (int i = 0; i < values.length; i++) {
                valueStr = (i == values.length - 1) ? valueStr + values[i] : valueStr + values[i] + ",";
            }
            //亂碼解決,這段代碼在出現亂碼時使用。如果mysign和sign不相等也可以使用這段代碼轉化
            if(charset)
                valueStr = getContentString(valueStr, "UTF-8");
            params.put(name, valueStr);
        }
        return params;
    }

    /**
     * 編碼轉換
     * @param content
     * @param charset
     * @return
     */
    private static String getContentString(String content, String charset) {
        if (charset == null || "".equals(charset)) {
            return new String(content.getBytes());
        }
        try {
            return new String(content.getBytes("ISO-8859-1"), charset);
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException("指定的編碼集不對,您目前指定的編碼集是:" + charset);
        }
    }
}

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章