??各位小伙伴們大家好,歡迎來到這個小扎扎的Redis 6專欄,在這個系列專欄中我對B站黑馬的Redis教程進行一個總結,鑒于 看到就是學到、學到就是賺到 精神,這波依然是血賺 ┗|`O′|┛
💡Redis知識點速覽
- 🍖 全局唯一ID
- 🥩 業務邏輯分析
- 🥩 代碼實現
- 🍖 優惠券秒殺
- 🥩 業務邏輯分析
- 🥩 代碼實現
- 🍖 定量商品多賣問題
- 🥩 業務邏輯分析
- 🥩 樂觀鎖與悲觀鎖
- 🥩 樂觀鎖代碼實現
- 🍖 一個用戶限買一單
- 🥩 業務邏輯分析
- 🥩 代碼實現
🍖 全局唯一ID
🥩 業務邏輯分析
??全局唯一ID是針對銷量比較大的一些商品而言的,這類商品的成交量比較多,用戶購買成功就會生成對應訂單信息并保存到一張表中,而訂單表的id如果使用數據庫自增ID就存在一些問題,比如說id的規律性太強導致安全性極低,還有如果訂單數量太多一張表存不下分成多張表存儲的話就會出現ID沖突問題,于是我們需要一個全局ID生成器,保證ID在全局中都是唯一的
??使用Redis即可完成這種全局ID生成器的功能,具體實現就是一種類雪花算法,也就是符號位、時間戳、序列號三部分拼接形成一個ID,邏輯就是符號位0代表整數,時間戳確定具體到下訂單的時候是哪一秒,至于序列號就是用于區分這一秒的訂單,序列號使用redis的值自增來保證所有序列號不一致,原則上一秒中最多可以有232個不同的ID
🥩 代碼實現
/*** @author : mereign* @date : 2022/5/11 - 14:06* @desc : 全局ID生成器*/@Component
public class RedisIdGenerator {/*** 構造方法注入stringRedisTemplate對象*/private StringRedisTemplate stringRedisTemplate;public RedisIdGenerator(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}// 定義序列號的位數private static final int COUNT_BITS = 30;public long nextId(String keyPrefix) {// 生成從指定時間到現在的時間戳LocalDateTime beginTime = LocalDateTime.of(2022, 1, 1, 0, 0, 0);long beginTimeStamp = beginTime.toEpochSecond(ZoneOffset.UTC);long endTimeStamp = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC);long timeStamp = endTimeStamp - beginTimeStamp;/*** 生成序列號 使用redis的incr方法 K值為"icr:" + keyPrefix + ":" + date* 也就是按照日期作為K 每下一次單V就自增1作為序列號添加到后面* 這樣的話既避免了K固定帶來的V超過最大閾值(redis中的V最大為2^64)* 而且還方便了統計一天、一個月、一年的訂單量,在這段時間內最大的序列號就是它的最多訂單數*/String date = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));Long sequenceId = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);// 拼接生成全局唯一ID并返回 兩個二進制的拼接可以使用前一個數左移一定位數 后一個數與位移后的進行或運算return timeStamp << COUNT_BITS | sequenceId;}
}
🍖 優惠券秒殺
🥩 業務邏輯分析
??用戶對秒殺商品下單的時候,后臺業務需要先完成對商品時間的判斷,判斷該商品的秒殺活動是否開始或者有沒有結束,但凡還未開始或者已經結束都無法下單;時間信息正確的話就判斷該商品的活動庫存還有沒有剩余,如果已經賣完的話也無法下單。時間和庫存的判斷都是通過前端傳過來的優惠券id,查出來該優惠券的時間和庫存信息,如果條件都滿足的話,將該商品券的庫存扣除,然后創建訂單返回訂單id
🥩 代碼實現
??controller層主要就是調用service接口里的secKillVoucher方法,所以整個業務邏輯代碼全部都在接口的實現類中完成
@Resource
private ISeckillVoucherService seckillVoucherService;@Resource
private RedisIdGenerator generator;@Override
@Transactional
public Result secKillVoucher(Long voucherId) {// 查詢優惠券SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);// 獲取時間 判斷秒殺活動是否開始或者結束if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {return Result.fail("活動暫未開始");} else if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {return Result.fail("活動已經結束");}// 判斷庫存是否充足if (seckillVoucher.getStock() < 1) {return Result.fail("庫存不足,活動結束");}// 扣減庫存seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).update();// 創建訂單 并返回idVoucherOrder order = new VoucherOrder();// 訂單id(redis全局唯一id) 下單用戶id(攔截器中做登錄驗證的用戶id) 優惠券id(直接傳過來的id)long orderId = generator.nextId("order");order.setId(orderId);order.setUserId(UserHolder.getUser().getId());order.setVoucherId(voucherId);save(order);return Result.ok(orderId);
}
🍖 定量商品多賣問題
🥩 業務邏輯分析
??像上面的優惠券秒殺的業務,優惠券或者商品的數量一般都是固定的,如果把這些數量都賣完之后應該就結束這個活動。但是現實中的秒殺業務都是多線程的,很多的用戶同時等著活動開啟一起點擊下單,這樣的話就極有可能出現線程安全問題也就是說最終成交的數量要多于活動商品的數量
??上述問題出現的原因就是多線程之間的執行順序所引起,我們的秒殺業務里面是先查詢庫存數量大于1就產生訂單,但是多線程之間的執行不會嚴格的按照這個順序執行,而是交叉執行,如果最后只剩一張票的時候進來了兩個線程AB,A查完B查AB查詢結果都可以下單,A產生訂單B再產生訂單,此時就已經產生超賣
🥩 樂觀鎖與悲觀鎖
??解決線程問題的最好方法就是加鎖,但是鎖也分為悲觀鎖和樂觀鎖,悲觀鎖認為線程安全問題一定會發生,因此在操作數據之前先獲取鎖,確保線程串行執行,例如Synchronized、Lock等。樂觀鎖認為線程安全問題不一定會發生,因此不加鎖,只是在更新數據時去判斷有沒有其它線程對數據做了修改,如果沒有修改則更新數據,修改說明發生了安全問題
??很顯然樂觀鎖的性能要顯著高于悲觀鎖,因此采用樂觀鎖保證線程的原子性。樂觀鎖又有兩種解決方案:版本號是指對修改的數據附帶一個version字段值,每次更新的時候判斷修改時的version與查詢的時候是否一致,一致則修改。CAS機制全稱為Compare And Swap譯為先比較再交換,也就是將修改的數據本身作為版本號,每次更新的時候判斷修改時的數據值與查詢時的值是否相同,相同則修改,不同就說明發生了線程安全問題,在我們的這個售賣業務中,可以設置成只要庫存大于0就可以執行成功
🥩 樂觀鎖代碼實現
??樂觀鎖的核心就是,在更新數據的時候(也就是減少庫存),判斷一下庫存是否大于0,如果判斷失敗的話也應該使該線程任務失敗
// 扣減庫存
boolean update = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).gt("stock", 0).update();
// 更新失敗說明在扣除庫存的時候 庫存小于等于0
if (!update) {return Result.fail("庫存不足!");
}
🍖 一個用戶限買一單
🥩 業務邏輯分析
??按照正常的業務邏輯,秒殺應該限制一個用戶只能購買一次該商品,最簡單的方法就是對user_id使用唯一索引,如果user_id重復就會拋出相關異常,但是這需要修改表結構。如果不修改標結果的話就需要扣除庫存之前根據voucher_id和user_id查詢訂單表,如果存在的話就返回錯誤,否則說明該用戶還未購買
🥩 代碼實現
??單機(服務部署在一臺tomcat服務器)的情況下,加synchronized 鎖即可解決(查詢判斷用戶是否下單和創建訂單)業務的線程安全問題,但是這種情況就只能
// 單用戶id(攔截器中做登錄驗證的用戶id)
Long userId = UserHolder.getUser().getId();// 根據user_id加鎖 intern方法是去字符常量池中查找值相同的,不加的話字符串值一樣的地址不一樣也會加上鎖
synchronized (userId.toString().intern()) {// 查詢優惠券// 判斷庫存是否充足// user_id和voucher_id聯合查詢訂單數Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();// 訂單數為1 就說明已經下過單了if (count.equals(1)) {return Result.fail("您已經購買過該商品了");}// 扣減庫存 創建訂單return Result.ok(orderId);
}
??以上加synchronized 鎖的解決方案只適用于單機模式下,此時所有的請求過來都會按照userId去常量池中查找是否一致,一致的話就鎖在一起防止一個用戶購買多單。但是集群模式下所有的請求會經過Nginx的負載均衡輪詢發送到集群上的所有服務器,如果一個用戶的多個請求被分配到不同的服務器上的話,不同服務器中的JVM虛擬機里的靜態常量池中的內容是不同步的,這樣的話就會導致雖然userId一致但是各自所在的靜態常量池中都沒有,于是這個用戶就可以在不同的服務器分別下單了。如果有用戶使用腳本同時發送很多的下單請求,那么就會有極大的可能在每一個服務器中都下一單,那么如何解決這個問題呢?那就要學習分布式鎖的內容了