SpringBoot如何保證介面的冪等性?六種方案一次講清楚~

張哥說技術發表於2024-01-29

來源:JAVA日知錄

1. 冪等概述

1.1 深入理解冪等性

在計算機領域中,冪等(Idempotence)是指任意一個操作的多次執行總是能獲得相同的結果,不會對系統狀態產生額外影響。在Java後端開發中,冪等性的實現通常透過確保方法或服務呼叫的結果具有確定性,無論呼叫次數如何,結果都是可預期的。

上面的定義是目前大多數文章和書籍對冪等的描述,然而,在實際的網際網路服務開發中,冪等性的理論定義與業務邏輯間的衝突是常見的。

例如,考慮查詢操作,當A系統呼叫B系統的查詢介面時,如果首次呼叫由於B系統中的程式錯誤而導致業務邏輯失敗,即使在程式修復後系統A重新使用相同引數進行重試,B系統可能仍然返回相同的失敗響應。儘管這符合冪等性的定義,卻與實際業務邏輯不符。同樣,以訂單支付為例,首次呼叫由於賬戶餘額不足而返回“餘額不足”提示,使用者充值後再次使用相同引數發起支付請求,服務仍然返回“餘額不足”響應,也符合冪等性的定義,但同樣不符合業務邏輯。

因此,在實現冪等性方案時,應該遵循冪等性方案的目標,而不僅僅是嚴格遵循冪等性的定義。尤其是涉及寫操作的服務,應當更關注防止重複請求帶來的不良副作用,例如重複扣款或退款。

1.2 冪等性的必要性

在微服務和分散式架構中,一個請求可能需要多個服務協作才能完成。在這個過程中,網路抖動、系統執行異常等不確定因素使得請求的成功率不可能達到100%,一旦發生失敗或未知異常,最常見的處理方式就是重試,而重試必然會導致重複請求問題。

冪等設計主要是為了處理重複請求而生的,好的冪等方案可以保證重複請求獲得預期結果,而不產生副作用。 在實際開發中,以下場景會產生重複請求:

  • 使用者不可靠:使用者透過客戶端發起請求,由於手抖或有意重複點選,很容易造成導致極短時間內發起多次重複請求。
  • 網路不可靠:網路抖動、閘道器內部抖動有可能觸發重試機制,這個在使用訊息佇列投遞訊息時經常會遇到。
  • 服務不可靠:在需要保證資料一致性的場景中,如果呼叫下游服務超時,在無法確認執行結果的情況下,常用的處理方法是重試。

1.3 冪等與併發的關係

在具有併發寫操作的場景下,通常需要考慮冪等問題。例如,當使用者在極短時間內多次提交表單或者使用特殊手段同時提交多個表單時,這就是典型的併發場景,需要進行冪等性處理。為了防止重複請求被執行,服務端需要實施冪等性控制,以避免產生不符合預期的結果。

雖然併發場景大都存在冪等問題,但冪等問題卻並非併發場景所特有。冪等設計是為了識別並處理重複請求,而併發僅僅是重複請求的一種特殊情況。 事實上,只要重複請求涉及寫操作,無論是否併發,都需要做好冪等處理。舉個例子,使用者在pc端同時開了兩個視窗,間隔10分鐘分別提交表單,所有引數完全相同,這顯然不屬於併發,但仍需要進行冪等處理。

在網際網路領域,併發處理與冪等性問題緊密相關,這也導致了一些人認為解決冪等性就是解決高併發的問題。

2. 冪等號的設計

冪等性設計的目的是確保即使在多次接收相同請求的情況下,也只執行一次操作,防止重複處理。要實現這一點,通常需要事先約定一個具有唯一性的識別符號,如Token或業務流水號,我們稱之為冪等號(Idempotency Key)。

冪等號有三個關鍵特性:唯一性、不變性和傳遞性。

唯一性確保每個請求都能被準確識別,不變性保證在請求處理期間冪等號保持不變,傳遞性則確保在多系統處理同一請求時,冪等號能夠被傳遞和保持。

冪等號通常有兩種設計方式:

  1. 非業務冪等號:透過唯一識別符號(如UUID、時間戳或業務流水號)在呼叫方和被呼叫方之間明確實現冪等性。由於非業務冪等號難以透過業務上下文追溯,因此呼叫雙方都必須將其持久化,從而保證請求與冪等號的關係有跡可循。

    例如,在DailyMart案例中,訂單服務在呼叫庫存服務時會傳遞訂單流水號作為冪等號,以便在多次請求時識別重複操作。

  2. 業務冪等號:由業務元素組合構成的冪等號,如“使用者ID+活動ID”。使用此方法時,呼叫方無需單獨持久化冪等號,被呼叫方可以根據請求引數和業務上下文直接獲取並組合這些引數。例如,透過設定“使用者ID”和“活動ID”的聯合唯一索引來實現冪等性。

