前言
日常開發中,我們可能會碰到需要進行防重放與操作冪等的業務,本文記錄SpringBoot實現簡單防重與冪等
防重放,防止資料重複提交
操作冪等性,多次執行所產生的影響均與一次執行的影響相同
解決什麼問題?
表單重複提交,使用者多次點選表單提交按鈕
介面重複呼叫,介面短時間內被多次呼叫
思路如下:
1、前端頁面表提交鈕置灰不可點選+js節流防抖
2、Redis防重Token令牌
3、資料庫唯一主鍵 + 樂觀鎖
具體方案
pom引入依賴
<!-- Redis --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- thymeleaf模板 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <!--新增MyBatis-Plus依賴 --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.4.0</version> </dependency> <!--新增MySQL驅動依賴 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency>
一個測試表
CREATE TABLE `idem` ( `id` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '唯一主鍵', `msg` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '業務資料', `version` int(8) NOT NULL COMMENT '樂觀鎖版本號', PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '防重放與操作冪等測試表' ROW_FORMAT = Compact;
前端頁面
先寫一個test頁面,引入jq
<!DOCTYPE html> <!--解決idea thymeleaf 表示式模板報紅波浪線--> <!--suppress ALL --> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8" /> <title>防重放與操作冪等</title> <!-- 引入靜態資源 --> <script th:src="@{/js/jquery-1.9.1.min.js}" type="application/javascript"></script> </head> <body> <form> <!-- 隱藏域 --> <input type="hidden" id="token" th:value="${token}"/> <!-- 業務資料 --> id:<input id="id" th:value="${id}"/> <br/> msg:<input id="msg" th:value="${msg}"/> <br/> version:<input id="version" th:value="${version}"/> <br/> <!-- 操作按鈕 --> <br/> <input type="submit" value="提交" onclick="formSubmit(this)"/> <input type="reset" value="重置"/> </form> <br/> <button id="btn">節流測試,點我</button> <br/> <button id="btn2">防抖測試,點我</button> </body> <script> /* //插入 for (let i = 0; i < 5; i++) { $.get("http://localhost:10010/idem/insert?id=1&msg=張三"+i+"&version=1",null,function (data){ console.log(data); }); } //修改 for (let i = 0; i < 5; i++) { $.get("http://localhost:10010/idem/update?id=1&msg=李四"+i+"&version=1",null,function (data){ console.log(data); }); } //刪除 for (let i = 0; i < 5; i++) { $.get("http://localhost:10010/idem/delete?id=1",null,function (data){ console.log(data); }); } //查詢 for (let i = 0; i < 5; i++) { $.get("http://localhost:10010/idem/select?id=1",null,function (data){ console.log(data); }); } //test表單測試 for (let i = 0; i < 5; i++) { $.get("http://localhost:10010/test/test?token=abcd&id=1&msg=張三"+i+"&version=1",null,function (data){ console.log(data); }); } //節流測試 for (let i = 0; i < 5; i++) { document.getElementById('btn').onclick(); } //防抖測試 for (let i = 0; i < 5; i++) { document.getElementById('btn2').onclick(); } */ function formSubmit(but){ //按鈕置灰 but.setAttribute("disabled","disabled"); let token = $("#token").val(); let id = $("#id").val(); let msg = $("#msg").val(); let version = $("#version").val(); $.ajax({ type: 'post', url: "/test/test", contentType:"application/x-www-form-urlencoded", data: { token:token, id:id, msg:msg, version:version, }, success: function (data) { console.log(data); //按鈕恢復 but.removeAttribute("disabled"); }, error: function (xhr, status, error) { console.error("ajax錯誤!"); //按鈕恢復 but.removeAttribute("disabled"); } }); return false; } document.getElementById('btn').onclick = throttle(function () { console.log('節流測試 helloworld'); }, 1000) // 節流:給定一個時間,不管這個時間你怎麼點選,點上天,這個時間內也只會執行一次 // 節流函式 function throttle(fn, delay) { var lastTime = new Date().getTime() delay = delay || 200 return function () { var args = arguments var nowTime = new Date().getTime() if (nowTime - lastTime >= delay) { lastTime = nowTime fn.apply(this, args) } } } document.getElementById('btn2').onclick = debounce(function () { console.log('防抖測試 helloworld'); }, 1000) // 防抖:給定一個時間,不管怎麼點選按鈕,每點一次,都會在最後一次點選等待這個時間過後執行 // 防抖函式 function debounce(fn, delay) { var timer = null delay = delay || 200 return function () { var args = arguments var that = this clearTimeout(timer) timer = setTimeout(function () { fn.apply(that, args) }, delay) } } </script> </html>
按鈕置灰不可點選
點選提交按鈕後,將提交按鈕置灰不可點選,ajax響應後再恢復按鈕狀態
function formSubmit(but){ //按鈕置灰 but.setAttribute("disabled","disabled"); let token = $("#token").val(); let id = $("#id").val(); let msg = $("#msg").val(); let version = $("#version").val(); $.ajax({ type: 'post', url: "/test/test", contentType:"application/x-www-form-urlencoded", data: { token:token, id:id, msg:msg, version:version, }, success: function (data) { console.log(data); //按鈕恢復 but.removeAttribute("disabled"); }, error: function (xhr, status, error) { console.error("ajax錯誤!"); //按鈕恢復 but.removeAttribute("disabled"); } }); return false; }
js節流、防抖
節流:給定一個時間,不管這個時間你怎麼點選,點上天,這個時間內也只會執行一次
document.getElementById('btn').onclick = throttle(function () { console.log('節流測試 helloworld'); }, 1000) // 節流:給定一個時間,不管這個時間你怎麼點選,點上天,這個時間內也只會執行一次 // 節流函式 function throttle(fn, delay) { var lastTime = new Date().getTime() delay = delay || 200 return function () { var args = arguments var nowTime = new Date().getTime() if (nowTime - lastTime >= delay) { lastTime = nowTime fn.apply(this, args) } } }
防抖:給定一個時間,不管怎麼點選按鈕,每點一次,都會在最後一次點選等待這個時間過後執行
document.getElementById('btn2').onclick = debounce(function () { console.log('防抖測試 helloworld'); }, 1000) // 防抖:給定一個時間,不管怎麼點選按鈕,每點一次,都會在最後一次點選等待這個時間過後執行 // 防抖函式 function debounce(fn, delay) { var timer = null delay = delay || 200 return function () { var args = arguments var that = this clearTimeout(timer) timer = setTimeout(function () { fn.apply(that, args) }, delay) } }
Redis
防重Token令牌
跳轉前端表單頁面時,設定一個UUID作為token,並設定在表單隱藏域
/** * 跳轉頁面 */ @RequestMapping("index") private ModelAndView index(String id){ ModelAndView mv = new ModelAndView(); mv.addObject("token",UUIDUtil.getUUID()); if(id != null){ Idem idem = new Idem(); idem.setId(id); List select = (List)idemService.select(idem); idem = (Idem)select.get(0); mv.addObject("id", idem.getId()); mv.addObject("msg", idem.getMsg()); mv.addObject("version", idem.getVersion()); } mv.setViewName("test.html"); return mv; }
<form> <!-- 隱藏域 --> <input type="hidden" id="token" th:value="${token}"/> <!-- 業務資料 --> id:<input id="id" th:value="${id}"/> <br/> msg:<input id="msg" th:value="${msg}"/> <br/> version:<input id="version" th:value="${version}"/> <br/> <!-- 操作按鈕 --> <br/> <input type="submit" value="提交" onclick="formSubmit(this)"/> <input type="reset" value="重置"/> </form>
後臺查詢redis快取,如果token不存在立即設定token快取,允許表單業務正常進行;如果token快取已經存在,拒絕表單業務
PS:token快取要設定一個合理的過期時間
/** * 表單提交測試 */ @RequestMapping("test") private String test(String token,String id,String msg,int version){ //如果token快取不存在,立即設定快取且設定有效時長(秒) Boolean setIfAbsent = template.opsForValue().setIfAbsent(token, "1", 60 * 5, TimeUnit.SECONDS); //快取設定成功返回true,失敗返回false if(Boolean.TRUE.equals(setIfAbsent)){ //模擬耗時 try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } //列印測試資料 System.out.println(token+","+id+","+msg+","+version); return "操作成功!"; }else{ return "操作失敗,表單已被提交..."; } }
for迴圈測試中,5個操作只有一個執行成功!
資料庫
唯一主鍵 + 樂觀鎖
查詢操作自帶冪等性
/** * 查詢操作,天生冪等性 */ @Override public Object select(Idem idem) { QueryWrapper<Idem> queryWrapper = new QueryWrapper<>(); queryWrapper.setEntity(idem); return idemMapper.selectList(queryWrapper); }
查詢沒什麼好說的,只要資料不變,查詢條件不變的情況下查詢結果必然冪等
唯一主鍵可解決插入操作、刪除操作
/** * 插入操作,使用唯一主鍵實現冪等性 */ @Override public Object insert(Idem idem) { String msg = "操作成功!"; try{ idemMapper.insert(idem); }catch (DuplicateKeyException e){ msg = "操作失敗,id:"+idem.getId()+",已經存在..."; } return msg; } /** * 刪除操作,使用唯一主鍵實現冪等性 * PS:使用非主鍵條件除外 */ @Override public Object delete(Idem idem) { String msg = "操作成功!"; int deleteById = idemMapper.deleteById(idem.getId()); if(deleteById == 0){ msg = "操作失敗,id:"+idem.getId()+",已經被刪除..."; } return msg; }
利用主鍵唯一的特性,捕獲處理重複操作
樂觀鎖可解決更新操作
/** * 更新操作,使用樂觀鎖實現冪等性 */ @Override public Object update(Idem idem) { String msg = "操作成功!"; // UPDATE table SET [... 業務欄位=? ...], version = version+1 WHERE (id = ? AND version = ?) UpdateWrapper<Idem> updateWrapper = new UpdateWrapper<>(); //where條件 updateWrapper.eq("id",idem.getId()); updateWrapper.eq("version",idem.getVersion()); //version版本號要單獨設定 updateWrapper.setSql("version = version+1"); idem.setVersion(null); int update = idemMapper.update(idem, updateWrapper); if(update == 0){ msg = "操作失敗,id:"+idem.getId()+",已經被更新..."; } return msg; }
執行更新sql語句時,where條件帶上version版本號,如果執行成功,除了更新業務資料,同時更新version版本號標記當前資料已被更新
UPDATE table SET [... 業務欄位=? ...], version = version+1 WHERE (id = ? AND version = ?)
執行更新操作前,需要重新執行插入資料
以上for迴圈測試中,5個操作同樣只有一個執行成功!
後記
redis、樂觀鎖不要在程式碼先查詢後if判斷,這樣會存在併發問題,導致資料不準確,應該把這種判斷放在redis、資料庫
錯誤示例:
//獲取最新快取 String redisToken = template.opsForValue().get(token); //為空則放行業務 if(redisToken == null){ //設定快取 template.opsForValue().set(token, "1", 60 * 5, TimeUnit.SECONDS); //業務處理 }else{ //拒絕業務 }
錯誤示例:
//獲取最新版本號 Integer version = idemMapper.selectById(idem.getId()).getVersion(); //版本號相同,說明資料未被其他人修改 if(version == idem.getVersion()){ //正常更新 }else{ //拒絕更新 }
防重與冪等暫時先記錄到這,後續再進行補充
程式碼開源
程式碼已經開源、託管到我的GitHub、碼雲: