接口冪等性(重復(fù)提交)

  • 冪等接口就是多次調(diào)用不會影響到系統(tǒng)。

數(shù)據(jù)庫唯一主鍵

  • 數(shù)據(jù)庫唯一主鍵的實現(xiàn)主要是利用數(shù)據(jù)庫中主鍵唯一約束的特性谷婆,一般來說唯一主鍵比較適用于“插入”時的冪等性慨蛙,其能保證一張表中只能存在一條帶該唯一主鍵的記錄。
  • 使用數(shù)據(jù)庫唯一主鍵完成冪等性時需要注意的是纪挎,該主鍵一般來說并不是使用數(shù)據(jù)庫中自增主鍵期贫,而是使用分布式 ID 充當(dāng)主鍵,這樣才能能保證在分布式環(huán)境下 ID 的全局唯一性异袄。

適用操作

  • 插入操作
  • 刪除操作

使用限制

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


    image.png

主要流程如下:

  • 客戶端執(zhí)行創(chuàng)建請求通砍,調(diào)用服務(wù)端接口。
  • 服務(wù)端執(zhí)行業(yè)務(wù)邏輯烤蜕,生成一個分布式 ID封孙,將該 ID 充當(dāng)待插入數(shù)據(jù)的主鍵,然 后執(zhí)數(shù)據(jù)插入操作讽营,運(yùn)行對應(yīng)的 SQL 語句虎忌。
  • 服務(wù)端將該條數(shù)據(jù)插入數(shù)據(jù)庫中,如果插入成功則表示沒有重復(fù)調(diào)用接口橱鹏。如果拋出主鍵重復(fù)異常膜蠢,則表示數(shù)據(jù)庫中已經(jīng)存在該條記錄堪藐,返回錯誤信息到客戶端。

數(shù)據(jù)庫樂觀鎖

  • 數(shù)據(jù)庫樂觀鎖方案一般只能適用于執(zhí)行更新操作的過程挑围,我們可以提前在對應(yīng)的數(shù)據(jù)表中多添加一個字段庶橱,充當(dāng)當(dāng)前數(shù)據(jù)的版本標(biāo)識。
  • 這樣每次對該數(shù)據(jù)庫該表的這條數(shù)據(jù)執(zhí)行更新時贪惹,都會將該版本標(biāo)識作為一個條件,值為上次待更新數(shù)據(jù)中的版本標(biāo)識的值寂嘉。

適用操作

  • 更新操作

使用限制

  • 需要數(shù)據(jù)庫對應(yīng)業(yè)務(wù)表中添加額外字段

為了每次執(zhí)行更新時防止重復(fù)更新奏瞬,確定更新的一定是要更新的內(nèi)容,我們通常都會添加一個 version 字段記錄當(dāng)前的記錄版本泉孩,這樣在更新時候?qū)⒃撝祹吓鸲耍敲粗灰獔?zhí)行更新操作就能確定一定更新的是某個對應(yīng)版本下的信息。
UPDATE my_table SET price=price+50,version=version+1 WHERE id=1 AND version=5

image.png

防重 Token 令牌

  • 針對客戶端連續(xù)點擊或者調(diào)用方的超時重試等情況寓搬,例如提交訂單珍昨,此種操作就可以用 Token 的機(jī)制實現(xiàn)防止重復(fù)提交。
  • 簡單的說就是調(diào)用方在調(diào)用接口的時候先向后端請求一個全局 ID(Token)句喷,請求的時候攜帶這個全局 ID 一起請求(Token 最好將其放到 Headers 中)镣典,后端需要對這個 Token 作為 Key,用戶信息作為 Value 到 Redis 中進(jìn)行鍵值內(nèi)容校驗唾琼,如果 Key 存在且 Value 匹配就執(zhí)行刪除命令兄春,然后正常執(zhí)行后面的業(yè)務(wù)邏輯。如果不存在對應(yīng)的 Key 或 Value 不匹配就返回重復(fù)執(zhí)行的錯誤信息锡溯,這樣來保證冪等操作赶舆。

適用操作

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

使用限制

  • 需要生成全局唯一 Token 串
  • 需要使用第三方組件 Redis 進(jìn)行數(shù)據(jù)效驗
