1. 程式人生 > 實用技巧 >後端開發之介面冪等性設計

後端開發之介面冪等性設計

在微服務架構下,我們在完成一個訂單流程時經常遇到下面的場景:

1.一個訂單建立介面,第一次呼叫超時了,然後呼叫方重試了一次
2.在訂單建立時,我們需要去扣減庫存,這時介面發生了超時,呼叫方重試了一次
3.當這筆訂單開始支付,在支付請求發出之後,在服務端發生了扣錢操作,介面響應超時了,呼叫方重試了一次
4.一個訂單狀態更新介面,呼叫方連續傳送了兩個訊息,一個是已建立,一個是已付款。但是你先接收到已付款,然後又接收到了已建立
5.在支付完成訂單之後,需要傳送一條簡訊,當一臺機器接收到簡訊傳送的訊息之後,處理較慢。訊息中介軟體又把訊息投遞給另外一臺機器處理

以上問題,就是在單體架構轉成微服務架構之後,帶來的問題。當然不是說單體架構下沒有這些問題,在單體架構下同樣要避免重複請求。但是出現的問題要比這少得多。

為了解決以上問題,就需要保證介面的冪等性,介面的冪等性實際上就是介面可重複呼叫,在呼叫方多次呼叫的情況下,介面最終得到的結果是一致的。有些介面可以天然的實現冪等性,比如查詢介面,對於查詢來說,你查詢一次和兩次,對於系統來說,沒有任何影響,查出的結果也是一樣。

除了查詢功能具有天然的冪等性之外,增加、更新、刪除都要保證冪等性。

一:那麼如何來保證冪等性呢?

全域性唯一ID

如果使用全域性唯一ID,就是根據業務的操作和內容生成一個全域性ID,在執行操作前先根據這個全域性唯一ID是否存在,來判斷這個操作是否已經執行。如果不存在則把全域性ID,儲存到儲存系統中,比如資料庫、redis等。如果存在則表示該方法已經執行。

從工程的角度來說,使用全域性ID做冪等可以作為一個業務的基礎的微服務存在,在很多的微服務中都會用到這樣的服務,在每個微服務中都完成這樣的功能,會存在工作量重複。另外打造一個高可靠的冪等服務還需要考慮很多問題,比如一臺機器雖然把全域性ID先寫入了儲存,但是在寫入之後掛了,這就需要引入全域性ID的超時機制。

使用全域性唯一ID是一個通用方案,可以支援插入、更新、刪除業務操作。但是這個方案看起來很美但是實現起來比較麻煩,下面的方案適用於特定的場景,但是實現起來比較簡單。

去重表

這種方法適用於在業務中有唯一標的插入場景中,比如在以上的支付場景中,如果一個訂單隻會支付一次,所以訂單ID可以作為唯一標識。這時,我們就可以建一張去重表,並且把唯一標識作為唯一索引,在我們實現時,把建立支付單據和寫入去去重表,放在一個事務中,如果重複建立,資料庫會丟擲唯一約束異常,操作就會回滾。

插入或更新

這種方法插入並且有唯一索引的情況,比如我們要關聯商品品類,其中商品的ID和品類的ID可以構成唯一索引,並且在資料表中也增加了唯一索引。這時就可以使用InsertOrUpdate操作

多版本控制

這種方法適合在更新的場景中,比如我們要更新商品的名字,這時我們就可以在更新的介面中增加一個版本號,來做冪等

狀態機控制

這種方法適合在有狀態機流轉的情況下,比如就會訂單的建立和付款,訂單的付款肯定是在之前,這時我們可以通過在設計狀態欄位時,使用int型別,並且通過值型別的大小來做冪等,比如訂單的建立為0,付款成功為100。付款失敗為99

二、如何理解冪等性

這是個高等代數中的概念。
簡而言之就是x^Y=x
x可能是任何元素,包括(數、矩陣等)

冪等的的意思就是一個操作不會修改狀態資訊,並且每次操作的時候都返回同樣的結果。即:做多次和做一次的效果是一樣 的。

在web設計上即指多次HTTP請求返回值相同

