前言:
在系統中,有些接口如果重複提交,可能會造成髒數據或者其他的嚴重的問題,所以我們一般會對與數據庫有交互的接口進行重複處理。我們首先會想到在前端做一層控制。當前端觸發操作時,或彈出確認界面,或disable入口並倒計時等等,但是這並不能徹底限制,因此我們這裏使用Redis來對某些操作加鎖
場景:
- 場景一:在網絡延遲的情況下讓用戶有時間點擊多次submit按鈕導致表單重複提交
- 場景二:表單提交後用戶點擊【刷新】按鈕導致表單重複提交
- 場景三:用戶提交表單後,點擊瀏覽器的【後退】按鈕回退到表單頁面後進行再次提交
應用:這裏我們用到Redis的SETNX key value命令,對於該命令的解釋是
將 key 的值設爲 value ,當且僅當 key 不存在。
若給定的 key 已經存在,則 SETNX 不做任何動作。
SETNX 是『SET if Not eXists』(如果不存在,則 SET)的簡寫。
思路(如果不想每一個請求都單獨處理,以下行爲,可以在自定義攔截器裏面統一處理,1,2步在preHandle中處理,第3步再afterCompletion中處理):
- 把參數組裝好,進行MD5加密作爲key,這樣如果重複提交的話,這個請求生成的key就是一樣的
- 在請求之前,改action先去拿鎖,拿到鎖再繼續進行下去
- 請求結束之後,必須釋放鎖,雖然我們已經對鎖做了過期處理,防止死鎖,但是不建議只靠這樣的操作解鎖
代碼實現:
@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);
}
}
}