<em>Mac</em>Book项目 2009年学校开始实施<em>Mac</em>Book项目,所有师生配备一本<em>Mac</em>Book,并同步更新了校园无线网络。学校每周进行电脑技术更新,每月发送技术支持资料,极大改变了教学及学习方式。因此2011
2021-06-01 09:32:01
在分散式系統中,經常需要使用全域性唯一ID
查詢對應的資料。產生這種ID需要保證系統全域性唯一,而且要高效能以及佔用相對較少的空間。
全域性唯一ID在資料庫中一般會被設成主鍵,這樣為了保證資料插入時索引的快速建立,還需要保持一個有序的趨勢。
這樣全域性唯一ID就需要保證這兩個需求:
我們的場景是 優惠卷秒殺搶購, 當用戶搶購時,就會生成訂單 並儲存到 資料庫 的訂單表中,而訂單表 如果使用資料庫自增ID就會存在以下問題
場景分析:如果我們的id具有太明顯的規則,使用者或者說商業對手很容易猜測出來我們的一些敏感資訊,比如商城在一天時間內,賣出了多少單,這明顯不合適。
場景分析二: 隨著我們商城規模越來越大,MySQL 的單表的容量不宜超過500W,資料量過大之後,我們要進行拆庫拆表,但拆分表了之後,他們從邏輯上講他們是同一張表,所以他們的id是不能一樣的, 於是乎我們需要保證id的唯一性。
全域性ID生成器,是一種在分散式系統下用來生成全域性唯一ID的工具,一般要滿足下列特性:
為了增加ID的安全性,我們可以不直接使用Redis自增的數值,而是拼接一些其它資訊:
ID的組合為
2^32
個 不同ID編寫工具類
@Component public class RedisIdWorker { /** * 開始時間戳 */ private static final long BEGIN_TIMESTAMP = 1640995200L; /** * 序列號的位數 */ 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; } }
測試存入Redis
@Autowired private RedisIdWorker redisIdWorker; private ExecutorService es = Executors.newFixedThreadPool(500); @Test public void testWorkerId() throws InterruptedException { CountDownLatch latch = new CountDownLatch(300); Runnable task = () -> { for (int i = 0; i < 100; i++) { long id = redisIdWorker.nextId("order"); System.out.println("id = " + id); } latch.countDown(); }; long begin = System.currentTimeMillis(); for (int i = 0; i < 300; i++) { es.submit(task); } latch.await(); long end = System.currentTimeMillis(); System.out.println("times = " + (end- begin)); }
這裡用到了 CountDownlatch,簡單的介紹一下:
CountDownLatch名為訊號槍:主要的作用是同步協調在多執行緒的等待於喚醒問題
我們如果沒有CountDownLatch ,那麼由於程式是非同步的,當非同步程式沒有執行完時,主執行緒就已經執行完了,然後我們期望的是分執行緒全部走完之後,主執行緒再走,所以我們此時需要使用到CountDownLatch
CountDownLatch 中有兩個最重要的方法
await 是阻塞方法,我們擔心執行緒沒有執行完時,main執行緒就執行,所以可以使用await就阻塞主執行緒, 那麼什麼時候main執行緒不在阻塞呢? 當 CountDownLatch 內部維護的變數為0時,就不再阻塞,直接放行。
什麼時候 CountDownLatch 維護的變數變為0 呢,我們只需要呼叫一次countDown ,內部變數就減少1,我們讓分執行緒和變數繫結, 執行完一個分執行緒就減少一個變數,當分執行緒全部走完,CountDownLatch 維護的變數就是0,此時await就不再阻塞,統計出來的時間也就是所有分執行緒執行完後的時間。
需要搭建登入環境,基礎環境程式碼和sql檔案
均已上傳 GitCode 連結:基礎環境和SQL
新增優惠卷
VoucherServiceImpl 核心程式碼
@Service public class VoucherServiceImpl extends ServiceImpl<VoucherMapper, Voucher> implements IVoucherService { // 該類無程式碼,直接MyBatis-Plus繼承實現類 即可,自動完成持久化 @Autowired private ISeckillVoucherService seckillVoucherService; @Override public ResultBean<List<Voucher>> queryVoucherOfShop(Long shopId) { // 查詢優惠券資訊 List<Voucher> vouchers = getBaseMapper().queryVoucherOfShop(shopId); // 返回結果 return ResultBean.create(0, "success", vouchers); } @Override public void addSeckillVoucher(Voucher voucher) { // 儲存優惠券 save(voucher); // 儲存秒殺資訊 SeckillVoucher seckillVoucher = new SeckillVoucher(); seckillVoucher.setVoucherId(voucher.getId()); seckillVoucher.setStock(voucher.getStock()); seckillVoucher.setBeginTime(voucher.getBeginTime()); seckillVoucher.setEndTime(voucher.getEndTime()); seckillVoucherService.save(seckillVoucher); } }
VoucherController 介面層
@RestController @CrossOrigin @RequestMapping("/voucher") public class VoucherController { @Autowired private IVoucherService voucherService; /** * 新增秒殺券 * @param voucher 優惠券資訊,包含秒殺資訊 * @return 優惠券id */ @PostMapping("seckill") public ResultBean addSeckillVoucher(@RequestBody Voucher voucher) { voucherService.addSeckillVoucher(voucher); return Result.ok(voucher.getId()); } }
編寫下單業務
VoucherOrderServiceImpl 優惠卷訂單核心業務類
@Service public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService { @Autowired private ISeckillVoucherService seckillVoucherService; @Autowired private RedisIdWorker redisIdWorker; @Override @Transactional public Result seckillVoucher(Long voucherId) { //1. 查詢優惠卷 SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId); //2. 判斷秒殺是否開始 開始時間大於當前時間表示未開始搶購 if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) { return Result.fail("秒殺尚未開始!"); } //3. 判斷秒殺是否結束 if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) { return Result.fail("秒殺已經結束!"); } //4. 判斷庫存是否充足 if (seckillVoucher.getStock() < 1) { return Result.fail("庫存不足!"); } Long userId = UserHolder.getUser().getId(); //5. 查詢訂單 //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") .eq("voucher_id", voucherId).update(); if (!success) { return Result.fail("庫存不足!"); } //7. 建立訂單 VoucherOrder voucherOrder = new VoucherOrder(); long orderId = redisIdWorker.nextId("order"); voucherOrder.setId(orderId); voucherOrder.setUserId(userId); voucherOrder.setVoucherId(voucherId); save(voucherOrder); //8. 返回訂單id return Result.ok(orderId); } }
VoucherOrderController 介面層
@RestController @CrossOrigin @RequestMapping("/voucher_order") public class VoucherOrderController { @Autowired private IVoucherOrderService voucherOrderService; @PostMapping("seckill/{id}") public Result seckillVoucher(@PathVariable("id") Long voucherId) { return voucherOrderService.seckillVoucher(voucherId); } }
測試搶購秒殺優惠卷
ApiFox 新增以下介面
新增秒殺卷
測試返回成功即可。
搶購秒殺優惠卷介面
測試無誤,搶購成功!
有關超賣問題分析:在我們原有程式碼中是這麼寫的
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,那麼這兩個執行緒都會去扣減庫存,最終多個執行緒相當於一起去扣減庫存,此時就會出現庫存的超賣問題。
超賣問題是典型的多執行緒安全問題, 這種情況下常見的解決方案就是 加 鎖:而對於加鎖,我們通常有兩種解決方案:
悲觀鎖:
悲觀鎖可以實現對於資料的序列化執行,比如syn,和lock都是悲觀鎖的代表,同時,悲觀鎖中又可以再細分為公平鎖,非公平鎖,可重入鎖,等等
樂觀鎖:
會有一個版本號,每次運算元據會對版本號+1,再提交回資料時,會去校驗是否比之前的版本大1 ,如果大1 ,則進行操作成功,這套機制的核心邏輯在於,**如果在操作過程中,版本號只比原來大1 ,那麼就意味著操作過程中沒有人對他進行過修改,他的操作就是安全的,**如果不大1,則資料被修改過,當然樂觀鎖還有一些變種的處理方式比如cas
樂觀鎖的典型代表:就是CAS,利用CAS進行無鎖化機制加鎖,varNum是操作前讀取的記憶體值,while中的var1+var2 是預估值,如果預估值 == 記憶體值,則代表中間沒有被人修改過,此時就將新值去替換 記憶體值
其中do while 是為了在操作失敗時,再次進行自旋操作,即把之前的邏輯再操作一次。
int varNum; do { varNum = this.getIntVolatile(var1, var2); } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4)); return var5;
我們採用的方式為:
在操作時,對版本號進行+1 操作,然後要求version 如果是1 的情況下,才能操作,那麼第一個執行緒在操作後,資料庫中的version變成了2,但是他自己滿足version=1 ,所以沒有問題,此時執行緒2執行,執行緒2 最後也需要加上條件version =1 ,但是現在由於執行緒1已經操作過了,所以執行緒2,操作時就不滿足version=1 的條件了,所以執行緒2無法執行成功
加入以下程式碼解決超賣問題
之前的方式要修改前後都保持一致,但是這樣我們分析過,成功的概率太低,所以我們的樂觀鎖需要變一下,改成stock大於0 即可
boolean success = seckillVoucherService.update() .setSql("stock= stock -1") .eq("voucher_id", voucherId).update().gt("stock",0); //where id = ? and stock > 0
知識拓展
針對CAS中的自旋壓力過大,我們可以使用Longaddr這個類去解決
Java8 提供的一個對AtomicLong改進後的一個類,LongAdder
大量執行緒並行更新一個原子性的時候,天然的問題就是自旋,會導致並行性問題,當然這也比我們直接使用syn來的好
所以利用這麼一個類,LongAdder來進行優化
如果獲取某個值,則會對cell和base的值進行遞增,最後返回一個完整的值
以上的解決方式,依然有些問題,下面使用Jmeter進行測試
新增執行緒組
新增JSON斷言,我們認為返回結果為false的就是請求失敗
線上程組右擊選擇斷言 --> JSON 斷言
加入以下判斷
判斷success欄位,值是否為true,是true就是返回成功~ 反之失敗
檢視結果樹、HTTP資訊請求頭、彙總報告、聚合報告等均在http請求右擊新增即可
啟動,檢視返回的結果
檢視聚合報告
異常率這麼高,再來看資料庫
數量正確,我們再看訂單表
id都一樣,這可不行啊,我們真實場景下,發放優惠卷不會讓一個使用者去搶購所有的訂單秒殺優惠卷,這樣商家就太虧了,全讓黃牛給搶走了,這可不行,我們需要限制使用者的搶購數量。
初步實現
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count(); if (count > 0) { return Result.fail("使用者已經購買過!"); }
存在問題:現在的問題還是和之前一樣,並行過來,查詢資料庫,都不存在訂單,所以我們還是需要加鎖,但是樂觀鎖比較適合更新資料,而現在是插入資料,所以我們需要使用悲觀鎖操作
注意:在這裡提到了非常多的問題,我們需要慢慢的來思考,首先我們的初始方案是封裝了一個createVoucherOrder方法,同時為了確保他執行緒安全,在方法上新增了一把synchronized 鎖
加上悲觀鎖
@Override public Result seckillVoucher(Long voucherId) { //1. 查詢優惠卷 SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId); //2. 判斷秒殺是否開始 開始時間大於當前時間表示未開始搶購 if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) { return Result.fail("秒殺尚未開始!"); } //3. 判斷秒殺是否結束 if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) { return Result.fail("秒殺已經結束!"); } //4. 判斷庫存是否充足 if (seckillVoucher.getStock() < 1) { return Result.fail("庫存不足!"); } Long userId = UserHolder.getUser().getId(); synchronized (userId.toString().intern()) { IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy(); return proxy.createVoucherOrder(voucherId, userId); } } @Transactional @Override public Result createVoucherOrder(Long voucherId, Long userId) { //5. 查詢訂單 //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") .eq("voucher_id", voucherId).gt("stock", 0). update(); if (!success) { return Result.fail("庫存不足!"); } //7. 建立訂單 VoucherOrder voucherOrder = new VoucherOrder(); long orderId = redisIdWorker.nextId("order"); voucherOrder.setId(orderId); voucherOrder.setUserId(userId); voucherOrder.setVoucherId(voucherId); save(voucherOrder); //8. 返回訂單id return Result.ok(orderId); }
在啟動類加入以下註解,啟動AspectJ
@EnableAspectJAutoProxy(exposeProxy = true)
以上程式碼,採用悲觀鎖解決了高並行下,一人多單的場景,同時,也解決了事務失效。引入了AspectJ解決!
Jmeter 測試
再次測試,檢視結果
可見返回的結果異常率如此高,再看請求資訊
可見已經成功的攔截了錯誤請求,JSON斷言正確。
檢視資料庫 資訊
優惠卷數量
可見成功的完成了 在高並行請求下 的一人一單功能。
以上就是【Bug 終結者】對 微服務Spring Boot 整合Redis 實現優惠卷秒殺 一人一單 的簡單介紹,在分散式系統下,高並行的場景下,會出現此類庫存超賣問題,本篇文章介紹了採用樂觀鎖來解決,但是依然是有弊端,下章節,我們將繼續進行優化,持續關注!
到此這篇關於Spring Boot 整合Redis 實現優惠卷秒殺 一人一單的文章就介紹到這了,更多相關Spring Boot 整合Redis 優惠卷秒殺內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!
相關文章
<em>Mac</em>Book项目 2009年学校开始实施<em>Mac</em>Book项目,所有师生配备一本<em>Mac</em>Book,并同步更新了校园无线网络。学校每周进行电脑技术更新,每月发送技术支持资料,极大改变了教学及学习方式。因此2011
2021-06-01 09:32:01
综合看Anker超能充系列的性价比很高,并且与不仅和iPhone12/苹果<em>Mac</em>Book很配,而且适合多设备充电需求的日常使用或差旅场景,不管是安卓还是Switch同样也能用得上它,希望这次分享能给准备购入充电器的小伙伴们有所
2021-06-01 09:31:42
除了L4WUDU与吴亦凡已经多次共事,成为了明面上的厂牌成员,吴亦凡还曾带领20XXCLUB全队参加2020年的一场音乐节,这也是20XXCLUB首次全员合照,王嗣尧Turbo、陈彦希Regi、<em>Mac</em> Ova Seas、林渝植等人全部出场。然而让
2021-06-01 09:31:34
目前应用IPFS的机构:1 谷歌<em>浏览器</em>支持IPFS分布式协议 2 万维网 (历史档案博物馆)数据库 3 火狐<em>浏览器</em>支持 IPFS分布式协议 4 EOS 等数字货币数据存储 5 美国国会图书馆,历史资料永久保存在 IPFS 6 加
2021-06-01 09:31:24
开拓者的车机是兼容苹果和<em>安卓</em>,虽然我不怎么用,但确实兼顾了我家人的很多需求:副驾的门板还配有解锁开关,有的时候老婆开车,下车的时候偶尔会忘记解锁,我在副驾驶可以自己开门:第二排设计很好,不仅配置了一个很大的
2021-06-01 09:30:48
不仅是<em>安卓</em>手机,苹果手机的降价力度也是前所未有了,iPhone12也“跳水价”了,发布价是6799元,如今已经跌至5308元,降价幅度超过1400元,最新定价确认了。iPhone12是苹果首款5G手机,同时也是全球首款5nm芯片的智能机,它
2021-06-01 09:30:45