3.1 全局唯一 ID
當用戶搶購時,就會生成訂單并保存到 tb_voucher_order 這張表中,而訂單表如果使用數據庫自增 ID 就存在一些問題:
-
id 的規律性太明顯
-
受單表數據量的限制
場景分析一:如果我們的 id 具有太明顯的規則,用戶或者說商業對手很容易猜測出來我們的一些敏感信息,比如商城在一天時間內,賣出了多少單,這明顯不合適。
場景分析二:隨著我們商城規模越來越大,mysql 的單表的容量不宜超過 500W,數據量過大之后,我們要進行拆庫拆表,但拆分表了之后,他們從邏輯上講他們是同一張表,所以他們的 id 是不能一樣的,于是乎我們需要保證 id 的唯一性。
全局 ID 生成器,是一種在分布式系統下用來生成全局唯一 ID 的工具,一般要滿足下列特性:
為了增加 ID 的安全性,我們可以不直接使用 Redis 自增的數值,而是拼接一些其它信息:
ID 的組成部分:
-
符號位:1bit,永遠為 0
-
時間戳:31bit,以秒為單位,可以使用 69 年
-
序列號:32bit,秒內的計數器,支持每秒產生 2^32 個不同 ID
3.2 添加優惠券
每個店鋪都可以發布優惠券,分為平價券和特價券。平價券可以任意購買,而特價券需要秒殺搶購:
-
tb_voucher:優惠券的基本信息,優惠金額、使用規則等
-
tb_seckill_voucher:優惠券的庫存、開始搶購時間,結束搶購時間。特價優惠券才需要填寫這些信息
-
平價卷由于優惠力度并不是很大,所以是可以任意領取
而代金券由于優惠力度大,所以像第二種卷,就得限制數量,從表結構上也能看出,特價卷除了具有優惠卷的基本信息以外,還具有庫存,搶購時間,結束時間等等字段
3.3 秒殺下單
秒殺下單應該思考的內容:
-
下單時需要判斷兩點:
-
秒殺是否開始或結束,如果尚未開始或已經結束則無法下單
-
庫存是否充足,不足則無法下單
-
-
下單核心邏輯分析:
-
當用戶開始進行下單,我們應當去查詢優惠卷信息,查詢到優惠卷信息,判斷是否滿足秒殺條件
-
比如時間是否充足,如果時間充足,則進一步判斷庫存是否足夠,如果兩者都滿足,則扣減庫存,創建訂單,然后返回訂單 id,如果有一個條件不滿足則直接結束。
-
3.4 超賣問題
有關超賣問題分析:在我們原有代碼中是這么寫的
if (voucher.getStock() < 1) {// 庫存不足return Result.fail("庫存不足!");}//5,扣減庫存boolean success = seckillVoucherService.update().setSql("stock= stock -1").eq("voucher_id", voucherId).update();if (!success) {//扣減庫存return Result.fail("庫存不足!");}
-
假設線程 1 過來查詢庫存判斷出來庫存大于 1
-
正準備去扣減庫存,但是還沒有來得及去扣減
-
此時線程 2 過來,線程 2 也去查詢庫存,發現這個數量一定也大于 1
-
那么這兩個線程都會去扣減庫存,最終多個線程相當于一起去扣減庫存
-
此時就會出現庫存的超賣問題。
3.5 解決方案
超賣問題是典型的多線程安全問題,針對這一問題的常見解決方案就是加鎖:
而對于加鎖,我們通常有兩種解決方案:見下圖:
悲觀鎖:
悲觀鎖可以實現對于數據的串行化執行,比如 syn,和 lock 都是悲觀鎖的代表,同時,悲觀鎖中又可以再細分為公平鎖,非公平鎖,可重入鎖,等等
樂觀鎖:
會有一個版本號,每次操作數據會對版本號 +1,再提交回數據時,會去校驗是否比之前的版本大 1,如果大 1,則進行操作成功,這套機制的核心邏輯在于,如果在操作過程中,版本號只比原來大 1,那么就意味著操作過程中沒有人對他進行過修改,他的操作就是安全的,如果不大 1,則數據被修改過,當然樂觀鎖還有一些變種的處理方式比如 cas
樂觀鎖的典型代表:就是 cas,利用 cas 進行無鎖化機制加鎖,var5 是操作前讀取的內存值,while 中的 var1+var2 是預估值,如果預估值 == 內存值,則代表中間沒有被人修改過,此時就將新值去替換 內存值
課程中的使用方式是沒有像 cas 一樣帶自旋的操作,也沒有對 version 的版本號 +1,他的操作邏輯是在操作時,對版本號進行 +1 操作,然后要求 version 如果是 1 的情況下,才能操作,那么第一個線程在操作后,數據庫中的 version 變成了 2,但是他自己滿足 version=1,所以沒有問題,此時線程 2 執行,線程 2 最后也需要加上條件 version =1,但是現在由于線程 1 已經操作過了,所以線程 2,操作時就不滿足 version=1 的條件了,所以線程 2 無法執行成功
boolean success = seckillVoucherService.update().setSql("stock= stock -1") //set stock = stock -1.eq("voucher_id", voucherId).eq("stock",voucher.getStock()).update(); //where id = ? and stock = ?
以上邏輯的核心含義是:只要我扣減庫存時的庫存和之前我查詢到的庫存是一樣的,就意味著沒有人在中間修改過庫存,那么此時就是安全的,但是以上這種方式通過測試發現會有很多失敗的情況,失敗的原因在于:在使用樂觀鎖過程中假設 100 個線程同時都拿到了 100 的庫存,然后大家一起去進行扣減,但是 100 個人中只有 1 個人能扣減成功,其他的人在處理時,他們在扣減時,庫存已經被修改過了,所以此時其他線程都會失敗
之前的方式要修改前后都保持一致,但是這樣我們分析過,成功的概率太低,所以我們的樂觀鎖需要變一下,改成 stock 大于 0 即可
boolean success = seckillVoucherService.update().setSql("stock= stock -1").eq("voucher_id", voucherId).update().gt("stock",0);
//where id = ? and stock > 0
CAS:
-
針對 cas 中的自旋壓力過大,我們可以使用 Longaddr 這個類去解決
-
Java8 提供的一個對 AtomicLong 改進后的一個類,LongAdder
-
大量線程并發更新一個原子性的時候,天然的問題就是自旋,會導致并發性問題,當然這也比我們直接使用 syn 來的好
-
所以利用這么一個類,LongAdder 來進行優化
-
如果獲取某個值,則會對 cell 和 base 的值進行遞增,最后返回一個完整的值
3.6 一人一單
需求:修改秒殺業務,要求同一個優惠券,一個用戶只能下一單
現在的問題在于:
優惠卷是為了引流,但是目前的情況是,一個人可以無限制的搶這個優惠卷,所以我們應當增加一層邏輯,讓一個用戶只能下一個單,而不是讓一個用戶下多個單
具體操作邏輯如下:
-
比如時間是否充足,如果時間充足
-
則進一步判斷庫存是否足夠
-
然后再根據優惠卷 id 和用戶 id 查詢是否已經下過這個訂單
-
如果下過這個訂單,則不再下單,否則進行下單
// 一人一單邏輯
Long userId = UserHolder.getUser().getId();
Long count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 判斷是否存在
if (count > 0) {// 用戶已經購買過了return Result.fail("用戶已經購買過一次!");
}
問題:
多線程情況下(該用戶)可能多個線程同時判斷到 count == 0,然后都去執行下面的邏輯增加訂單…
解決:
封裝方法,加 synchronized 鎖
@Transactional
public synchronized Result createVoucherOrder(Long voucherId) {Long userId = UserHolder.getUser().getId();// 5.1.查詢訂單int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();// 5.2.判斷是否存在if (count > 0) {// 用戶已經購買過了return Result.fail("用戶已經購買過一次!");}// 6.扣減庫存boolean success = seckillVoucherService.update().setSql("stock = stock - 1") // set stock = stock - 1.eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0.update();if (!success) {// 扣減失敗return Result.fail("庫存不足!");}// 7.創建訂單VoucherOrder voucherOrder = new VoucherOrder();// 7.1.訂單idlong orderId = redisIdWorker.nextId("order");voucherOrder.setId(orderId);// 7.2.用戶idvoucherOrder.setUserId(userId);// 7.3.代金券idvoucherOrder.setVoucherId(voucherId);save(voucherOrder);// 7.返回訂單idreturn Result.ok(orderId);
}
但是這樣添加鎖,鎖的粒度太粗了,在使用鎖過程中,控制**鎖粒度** 是一個非常重要的事情,因為如果鎖的粒度太大,會導致每個線程進來都會鎖住,所以我們需要去控制鎖的粒度,以下這段代碼需要修改為:
intern() 這個方法是從常量池中拿到數據,如果我們直接使用 userId.toString() 他拿到的對象實際上是不同的對象,new 出來的對象,我們使用鎖必須保證鎖必須是同一把,所以我們需要使用 intern() 方法
@Transactional
public Result createVoucherOrder(Long voucherId) {Long userId = UserHolder.getUser().getId();synchronized(userId.toString().intern()){// 5.1.查詢訂單int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();// 5.2.判斷是否存在if (count > 0) {// 用戶已經購買過了return Result.fail("用戶已經購買過一次!");}// 6.扣減庫存boolean success = seckillVoucherService.update().setSql("stock = stock - 1") // set stock = stock - 1.eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0.update();if (!success) {// 扣減失敗return Result.fail("庫存不足!");}// 7.創建訂單VoucherOrder voucherOrder = new VoucherOrder();// 7.1.訂單idlong orderId = redisIdWorker.nextId("order");voucherOrder.setId(orderId);// 7.2.用戶idvoucherOrder.setUserId(userId);// 7.3.代金券idvoucherOrder.setVoucherId(voucherId);save(voucherOrder);// 7.返回訂單idreturn Result.ok(orderId);}
}
但是以上代碼還是存在問題,問題的原因在于當前方法被 spring 的事務控制,如果你在方法內部加鎖,可能會導致當前方法事務還沒有提交,但是鎖已經釋放也會導致問題,所以我們選擇將當前方法整體包裹起來,確保事務不會出現問題:如下:
在 seckillVoucher 方法中,添加以下邏輯,這樣就能保證事務的特性,同時也控制了鎖的粒度
3.7 并發問題
通過加鎖可以解決在單機情況下的一人一單安全問題,但是在集群模式下就不行了。
1、我們將服務啟動兩份,端口分別為 8081 和 8082:
2、然后修改 nginx 的 conf 目錄下的 nginx.conf 文件,配置反向代理和負載均衡:
有關鎖失效原因分析
由于現在我們部署了多個 tomcat,每個 tomcat 都有一個屬于自己的 jvm,那么假設在服務器 A 的 tomcat 內部,有兩個線程,這兩個線程由于使用的是同一份代碼,那么他們的鎖對象是同一個,是可以實現互斥的,但是如果現在是服務器 B 的 tomcat 內部,又有兩個線程,但是他們的鎖對象寫的雖然和服務器 A 一樣,但是鎖對象卻不是同一個,所以線程 3 和線程 4 可以實現互斥,但是卻無法和線程 1 和線程 2 實現互斥,這就是 集群環境下,syn 鎖失效的原因,在這種情況下,我們就需要使用分布式鎖來解決這個問題。