image.png
  1. 服務(wù)端提供獲取 Token 的接口,該 Token 可以是一個序列號祭饭,也可以是一個分布式 ID 或者 UUID 串芜茵。
  2. 客戶端調(diào)用接口獲取 Token,這時候服務(wù)端會生成一個 Token 串倡蝙。
  3. 然后將該串存入 Redis 數(shù)據(jù)庫中九串,以該 Token 作為 Redis 的鍵(注意設(shè)置過期時間)。
  4. 將 Token 返回到客戶端悠咱,客戶端拿到后應(yīng)存到表單隱藏域中蒸辆。
  5. 客戶端在執(zhí)行提交表單時,把 Token 存入到 Headers 中析既,執(zhí)行業(yè)務(wù)請求帶上該 Headers躬贡。
  6. 服務(wù)端接收到請求后從 Headers 中拿到 Token,然后根據(jù) Token 到 Redis 中查找該 key 是否存在眼坏。
  7. 服務(wù)端根據(jù) Redis 中是否存該 key 進(jìn)行判斷拂玻,如果存在就將該 key 刪除酸些,然后正常執(zhí)行業(yè)務(wù)邏輯。如果不存在就拋異常檐蚜,返回重復(fù)提交的錯誤信息魄懂。

注意,在并發(fā)情況下闯第,執(zhí)行 Redis 查找數(shù)據(jù)與刪除需要保證原子性市栗,否則很可能在并發(fā)下無法保證冪等性。其實現(xiàn)方法可以使用分布式鎖或者使用 Lua 表達(dá)式來注銷查詢與刪除操作咳短。

下游傳遞唯一序列號

  • 所謂請求序列號填帽,其實就是每次向服務(wù)端請求時候附帶一個短時間內(nèi)唯一不重復(fù)的序列號,該序列號可以是一個有序 ID咙好,也可以是一個訂單號篡腌,一般由下游生成,在調(diào)用上游服務(wù)端接口時附加該序列號和用于認(rèn)證的 ID勾效。

  • 當(dāng)上游服務(wù)器收到請求信息后拿取該 序列號 和下游 認(rèn)證ID 進(jìn)行組合嘹悼,形成用于操作 Redis 的 Key,然后

    • 到 Redis 中查詢是否存在對應(yīng)的 Key 的鍵值對层宫,根據(jù)其結(jié)果:
      如果存在杨伙,就說明已經(jīng)對該下游的該序列號的請求進(jìn)行了業(yè)務(wù)處理,這時可以直接響應(yīng)重復(fù)請求的錯誤信息卒密。
    • 如果不存在缀台,就以該 Key 作為 Redis 的鍵,以下游關(guān)鍵信息作為存儲的值(例如下游商傳遞的一些業(yè)務(wù)邏輯信息)哮奇,將該鍵值對存儲到 Redis 中 膛腐,然后再正常執(zhí)行對應(yīng)的業(yè)務(wù)邏輯即可。

適用操作

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

使用限制

  • 要求第三方傳遞唯一序列號鼎俘;
  • 需要使用第三方組件 Redis 進(jìn)行數(shù)據(jù)效驗
image.png

主要流程

  • 下游服務(wù)生成分布式 ID 作為序列號哲身,然后執(zhí)行請求調(diào)用上游接口,并附帶唯一序列號與請求的認(rèn)證憑據(jù)ID贸伐。
  • 上游服務(wù)進(jìn)行安全效驗勘天,檢測下游傳遞的參數(shù)中是否存在序列號和憑據(jù)ID。
  • 上游服務(wù)到 Redis 中檢測是否存在對應(yīng)的序列號與認(rèn)證ID組成的 Key捉邢,如果存在就拋出重復(fù)執(zhí)行的異常信息脯丝,然后響應(yīng)下游對應(yīng)的錯誤信息。如果不存在就以該序列號和認(rèn)證ID組合作為 Key伏伐,以下游關(guān)鍵信息作為 Value宠进,進(jìn)而存儲到 Redis 中,然后正常執(zhí)行接來來的業(yè)務(wù)邏輯

上面步驟中插入數(shù)據(jù)到 Redis 一定要設(shè)置過期時間藐翎。這樣能保證在這個時間范圍內(nèi)材蹬,如果重復(fù)調(diào)用接口实幕,則能夠進(jìn)行判斷識別。如果不設(shè)置過期時間堤器,很可能導(dǎo)致數(shù)據(jù)無限量的存入 Redis昆庇,致使 Redis 不能正常工作。

怎么選

  • 對于下單等存在唯一主鍵的闸溃,可以使用“唯一主鍵方案”的方式實現(xiàn)整吆。
  • 對于更新訂單狀態(tài)等相關(guān)的更新場景操作,使用“樂觀鎖方案”實現(xiàn)更為簡單辉川。
  • 對于上下游這種掂为,下游請求上游,上游服務(wù)可以使用“下游傳遞唯一序列號方案”更為合理员串。
  • 類似于前端重復(fù)提交、重復(fù)下單昼扛、沒有唯一ID號的場景寸齐,可以通過 Token 與 Redis 配合的“防重 Token 方案”實現(xiàn)更為快捷。
image.png

SpringBoot利用AOP防止請求重復(fù)提交

