騰訊二面:如何保證介面冪等性?高併發下的介面冪等性如何實現?

码农Academy發表於2024-03-19

什麼是介面冪等性

介面冪等性這一概念源於數學,原意是指一個操作如果連續執行多次所產生的結果與僅執行一次的效果相同,那麼我們就稱這個操作是冪等的。在網際網路領域,特別是在Web服務、API設計和分散式系統中,介面冪等性具有非常重要的意義。

具體到HTTP介面或者服務間的API呼叫,介面冪等性就可以理解為當客戶端對同一介面發起多次相同的請求時,服務端系統也應該確保只執行一次相應的操作,並且不論接收到了多少次請求,系統的狀態變更始終是一致的,不會因為重複的請求而導致資料的錯誤。

比如我們常常遇到的訂單建立,支付等業務。

  • 如果一個“建立訂單”介面實現了冪等性,當收到兩次同樣的建立請求時,系統應該要麼拒絕第二個請求(因為它已經是重複請求),要麼確保只有一個訂單被建立,而不是兩個完全一樣的訂單。
  • 對於一個“支付”介面,冪等性要求即便使用者由於網路原因反覆點選支付按鈕,服務端也只會扣除使用者賬戶一次金額,避免重複扣費。

導致介面冪等性問題的原因

要向杜絕冪等性,那麼我們就要之道導致介面冪等性問題的原因有哪些。介面冪等性問題通常由以下多種原因引起:

  1. 網路波動不穩定
    網路通訊中的丟包、延遲等情況可能導致客戶端未收到服務端的響應或服務端未收到客戶端的請求,此時客戶端可能會重試傳送請求,導致介面被重複呼叫。

  2. 使用者操作
    使用者快速重複點選導致,例如使用者在等待響應時,由於不確定是否操作成功,可能會多次點選提交按鈕,進而傳送多次相同的請求。再比如頁使用者頻繁重新整理頁面,尤其是在某些提交操作尚未完成時,重新整理頁面可能會重新傳送請求。還有使用者可能在瀏覽器上點選回退然後再重複之間的提交操作,這都可能會導致重新傳送請求。

  3. 重試機制
    在高可用性設計中,客戶端常常設定有重試機制,當請求失敗或超時時會自動重新發起請求。而在分散式系統中,服務間呼叫也可能有重試策略,以應對臨時故障。比如Nginx重試,RPC重試,或者呼叫方業務層中進行重試。

  4. 定時任務或非同步處理
    在定時任務中如果定時任務排程或邏輯設計不當,可能會導致同一任務被執行多次。或者在訊息佇列中,訊息可能會因為異常等原因被重複消費。

  5. 併發控制
    缺乏有效的併發控制手段,導致在併發環境下,針對同一資源的操作被多次執行。

總的來說,導致介面冪等性問題可以粗略的歸類於兩種情況:前端呼叫以及服務端呼叫,那麼我們可以針對這兩種情況看一下如何去保證介面冪等。

如何保證介面冪等?

前端呼叫

頁面控制

頁面呼叫介面時可以透過禁用(如按鈕置灰或顯示載入狀態)防止使用者在請求未完成前重複點選,從而減少不必要的重複請求和可能的資料衝突。雖然在前端進行按鈕置灰等操作可以輔助提高系統的冪等性表現,但是這個方式只是從使用者體驗和使用者行為控制的角度來避免重複提交的一種方法,並沒有從系統設計層面完全解決介面本身的冪等性問題。

使用RPG模式

PRG(POST/Redirect/GET)模式是一種前端互動策略,旨在解決使用者重新整理頁面時可能導致表單資料重複提交的問題。它巧妙地利用了HTTP協議的特性,具體的互動流程如下:

  1. 使用者在網頁表單中填寫資料,並透過POST請求將其傳送至伺服器進行處理,例如建立新資源或更新現有資料。
  2. 伺服器接收到POST請求後,對提交的資料進行有效處理和持久化儲存,並在操作成功後不直接返回處理結果,而是透過HTTP響應碼302或303實現重定向,指示客戶端發起一個新的GET請求去訪問一個特定的URL。
  3. 客戶端遵照伺服器的重定向指示,自動傳送GET請求訪問新的URL,此時返回的頁面將展示之前POST操作處理完畢的結果。
  4. 當使用者在此後重新整理頁面時,瀏覽器只會按照常規方式重新發起GET請求,而非重新提交POST資料,因此有效地避免了重複提交引發的潛在問題
