你所不知道的庫存超限做法


在互聯網企業中,限購的做法,多種多樣,有的別出心裁,有的因循守舊,但是種種做法皆想達到的目的,無外乎幾種,商品賣的完,系統抗的住,庫存不超限。雖然短短數語,卻有着說不完,道不盡,輕者如釋重負,重者涕淚橫流的架構體驗。 但是,在實際開發過程中,庫存超限,作為其中最核心的一員,到底該怎么做,如何做才會是最合適的呢?

今天這篇文章,我將會展示給大家庫存限購的五種常見的做法,並對其利弊一一探討,由於這五種做法,有的在設計之初當做提案被否定掉的,有的在線上跑着,但是在沒有任何單元測試和壓測情況下,這幾種超限控制的做法也許是不符合你的業務的,所以不建議直接用於生產環境。我這里權當是做拋磚引玉,期待大家更好的做法。

工欲善其事必先利其器,在這里,我們將利用一台測試環境的redis服務器當做庫存超限控制的主戰場,先設置庫存量為10進去,然后根據此庫存量,一一展開,設置庫存代碼如下:

   1:  def set_storage():
   2:      conn = redis_conn()
   3:      key = "storage_seckill"
   4:      current_storage = conn.get(key)
   5:      if current_storage == None:
   6:          conn.set(key, 10)

為了方便性,我這里使用了python語言來書寫邏輯,但是今天我們只是講解思想,語言這類的,大家可以自己嘗試轉一下。

上面就是我們的設置庫存到redis中的做法,很簡單,就是在redis中設置一個storage_seckill的庫存key,然后里面放上庫存量10.

 

超限限制做法一:先獲取當前庫存值進行比對,然后進行扣減操作

   1:  def storage_scenario_one():
   2:      conn = redis_conn()
   3:      key = "storage_seckill"
   4:      current_storage = conn.get(key)
   5:      current_storage_int = int(current_storage)
   6:      if current_storage_int<=0 :
   7:          return 0
   8:      result = conn.decr(key)
   9:      return result

首先,我們拿到當前的庫存值,然后看看是否已經扣減到了零,如果扣減到了零,則不繼續扣減,直接返回;如果庫存還有,則利用decr原子操作進行扣減,同時返回扣減后的庫存值。

此種做法在小並發量下訪問,問題不大;在對庫存量控制不嚴格的業務中,問題也不大。但是如果並發量比較大一些,同時業務要求嚴格控制庫存,那么此種做法是非常不合適的,原因在於,在高並發情況下,get命令,decr命令,都是分開發給redis的,這樣會導致比對的時候,很容易出現限制不住的情況,也就是會造成第六行的比對失效。

設想如下一個場景,AB兩個請求進來,A獲取的庫存值為1,B獲取的庫存值為1,然后兩個請求都被發到redis中進行扣減操作,然后這種場景下,A最后得到的庫存值為0;但是B最后得到的庫存值為-1,超限。

所以此種場景,由於在高並發下,get和decr操作不是一組原子性操作,會引發超限問題,被直接pass。

 

超限限制做法二:先扣減庫存,然后比對,最后根據情況回滾

   1:  def storage_scenario_two():
   2:      conn = redis_conn()
   3:      key = "storage_seckill"
   4:      current = conn.decr(key)
   5:      if current>=0:
   6:          return current
   7:      else:
   8:          #回滾庫存
   9:          conn.incr(key)
  10:          return 0

首先,請求進來,直接對庫存值進行扣減,然后得到當前的庫存值;然后,對此庫存值進行校驗,如果庫存還有,則返回庫存值,如果庫存沒有了,則回滾庫存,以便於防止負庫存量的存在。

此做法,相比做法一,要稍微可靠一些,由於redis的decr操作直接返回真實的庫存值,所以每個請求進來,只要執行了decr操作,拿到的肯定是當前最准確的庫存值。然后進行比對,如果庫存值大於等於零,返回當前庫存值,如果小於零,則將庫存進行回滾。

此種做法,最大的一個問題就是,如果大批量的並發請求過來,redis承受的寫操作的量,相對於方法一來說,是加倍的,因為回滾庫存的存在導致的。所以這種情況下,高並發量進來,極有可能將redis的寫操作打出極限值,然后會出現很多redis寫失敗的錯誤警告。 另一個問題和做法一是一樣的,就是第五行的比對在高並發下,也是限不住的,具體的壓測結果請看我的這篇stackoverflow的提問:Will redis incr command can be limitation to specific number?

所以此種場景,雖然在高並發情況下避免了redis命令的分開操作,但是卻大大增加了redis的寫並發量,被pass。

 