簡單的說,純查詢,如SELECT,用GET。如果改變資料庫的內容,如UPDATE,INSERT,DELETE,用POST。

三、理解HTTP冪等性

根據HTTP標準,HTTP請求可以使用多種請求方式,HTTP/1.1協議中共定義了八種方法/動作,來表明Request-URL指定的資源不同的操作方式

HTTP1.0定義了三種請求方法:GET, POST 和 HEAD方法

HTTP1.1新增了五種請求方法:OPTIONS, PUT, DELETE, TRACE 和 CONNECT 方法

下面列舉四個常用的方法,來說明下各自是否滿足冪等要求

  • GET

    經常使用的方式之一,用於獲取資料和資源,不會有副作用,所以是冪等的。

如:URL為http://localhost:8080/crm/get/1,無論呼叫多少次,資料庫資料是不會變 更的,只是每次返回的結果可能不一樣,所以是滿足冪等。

  • POST

    也是經常使用的方式之一,用於往資料庫新增或修改資料,每呼叫一次

會產生新的資料,是資料經常發生變化,所以不是冪等的。

  • PUT

常用於建立和更新指定的一條資料,如果資料不存在則新建,如果存在則更新資料,多次和一次呼叫產生的副作用是一樣的,所以是滿足冪等。

  • DELETE

從單詞就能理解字面意思,用於刪除資料,一般根據ID,如URL:為http://localhost:8080/crm/delete/100,刪掉客戶ID為100的資料,呼叫一次或多次對系統產生的副作用是一樣的,所以是滿足冪等。

四、需要冪等的場景

冪等性問題在我們開發過程中、高併發、分散式、微服務架構中隨處可見的,具體舉例以下幾個經常遇到的場景

  • 網路波動

    因網路波動,可能會引起重複請求

  • MQ訊息重複

生產者已把訊息傳送到mq,在mq給生產者返回ack的時候網路中斷,故生產者未收到確定資訊,生產者認為訊息未傳送成功,但實際情況是,mq已成功接收到了訊息,在網路重連後,生產者會重新發送剛才的訊息,造成mq接收了重複的訊息。

  • 使用者重複點選

使用者在使用產品時,可能會誤操作而觸發多筆交易,或者因為長時間沒有響應,而有意觸發多筆交易。

  • 應用使用失敗或超時重試機制

為了考慮系統業務穩定性,開發人員一般設計系統時,會考慮失敗瞭如何進行下一步操作或等待一定時間繼續前面的動作的。

五、應該在哪一層進行冪等設計

目前網際網路技術架構基本都是分散式、微服務架構,層次分的也比較清晰,如

  • 第一層:APP、H5、PC

  • 第二層:負載均衡裝置(Nginx)

  • 第三層:閘道器層(GateWay)

  • 第四層:業務層(Service)

  • 第五層:持久層(ORM)

  • 第六層:資料庫層(DB)

那到底在哪一層實現冪等呢?

一般閘道器層主要的任務是路由轉發、請求鑑權和身份認證、限流、跨域、流量監控、請求日誌、ACL控制等。如果在閘道器層實現冪等性,那需要把業務程式碼寫在閘道器層,這種做法一般在設計中是很少推薦的,所以不適合

業務層主要是處理業務邏輯,對查詢或新增的結果進行一些運算等,所以也不合適

持久層也叫資料訪問層,和資料庫打交道,這塊不做冪等性的話,可能對資料產生一定影響,所以這一層是需要作品冪等性校驗。

通過以上分析我們得知冪等性一般在持久層去實現。

六、談談解決方案

  • 前端冪等性控制

1、按鈕只能點選一次

如使用者點選查詢或提交訂單號,按鈕變灰或頁面顯示loding狀態。防止使用者重複點選。

2、token機制

產品允許重複提交,但要保證提交多次和一次產生的結果是一致的。

具體實現是進入頁面時申請一個token,然後後面所有請求都帶上這個token,根據token來避免重複請求。見下圖

3、使用重定向機制(Post-Redirect-Get模式)

當用戶提交了表單,後端處理完成後,跳轉到另外一個成功或失敗的頁面,這樣避免使用者按F5重新整理瀏覽器導致重複提交。

