SpringBoot系列教程之防重放與操作冪等
前言
日常開(kāi)發(fā)中,我們可能會(huì)碰到需要進(jìn)行防重放與操作冪等的業(yè)務(wù),本文記錄SpringBoot實(shí)現(xiàn)簡(jiǎn)單防重與冪等
防重放,防止數(shù)據(jù)重復(fù)提交
操作冪等性,多次執(zhí)行所產(chǎn)生的影響均與一次執(zhí)行的影響相同
解決什么問(wèn)題?
表單重復(fù)提交,用戶多次點(diǎn)擊表單提交按鈕
接口重復(fù)調(diào)用,接口短時(shí)間內(nèi)被多次調(diào)用
思路如下:
1、前端頁(yè)面表提交鈕置灰不可點(diǎn)擊+js節(jié)流防抖
2、Redis防重Token令牌
3、數(shù)據(jù)庫(kù)唯一主鍵 + 樂(lè)觀鎖
具體方案
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驅(qū)動(dòng)依賴 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency>
一個(gè)測(cè)試表
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 '業(yè)務(wù)數(shù)據(jù)', `version` int(8) NOT NULL COMMENT '樂(lè)觀鎖版本號(hào)', PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '防重放與操作冪等測(cè)試表' ROW_FORMAT = Compact;
前端頁(yè)面
先寫一個(gè)test頁(yè)面,引入jq
<!DOCTYPE html> <!--解決idea thymeleaf 表達(dá)式模板報(bào)紅波浪線--> <!--suppress ALL --> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8" /> <title>防重放與操作冪等</title> <!-- 引入靜態(tài)資源 --> <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}"/> <!-- 業(yè)務(wù)數(shù)據(jù) --> 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">節(jié)流測(cè)試,點(diǎn)我</button> <br/> <button id="btn2">防抖測(cè)試,點(diǎn)我</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表單測(cè)試 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); }); } //節(jié)流測(cè)試 for (let i = 0; i < 5; i++) { document.getElementById('btn').onclick(); } //防抖測(cè)試 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); //按鈕恢復(fù) but.removeAttribute("disabled"); }, error: function (xhr, status, error) { console.error("ajax錯(cuò)誤!"); //按鈕恢復(fù) but.removeAttribute("disabled"); } }); return false; } document.getElementById('btn').onclick = throttle(function () { console.log('節(jié)流測(cè)試 helloworld'); }, 1000) // 節(jié)流:給定一個(gè)時(shí)間,不管這個(gè)時(shí)間你怎么點(diǎn)擊,點(diǎn)上天,這個(gè)時(shí)間內(nèi)也只會(huì)執(zhí)行一次 // 節(jié)流函數(shù) 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('防抖測(cè)試 helloworld'); }, 1000) // 防抖:給定一個(gè)時(shí)間,不管怎么點(diǎn)擊按鈕,每點(diǎn)一次,都會(huì)在最后一次點(diǎn)擊等待這個(gè)時(shí)間過(guò)后執(zhí)行 // 防抖函數(shù) 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>
按鈕置灰不可點(diǎn)擊
點(diǎn)擊提交按鈕后,將提交按鈕置灰不可點(diǎn)擊,ajax響應(yīng)后再恢復(fù)按鈕狀態(tài)
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); //按鈕恢復(fù) but.removeAttribute("disabled"); }, error: function (xhr, status, error) { console.error("ajax錯(cuò)誤!"); //按鈕恢復(fù) but.removeAttribute("disabled"); } }); return false; }
js節(jié)流、防抖
節(jié)流:給定一個(gè)時(shí)間,不管這個(gè)時(shí)間你怎么點(diǎn)擊,點(diǎn)上天,這個(gè)時(shí)間內(nèi)也只會(huì)執(zhí)行一次
document.getElementById('btn').onclick = throttle(function () { console.log('節(jié)流測(cè)試 helloworld'); }, 1000) // 節(jié)流:給定一個(gè)時(shí)間,不管這個(gè)時(shí)間你怎么點(diǎn)擊,點(diǎn)上天,這個(gè)時(shí)間內(nèi)也只會(huì)執(zhí)行一次 // 節(jié)流函數(shù) 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) } } }
防抖:給定一個(gè)時(shí)間,不管怎么點(diǎn)擊按鈕,每點(diǎn)一次,都會(huì)在最后一次點(diǎn)擊等待這個(gè)時(shí)間過(guò)后執(zhí)行
document.getElementById('btn2').onclick = debounce(function () { console.log('防抖測(cè)試 helloworld'); }, 1000) // 防抖:給定一個(gè)時(shí)間,不管怎么點(diǎn)擊按鈕,每點(diǎn)一次,都會(huì)在最后一次點(diǎn)擊等待這個(gè)時(shí)間過(guò)后執(zhí)行 // 防抖函數(shù) 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令牌
跳轉(zhuǎn)前端表單頁(yè)面時(shí),設(shè)置一個(gè)UUID作為token,并設(shè)置在表單隱藏域
/** * 跳轉(zhuǎn)頁(yè)面 */ @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}"/> <!-- 業(yè)務(wù)數(shù)據(jù) --> 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>
后臺(tái)查詢r(jià)edis緩存,如果token不存在立即設(shè)置token緩存,允許表單業(yè)務(wù)正常進(jìn)行;如果token緩存已經(jīng)存在,拒絕表單業(yè)務(wù)
PS:token緩存要設(shè)置一個(gè)合理的過(guò)期時(shí)間
/** * 表單提交測(cè)試 */ @RequestMapping("test") private String test(String token,String id,String msg,int version){ //如果token緩存不存在,立即設(shè)置緩存且設(shè)置有效時(shí)長(zhǎng)(秒) Boolean setIfAbsent = template.opsForValue().setIfAbsent(token, "1", 60 * 5, TimeUnit.SECONDS); //緩存設(shè)置成功返回true,失敗返回false if(Boolean.TRUE.equals(setIfAbsent)){ //模擬耗時(shí) try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } //打印測(cè)試數(shù)據(jù) System.out.println(token+","+id+","+msg+","+version); return "操作成功!"; }else{ return "操作失敗,表單已被提交..."; } }
for循環(huán)測(cè)試中,5個(gè)操作只有一個(gè)執(zhí)行成功!
數(shù)據(jù)庫(kù)
唯一主鍵 + 樂(lè)觀鎖
查詢操作自帶冪等性
/** * 查詢操作,天生冪等性 */ @Override public Object select(Idem idem) { QueryWrapper<Idem> queryWrapper = new QueryWrapper<>(); queryWrapper.setEntity(idem); return idemMapper.selectList(queryWrapper); }
查詢沒(méi)什么好說(shuō)的,只要數(shù)據(jù)不變,查詢條件不變的情況下查詢結(jié)果必然冪等
唯一主鍵可解決插入操作、刪除操作
/** * 插入操作,使用唯一主鍵實(shí)現(xiàn)冪等性 */ @Override public Object insert(Idem idem) { String msg = "操作成功!"; try{ idemMapper.insert(idem); }catch (DuplicateKeyException e){ msg = "操作失敗,id:"+idem.getId()+",已經(jīng)存在..."; } return msg; } /** * 刪除操作,使用唯一主鍵實(shí)現(xiàn)冪等性 * PS:使用非主鍵條件除外 */ @Override public Object delete(Idem idem) { String msg = "操作成功!"; int deleteById = idemMapper.deleteById(idem.getId()); if(deleteById == 0){ msg = "操作失敗,id:"+idem.getId()+",已經(jīng)被刪除..."; } return msg; }
利用主鍵唯一的特性,捕獲處理重復(fù)操作
樂(lè)觀鎖可解決更新操作
/** * 更新操作,使用樂(lè)觀鎖實(shí)現(xiàn)冪等性 */ @Override public Object update(Idem idem) { String msg = "操作成功!"; // UPDATE table SET [... 業(yè)務(wù)字段=? ...], version = version+1 WHERE (id = ? AND version = ?) UpdateWrapper<Idem> updateWrapper = new UpdateWrapper<>(); //where條件 updateWrapper.eq("id",idem.getId()); updateWrapper.eq("version",idem.getVersion()); //version版本號(hào)要單獨(dú)設(shè)置 updateWrapper.setSql("version = version+1"); idem.setVersion(null); int update = idemMapper.update(idem, updateWrapper); if(update == 0){ msg = "操作失敗,id:"+idem.getId()+",已經(jīng)被更新..."; } return msg; }
執(zhí)行更新sql語(yǔ)句時(shí),where條件帶上version版本號(hào),如果執(zhí)行成功,除了更新業(yè)務(wù)數(shù)據(jù),同時(shí)更新version版本號(hào)標(biāo)記當(dāng)前數(shù)據(jù)已被更新
UPDATE table SET [... 業(yè)務(wù)字段=? ...], version = version+1 WHERE (id = ? AND version = ?)
執(zhí)行更新操作前,需要重新執(zhí)行插入數(shù)據(jù)
以上for循環(huán)測(cè)試中,5個(gè)操作同樣只有一個(gè)執(zhí)行成功!
后記
redis、樂(lè)觀鎖不要在代碼先查詢后if判斷,這樣會(huì)存在并發(fā)問(wèn)題,導(dǎo)致數(shù)據(jù)不準(zhǔn)確,應(yīng)該把這種判斷放在redis、數(shù)據(jù)庫(kù)
錯(cuò)誤示例:
//獲取最新緩存 String redisToken = template.opsForValue().get(token); //為空則放行業(yè)務(wù) if(redisToken == null){ //設(shè)置緩存 template.opsForValue().set(token, "1", 60 * 5, TimeUnit.SECONDS); //業(yè)務(wù)處理 }else{ //拒絕業(yè)務(wù) }
錯(cuò)誤示例:
//獲取最新版本號(hào) Integer version = idemMapper.selectById(idem.getId()).getVersion(); //版本號(hào)相同,說(shuō)明數(shù)據(jù)未被其他人修改 if(version == idem.getVersion()){ //正常更新 }else{ //拒絕更新 }
防重與冪等暫時(shí)先記錄到這,后續(xù)再進(jìn)行補(bǔ)充
代碼開(kāi)源
代碼已經(jīng)開(kāi)源、托管到我的GitHub、碼云:
GitHub:https://github.com/huanzi-qch/springBoot
碼云:https://gitee.com/huanzi-qch/springBoot
總結(jié)
到此這篇關(guān)于SpringBoot系列教程之防重放與操作冪等的文章就介紹到這了,更多相關(guān)SpringBoot防重放與操作冪等內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
解決Mybatis-plus和pagehelper依賴沖突的方法示例
這篇文章主要介紹了解決Mybatis-plus和pagehelper依賴沖突的方法示例,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2021-04-04基于MyBatis的數(shù)據(jù)持久化框架的使用詳解
Mybatis是一個(gè)優(yōu)秀的開(kāi)源、輕量級(jí)持久層框架,它對(duì)JDBC操作數(shù)據(jù)庫(kù)的過(guò)程進(jìn)行封裝。本文將為大家講解一下基于MyBatis的數(shù)據(jù)持久化框架的使用,感興趣的可以了解一下2022-08-08關(guān)于使用Mybatisplus自帶的selectById和insert方法時(shí)的一些問(wèn)題
這篇文章主要介紹了關(guān)于使用Mybatisplus自帶的selectById和insert方法時(shí)的一些問(wèn)題,本文給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2020-08-08springmvc與mybatis集成配置實(shí)例詳解
這篇文章主要介紹了springmvc與mybatis集成配置實(shí)例詳解的相關(guān)資料,非常不錯(cuò),具有參考借鑒價(jià)值,需要的朋友可以參考下2016-09-09Java基礎(chǔ)之switch分支結(jié)構(gòu)詳解
這篇文章主要介紹了Java基礎(chǔ)之switch分支結(jié)構(gòu)詳解,文中有非常詳細(xì)的代碼示例,對(duì)正在學(xué)習(xí)java的小伙伴們有很大的幫助,需要的朋友可以參考下2021-05-05SpringBoot在RequestBody中使用枚舉參數(shù)案例詳解
這篇文章主要介紹了SpringBoot在RequestBody中使用枚舉參數(shù)案例詳解,本篇文章通過(guò)簡(jiǎn)要的案例,講解了該項(xiàng)技術(shù)的了解與使用,以下就是詳細(xì)內(nèi)容,需要的朋友可以參考下2021-09-09