(一)全局唯一ID
一、全局ID生成器
可以看到在優惠卷訂單表中的主鍵id并沒有設置Auto increment自增長
假如未來訂單量達到數億單,單表無法保存如此多數據,就需要對其進行分表存儲(分布式)。假如每張表都采用自增長,各自從1開始自增,那么這個id就一定會出現重復,而從業務角度考慮,訂單id都應當是獨立且唯一的,那么也就會出現危險。也就需要去用到全局ID生成器。
- 唯一性
Redis當中的String數據結構有一種自增且唯一特性的命令increm。因為Redis是獨立于MySQL數據庫之外的,無論有幾個不同數據庫幾張不同的表,Redis都只有一個,這時當所有人來訪問Redis時,它的自增ID一定是唯一的。 - 高可用
將來講解Redis的集群方案、主從方案、哨兵方案都可以確保Redis的高可用性。 - 高性能
Redis的數據就是存儲在內存當中,是以高性能著稱的。 - 遞增性
Redis的自增方案就能保證數據的遞增性、連續性。 - 安全性
假如Redis直接采用這種低端自增方案,那么就會與MySQL數據庫一樣不存在安全性(因為太容易被猜測出規律),所以在使用increm全局自增ID時不能直接把這個數值用來當作ID,而是拼接其他信息來減弱規律性。
為了提高數據庫性能,id會采用數值類型(也就是java中的Long型)。時間戳用于增加ID復雜性,長度為31位是因為將來要以秒為單位,定義一個初始時間至當下時間的時間差,31位能夠存儲21億個單位,也就是接近69年,已經足夠我們使用了。假如在一秒內生成多個訂單需要生成多個ID,那么就會去自增后面的32位序列號,也就是Redis自增的值。
- Redis并不是全局唯一ID的唯一實現方案,還會有很多其他的方案。
二、Redis實現全局唯一ID
- 為什么需要引入一個keyPrefix參數:
該ID的生成策略是基于Redis自增長的,而我們也需要有一個key來對應該值,不同業務會有不同的key也就不能都去使用同一個自增長ID,也就是需要有一個前綴來區分不同業務。 - id生成策略也就是我們的核心業務-生成時間戳與序列號
- 為什么需要在key中拼接動態日期字符串:
若將key值寫死,那么就代表整個業務永遠采用同一個key來做自增長,也就是說無論該業務是經歷了多少年,使用的永遠是同一個key值,隨著業務逐漸發展,值會越來越大。而我們知道,Redis中該自增值的上限是2的64次方,雖然該值看似足夠大永遠不會觸及,但是它也是永遠存在這個上限的,而且在key的生成策略當中用于記錄序列號的值只有32個bit,這個值是很有可能被超過的。所以說我們不能永遠使用同一個key,可以考慮在key值中拼接上當天的日期值,這樣可以實現每天刷新value值上限,同時也更方便統計每天的記錄數量。 - 為什么要將timestamp左移并且與count值進行或運算
因為我們要想生成的全局id的最后32位都應當為從Redis當中得到的自增值,而當前僅有的是時間戳值,所以我們需要將時間戳值左移32位來空出擺放自增值的空間。并且因為或運算的效果是只要當前位置上的數字不為0即可直接賦值,所以這里與count值進行或運算就是相當于將count值直接賦值到序列號位的數字上,就能實現一個拼接的效果。
三、單元測試
測試在并發情況下生成id的性能以及值的情況,并且記錄運行時間,因為使用的線程池是異步的,需要用CountDownLatch來記錄每一條線程的執行時間并打印總耗費時間。
最終得到生成的全局ID以及總耗費時間,并且可以在Redis中查看到生成的id個數達30000個:
四、總結
(二)實現優惠卷秒殺下單
一、添加優惠卷
二、實現秒殺下單
優惠卷訂單表:
(三)超賣問題
一、庫存超賣問題分析
假設數據庫中某優惠券的庫存為100張,當我們使用jmeter調用200個線程來進行并發購買優惠券時會發現執行異常率為45%,也就是說有超過100條線程執行成功了,數據庫中優惠券庫存也變為負數,說明此時出現了超賣問題。
(1)執行流程分析
- 正常邏輯
- 交叉執行
(2)解決方案
悲觀鎖與樂觀鎖并不是一種真正的鎖,而是一種鎖的設計理念
- 悲觀鎖
悲觀鎖認為一定會出現線程安全問題,因此會在操作數據之前先去獲取鎖,來確保所有線程串行執行,減少并發情況。但是這也代表它的性能并不是很好,因為所有線程都是一個一個去執行的,不適合高并發場景。 - 樂觀鎖
樂觀鎖認為不一定會出現線程安全問題(認為出現問題的概率比較低),因此不會直接加鎖,而是會在線程對數據做修改時去判斷在這之前是否有別的線程已經對數據進行修改。也就是當我們查詢到數據庫中數據且將要對其做修改之前,會去檢查當前被修改的數據是否與一開始查詢到的數據相同,若不同則說明有別人已經對該數據進行修改了,會有線程安全問題,此時可以去重試或拋異常。它的性能會比悲觀鎖強很多,核心就是要去判斷數據是否被修改。
(3)樂觀鎖
- 版本號法
版本號法也就是給數據加上一個版本,在多線程并發時基于版本號來判斷是否被修改過,每當數據做一次修改,版本號就會加一。要想判斷一個數據是否被修改過,就是要判斷它的版本號是否有變化。
- CAS法
直接將目標修改的數據值作為比較值,替代版本號的作用
二、樂觀鎖解決超賣問題
- 樂觀鎖實現邏輯與業務邏輯的沖突問題
在樂觀鎖中要求對數據修改前原數據不能發生變化,而在當前業務中僅要求庫存大于0即可,樂觀鎖與業務的邏輯差異會導致線程異常率增大,也就是實際扣減成功率太低了,所以要對其進行優化。
- 最終代碼修改情況
再次執行測試,發現線程異常率達50%,也就是正好賣空全部庫存
- 總結
(四)一人一單
一、一人一單功能實現
但是此時的業務邏輯并不完美,因為此時一人一單的邏輯與之前下單的邏輯相同,都是先查詢再判斷,這使得同樣會出現多個線程穿插執行的情況,導致出現超賣,也就是并發安全問題。
之前提到的樂觀鎖是在更新數據時去使用的,這里是需要去插入數據,所以不能直接去判斷數據是否有被修改過,而是要去判斷數據是否存在,也就只能去使用悲觀鎖。
(1)具體改造流程
- 封裝方法
- 根據用戶id進行加鎖并且通過獲取代理對象來開啟createVoucherOrder方法的事務
引入依賴并添加注解去暴露代理對象
- 測試
二、集群下的線程并發安全問題
- 正常執行情況
- 交叉執行情況(出現并發問題)
會導致插入了兩次訂單 - 加入互斥鎖的執行情況
產生線程安全問題的原因:
在集群部署模式或分布式系統下,每一臺服務都會有一個獨立的JVM,而每個JVM當中都會存在獨立的鎖監視器去維護互斥鎖,導致了每臺服務中都有一個線程是能獲取到互斥鎖的,也就會發生并行運行,就可能出現線程安全問題。
解決辦法:讓多個JVM只能使用同一把鎖,也就是實現跨進程的分布式鎖。