接口冪等性(重復提交)

  • 冪等接口就是多次調用不會影響到系統。

數據庫唯一主鍵

  • 數據庫唯一主鍵的實現主要是利用數據庫中主鍵唯一約束的特性,一般來說唯一主鍵比較適用于“插入”時的冪等性,其能保證一張表中只能存在一條帶該唯一主鍵的記錄。
  • 使用數據庫唯一主鍵完成冪等性時需要注意的是,該主鍵一般來說并不是使用數據庫中自增主鍵,而是使用分布式 ID 充當主鍵,這樣才能能保證在分布式環境下 ID 的全局唯一性。

適用操作

  • 插入操作
  • 刪除操作

使用限制

  • 需要生成全局唯一主鍵 ID


    image.png

主要流程如下:

  • 客戶端執行創建請求,調用服務端接口。
  • 服務端執行業務邏輯,生成一個分布式 ID,將該 ID 充當待插入數據的主鍵,然 后執數據插入操作,運行對應的 SQL 語句。
  • 服務端將該條數據插入數據庫中,如果插入成功則表示沒有重復調用接口。如果拋出主鍵重復異常,則表示數據庫中已經存在該條記錄,返回錯誤信息到客戶端。

數據庫樂觀鎖

  • 數據庫樂觀鎖方案一般只能適用于執行更新操作的過程,我們可以提前在對應的數據表中多添加一個字段,充當當前數據的版本標識。
  • 這樣每次對該數據庫該表的這條數據執行更新時,都會將該版本標識作為一個條件,值為上次待更新數據中的版本標識的值。

適用操作

  • 更新操作

使用限制

  • 需要數據庫對應業務表中添加額外字段

為了每次執行更新時防止重復更新,確定更新的一定是要更新的內容,我們通常都會添加一個 version 字段記錄當前的記錄版本,這樣在更新時候將該值帶上,那么只要執行更新操作就能確定一定更新的是某個對應版本下的信息。
UPDATE my_table SET price=price+50,version=version+1 WHERE id=1 AND version=5

image.png

防重 Token 令牌

  • 針對客戶端連續點擊或者調用方的超時重試等情況,例如提交訂單,此種操作就可以用 Token 的機制實現防止重復提交。
  • 簡單的說就是調用方在調用接口的時候先向后端請求一個全局 ID(Token),請求的時候攜帶這個全局 ID 一起請求(Token 最好將其放到 Headers 中),后端需要對這個 Token 作為 Key,用戶信息作為 Value 到 Redis 中進行鍵值內容校驗,如果 Key 存在且 Value 匹配就執行刪除命令,然后正常執行后面的業務邏輯。如果不存在對應的 Key 或 Value 不匹配就返回重復執行的錯誤信息,這樣來保證冪等操作。

適用操作

  • 插入操作
  • 更新操作
  • 刪除操作

使用限制

  • 需要生成全局唯一 Token 串
  • 需要使用第三方組件 Redis 進行數據效驗
image.png
  1. 服務端提供獲取 Token 的接口,該 Token 可以是一個序列號,也可以是一個分布式 ID 或者 UUID 串。
  2. 客戶端調用接口獲取 Token,這時候服務端會生成一個 Token 串。
  3. 然后將該串存入 Redis 數據庫中,以該 Token 作為 Redis 的鍵(注意設置過期時間)。
  4. 將 Token 返回到客戶端,客戶端拿到后應存到表單隱藏域中。
  5. 客戶端在執行提交表單時,把 Token 存入到 Headers 中,執行業務請求帶上該 Headers。
  6. 服務端接收到請求后從 Headers 中拿到 Token,然后根據 Token 到 Redis 中查找該 key 是否存在。
  7. 服務端根據 Redis 中是否存該 key 進行判斷,如果存在就將該 key 刪除,然后正常執行業務邏輯。如果不存在就拋異常,返回重復提交的錯誤信息。

