冪等設計:確保操作的安全與可靠

雩娄的木子發表於2024-12-02

目錄
  • 一、概述
    • 1.1、什麼是冪等
    • 1.2、為什麼需要冪等?
  • 二、冪等如何設計
    • 實現冪等的8 種方案
    • select+insert+主鍵/唯一索引衝突
    • 狀態機冪等
    • 抽取防重表
    • token令牌
    • 悲觀鎖(如select for update)
    • 樂觀鎖
    • 分散式鎖
  • 三、HTTP的冪等

一、概述

在分散式系統和微服務架構中,確保操作的安全性和可靠性至關重要。冪等性(Idempotency)是實現這一目標的重要概念。本文將探討冪等性的基本原理、應用場景以及設計冪等操作的方法。

在工作中,做冪等的場景蠻多的,恰逢阿里雲網盤事件爆發,能夠訪問到陌生人的圖片,那麼在冪等上肯定就存在著問題。

所以針對冪等性針對要來做一個總結說明。

1.1、什麼是冪等

冪等性是指一個操作無論執行多少次,其結果都是相同的。在數學上,函式 f(x)f(x)f(x) 滿足冪等性是指 f(f(x))=f(x)f(f(x)) = f(x)f(f(x))=f(x)。在電腦科學中,冪等操作的一個典型例子是HTTP GET請求,不管你傳送多少次GET請求,伺服器返回的資源狀態都是一致的。

1.2、為什麼需要冪等?

在某些提交表單的場景,如果使用者一直重複點選,可能會產生兩條一樣的資料,這種是前端重複提交的場景。還有就是轉賬場景,出現網路超時,有可能請求到,或者出現網路丟包的情況,這種情況導致重複請求的情況,這種情況如果重試,那麼就是需要做好冪等,否則的話就會出現轉賬多轉的情況。

二、冪等如何設計

唯一請求識別符號:使用唯一請求ID(如UUID)來標識每個操作。伺服器可以使用這個ID來跟蹤和記錄請求,確保每個請求只執行一次。

一般會生成一個全域性性的唯一 id,全域性唯一 id 有許多種生成方式,如以下方式

  • UUID:UUID的缺點比較明顯,它字串佔用的空間比較大,生成的ID過於隨機,可讀性差,而且沒有遞增。

  • 分散式 id:經典分散式 id 就是使用雪花演算法來生成唯一 id。

分散式 id 生成演算法雪花演算法是一種生成分散式全域性唯一ID的演算法,生成的ID稱為Snowflake IDs。這種演算法由Twitter建立,並用於推文的ID。一個Snowflake ID有64位。

第1位:Java中long的最高位是符號位代表正負,正數是0,負數是1,一般生成ID都為正數,所以預設為0。

接下來前41位是時間戳,表示了自選定的時期以來的毫秒數。接下來的10位代表計算機ID,防止衝突。

其餘12位代表每臺機器上生成ID的序列號,這允許在同一毫秒內建立多個Snowflake ID。

當然,全域性唯一性的ID,還可以使用百度的Uidgenerator,或者美團的Leaf。

實現冪等的8 種方案

如果你呼叫下游介面超時了,是不是要考慮重試,如果重試的話,下游介面就需要支援冪等。

實現冪等一般有以下方案:

  1. select+insert+主鍵/唯一索引衝突
  2. 直接insert + 主鍵/唯一索引衝突
  3. 狀態機冪等
  4. 抽取防重表
  5. token令牌
  6. 悲觀鎖(如select for update,很少用)
  7. 樂觀鎖
  8. 分散式鎖

select+insert+主鍵/唯一索引衝突

日常開發中,為了實現交易介面冪等,我是這樣實現的:

交易請求過來,我會先根據請求的唯一流水號 reqNo欄位+userId欄位,先select一下資料庫的流水錶

  • 如果資料已經存在,就攔截是重複請求,直接返回成功;

  • 如果資料不存在,就執行insert插入,如果insert成功,則直接返回成功,如果insert產生主鍵衝突異常,則捕獲異常,開始來做一些補償處理。

notion image

狀態機冪等

很多業務表,都是有狀態的,比如轉賬流水錶,就會有0-待處理,1-處理中、2-成功、3-失敗狀態。轉賬流水更新的時候,都會涉及流水狀態更新,即涉及狀態機 (即狀態變更圖)。我們可以利用狀態機實現冪等,一起來看下它是怎麼實現的。

比如轉賬成功後,把處理中的轉賬流水更新為成功狀態,SQL這麼寫:

很多業務表,都是有狀態的,比如轉賬流水錶,就會有0-待處理,1-處理中、2-成功、3-失敗狀態。轉賬流水更新的時候,都會涉及流水狀態更新,即涉及狀態機 (即狀態變更圖)。我們可以利用狀態機實現冪等,一起來看下它是怎麼實現的。

比如轉賬成功後,把處理中的轉賬流水更新為成功狀態,SQL這麼寫:

update transfr_flow set status=2 where biz_seq=‘666’ and status=1;

如下是虛擬碼

Rsp idempotentTransfer(Request req){
   String bizSeq = req.getBizSeq();
   int rows= "update transfr_flow set status=2 where biz_seq=#{bizSeq} and status=1;"
   if(rows==1){
      log.info(“更新成功,可以處理該請求”);
      //其他業務邏輯處理
      return rsp;
   }else if(rows==0){
      log.info(“更新不成功,不處理該請求”);
      //不處理,直接返回
      return rsp;
   }
   
   log.warn("資料異常")
   return rsp:
}

Java

Copy

抽取防重表

很多時候,我們業務表唯一流水號希望後端系統生成,又或者我們希望防重功能與業務表分隔開來,這時候我們可以單獨搞個防重表。當然防重表也是利用主鍵/索引的唯一性,如果插入防重表衝突即直接返回成功,如果插入成功,即去處理請求。

