介面的冪等性如何設計?

2022-05-27 12:03:00

前言

所謂冪等: 多次呼叫方法或者介面不會改變業務狀態,可以保證重複呼叫的結果和單次呼叫的結果一致

我們在開發中主要操作也就是CURD,其中讀取操作和刪除操作是天然冪等的,我們所關心的就是建立操作、更新操作。

建立操作一定是非冪等的因為要涉及到新資料的產生,而更新操作有可能冪等有可能非冪等,這個要看具體業務場景。

一、冪等性的使用場景

1、前端重複提交

就好比有個新增商品的功能,有個儲存按鈕,如果前端連續多次點選儲存,後端就會收到多次請求介面,如果沒做好冪等就會重複建立了多條記錄,
就會出現髒資料。

這個也就是我們所說的如何防止前端重複提交的問題。

2、介面超時重試

當我們調取第三方介面的時候,有可能會因為網路等原因導致呼叫失敗,所以我們會對介面呼叫新增失敗重試的機制,Spring可以通過@Retryable註解實現重試機制。

既然重試就可能出現重複呼叫介面。這時再次呼叫時如果沒有做好冪等,就可能出現髒資料。

3、訊息重複消費

這個是無法避免的,因為我們說MQ在生產端和消費端都有重試機制,也就是同一訊息很可能會被重複消費。

如果業務保證多次消費的結果是一樣的那沒問題,但是如果業務無法滿足那就需要通過其它方式來保證消費端的冪等。


二、初級方式來保證儘量冪等

1、插入前先判斷資料是否存在

這種是最基礎的,也是我們在開發中必須要做的。我們會在插入或者更新前先判斷下,當前這個資料資料庫中是否已經存在,如果不存在則不允許重複插入,不存在則可插入。

程式碼範例如下:

    public void save(Goods goods) {
        // 1、先通過商品唯一code,查詢資料庫屬否存在   
        Goods goods = findGoods(goods.getCode);
        // 2、如果這條資料在db裡已經存在了,此時就直接返回了   
        if (goods != null) {
            return;
        }
        // 3、如果要是這條資料在db裡不存在,此時就會執行資料插入邏輯了   
        insertGoods(goods);
    }

2、前端做一些互動控制

好比有個新增商品的功能,有個儲存按鈕,使用者點選儲存按鈕後,立馬按鈕置灰,或者頁面跳轉到商品列表頁面,這樣可以防止很大部分的前端重複提交。


三、高並行下如何保證冪等?

上面兩種初級方法,在高並行下顯然是無法保證介面冪等的,所以在高並行下,我們來如何保證介面的冪等呢,這裡整理幾種常見的解決辦法。

1、基於悲觀鎖

定義: 當要對資料庫中的一條資料進行修改的時候,為了避免同時被其他人修改,最好的辦法就是直接對該資料進行加鎖以防止並行。

這裡以更新商品訂單狀態來舉例:一般訂單有訂單建立訂單確認訂單支付訂單完成取消訂單等訂單流程。

當我更新訂單狀態為訂單完成的時候,我們首先通過判斷該訂單的狀態是否是訂單支付,如果是不是則直接返回,否則更新狀態為已完成。

虛擬碼範例如下

  begin; -- 1.開始事務
  -- 查詢訂單,判斷狀態
  select order_no,status from order where order_no='20200524-1' 
  if(status !=訂單支付狀態){
        -- 非訂單支付狀態,不能更新為已完成;
        return ;
    }
  -- 更新完成
  update order set status='訂單完成' order_no='20200524-1' 
   commit; -- 2.提交事務

這是我們常見的一種寫法,但這種寫法在高並行環境下,可能會造成一個業務被執行兩次的情況發生:

同時有兩個請求過來,大家幾乎同時查資料庫訂單狀態,都是訂單支付狀態,然後就支援接下來一系列操作,這就導致一個業務被執行了兩次,如果接下來一系列操作不是冪等的

那麼就會出現髒資料。這裡我們就可以通過悲觀鎖實現,也就是新增for update欄位。

虛擬碼範例如下

  begin;  --  1.開始事務
  --  查詢訂單,判斷狀態
  select order_no,status from order where order_no='20200524-1' for update 
  if(status !=訂單支付狀態){
        -- 非訂單狀態,不能更新為已完成;
        return ;
    }
 --  更新完成
  update order set status='完成' order_no='20200524-1' 
   commit; -- 2.提交事務

1)這裡order_no需要新增索引,否則會鎖表