超限限制做法三:先遞減庫存,然后通過整數溢出控制,最后根據情況回滾

   1:  def storage_scenario_three():
   2:      conn = redis_conn()
   3:      key = "storage_seckill"
   4:      current = conn.decr(key)
   5:      #通過整數控制溢出的做法
   6:      if storage_overflow_checker(current):
   7:          return current
   8:      else:
   9:          #回滾庫存
  10:          conn.incr(key)
  11:          return 0
  12:   
  13:  def storage_overflow_checker(current_storage):
  14:      #如果當前庫存未被遞減到0,則check_number為int類型,isinstance方法檢測結果為true
  15:      #如果當前庫存已被遞減到負數,則check_number為long類型,isinstance方法檢測結果為false
  16:      check_number = sys.maxint - current_storage
  17:      check_result = isinstance(check_number,int)
  18:      return check_result

說明一下,當前庫存,如果為負數,則利用python的isinstance(check_number,int)檢測的時候,check_result返回是false;如果為非負數,則檢測的時候,check_result返回的是true,上面的storage_overflow_checker的做法,和下面的C#語言的做法是一樣的,利用C#語言描述,大家可能對上面的代碼更清晰一些:

   1:      /**
   2:       * 通過讓Integer溢出的方式來控制數量超賣(遞減導致溢出)
   3:       * @param current
   4:       * @return
   5:       */
   6:      public boolean StorageOverFillChecker(long current) {
   7:          try {
   8:              //當前數值的結果計算
   9:              Long value = Integer.MAX_VALUE - current;
  10:              //嘗試轉變為Inter類型,如果超賣,則轉換會出錯;如果未超賣,則轉換不會出錯
  11:              Integer.parseInt(value.toString());
  12:          } catch (Exception ex) {
  13:              //值溢出
  14:              return true;
  15:          }
  16:   
  17:          return false;
  18:      }

可以看出,此種做法和方法二很相似,只是比對部分由,直接和零比對,變成了通過檢測integer是否溢出的方式來進行。這樣就徹底解決了高並發情況下,直接和零比對,限制不住的問題了。

雖然此種做法,相對於做法二說來,要靠譜很多,但是仍然解決不了在高並發情況下,redis寫並發量加倍的問題,極有可能某個促銷活動,在開始的那一刻,直接將redis的寫操作打出問題來。

 

超限限制做法四:共享鎖

   1:  def storage_scenario_four():
   2:      conn = redis_conn()
   3:      key = "storage_seckill"
   4:      key_lock = key + "_lock"
   5:      if conn.setnx(key_lock, "1"):
   6:          #客戶端掛掉,設置過期時間,防止其不釋放鎖
   7:          conn.pexpire(key_lock, 5)
   8:          current_storage = conn.get(key)
   9:          if int(current_storage)<=0 :
  10:              return 0
  11:          result = conn.decr(key)
  12:          #客戶端正常,刪除共享鎖,提高性能
  13:          conn.delete(key_lock)
  14:          return result
  15:      else :
  16:          return "someone in it"

前面三種,由於在高並發下都有問題,所以本做法,主要是通過setnx設置共享鎖,然后請求到鎖的用戶請求,正常進行庫存扣減操作;請求不到鎖的用戶請求,則直接提示有其他人在操作庫存。

由於setnx的特殊性,當客戶端掛掉的時候,是不會釋放這個鎖的,所以當請求進來的時候,首先通過pexpire命令,為鎖設置過期時間,防止死鎖不釋放。然后執行正常的庫存扣減操作,當操作完畢,刪掉共享鎖,可以極大的提高程序性能,否則只能等待鎖慢慢過期了。

此種做法相對於上面的三種操作,通過采用共享鎖,犧牲了部分性能,來規避了高並發的問題,比較推薦,但是由於redis操作命令還是很多,並且每條都要發送到redis端執行,所以在網絡傳輸上,耗費的時間開銷是不小的。這是后面需要着力優化的方向。

 

看了上面四種做法,都不是很完美,其中最大的問題在於,高並發情況下,多條redis命令分開操作庫存,極容易發生庫存限不住的問題;同時,由於加了rollback庫存操作,極容易由於redis寫命令的操作數加倍導致壓垮redis的風險。加了鎖,雖然犧牲了部分性能,規避了高並發問題,但是redis命令操作量過多。

其實我上面一直在強調高並發,高並發。上面的四個場景,只有在高並發的情況下,才會出現問題,如果你的用戶請求量沒有那么多,那么采用上面四種方式之一,也不是不可以。但是如何才能知道采用起來沒問題呢?其實最簡單的一個方式,就是在你們自己的集群機器上,模擬活動的真實用戶量,進行壓測,看看會不會超限就行了,不超限的話,上面四種做法完全滿足需求。

 

那么,就沒有比較好一些的解決方案了嗎?

 

也不是,雖然解決這個問題,沒有絕對好用的銀彈,但是有相對好用的大蒜和聖水。下面的講解,會涉及到Redisson的Redlock的源碼實現,當然也會涉及一點lua方面的知識,還請提前預備一下。

