金融系統中高併發下投資餘額扣減問題的解決思路

業務場景介紹

爲了提高投資的便利性,我們系統將後端的多個借款標的封裝統一的理財產品,用戶直接投資封裝好的理財產品,然後經過系統後端匹配到借款標的。所以一般理財產品的投資額度就是後端對應的標的的總的借款額,爲了能夠做到用戶投資總額與後端借款標的借款額的100%的匹配,不允許系統出現超賣情況,即投資額大於了後端借款標的總借款額。

PS:

1)爲了說明技術問題,暫時簡化了業務模型,業務看起來不怎麼合規,公司也在改進。

2)庫存扣減,超賣等概念借鑑於電商系統,其實金融系統對超賣的容忍度更低。

扣減超賣問題的產生

爲了控制超賣,理財產品上有個餘額的字段,記錄當前還剩多少可投資額度。每次用戶投資時候,都會先校驗投資amount是否小於等於理財產品餘額available amount,如果校驗通過,再繼續其他邏輯,最後更新available amount = available amount - amount; 整個邏輯的僞代碼如下:

invest() {
  select available amount from db
  if (available amount < amount) {
    return invest failed.
  }

  // …. main logic ….

  update available amount = available amount - amount;
}

這種CAS操作會有誤判情況,比如兩個並行執行投資的線程都從db中select到available amount 1000,validation發現都能滿足投資條件,一個投資800,一個投資700,然後都執行成功,並且update了available amount,導致超賣800+700-1000=500.

要解決兩個問題:

1)扣減超賣—本文重點

2)高併發下投資 — 順帶說明一下如何來做

線程加鎖解決方案

採用synchronize或者concurrent util lock對上面這段代碼加線程鎖,多線程下串行執行,看着挺好,其實有幾個問題:

1)只對單進程下有效,多進程多jvm下無效!

2)需要注意事務和加鎖的順序,數據庫事務隔離級別,不然高併發下會有事務問題,這個問題不展開討論了,單獨另一篇文章裏講。

3)一把大鎖加住所有邏輯,性能可想而知的低。

所以這種方案在極少數特殊場景下才可用,不展開討論了!

數據庫鎖select for update

利用數據庫鎖做分佈式鎖,select for update會鎖住某行數據,直到事務提交。這種方法最簡單,在大部分情況下都能滿足要求。但是也存在一些問題,我們逐一解釋:

1)事務的隔離級別需要是Read Committed

select for update會一直阻塞到事務提交,一旦事務提交鎖就釋放掉了。比如:使用spring的transaction機制,在service層添加事務。

@Tranactional
invest() {
  select available amount from db
  if (available amount < amount) {
    return invest failed.
  }

  // …. main logic ….

  update available amount = available amount - amount;
}

需要注意的是事務隔離機制必須是read committed,不然高併發情況下,一個已經啓動的事務無法看到另個事務提交的數據。對應我們的代碼就是無法看到update更新之後的available amount,也是會導致超賣問題!

2)超時問題處理

高併發情況下,會導致很多投資請求同時排隊等待select for update鎖,會導致一些接口請求一直阻塞,如果對於響應時間敏感的應用來說,比如面向用戶的應用,顯然是不能接受的,相比於一直阻塞,導致客戶端超時,處於一個不確定狀態更煩人,不如鎖等待太久就直接fail fast。

兩種sql防止一直等待鎖的方式:

select for update wait 1; 等待1s,超時獲取不到鎖報錯ORA-00054

select for update nowait; 獲取不到鎖,直接報錯

除此之外,還可以使用spring transaction的超時機制。

3)避免表鎖

Oracle和Mysql的鎖機制是不一樣的,我在另一篇文章來講,哪些情況會產生行鎖,哪些情況會產生表鎖。對於產生表鎖的sql語句,一定要謹慎使用。

4)避免死鎖

如果多個業務都涉及到加鎖邏輯,並且都涉及相同的多個select for update加鎖操作,如果順序控制不好,很容易出現死鎖問題,因爲oracle有自動檢測死鎖機制,會自動斷開死鎖,但仍然會導致很多鎖等待,而且因爲涉及到多個系統很難排查錯誤。

所以最好是通過模塊化,soa,微服務思想,將db操作,加鎖相關的操作收口到統一的系統中,這樣可以做到加鎖順序同一個team來控制,容易做到一致。

Redis,ZK分佈式鎖方案

前面採用db來實現分佈式鎖,當然也可以採用其他系統,比如redis,zookeeper來實現。但是引入越多的系統,系統的複雜度越高,出錯的情況就閱讀,按照KISS架構原則,能用已有系統(比如db)解決就不要引入更多的系統。

而且用redis,zookeeper來實現分佈式鎖性能也不見得有多高,雖然筆者沒有測試過。

CAS樂觀鎖解決方案

上面的解決思路其實差不多,一把大鎖把併發邏輯變成串行執行。性能可想而知,肯定是最先出現瓶頸的最需要優化的地方。優化的基本思想是:儘量的減小鎖粒度甚至不要加鎖。

