SpringBoot系列——防重放與操作冪等

qch發表於2022-04-08

  前言

  日常開發中,我們可能會碰到需要進行防重放與操作冪等的業務,本文記錄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

SpringBoot系列——防重放與操作冪等
<!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>
View Code

 

  按鈕置灰不可點選

 

  點選提交按鈕後,將提交按鈕置灰不可點選,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、碼雲:

  GitHub:https://github.com/huanzi-qch/springBoot

  碼雲:https://gitee.com/huanzi-qch/springBoot

相關文章