偶然在研究分布式鎖的時候,嘗試翻閱過Redisson的Redlock的實現,並對其底層的實現方式有所記錄,我們先來看看其加鎖過程的源碼實現:

redlocklockinner

從上面的方法中,我們可以看到,分布式鎖的上鎖過程,是首先判斷一個key存不存在,如果不存在,則設置這個key,然后pexpire設置一個過期時間,來防止客戶端訪問的時候,掛掉了后,不釋放鎖的問題。為什么這段lua代碼就能實現分布式鎖的核心呢? 原因就是,這段代碼放到一個lua腳本中,那么這段lua腳本就是一個原子性的操作。redis在執行這段lua腳本的過程中,不會摻雜任何其他的命令。所以從根本上避免了並發操作命令的問題。

我們都知道,一個key如果設置了過期時間,key過期后,redis是不會刪掉這個key的,只有用戶訪問才會刪除掉這個key,所以,當使用分布式鎖的時候,如果設置的pexpire過期時間為5ms,那么一秒鍾只能處理200個並發,性能非常低。如何解決這種性能問題呢?來看來解鎖的操作:

redlockunlockinner

從上面解鎖的方法中,我們可以看到,如果這個鎖用完了之后,Redisson的做法是是直接刪除掉的。這樣可以提高不少的性能。(源碼參閱,屬於我自己的理解,如有謬誤,還請指教)

那么按照上面這種設計思路,新的超限做法就出來了。

 

超限做法五:基於lua的共享鎖

   1:  def storage_scenario_five():
   2:      conn = redis_conn()
   3:      key = "storage_seckill"
   4:      key_lock = key + "_lock"
   5:      key_val = "get_the_key"
   6:      lua = """
   7:              local key    = KEYS[1]
   8:              local expire = KEYS[2]
   9:              local value  = KEYS[3]
  10:   
  11:              local result = redis.call('setnx',key,value)
  12:              if result == 1 then
  13:                 redis.call('pexpire', key, expire)
  14:              end
  15:              return result
  16:            """
  17:      locked = conn.eval(lua, 3, key_lock, 5, key_val)
  18:      print (locked == 1)
  19:      if locked == 1:
  20:          val = storage_scenario_one()
  21:          print("val:"+str(val))
  22:          #刪掉共享key,用以提高性能, 否則只能默默的等其過期
  23:          conn.delete(key_lock)
  24:          return val
  25:      else:
  26:          return "someone in it"

這種做法,其實是做法四的衍生優化版本,優化的地方在於,將多條redis操作命令多次發送,改成了將多條redis操作命令放在了一個原子性操作事務中一次性執行完畢,省去了很多的網絡請求。如果可以,其實你也可以將業務邏輯糅合到上面的lua代碼中,這樣一來,性能當然會更好。

上面這種做法,如果 storage_scenario_one()這種操作是直接操作的mysql庫存,則非常推薦這種做法,但是如果storage_scenario_one()這種操作直接操作的redis中的虛擬庫存,則不是很推薦這種做法,不如直接用限流操作。

 

超限做法六: All In Lua

   1:  def storage_scenario_six():
   2:      conn = redis_conn()
   3:      lua = """
   4:              local storage = redis.call('get','storage_seckill')
   5:              if  storage ~= false then
   6:                  if tonumber(storage) > 0 then
   7:                      return redis.call('decr','storage_seckill')
   8:                  else
   9:                      return 'storage is zero now, can't perform decr action'
  10:                  end
  11:              else
  12:                  return redis.call('set','storage_seckill',10)
  13:              end
  14:            """
  15:      result = conn.eval(lua,0)
  16:      print(result)

此種做法是當前最好的做法,所有的庫存扣減操作都放在lua腳本中進行,形成一個原子性操作,redis在執行上面的lua腳本的時候,是不會摻雜任何其他的執行命令的。所以這樣從根本上避免了高並發下,多條命令執行帶來的問題。而且上面的redis命令執行,都直接在redis服務器上,省去了網絡傳輸時間,也沒有共享鎖的限制,從性能上而言,是最好的。但是,業務邏輯的lua化,相對而言是比較麻煩的,所以對於追求極限庫存控制的業務,可以考慮這種做法。

 

好了,這就是我今天為大家帶來的六種庫存超限的做法,每種做法都有自己的優缺點,好使的限不住,限的住的性能不行,性能好的又需要引入lua,真心不知道如何選擇了。

 

聲明:上面六種庫存超限做法,有些屬於本人的推理,線上並未實際用過,如果你貿然使用而未經過壓測,由此造成的損失,找老板去討論吧。

參考資源:

redis lua can't work properly due to wrong type comparison

《深入理解redis》

 

Edit:2018年2月9日11:19:35

修改了第五種方法中的說明部分。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM