介面的冪等性如何設計?
- 2022 年 5 月 27 日
- 筆記
- 【Study】--優化經驗
前言
所謂冪等: 多次調用方法或者介面不會改變業務狀態,可以保證重複調用的結果和單次調用的結果一致。
我們在開發中主要操作也就是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