3. 冪等的實現方案

冪等性的實現關鍵在於確保相同的請求僅被處理一次,這通常可以透過設定唯一性約束和檢查來實現。實踐中有六種常見的方案:唯一索引、Token機制、悲觀鎖、樂觀鎖、分散式鎖和狀態機。

3.1 唯一索引方案機制

唯一索引方案依賴於資料庫表中不允許存在具有相同索引值的重複行。這種策略在關係型資料庫中廣泛支援,並且能有效利用唯一性約束來確保冪等性。在高併發場景中,唯一索引能保證當多個執行緒嘗試同時插入相同記錄時,只有一個執行緒能成功執行,而其他執行緒將會因違反唯一性約束而丟擲異常。

通常,業務流水錶的建立是基於以下核心欄位:

  • id(bigint 型別):作為主鍵,唯一標識每條記錄。
  • gmt_create(datetime 型別):記錄的建立時間。
  • gmt_modified(datetime 型別):記錄的最後修改時間。
  • user_id(varchar(32) 型別):使用者ID,這個欄位也可以作為分表的依據。
  • out_biz_no(varchar(64) 型別):外部業務流水號,即呼叫方的冪等號。
  • biz_no(varchar(64) 型別):內部業務流水號,用於系統內部追蹤。
  • status(char(1) 型別):記錄執行狀態。

在這種設計中,user_idout_biz_no通常會組合成一個聯合索引,這樣做能有效避免在併發情況下的資料重複插入問題,從而保障了業務操作的冪等性。

3.2 Token機制

Token機制是用於防止客戶端重複提交的一種特殊機制,特別適用於客戶端建立訂單等提交表單場景。其執行流程如下:

SpringBoot如何保證介面的冪等性?六種方案一次講清楚~ 1)當使用者訪問表單頁面時,客戶端請求服務端介面以獲取唯一的Token(可以是UUID或全域性ID),服務端生成的Token會被儲存在Redis或資料庫中。

2)使用者首次提交表單時,將Token與表單一起傳送至服務端,服務端會驗證Token的存在性,如果Token存在,則執行業務邏輯,並在完成後銷燬Token。

3)使用者再次提交表單時,同樣攜帶Token一起傳送至服務端。但由於Token已被銷燬,服務端無法找到對應的Token,從而拒絕重複提交請求。

3.3 悲觀鎖機制

悲觀鎖依賴資料庫提供的鎖機制來實現,整個資料處理過程中,資料處於鎖定狀態,並與事務機制配合,能夠有效實現業務冪等性。操作示例如下:

// 1. 開啟事務
begin;

// 2. 基於冪等號查詢
record = select * from tbl_xxx where out_biz_no = 'xxx' for update;

// 3. 根據狀態進行決策
if(record.getStatus() != 預期狀態){
   return;
}

// 4. 更新記錄
update tbl_xxx set status = '目標狀態' where out_biz_no = 'xxx';

// 5. 提交事務
commit;

悲觀鎖主要適用於更新場景,透過序列化請求處理來確保冪等性,但需要小心使用,因為在併發場景下,重複請求可能會導致執行緒長時間處於等待狀態,浪費資源且降低效能。

3.4 樂觀鎖機制

樂觀鎖主要依靠"帶條件更新"(update with condition)來確保多次外部請求的一致性。在系統設計中,可以在資料表中新增版本號欄位,用於標識當前資料的版本。每次對該資料表的記錄進行更新時,都需要提供上一次更新的版本號,示例操作如下:

//1. 取出要更新的物件,帶有版本versoin
select * from tablename where id = xxx

//2. 更新資料
update tableName set sq = sq-#{quantity},version = #{version}+1 where id = xxx and version=#{version}

樂觀鎖主要適用於更新場景,確保多次更新不會影響結果的一致性。

3.5 分散式鎖機制

分散式鎖與悲觀鎖本質上相似,都透過序列化請求處理來實現冪等性。與悲觀鎖不同的是,分散式鎖更輕量。在系統接收請求後,首先嚐試獲取分散式鎖。如果成功獲取鎖,則執行業務邏輯;如果獲取失敗,則立即拒絕請求。

SpringBoot如何保證介面的冪等性?六種方案一次講清楚~

分散式鎖的核心是識別重複請求,實現序列化處理。但要注意,獲取鎖成功後,業務邏輯的執行並沒有可靠保證。因此,在實際應用中,分散式鎖需要結合事務機制和重試機制,以形成完整的冪等性解決方案。

3.6 狀態機機制

在許多業務單據中,存在有限數量的狀態,並且這些狀態之間的流轉順序是固定的。如果狀態已經處於下一個狀態,那麼再次應用上一個狀態的變更邏輯是不會產生任何效果的,這就確保了有限狀態機的冪等性。

