所謂冪等: 多次呼叫方法或者介面不會改變業務狀態,可以保證重複呼叫的結果和單次呼叫的結果一致。
我們在開發中主要操作也就是CURD
,其中讀取操作和刪除操作是天然冪等的,我們所關心的就是建立操作、更新操作。
建立操作一定是非冪等的因為要涉及到新資料的產生,而更新操作有可能冪等有可能非冪等,這個要看具體業務場景。
就好比有個新增商品的功能,有個儲存按鈕
,如果前端連續多次點選儲存,後端就會收到多次請求介面,如果沒做好冪等就會重複建立了多條記錄,
就會出現髒資料。
這個也就是我們所說的如何防止前端重複提交的問題。
當我們調取第三方介面的時候,有可能會因為網路等原因導致呼叫失敗,所以我們會對介面呼叫新增失敗重試的機制,Spring可以通過@Retryable
註解實現重試機制。
既然重試就可能出現重複呼叫介面。這時再次呼叫時如果沒有做好冪等,就可能出現髒資料。
這個是無法避免的,因為我們說MQ在生產端和消費端都有重試機制,也就是同一訊息很可能會被重複消費。
如果業務保證多次消費的結果是一樣的那沒問題,但是如果業務無法滿足那就需要通過其它方式來保證消費端的冪等。
這種是最基礎的,也是我們在開發中必須要做的。我們會在插入或者更新前先判斷下,當前這個資料資料庫中是否已經存在,如果不存在則不允許重複插入,不存在則可插入。
程式碼範例如下:
public void save(Goods goods) {
// 1、先通過商品唯一code,查詢資料庫屬否存在
Goods goods = findGoods(goods.getCode);
// 2、如果這條資料在db裡已經存在了,此時就直接返回了
if (goods != null) {
return;
}
// 3、如果要是這條資料在db裡不存在,此時就會執行資料插入邏輯了
insertGoods(goods);
}
好比有個新增商品的功能,有個儲存按鈕
,使用者點選儲存按鈕後,立馬按鈕置灰,或者頁面跳轉到商品列表頁面,這樣可以防止很大部分的前端重複提交。
上面兩種初級方法,在高並行下顯然是無法保證介面冪等的,所以在高並行下,我們來如何保證介面的冪等呢,這裡整理幾種常見的解決辦法。
定義
: 當要對資料庫中的一條資料進行修改的時候,為了避免同時被其他人修改,最好的辦法就是直接對該資料進行加鎖以防止並行。
這裡以更新商品訂單狀態來舉例:一般訂單有訂單建立、訂單確認、訂單支付、訂單完成、取消訂單等訂單流程。
當我更新訂單狀態為訂單完成的時候,我們首先通過判斷該訂單的狀態是否是訂單支付,如果是不是則直接返回,否則更新狀態為已完成。
虛擬碼範例如下
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) 悲觀鎖在同一事務操作過程中,鎖住了一行資料。悲觀鎖效能不佳所以一般不建議用悲觀鎖做這個事情。
定義
:樂觀鎖就是很樂觀,每次去拿資料的時候都認為別人不會修改,所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去更新這個資料,可以使用版本號機制
。
所謂的樂觀鎖就是在表中新增一個version
(版本號)欄位。
通過版本號的方式,來控制update的操作的冪等性,使用者查詢出要修改的資料,系統將資料返回給頁面,將資料版本號放入隱藏域,使用者修改資料,點選提交,將版本號一同提交
給後臺,後臺使用版本號作為更新條件。
update set version = version +1 ,count=count+1 where id =xxx and version = ${version};
注意
:樂觀鎖能夠保證的是update的操作的冪等性,如果你的update本身就是冪等操作,或者install操作那就不能用樂觀鎖了。
很多業務表,都是有狀態的,比如訂單表,一般訂單有1-訂單建立、2-訂單確認、3-訂單支付、 4-訂單完成、5-取消訂單等訂單流程,當我們更新訂單狀態
update order_table set status=3 where order_no='20200524-1' and status=2;
第一個請求時,成功把 訂單確認 狀態修改成 訂單支付,sql執行結果的影響行數是1。
第二個請求時,同樣想把 訂單確認 狀態修改成 訂單支付,但是sql執行結果的影響行數為0。如果是0,那麼我們直接可以返回成功了。而不需要做接下來的業務操作,以此來保證保證
介面的冪等性。
一般來講悲觀鎖、樂觀鎖、狀態碼作用於update操作來實現冪等,而唯一索引是針對install操作來保證冪等。
1) 建立訂單時,前端先通過介面獲取訂單號,再請求後端時帶入訂單號,訂單表中訂單號新增唯一索引,如果存在插入相同訂單號則直接報錯。
2) 消費MQ訊息時,messageId
是唯一的,我們可以新新增一種消費記錄表,將messageId作為主鍵,如果重複消費那麼就會存在相同的messageId,插入直接報錯。
分散式鎖實現冪等性的邏輯就是,請求過來時,先去嘗試獲得分散式鎖,如果獲得成功,就執行業務邏輯,反之獲取失敗的話,就捨棄請求直接返回成功。
其實前面介紹過的悲觀鎖,本質是使用了資料庫的分散式鎖,都是將多個操作打包成一個原子操作,保證冪等。但由於資料庫分散式鎖的效能不太好,
我們可以改用:redis或zookeeper來實現分散式鎖。
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