思路

  1. 自定義注解@NoRepeatSubmit 標(biāo)記所有Controller中提交的請求抄谐。
  2. 通過AOP對所有標(biāo)記了@NoRepeatSubmit 的方法進(jìn)行攔截渺鹦。
  3. 在業(yè)務(wù)方法執(zhí)行前,獲取當(dāng)前用戶的token或者JSessionId+當(dāng)前請求地址蛹含,作為一個唯一的key毅厚,去獲取redis分布式鎖,如果此時并發(fā)獲取浦箱,只有一個線程能獲取到吸耿。
  4. 業(yè)務(wù)執(zhí)行后,釋放鎖酷窥。

關(guān)于Redis分布式鎖

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

代碼

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

    /**
     * 設(shè)置請求鎖定時間
     *
     * @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 {
                // 執(zhí)行進(jìn)程
                result = pjp.proceed();
            } finally {
                // 解鎖
                redisLock.releaseLock(key, clientId);
                LOGGER.info("releaseLock success, key = [{}], clientId = [{}]", key, clientId);
            }

            return result;

        } else {
            // 獲取鎖失敗,認(rèn)為是重復(fù)提交的請求
            LOGGER.info("tryLock fail, key = [{}]", key);
            return new ApiResult(200, "重復(fù)請求蓬推,請稍后再試", 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 {
            // 模擬業(yè)務(wù)場景
            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數(shù)據(jù)庫索引(默認(rèn)為0)
spring.redis.database=0  
# Redis服務(wù)器地址
spring.redis.host=localhost
# Redis服務(wù)器連接端口
spring.redis.port=6379  
# Redis服務(wù)器連接密碼(默認(rèn)為空)
#spring.redis.password=yourpwd
# 連接池最大連接數(shù)(使用負(fù)值表示沒有限制)
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("執(zhí)行多線程測試");
        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);
    }
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末妆棒,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子沸伏,更是在濱河造成了極大的恐慌糕珊,老刑警劉巖,帶你破解...
    沈念sama閱讀 218,682評論 6 507
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件毅糟,死亡現(xiàn)場離奇詭異红选,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)留特,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,277評論 3 395
  • 文/潘曉璐 我一進(jìn)店門纠脾,熙熙樓的掌柜王于貴愁眉苦臉地迎上來玛瘸,“玉大人,你說我怎么就攤上這事苟蹈『ǎ” “怎么了?”我有些...
    開封第一講書人閱讀 165,083評論 0 355
  • 文/不壞的土叔 我叫張陵慧脱,是天一觀的道長渺绒。 經(jīng)常有香客問我,道長菱鸥,這世上最難降的妖魔是什么宗兼? 我笑而不...
    開封第一講書人閱讀 58,763評論 1 295
  • 正文 為了忘掉前任,我火速辦了婚禮氮采,結(jié)果婚禮上殷绍,老公的妹妹穿的比我還像新娘。我一直安慰自己鹊漠,他們只是感情好主到,可當(dāng)我...
    茶點故事閱讀 67,785評論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著躯概,像睡著了一般登钥。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上娶靡,一...
    開封第一講書人閱讀 51,624評論 1 305
  • 那天牧牢,我揣著相機(jī)與錄音,去河邊找鬼姿锭。 笑死塔鳍,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的呻此。 我是一名探鬼主播献幔,決...
    沈念sama閱讀 40,358評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼趾诗!你這毒婦竟也來了蜡感?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,261評論 0 276
  • 序言:老撾萬榮一對情侶失蹤恃泪,失蹤者是張志新(化名)和其女友劉穎郑兴,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體贝乎,經(jīng)...
    沈念sama閱讀 45,722評論 1 315
  • 正文 獨居荒郊野嶺守林人離奇死亡情连,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,900評論 3 336
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了览效。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片却舀。...
    茶點故事閱讀 40,030評論 1 350
  • 序言:一個原本活蹦亂跳的男人離奇死亡虫几,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出挽拔,到底是詐尸還是另有隱情辆脸,我是刑警寧澤,帶...
    沈念sama閱讀 35,737評論 5 346
  • 正文 年R本政府宣布螃诅,位于F島的核電站啡氢,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏术裸。R本人自食惡果不足惜倘是,卻給世界環(huán)境...
    茶點故事閱讀 41,360評論 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望袭艺。 院中可真熱鬧搀崭,春花似錦、人聲如沸猾编。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,941評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽袍镀。三九已至,卻和暖如春冻晤,著一層夾襖步出監(jiān)牢的瞬間苇羡,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,057評論 1 270
  • 我被黑心中介騙來泰國打工鼻弧, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留设江,地道東北人。 一個月前我還...
    沈念sama閱讀 48,237評論 3 371
  • 正文 我出身青樓攘轩,卻偏偏與公主長得像叉存,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子度帮,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 44,976評論 2 355

推薦閱讀更多精彩內(nèi)容