例如,庫存狀態通常包括"預扣中"、"扣減中"、"佔用中"和"已釋放"等狀態。如果系統重複呼叫扣減介面,而庫存狀態已經是"扣減中",則可以直接返回結果。

狀態機可以與樂觀鎖機制結合使用,示例操作如下:

update tableName set sq=sq-#{quantity},status=#{udpate_status} where id =#{id} and status=#{status}

3.7 小結

上面介紹了冪等方式的6種實現方案並簡單介紹了每週方案的適合場景,這些方案的技術路線可以總結成三條:唯一索引、唯一資料、狀態機約束。

唯一索引是指資料庫唯一索引,唯一索引大部分是基於業務流水錶建立,也可單獨建表實現;唯一資料是指悲觀鎖、樂觀鎖、分散式鎖等機制;狀態機約束,對於存在狀態流轉的業務,透過狀態機的流轉約束,可以實現有限狀態機的冪等。

需要注意的是:在實際開發中,這些方案單獨使用很難奏效,比如悲觀鎖、分散式鎖只是將請求序列化處理,對於出現異常後的重試並沒有什麼抵禦能力,需要搭配唯一索引才能形成完整的冪等方案。而在唯一索引方案中也還需要搭配事務機制才能生效。所以需要結合具體的業務場景靈活運用上面的實現方案。

以上介紹了六種實現冪等性的方式,每種方式的適用場景和關鍵資訊。這些方式可以總結為三個技術路線:唯一索引、唯一資料和狀態機約束。

需要注意的是,在實際開發中,單獨使用這些方式可能無法完全解決問題。例如,悲觀鎖和分散式鎖只將請求序列化處理,沒有處理異常後的重試,因此需要結合唯一索引來實現完整的冪等性解決方案。同樣,因此,在實際應用中,需要根

以上介紹了六種實現冪等性的方式,並簡要介紹了每種方式適用的場景和關鍵資訊。這些方式可以總結為三個技術路線:唯一索引、唯一資料和狀態機約束。

  • 唯一索引指的是資料庫的唯一索引,通常基於業務流水錶建立,也可以單獨建立表來實現。
  • 唯一資料包括悲觀鎖、樂觀鎖、分散式鎖等機制。
  • 狀態機約束適用於具有狀態流轉的業務,透過狀態機的流轉約束,可以實現有限狀態機的冪等性。

然而,需要注意的是,在實際開發中,單獨使用這些方法往往效果有限。 例如,悲觀鎖和分散式鎖只是將請求序列處理,對於異常情況的重試並沒有足夠的防禦能力,因此需要結合唯一索引來實現完整的冪等性解決方案。同樣,唯一索引方案也需要與事務機制結合使用。因此,在實際應用中,需要根據具體的業務場景靈活選擇、合理的運用上述實現方法。

4. 程式碼實現

在Dailymart專案中,實現了除悲觀鎖以外的五種冪等方案。為了方便使用,我將分散式鎖機制和Token機制封裝在一個單獨的冪等元件dailymart-idempotent-spring-boot-starter中。

在業務模組中,只需在pom檔案中引入依賴即可使用封裝好的冪等功能。

<dependency>
 <groupId>com.jianzh5</groupId>
 <artifactId>dailymart-idempotent-spring-boot-starter</artifactId>
 <version>${project.version}</version>
</dependency>

冪等元件的核心是利用Spring的AOP機制實現。在使用時,只需在需要實現冪等的方法上新增自定義註解@Idempotent,並指定冪等方案IdempotentTypeEnum

SpringBoot如何保證介面的冪等性?六種方案一次講清楚~

在自定義冪等元件中,分散式鎖方案依賴於Redis。因此,在SpringBoot配置檔案中需要加上Redis的相關配置,並新增一些自定義配置,如Redis key的自定義字首以及分散式鎖key的字首。

spring:
  data:
    redis:
      host: 
xxx.xx.xx.xx
      
port: 29359
dailymart:
  
cache:
    
redis:
      
prefix: "inventory:"
  
idempotent:
    
token:
      
prefix: "token-"
      
timeout: 30000

接下來,結合具體應用場景,演示在DailyMart中如何實現這些冪等方案。

4.1 基於唯一索引實現

使用者下單時需要呼叫庫存預扣介面,在這種新增場景下,可以使用唯一索引結合事務機制實現冪等方案。

1、在扣減流水錶中給業務流水欄位transactionId加上唯一索引。

2、在Service層讓庫存扣減和庫存修改在同一個事務中,確保出現重複請求時事務回滾,從而保證冪等性。

這部分程式碼已在上篇文章中展示,原始碼位於com/jianzh5/dailymart/module/inventory/application/service/impl/InventoryServiceImpl.java

4.2 基於樂觀鎖實現