4、在Session存放唯一標識

使用者進入頁面時,服務端生成一個唯一的標識值,存到session中,同時將它寫入表單的隱藏域中,使用者在輸入資訊後點擊提交,在服務端獲取表單的隱藏域欄位的值來與session中的唯一標識值進行比較,相等則說明是首次提交,就處理本次請求,然後刪除session唯一標識,不相等則標識重複提交,忽略本次處理。

因前端涉及到多裝置,相容性等問題,以上方案不一定非常可靠。

  • 後端冪等性控制

1、使用資料庫唯一索引

開發的同學對資料庫肯定不陌生,對資料庫的約束也應該比較熟悉,

如MySQL有五大約束,主鍵、外來鍵、非空、唯一、預設約束。我們可以使用資料庫提供的唯一約束來保證資料重複插入,避免髒資料產生。這種做法比較簡單粗暴,直接丟擲異常資訊即可。
2、token+redis

這種方式分成兩個階段:獲取token和業務操作階段。

我們以支付為例

第一階段,在進入到提交訂單頁面之前,需要在訂單系統根據當前使用者資訊向支付系統發起一次申請token請求,支付系統將token儲存到redis中,作為第二階段支付使用

第二階段,訂單系統拿著申請到的token發起支付請求,支付系統會檢查redis中是否存在該token,如果有,表示第一次請求支付,開始處理支付邏輯,處理完成後刪除redis中的token

當重複請求時候,檢查redis中token是否存在,不存在,則表示非法請求。可以見下圖

該方案唯一的缺點就是需要與系統進行兩次互動

3、基於狀態控制

如:購物下單,邏輯是當訂單狀態為已付款,才允許發貨

在設計時候最好只支援狀態的單向改變(不可逆),這樣在更新的時候where條件裡可以加上status =已付款

如:update table set status=下一種狀態 where id =1 and status=已付款

4、基於樂觀鎖來實現

如果更新已有資料,可以進行加鎖更新,也可以設計表結構時使用version來做樂觀鎖,這樣既能保證執行效率,又能保證冪等。

樂觀鎖version欄位在更新業務資料時值要自增。

也可以採用update with condition更新帶條件來實現樂觀鎖。

具體看下version如何定義

sql為:update table set q =q,version = version + 1 where id =1 and version =#{version }

5、防重表

需要增加一個表,這個表叫做防重表(防止資料重複的表)

使用唯一主鍵如:uuid去做防重表的唯一索引,每次請求都往防重表中插入一條資料。第一次請求由於沒有記錄插入成功,成功後進行後續業務處理,處理完後(無論成功或失敗)刪除去重表中的資料,如果在處理過程中,有新的相同uuid請求過來,插入的時候因為表中唯一索引而插入失敗,則返回操作失敗。可以看出防重表作用是加鎖的功能。

6、分散式鎖

在進入方法時,先獲取鎖,假如獲取到鎖,就繼續後面流程。假設沒有獲取到鎖,就等待鎖的釋放直到獲取鎖,當執行完方法時,釋放鎖,當然,鎖要設個超時時間,防止意外沒有釋放到鎖,它可以用來解決分散式系統的冪等性;

常用的分散式鎖實現方案是redis 和 zookeeper 等工具。

使用分散式鎖類似於防重表,將防重併發放到了快取中,較為高效,同一時間只能完成一次操作。

zk實現分散式鎖的流程如下

redis 分散式鎖工具類

@Component
public class RedisLock {
 
  private static final Long RELEASE_SUCCESS = 1L;
  private static final String LOCK_SUCCESS = "OK";
  private static final String SET_IF_NOT_EXIST = "NX";
  // 當前設定 過期時間單位, EX = seconds; PX = milliseconds
  private static final String SET_WITH_EXPIRE_TIME = "EX";
  //lua
  private static final String RELEASE_LOCK_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
 