注意,在并發情況下,執行 Redis 查找數據與刪除需要保證原子性,否則很可能在并發下無法保證冪等性。其實現方法可以使用分布式鎖或者使用 Lua 表達式來注銷查詢與刪除操作。

下游傳遞唯一序列號

  • 所謂請求序列號,其實就是每次向服務端請求時候附帶一個短時間內唯一不重復的序列號,該序列號可以是一個有序 ID,也可以是一個訂單號,一般由下游生成,在調用上游服務端接口時附加該序列號和用于認證的 ID。

  • 當上游服務器收到請求信息后拿取該 序列號 和下游 認證ID 進行組合,形成用于操作 Redis 的 Key,然后

    • 到 Redis 中查詢是否存在對應的 Key 的鍵值對,根據其結果:
      如果存在,就說明已經對該下游的該序列號的請求進行了業務處理,這時可以直接響應重復請求的錯誤信息。
    • 如果不存在,就以該 Key 作為 Redis 的鍵,以下游關鍵信息作為存儲的值(例如下游商傳遞的一些業務邏輯信息),將該鍵值對存儲到 Redis 中 ,然后再正常執行對應的業務邏輯即可。

適用操作

  • 插入操作
  • 更新操作
  • 刪除操作

使用限制

  • 要求第三方傳遞唯一序列號;
  • 需要使用第三方組件 Redis 進行數據效驗
image.png

主要流程

  • 下游服務生成分布式 ID 作為序列號,然后執行請求調用上游接口,并附帶唯一序列號與請求的認證憑據ID。
  • 上游服務進行安全效驗,檢測下游傳遞的參數中是否存在序列號和憑據ID。
  • 上游服務到 Redis 中檢測是否存在對應的序列號與認證ID組成的 Key,如果存在就拋出重復執行的異常信息,然后響應下游對應的錯誤信息。如果不存在就以該序列號和認證ID組合作為 Key,以下游關鍵信息作為 Value,進而存儲到 Redis 中,然后正常執行接來來的業務邏輯

上面步驟中插入數據到 Redis 一定要設置過期時間。這樣能保證在這個時間范圍內,如果重復調用接口,則能夠進行判斷識別。如果不設置過期時間,很可能導致數據無限量的存入 Redis,致使 Redis 不能正常工作。

怎么選

  • 對于下單等存在唯一主鍵的,可以使用“唯一主鍵方案”的方式實現。
  • 對于更新訂單狀態等相關的更新場景操作,使用“樂觀鎖方案”實現更為簡單。
  • 對于上下游這種,下游請求上游,上游服務可以使用“下游傳遞唯一序列號方案”更為合理。
  • 類似于前端重復提交、重復下單、沒有唯一ID號的場景,可以通過 Token 與 Redis 配合的“防重 Token 方案”實現更為快捷。
image.png

SpringBoot利用AOP防止請求重復提交

思路

  1. 自定義注解@NoRepeatSubmit 標記所有Controller中提交的請求。
  2. 通過AOP對所有標記了@NoRepeatSubmit 的方法進行攔截。
  3. 在業務方法執行前,獲取當前用戶的token或者JSessionId+當前請求地址,作為一個唯一的key,去獲取redis分布式鎖,如果此時并發獲取,只有一個線程能獲取到。
  4. 業務執行后,釋放鎖。

關于Redis分布式鎖

  • 使用Redis是為了在負載均衡部署,如果是單機的項目可以使用一個本地線程安全的Cache替代Redis

代碼

  • 自定義注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoRepeatSubmit {

    /**
     * 設置請求鎖定時間
     *
     * @return
     */
    int lockTime() default 10;

}
  • AOP
@Aspect
@Component
public class RepeatSubmitAspect {

    private static final Logger LOGGER = LoggerFactory.getLogger(RepeatSubmitAspect.class);

    @Autowired
    private RedisLock redisLock;

    @Pointcut("@annotation(noRepeatSubmit)")
    public void pointCut(NoRepeatSubmit noRepeatSubmit) {
    }