token令牌

token 令牌方案一般包括兩個請求階段:

  1. 客戶端請求申請獲取token,服務端生成token返回

  2. 客戶端帶著token請求,服務端校驗token

notion image

  1. 客戶端發起請求,申請獲取token。
  2. 服務端生成全域性唯一的token,儲存到redis中(一般會設定一個過期時間),然後返回給客戶端。
  3. 客戶端帶著token,發起請求。
  4. 服務端去redis確認token是否存在,一般用 redis.del(token)的方式,如果存在會刪除成功,即處理業務邏輯,如果刪除失敗不處理業務邏輯,直接返回結果。

悲觀鎖(如select for update)

什麼是悲觀鎖

通俗點講就是很悲觀,每次去運算元據時,都覺得別人中途會修改,所以每次在拿資料的時候都會上鎖。官方點講就是,共享資源每次只給一個執行緒使用,其它執行緒阻塞,用完後再把資源轉讓給其它執行緒。

悲觀鎖如何控制冪等的呢?就是加鎖呀,一般配合事務來實現。

舉個更新訂單的業務場景:

假設先查出訂單,如果查到的是處理中狀態,就處理完業務,再然後更新訂單狀態為完成。如果查到訂單,並且不是處理中的狀態,則直接返回

整體的虛擬碼如下:

begin;  # 1.開始事務
select * from order where order_id='666' # 查詢訂單,判斷狀態
if(status !=處理中){
   //非處理中狀態,直接返回;
   return ;
}
## 處理業務邏輯
update order set status='完成' where order_id='666' # 更新完成
commit; # 5.提交事務
  • 這裡面order_id需要是索引主鍵哈,要鎖住這條記錄就好,如果不是索引或者主鍵,會鎖表的!

  • 悲觀鎖在同一事務操作過程中,鎖住了一行資料。別的請求過來只能等待,如果當前事務耗時比較長,就很影響介面效能。所以一般不建議用悲觀鎖做這個事情。

除了會導致事務過長之外,還會導致大量的鎖迴圈檢測,導致CPU飆升,一般來說不會利用資料庫來熱點資料更新。

可以採用的方案有:

  • 如果使用的是阿里雲的資料庫,可以使用到hit機制;
  • Redis+MQ,利用Redis來抗住壓力,然後將壓力減小,利用MQ來進行消費,然後來對其進行更新;

樂觀鎖

悲觀鎖有效能問題,可以試下樂觀鎖

什麼是樂觀鎖

樂觀鎖在運算元據時,則非常樂觀,認為別人不會同時在修改資料,因此樂觀鎖不會上鎖。只是在執行更新的時候判斷一下,在此期間別人是否修改了資料。

怎樣實現樂觀鎖呢?

就是給表的加多一列version版本號,每次更新記錄version都升級一下(version=version+1)。具體流程就是先查出當前的版本號version,然後去更新修改資料時,確認下是不是剛剛查出的版本號,如果是才執行更新

比如,我們更新前,先查下資料,查出的版本號是version =1

select order_id,version from order where order_id='666';

然後使用version =1訂單Id一起作為條件,再去更新

update order set version = version +1,status='P' where  order_id='666' and version =1

最後更新成功,才可以處理業務邏輯,如果更新失敗,預設為重複請求,直接返回。

流程圖如下:

notion image

為什麼版本號建議自增的呢?

因為樂觀鎖存在ABA的問題,如果version版本一直是自增的就不會出現ABA的情況啦。

分散式鎖

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

notion image

三、HTTP的冪等

我們的介面,一般都是基於http的,所以我們再來聊聊Http的冪等吧。HTTP 請求方法主要有以下這幾種,我們看下各個介面是否都是冪等的。

  • GET方法

  • HEAD方法

  • OPTIONS方法

  • DELETE方法

  • POST 方法

  • PUT方法

GET 方法

HTTP 的GET方法用於獲取資源,可以類比於資料庫的select查詢,不應該有副作用,所以是冪等的。它不會改變資源的狀態,不論你呼叫一次還是呼叫多次,效果一樣的,都沒有副作用。

如果你的GET方法是獲取最近最新的新聞,不同時間點呼叫,返回的資源內容雖然不一樣,但是最終對資源本質是沒有影響的哈,所以還是冪等的。

HEAD 方法

HTTP HEAD和GET有點像,主要區別是HEAD不含有呈現資料,而僅僅是HTTP的頭資訊,所以它也是冪等的。如果想判斷某個資源是否存在,很多人會使用GET,實際上用HEAD則更加恰當。即HEAD方法通常用來做探活使用。

OPTIONS方法

HTTP OPTIONS 主要用於獲取當前URL所支援的方法,也是有點像查詢,因此也是冪等的。

DELETE方法

HTTP DELETE 方法用於刪除資源,它是的冪等的。比如我們要刪除id=666的帖子,一次執行和多次執行,影響的效果是一樣的呢。

POST 方法

HTTP POST 方法用於建立資源,可以類比於提交資訊,顯然一次和多次提交是有副作用,執行效果是不一樣的,不滿足冪等性

比如:POST 的語義是建立一篇帖子,HTTP 響應中應包含帖子的建立狀態以及帖子的 URI。兩次相同的POST請求會在伺服器端建立兩份資源,它們具有不同的 URI;所以,POST方法不具備冪等性。

PUT 方法

HTTP PUT 方法用於建立或更新操作,所對應的URI是要建立或更新的資源本身,有副作用,它應該滿足冪等性。

比如:PUT的語義是建立或更新 ID 為666的帖子。對同一 URI 進行多次 PUT 的副作用和一次 PUT 是相同的;因此,PUT 方法具有冪等性。

相關文章