使用者付款時會呼叫庫存扣減介面,這種更新場景可以使用樂觀鎖機制來實現冪等方案。在Dailymart中,有兩種實現方式。

4.2.1 基於原生SQL實現

public interface InventoryItemMapper extends BaseMapper<InventoryItemDO{
    /**
     * 基於樂觀鎖實現更新
     * @param inventoryItemDO 庫存實體
     */

    @Update("UPDATE inventory_item SET sellable_quantity = #{sellableQuantity},withholding_quantity = #{withholdingQuantity}, occupy_quantity = #{occupyQuantity} ,version = #{version} + 1 , update_time = NOW() WHERE id = #{id} AND version = #{version} ")
    void updateByVersion(InventoryItemDO inventoryItemDO);
}

4.2.2 使用mybatis-plus提供的樂觀鎖外掛

1、在DO物件中使用@Version註解對樂觀鎖欄位進行標註。

public class InventoryItemDO extends BaseDO {
    ...
    @Version
    private Integer version;
}

2、在mybatis-plus的配置類中新增樂觀鎖外掛

public class DailyMartDsAutoConfiguration {    
    /**
     * 設定mybatis-plus攔截器
     * 1. 分頁攔截器
     * 2. 樂觀鎖攔截器
     */

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        //分頁
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        // 樂觀鎖
        interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
        return interceptor;
}

這樣,當使用inventoryItemMapper.updateById(inventoryItemDO);方法時會自動實現樂觀鎖。

4.3 基於狀態機實現

使用者退貨時需要呼叫庫存釋放介面,可以基於有限狀態機來實現冪等。

@Override
@Transactional
public void releaseInventory(Long transactionId) {
 ...
 //如果已經是釋放狀態直接返回結果
 if(inventoryRecord.getState() == InventoryRecordStateEnum.RELEASE.code()){
  return;
 }
 ...
}

單一的狀態機機制不能很好地保證冪等性,因此需要結合樂觀鎖機制才更有效。

4.3 基於Token實現

使用者在建立訂單時需要呼叫後臺介面提交表單,像這種客戶端提交表單的操作就很適合使用token機制。

1、在客戶端進入頁面時呼叫冪等元件提供的/token方法,後端自動生成token並儲存到Redis中。

@Override
public String createToken() {
 String token = Optional.ofNullable(Strings.emptyToNull(idempotentProperties.getPrefix())).orElse(TOKEN_PREFIX_KEY) + UUID.randomUUID();
 log.info("Generated Idempotency Key is: {}", token);
 distributedCache.put(token, "", Optional.ofNullable(idempotentProperties.getTimeout()).orElse(TOKEN_EXPIRED_TIME));
 return token;
}

2、在建立訂單介面加上自定義冪等註解,指定冪等型別為Token機制。

@PostMapping("/api/order/create")
@Idempotent(
 type = IdempotentTypeEnum.TOKEN,
 message = "訂單正在建立,請勿重複提交"
)
public void create(@RequestBody OrderDTO orderDTO) {
 orderService.save(orderDTO);
}

Token機制也需要結合唯一索引才能形成完整的冪等方案。

4.3 基於分散式鎖實現

使用分散式鎖冪等方案很簡單,在方法上加上冪等註解即可。有兩種使用方式:

1、指定type為IdempotentTypeEnum.PARAM,此時冪等元件會將整個表單的引數做MD5摘要後作為分散式鎖的key

@Idempotent(
 type = IdempotentTypeEnum.PARAM,
 message = "訂單正在建立,請勿重複提交"
)
@PostMapping("/api/order/create")  
public void create(@RequestBody OrderDTO orderDTO) {  
    orderService.create(orderDTO);  
}

2、指定type為IdempotentTypeEnum.SpEL,此時冪等元件會根據key的值選取引數作為分散式鎖的key,冪等key可以使用SpEL表示式選擇引數中的欄位。

@Idempotent(  
 key = "#lockRequest.transactionId",  type = IdempotentTypeEnum.SpEL  
)
@PostMapping("/api/order/update")  
public void update(@RequestBody OrderDTO orderDTO) {  
    orderService.update(orderDTO);  
}

透過以上實現,Dailymart專案成功應用了多種冪等性方案,確保了系統的可靠性和穩定性。

5. 小結

本文詳細介紹了在分散式系統中冪等性實現方案,同時著重講解了冪等和併發之間的區別。一般而言,併發都會伴隨冪等,而冪等又並非併發獨有。文章中提供了多種關於冪等的實現方案,不過需要記住,單一使用某種冪等方案往往很難奏效,需要組合多種方式才能形成完整的解決方案。


來自 “ ITPUB部落格 ” ,連結:https://blog.itpub.net/70024923/viewspace-3005466/,如需轉載,請註明出處,否則將追究法律責任。

相關文章