減小鎖粒度要結合具體的業務邏輯,將非race condition的代碼移到鎖外,我們詳細講下如何使用CAS樂觀鎖來做到不加鎖。

invest() {
  select available amount from db
  if (available amount < amount) {
    return invest failed.
  }

  // …. main logic ….

  update available amount = available amount - amount;
}

這個是之前的代碼,我們不使用任何鎖,在update的地方改爲:

update available amount = available amount - amount where available amount >= amount;

在更新available amount之前,再校驗一下是否available amount >= amount; 如果return row num=1則說明成功,如果等於0,則說明available amount在線程執行過程中被其他線程更改了。

看起來很完美,如果邏輯僅包含CAS(compare and swap) available amount,那這塊邏輯完全沒問題。但是我們看到代碼中還有一大塊main logic,如果這一塊有寫邏輯,並且基於的是available amount >= amount,那如何回滾main logic的操作呢?

我們繼續改一下代碼:

@Transactional
invest() {
  select available amount from db
  if (available amount < amount) {
    return invest failed.
  }

  // …. main logic ….
 
  update available amount = available amount - amount where available amount>=amount;
  if (update return row num == 0) {
    throw XRuntimeException;
  }
}

還是存在問題:

因爲事務的ACID特性,如果一個線程執行完update之後,就阻塞住了,另一個線程啓動的事務是看不到update之後的值的,導致不應該通過的校驗通過了,並且update available amount成功,就會導致兩條udpate都成功了。

這種情況雖然只有在極端情況下才能發生,但也是需要考慮的。爲什麼cas不好用了,就是因爲我們加了transaction,但不加又不行。我們來看下面這種解決方案,也是我們項目中用的。

基於訂單reconcile的解決方案

拋開業務談架構都是耍流氓,我們的投資業務中,不僅僅扣減available amount(庫存),而且更重要的操作是創建投資記錄(訂單)。

還是上面的代碼,我們改爲下面的邏輯:

invest() {
  update available amount = available amount - amount if available amount > amount;
  if (update row num == 0) {
    return invest failed.
  }

  // …. main logic ….
}

注意update操作在一個獨立的事務中,剩下的邏輯可以放到一個事務中。這樣先扣減庫存,就不會出現超賣情況了。

但是新的問題又出現了,可能會存在扣減了available amount,但投資記錄沒有創建成功的情況,比如用戶錢包錢不夠了,雖然不會出現超賣情況了,但又會出現少賣情況,我們繼續改進代碼:

invest() {
  update available amount = available amount - amount if available amount > amount;
  if (update row num == 0) {
    return invest failed.
  }

  // …. main logic ….

  if (create order failed) {
    update available amount = available amount + amount;
  }
}

當創建訂單失敗了(在預期之內),則將available amount再加回去。但是如果創建訂單失敗之後在re-update available amount之前,服務掛掉了(比如OOM了,機器宕機了,掉電了等),還是會出現少賣的情況。

繼續改進,來解決少賣情況:

投資訂單中的總金額是最準確的,可以通過這個值來reconcile available amount,至於這個思想,我們後臺啓動一個job,定期reconcile sum(投資記錄上的金額) 到available amount。存在一個致命問題,就是我們投資記錄一直不停的產生,應該按哪一時刻的sum值來訂正available amount呢?

其實,並非一直都要reconcile,只有在出現available amount小於等於0的時候,我們再觸發reconcile即可。所以方案繼續改進一下:

invest() {
  If (closed) return; // 如果投資慢了,則標記爲closed,則直接返回

  update available amount = available amount - amount if available amount > amount;
  if (update row num == 0) {
    mark closed=true in db
    return invest failed.
  }

  // …. main logic ….

  if (create order failed) {
    update available amount = available amount + amount;
  }
}

後臺job在發現closed=true之後,發起reconcile操作,這個過程所有的投資行爲都暫停了,如果reconcile發現少買了,再重新更正available amount並且將closed標記爲false。

現在這種方案,沒有加任何鎖,大部分情況下又不會出現超賣少買情況,只有極端服務中斷情況下才會發生少買情況,也通過job做了reconcile,只是實時性可能沒有那麼高,對用戶體驗來說,會出現之前投不進去,之後又能投進去的情況,但是時間窗口會儘可能的短,影響很小,在我們的業務接受範圍之內。

扣減庫存update available amount和創建訂單create invest transaction實際上是兩個業務域的操作,按照拆分思想,一般公司都會將其解耦到兩個服務,或者是兩個接口,又或者是兩個獨立的事務中。上面的解決思路對於這種拆分的情況也是適用的。

基於隊列的異步排隊解決方案

基於業務上的妥協改造來應對高併發有很多策略,比如基於消息隊列將投資請求排隊異步處理,但這並非我們本文的討論主題。



轉自:https://zhuanlan.zhihu.com/p/34378854


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章