Token機制

Token機制是一種廣泛應用網際網路領域的認證與授權方法,特別是Web服務系統。token可以理解為一種安全憑證,它是由服務端生成並頒發給客戶端的一段經過加密處理的字串或資料結構,用來代表使用者的某種狀態或許可權。

透過Token機制,我們可以解決介面冪等性問題。在介面中,我們允許重複提交,但是要保證重複提交不產生副作用,比如點選n次只產生一條記錄,客戶端每次請求都需要攜帶一個唯一的Token,而伺服器則驗證這個Token的有效性。如果伺服器收到了一個已經使用過的Token就會認為這是一個重複請求並拒絕處理,從而確保介面的冪等性具體流握如下Token機制是一種常用的方法,用於確保介面的冪等性和防止重複請求。具體流程如下:

  1. 生成Token
    當使用者開始執行一個需要確保冪等性的操作(如支付、下單、更新使用者資訊等)時,服務端會生成一個唯一的、有時效性的token。這個token可以是一個隨機字串或者帶有時間戳和其他相關資訊的雜湊值,確保其唯一性。

  2. 儲存Token
    生成的token會被儲存在服務端的一個臨時儲存介質中,如Redis、Memcached或資料庫,同時設定一個合理的過期時間(例如15分鐘)。

  3. 傳遞Token
    將生成的token返回給客戶端,客戶端在進行後續的API呼叫時,需將此token作為請求引數或放在請求頭中一併傳送給服務端。

  4. 驗證Token
    服務端在接收到帶有token的請求時,首先檢查token是否存在並且有效(未過期且未被使用過)。如果token有效且未被使用,則執行相應的業務邏輯,並在執行完成後立即從儲存介質中移除或標記為已使用。若token已失效或已被使用,則拒絕此次請求,返回相應的錯誤提示,確保同一個操作不會被執行兩次。

  5. 限制併發
    在併發場景下,透過原子操作(如Redis的SETNX命令)確保在驗證token有效的同時,將其刪除或更新狀態,避免多個請求同時透過驗證。

image.png

服務端控制

在服務端介面處理邏輯時,可以透過透過一些特定的識別符號或請求引數來校驗請求的冪等性,以確保同樣的請求不會被重複處理。

唯一識別符號

客戶端每次發起請求會攜帶一個全域性唯一的識別符號。伺服器接收到請求後就會對這個識別符號進行檢查,若伺服器發現該識別符號已經在系統中存在,表明這是一個重複請求,此時伺服器可以選擇忽略該請求,或者向客戶端返回已處理過相同請求的結果資訊。若伺服器未找到該識別符號存在於系統內,則認定該請求為新請求,伺服器將繼續對其進行正常處理,並將此唯一識別符號儲存至系統中,以便於後續對接收的請求進行有效性校驗,防止同一請求的重複處理。比如我們在要求上游ERP系統對接訂單平臺時就會要求上游傳遞一個賬號下全域性唯一的一個參考單號,這個參考單號一個很重要的作用就是保證介面冪等性。

請求引數

某些請求引數確實可以用來輔助校驗請求的冪等性。例如,時間戳可以作為一種可能的請求引數,在處理請求時,伺服器可以透過比較時間戳與伺服器當前時間來判斷請求的有效性。若時間戳與當前時間之間的差異超出預設的合理範圍(如幾秒鐘到幾分鐘不等,具體閾值視業務場景而定),伺服器可以推測該請求可能是由於網路延遲或者其他原因導致的重複提交。

單純依靠時間戳來判斷冪等性和重複請求並不完全準確,因為不同的客戶端時間可能並不精確同步,而且時間戳本身無法保證全域性唯一性。但是它可以作為一種有效的輔助手段來減少重複處理的可能性

狀態機設計

對於狀態轉移類的操作型別的業務,可採用狀態機設計,每次請求只允許合法的狀態變遷,非法狀態變遷(如已經完成的訂單不允許再次支付)將被拒絕。