2) 悲觀鎖在同一事務操作過程中,鎖住了一行資料。悲觀鎖效能不佳所以一般不建議用悲觀鎖做這個事情。

2、基於樂觀鎖

定義:樂觀鎖就是很樂觀,每次去拿資料的時候都認為別人不會修改,所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去更新這個資料,可以使用版本號機制

所謂的樂觀鎖就是在表中新增一個version(版本號)欄位。

通過版本號的方式,來控制update的操作的冪等性,使用者查詢出要修改的資料,系統將資料返回給頁面,將資料版本號放入隱藏域,使用者修改資料,點選提交,將版本號一同提交

給後臺,後臺使用版本號作為更新條件。

update set version = version +1 ,count=count+1 where id =xxx and version = ${version};

注意:樂觀鎖能夠保證的是update的操作的冪等性,如果你的update本身就是冪等操作,或者install操作那就不能用樂觀鎖了。

3、基於狀態碼

很多業務表,都是有狀態的,比如訂單表,一般訂單有1-訂單建立2-訂單確認3-訂單支付4-訂單完成5-取消訂單等訂單流程,當我們更新訂單狀態

update order_table set status=3 where order_no='20200524-1' and status=2;

第一個請求時,成功把 訂單確認 狀態修改成 訂單支付,sql執行結果的影響行數是1。

第二個請求時,同樣想把 訂單確認 狀態修改成 訂單支付,但是sql執行結果的影響行數為0。如果是0,那麼我們直接可以返回成功了。而不需要做接下來的業務操作,以此來保證保證

介面的冪等性。

4、基於唯一索引

一般來講悲觀鎖、樂觀鎖、狀態碼作用於update操作來實現冪等,而唯一索引是針對install操作來保證冪等。

1) 建立訂單時,前端先通過介面獲取訂單號,再請求後端時帶入訂單號,訂單表中訂單號新增唯一索引,如果存在插入相同訂單號則直接報錯。

2) 消費MQ訊息時,messageId是唯一的,我們可以新新增一種消費記錄表,將messageId作為主鍵,如果重複消費那麼就會存在相同的messageId,插入直接報錯。

5、基於分散式鎖

分散式鎖實現冪等性的邏輯就是,請求過來時,先去嘗試獲得分散式鎖,如果獲得成功,就執行業務邏輯,反之獲取失敗的話,就捨棄請求直接返回成功。

其實前面介紹過的悲觀鎖,本質是使用了資料庫的分散式鎖,都是將多個操作打包成一個原子操作,保證冪等。但由於資料庫分散式鎖的效能不太好,

我們可以改用:redis或zookeeper來實現分散式鎖。

6、基於 Token

token方案的特點就是:需要兩次請求才能完成一次業務的操作。

一般包括兩個請求階段:

1)使用者端請求申請獲取token,伺服器端生成token返回。

2)第二次請求帶著這個token,伺服器端驗證token,完成業務操作。

注意:,在驗證token是否存在,不要用redis.get(token)之後,在用redis.del(token),這樣不是原子操作在高並行情況下依然會存在冪等問題。

我們可以直接用redis.del(token)的方式:

redis> SET key1 "Hello"
OK
redis> SET key2 "World"
OK
redis> DEL key1 key2 key3
(integer) 2
redis> 

我們看返回是否大於0,就知道是否有資料了,而且因為redis命令操作是單執行緒的,所以不會出現同時返回1,所以是能夠保證冪等的。

這種方式最大的缺點需要兩次請求,其實簡單點我們可以進行一次請求,那就是前端生成唯一token,而不通過後端獲取。

Setnx 命令

在指定的 key 不存在時,為 key 設定指定的值。設定成功,返回1。 設定失敗,返回 0。

範例

redis> EXISTS job      --  job 不存在
(integer) 0

redis> SETNX job "programmer"  --  job 設定成功
(integer) 1

redis> SETNX job "code-farmer" --  嘗試覆蓋 job ,失敗
(integer) 0

redis> GET job                 --  沒有被覆蓋
"programmer"

如果返回1則說明第一次請求,如果返回0則說明不是第一次請求,直接返回。

這裡需要注意的是Setnx命令key值不會自動過期的,所以不清除會一直佔用記憶體,我們可以藉助Expire命令來設定有效時間。

redis> SETNX mykey "programmer"  --  job 設定成功
(integer) 1
--  如果設定成功,那麼設定將該鍵的超時設定為 10 秒
redis> expire mykey 10