    @Around("pointCut(noRepeatSubmit)")
    public Object around(ProceedingJoinPoint pjp, NoRepeatSubmit noRepeatSubmit) throws Throwable {
        int lockSeconds = noRepeatSubmit.lockTime();

        HttpServletRequest request = RequestUtils.getRequest();
        Assert.notNull(request, "request can not null");

        // 此處可以用token或者JSessionId
        String token = request.getHeader("Authorization");
        String path = request.getServletPath();
        String key = getKey(token, path);
        String clientId = getClientId();

        boolean isSuccess = redisLock.tryLock(key, clientId, lockSeconds);
        LOGGER.info("tryLock key = [{}], clientId = [{}]", key, clientId);

        if (isSuccess) {
            LOGGER.info("tryLock success, key = [{}], clientId = [{}]", key, clientId);
            // 獲取鎖成功
            Object result;

            try {
                // 執行進程
                result = pjp.proceed();
            } finally {
                // 解鎖
                redisLock.releaseLock(key, clientId);
                LOGGER.info("releaseLock success, key = [{}], clientId = [{}]", key, clientId);
            }

            return result;

        } else {
            // 獲取鎖失敗,認為是重復提交的請求
            LOGGER.info("tryLock fail, key = [{}]", key);
            return new ApiResult(200, "重復請求,請稍后再試", null);
        }

    }

    private String getKey(String token, String path) {
        return token + path;
    }

    private String getClientId() {
        return UUID.randomUUID().toString();
    }

}
  • 測試接口
@RestController
public class SubmitController {

    @PostMapping("submit")
    @NoRepeatSubmit(lockTime = 30)
    public Object submit(@RequestBody UserBean userBean) {
        try {
            // 模擬業務場景
            Thread.sleep(1500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        return new ApiResult(200, "成功", userBean.userId);
    }

    public static class UserBean {
        private String userId;

        public String getUserId() {
            return userId;
        }

        public void setUserId(String userId) {
            this.userId = userId == null ? null : userId.trim();
        }
    }

}
  • 配置文件
server.port=8000

# Redis數據庫索引(默認為0)
spring.redis.database=0  
# Redis服務器地址
spring.redis.host=localhost
# Redis服務器連接端口
spring.redis.port=6379  
# Redis服務器連接密碼(默認為空)
#spring.redis.password=yourpwd
# 連接池最大連接數(使用負值表示沒有限制)
spring.redis.jedis.pool.max-active=8  
# 連接池最大阻塞等待時間
spring.redis.jedis.pool.max-wait=-1ms
# 連接池中的最大空閑連接
spring.redis.jedis.pool.max-idle=8  
# 連接池中的最小空閑連接
spring.redis.jedis.pool.min-idle=0  
# 連接超時時間(毫秒)
spring.redis.timeout=5000ms
  • 測試類(模擬測試)
@Component
public class RunTest implements ApplicationRunner {

    private static final Logger LOGGER = LoggerFactory.getLogger(RunTest.class);

    @Autowired
    private RestTemplate restTemplate;

    @Override
    public void run(ApplicationArguments args) throws Exception {
        System.out.println("執行多線程測試");
        String url="http://localhost:8000/submit";
        CountDownLatch countDownLatch = new CountDownLatch(1);
        ExecutorService executorService = Executors.newFixedThreadPool(10);

        for(int i=0; i<10; i++){
            String userId = "userId" + i;
            HttpEntity request = buildRequest(userId);
            executorService.submit(() -> {
                try {
                    countDownLatch.await();
                    System.out.println("Thread:"+Thread.currentThread().getName()+", time:"+System.currentTimeMillis());
                    ResponseEntity<String> response = restTemplate.postForEntity(url, request, String.class);
                    System.out.println("Thread:"+Thread.currentThread().getName() + "," + response.getBody());

                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }

        countDownLatch.countDown();
    }

    private HttpEntity buildRequest(String userId) {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        headers.set("Authorization", "yourToken");
        Map<String, Object> body = new HashMap<>();
        body.put("userId", userId);
        return new HttpEntity<>(body, headers);
    }
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容