樂觀鎖

在更新資料時,可以透過版本號或時間戳等機制判斷資料是否已被修改,防止因併發請求導致的多次更新問題。具體做法:

  1. 在資料庫表中增加一個版本號欄位(version)或者時間戳欄位(timestamp)。
  2. 客戶端第一次請求時獲取資料的版本號或時間戳。
  3. 客戶端發起更新操作時,將上次讀取的版本號或時間戳一起傳送回伺服器。
  4. 伺服器在執行更新操作前,首先檢查當前資料庫中的版本號或時間戳是否與客戶端提交的一致。
    • 如果一致,說明在這期間資料沒有被其他事務修改過,於是更新資料並遞增版本號或更新時間戳。
    • 如果不一致,說明資料已經被修改過,此時伺服器拒絕本次更新請求,返回錯誤提示,客戶端可以根據錯誤資訊決定是否重新獲取最新資料再嘗試更新。

透過這種方式,即使客戶端因為網路原因或其他因素導致同一請求被多次傳送,樂觀鎖機制能確保只有在資料未被其他事務修改的前提下,才會執行更新操作,從而達到介面冪等的效果。

實現冪等性方案示例

從上述的幾種解決冪等性問題的方案來看,使用token機制可以保證在不同請求動作下的冪等性。所以我們以此作為方案作為示例方案。

準備工作

我們使用Redis儲存Token令牌,引入SpringBootRedisULID相關的依賴

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-data-redis</artifactId>
	<version>2.7.0</version>
</dependency>

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-web</artifactId>
	<version>2.7.0</version>
</dependency>

<dependency>
	<groupId>com.github.f4b6a3</groupId>
	<artifactId>ulid-creator</artifactId>
	<version>5.2.0</version>
</dependency>

Redis相關的配置

spring.redis.database=0  
spring.redis.host=127.0.0.1  
spring.redis.port=6379  
spring.redis.password=  
spring.redis.pool.max-active=8  
spring.redis.pool.max-wait=-1  
spring.redis.pool.max-idle=8  
spring.redis.pool.min-idle=0  
spring.redis.timeout=60  
  
  
server.port=8080  
server.servlet.context-path=/coderacademy

生成Token令牌

使用ULID生成隨機字串,然後將其儲存在Redis當中。這裡以idempotent_token+賬戶+請求操作型別+token作為key。

private StringRedisTemplate stringRedisTemplate;

/**
 * 存入 Redis 的 Token 鍵的字首
 */
private static final String IDEMPOTENT_TOKEN_PREFIX = "idempotent_token:%s:$s:%s";


/**
 * 生成token令牌
 *
 * @param accountSecret 賬戶令牌
 * @param operatorType 介面請求型別,可以是介面url或者其他可以區分介面服務型別的值
 * @return token令牌
 */
@Override
public String generateToken(String accountSecret, String operatorType) {
	// 建立或獲取ULID生成器例項
	long timestampInMillis = LocalDateTime.now().atZone(ZoneOffset.systemDefault()).toInstant().toEpochMilli();
	Ulid ulid = UlidCreator.getUlid(timestampInMillis);
	String token = ulid.toString();
	// 設定存入 Redis 的 Key
	String key = String.format(IDEMPOTENT_TOKEN_PREFIX, accountSecret, operatorType, token);
	// 儲存 Token 到 Redis,且設定過期時間為5分鐘
	stringRedisTemplate.opsForValue().set(key, accountSecret, 5, TimeUnit.MINUTES);
	// 返回 Token
	return token;
}

校驗Token令牌

這裡我們使用Redis執行Lua命令去查詢以及刪除key,Lua 表示式能保證命令執行的原子性。

/**
     * 驗證 Token 正確性
     *
     * @param token token 字串
     * @param operatorType 介面請求型別,可以是介面url或者其他可以區分介面服務型別的值
     * @return 驗證結果
     */