  @Autowired
  private StringRedisTemplate redisTemplate;
 
 
  /**
   * 該加鎖方法僅針對單例項 Redis 可實現分散式加鎖
   * 對於 Redis 叢集則無法使用
   * <p>
   * 支援重複,執行緒安全
   *
   * @param lockKey  加鎖鍵
   * @param clientId 加鎖客戶端唯一標識(採用UUID)
   * @param seconds  鎖過期時間
   * @return
   */
  public boolean tryLock(String lockKey, String clientId, long seconds) {
      return redisTemplate.execute((RedisCallback<Boolean>) redisConnection -> {
//            Jedis jedis = (Jedis) redisConnection.getNativeConnection();
          Object nativeConnection = redisConnection.getNativeConnection();
          RedisSerializer<String> stringRedisSerializer = (RedisSerializer<String>) redisTemplate.getKeySerializer();
          byte[] keyByte = stringRedisSerializer.serialize(lockKey);
          byte[] valueByte = stringRedisSerializer.serialize(clientId);
 
          // lettuce連線包下 redis 單機模式
          if (nativeConnection instanceof RedisAsyncCommands) {
              RedisAsyncCommands connection = (RedisAsyncCommands) nativeConnection;
              RedisCommands commands = connection.getStatefulConnection().sync();
              String result = commands.set(keyByte, valueByte, SetArgs.Builder.nx().ex(seconds));
              if (LOCK_SUCCESS.equals(result)) {
                  return true;
              }
          }
          // lettuce連線包下 redis 叢集模式
          if (nativeConnection instanceof RedisAdvancedClusterAsyncCommands) {
              RedisAdvancedClusterAsyncCommands connection = (RedisAdvancedClusterAsyncCommands) nativeConnection;
              RedisAdvancedClusterCommands commands = connection.getStatefulConnection().sync();
              String result = commands.set(keyByte, valueByte, SetArgs.Builder.nx().ex(seconds));
              if (LOCK_SUCCESS.equals(result)) {
                  return true;
              }
          }
 
          if (nativeConnection instanceof JedisCommands) {
              JedisCommands jedis = (JedisCommands) nativeConnection;
              String result = jedis.set(lockKey, clientId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, seconds);
              if (LOCK_SUCCESS.equals(result)) {
                  return true;
              }
          }
          return false;
      });
  }
 
  /**
   * 與 tryLock 相對應,用作釋放鎖
   *
   * @param lockKey
   * @param clientId
   * @return
   */
  public boolean releaseLock(String lockKey, String clientId) {
      DefaultRedisScript<Integer> redisScript = new DefaultRedisScript<>();
      redisScript.setScriptText(RELEASE_LOCK_SCRIPT);
      redisScript.setResultType(Integer.class);
//        Integer execute = redisTemplate.execute(redisScript, Collections.singletonList(lockKey), clientId);
 
      Object execute = redisTemplate.execute((RedisConnection connection) -> connection.eval(
              RELEASE_LOCK_SCRIPT.getBytes(),
              ReturnType.INTEGER,
              1,
              lockKey.getBytes(),
              clientId.getBytes()));
      if (RELEASE_SUCCESS.equals(execute)) {
          return true;
      }
      return false;
  }
}

6、快取佇列

將請求快速的接收下來,放入緩衝佇列中,後續使用非同步任務處理佇列的資料,過濾掉重複請求,我們可以用LinkedList來實現佇列,一個HashSet來實現去重。此方法優點是非同步處理、高吞吐,不足是不能及時返回請求結果,需要後續輪詢處理結果。

7、全域性唯一ID

比如通過source來源+seq組成ID來判斷請求是否重複,在併發時,只能處理一個請求,其它要麼併發請求那麼返回請求重複,那麼等待前面的請求執行完成後 在執行。具體我們可以將請求關鍵性資料或者請求的全部資料組合生成md5碼,這樣的話,重複請求都是一個相同ID;如果所有請求包括重複請求都要唯一ID,那麼可以用UUID或者用雪花演算法生成唯一ID。

六、保證冪等性總結

冪等性應該是合格程式設計師的一個基因,在設計系統時,是首要考慮的問題,尤其是在像支付寶,銀行,網際網路金融公司等涉及的網上資金系統,既要高效,

資料也要準確,所以不能出現多扣款,多打款等問題,這樣會很難處理,並會大大降低使用者體驗。