文章目錄
- 全局ID生成器
- 超賣
- 樂觀鎖
- 一人一單
- 悲觀鎖
當我們確認訂單時,系統需要給我們返回我們的訂單編號。這個時候就會出現兩個大問題。
1.訂單id采用數據庫里的自增的話,安全性降低。比如今天我的訂單是10,我明天的訂單是100,那么我就可以知道在昨天總共有多少訂單,從而給惡意用戶鉆空子。
2.訂單數很多時,訂單id增長到幾百上千萬,單張表無法存儲大量數據,那就需要將這些數據分到多張表,同時要重新設計訂單id,避免出現相同ID。
所以,這里我們使用全局ID生成器:
全局ID生成器
在分布式系統下用來生成全局唯一ID的工具。
其基本核心就是ID的生成:
符號位:正負數
時間戳:當前時間減初始時間
序列號:基于redis自增INCR命令
對存儲在指定鍵中的整數值進行原子性遞增的核心命令.
當key不存在時,redis自動創建一個新key,并設置其value為0.然后執行incr操作,將value遞增為1并返回。
key存在時,直接將value遞增。
@Component
public class RedisIdWorker {/*** 開始時間戳*/private static final long BEGIN_TIMESTAMP = 1640995200L;//2022年1月1日0點0分0秒/*** 序列號的位數*/private static final int COUNT_BITS = 32;private StringRedisTemplate stringRedisTemplate;public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}public long nextId(String keyPrefix) {// 1.生成時間戳LocalDateTime now = LocalDateTime.now();long nowSecond = now.toEpochSecond(ZoneOffset.UTC);long timestamp = nowSecond - BEGIN_TIMESTAMP;// 2.生成序列號// 2.1.獲取當前日期,精確到天String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));// 2.2.自增長long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);// 3.拼接并返回return timestamp << COUNT_BITS | count;}
}
超賣
在簡單的優惠券秒殺下單中,我們的基本步驟:
1.根據優惠券id查詢是否存在
2.確認搶購時間在時間范圍內
3.確認優惠券庫存>0
4.根據RedisIdWorker生成訂單id,優惠券數量減1
這在簡單的場景下是沒有問題的,但是在實際場景中,我們要考慮到多線程導致的超賣問題。
現在有100張優惠券,有200人來搶
理論上來說,應賣出100張,有100人搶到,但事實卻是多賣出了9張
當涉及多線程時,各個線程的運行順序我們是無法肯定的。
當線程1查詢庫存為1時,線程2插進來了,也查到為1,線程1按照邏輯扣減庫存,線程2也按照邏輯扣除庫存,這樣就導致最終庫存為-1。更多個線程,可能導致庫存更低,這就是超賣。
樂觀鎖
悲觀鎖和樂觀鎖都只是一種思想!
樂觀鎖:先操作,提交時再檢查沖突
認為并發操作很少發生沖突,只在提交操作時檢查是否沖突,比如CAS操作,數據庫的樂觀鎖和Java中的Atomic類。
舉個例子:
1.購物車結算時才檢查庫存(默認沒人搶購)
2.或者在網上訂票,系統顯示還有1個座位,你點擊預訂,系統會先讓你填寫信息,然后提交的時候檢查是否還有座位。如果有,預訂成功;如果沒有,提示你重新選擇
這里就以樂觀鎖為核心解決方法:判斷之前查詢到的數據是否有被修改過。
-
版本號法
給優惠券再設置一個字段“版本號”,初始值為1,每次被修改就加1。
這樣每個線程在查詢到庫存和版本號時,要想修改數據,必須在當前版本號基礎上實現,否則不成功。
-
CAS法
本質還是版本思想,做了簡化,每個線程在查詢到庫存后,要想修改數據,必須在當前庫存基礎上實現,否則不成功。
但是樂觀鎖同樣存在問題,當其他線程發現數據被修改后,他就不再執行,導致優惠券沒有賣完。
所以這里其他線程只需要在將修改條件改為stock>0。只要有庫存,我就可以減。
這樣會不會恍然中帶點疑惑:這跟最初有什么區別?都是判斷庫存是否>0。
NO,最初的問題出現在先判斷,再修改;而現在是要修改的時候才做判斷。
@Transactionalpublic Result createVoucherOrder(Long voucherId) {// 5.一人一單Long userId = UserHolder.getUser().getId();// 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);}}
一人一單
悲觀鎖
悲觀鎖:提前加鎖
認為并發操作一定會發生沖突,因此每次訪問數據時都會加鎖,比如synchronized和ReentrantLock。
舉個例子:出門時鎖門(默認有小偷)
上面的解決中,還存在一個問題:一個用戶不可以買多張優惠券。
那如果我們直接簡單的判斷該用戶是否下過單來處理的話:
// 5.1.查詢訂單int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();// 5.2.判斷是否存在if (count > 0) {// 用戶已經購買過了log.error("不允許重復下單!");return;}// 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) {// 扣減失敗log.error("庫存不足!");return;}// 7.創建訂單save(voucherOrder);
假設用戶A同時發起多個請求,每個請求都執行這段代碼。這時候,可能會出現多個線程同時通過第5.1步的查詢(count=0),然后都進入扣減庫存和創建訂單的步驟,導致用戶A創建了多個訂單,違反了“一人一單”的要求。
為什么會這樣?
兩個線程同時執行查詢時,此時數據庫中還沒有該用戶的訂單,所以兩個線程都認為可以繼續執行。然后它們都會去扣減庫存,假設庫存足夠,兩個線程都成功扣減,然后各自創建訂單。
所以我們最終的解決方法就是再加上一個悲觀鎖:
一個用戶加一把鎖(確保不會重復下單),不同用戶加不同鎖
@Transactionalpublic Result createVoucherOrder(Long voucherId) {// 5.一人一單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);}}
synchronized (userId.toString().intern())
這里為什么通過用戶ID來加鎖,為什么是userId.toString().intern()?
synchronized:實現線程同步,確保同一時刻只有一個線程可以執行某個代碼塊或方法。
toString()
將userId
轉換為字符串,雖然是同一個userId,但是會新生成不同的字符串對象。public static String toString(long i) {int size = stringSize(i);if (COMPACT_STRINGS) {byte[] buf = new byte[size];getChars(i, size, buf);return new String(buf, LATIN1);} else {byte[] buf = new byte[size * 2];StringUTF16.getChars(i, size, buf);return new String(buf, UTF16);}}
而
intern()
方法會返回該字符串在常量池中的引用,確保相同值的字符串引用同一個對象,從而正確同步。
- 如果常量池已存在相同值的字符串,直接返回該引用;
- 如果不存在,將該字符串加入常量池后再返回引用。
不過又發現一個問題:這里用戶加鎖-操作-釋放鎖,但如果此時事務還沒有提交上去,其他線程來了,依然可能出現并發問題。
我們希望整個事務提交上去后再釋放鎖。
也就是給這個函數加上鎖。
當函數1(無事務)調用這個函數2時(有事務),事務是否還生效?
事務
當我們在一個類的方法上使用 @Transactional注解時,Spring會為該類創建一個代理對象。這個代理對象在調用方法時會處理事務的開啟、提交或回滾等操作。
如果在一個類內部的方法A調用另一個有@Transactional注解的方法B,這時候方法A調用的是實際的實例方法,而不是通過代理對象調用的。因此,事務不會生效,因為代理對象沒有被使用到。
解決:
不斷學習中,感謝大家的觀看>W<