private boolean validToken(String token, String accountSecret, String operatorType) {
	// 設定 Lua 指令碼,其中 KEYS[1] 是 key,KEYS[2] 是 value
	String script = "if redis.call('get', KEYS[1]) == KEYS[2] then return redis.call('del', KEYS[1]) else return 0 end";
	RedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
	// 根據 Key 字首拼接 Key
	String key = String.format(IDEMPOTENT_TOKEN_PREFIX, accountSecret, operatorType, token);
	// 執行 Lua 指令碼
	Long result = stringRedisTemplate.execute(redisScript, Arrays.asList(key, operatorType));
	// 根據返回結果判斷是否成功成功匹配並刪除 Redis 鍵值對,若果結果不為空和0,則驗證透過
	if (result != null && result != 0L) {
		System.out.println(String.format("驗證 token=%s,key=%s,value=%s 成功", token, key, operatorType));
		return true;
	}
	System.err.println(String.format("驗證 token=%s,key=%s,value=%s 失敗", token, key, operatorType));
	return false;
}

業務程式碼以及介面

我們在實現模擬建立訂單的服務,在建立訂單之前,首先校驗token令牌。

/**
 * 建立訂單介面
 *
 * @param requestVO     建立訂單引數
 * @param accountSecret 賬戶令牌
 * @param token         token令牌
 * @return 生成的訂單號
 */
@Override
public String createOrder(OrderCreateRequestVO requestVO, String accountSecret, String token) {
	// 根據 Token 和與使用者相關的資訊到 Redis 驗證是否存在對應的資訊
	boolean result = validToken(token, accountSecret, "createOrder");
	if (!result){
		// 這裡需要自定義異常,統一處理異常,再統一響應返回
		throw new RuntimeException("重複的請求");
	}
	// 根據驗證結果響應不同資訊
	return "Success";
}

校驗如果不存在token,則說明請求時重複請求,直接丟擲異常,由統一異常管理,直接返回客戶端請求失敗的錯誤資訊。關於SpringBoot中統一異常處理,統一結果響應,請檢視:

我們在定義獲取Token令牌的介面,以及建立訂單的介面

@RestController
@RequestMapping("order")
public class OrderController {

    private IOrderService orderService;

    /**
     * 獲取token介面
     * @param secret 賬戶令牌
     * @return
     */
    @GetMapping("getToken")
    public String getToken(@RequestHeader("secret") String secret){
        return orderService.generateToken(secret, "createOrder");
    }

    /**
     * 建立訂單介面
     * @param requestVO 引數
     * @param token token令牌
     * @param secret 賬戶令牌
     * @return 響應資訊
     */
    @PostMapping("create")
    public OrderCreateResponseVO createOrder(@RequestBody OrderCreateRequestVO requestVO,
                                             @RequestHeader("token") String token,
                                             @RequestHeader("secret") String secret){
        OrderCreateResponseVO responseVO = new OrderCreateResponseVO();
        String result = orderService.createOrder(requestVO, secret, token);
        responseVO.setSuccess(Boolean.TRUE);
        responseVO.setMsg(result);
        return responseVO;
    }

    @Autowired
    public void setOrderService(IOrderService orderService) {
        this.orderService = orderService;
    }
}

我們使用Apifox模擬3個請求併發操作

image.png

執行結果如下:
image.png

控制檯列印日誌如下

image.png

可以看見只有1個請求成功了,並且控制檯中列印只有一個token校驗成功。

總結

冪等性是開發當中很常見也很重要的一個需求,尤其是訂單,支付以及與金錢掛鉤的服務,保證介面冪等性尤其重要。在實際開發中,我們需要針對不同的業務場景我們需要靈活的選擇冪等性的實現方式:

  • 如果是web服務,客戶端可以採取在頁面上使用按鈕置灰禁用,使用PRG模式,或者搭配後端的Token令牌進行解決。
  • 在服務端,我們可以採取唯一識別符號,樂觀鎖,Token令牌,狀態機等校驗方式。

最後強調一下,實現冪等性需要先理解自身業務需求,根據業務邏輯來實現這樣才合理,處理好其中的每一個結點細節,完善整體的業務流程設計,才能更好的保證系統的正常執行。

本文已收錄於我的個人部落格:碼農Academy的部落格,專注分享Java技術乾貨,包括Java基礎、Spring Boot、Spring Cloud、Mysql、Redis、Elasticsearch、中介軟體、架構設計、面試題、程式設計師攻略等

相關文章