功能03-優惠券秒殺01
4.功能03-優惠券秒殺
4.1全域唯一ID
4.1.1全域ID生成器
每個店鋪都可以發布優惠券:

當用戶搶購時,就會生成訂單,并保存到tb_voucher_order這張表中,訂單表如果使用資料庫的自增id就存在一些問題:
- id的規律性太明顯:用戶可以根據id猜測一些資訊,從而非法得到資料
- 受單表資料量的限制:由于單張表的資料限制,需要進行分表,而如果每張表都采取自增長,容易出現id重復,會影響訂單之后的業務,比如說售后服務(因為售后服務一般是根據訂單id來進行的)
解決方案:使用全域ID生成器,
(1)全域ID生成器是一種在分布式系統下用來生成全域唯一ID的工具(也稱為分布式唯一ID),一般要滿足下列特性:
-
唯一性
-
高可用
-
高性能
-
遞增性
-
安全性
(2)全域唯一ID生成策略:
- UUID
- Redis自增
- snowflake演算法
- 資料庫自增
(3)我們這里使用redis作為全域唯一生成器的實作方案,原因如下:
-
redis是獨立于資料庫之外的,它只有一個,當所有人都來訪問redis時,它的自增一定是唯一的(唯一性)
-
使用redis的集群、主從方案、哨兵功能,可以維持它的高可用性(高可用)
-
redis具有高性能(高性能)
-
可以使用redis的String型別,具有自增性(如:incr命令)(自增性)
Redis Incr 命令將 key 中儲存的數字值增一
如果 key 不存在,那么 key 的值會先被初始化為 0 ,然后再執行 INCR 操作
-
為了增加id的安全性,我們不會直接使用自增redis自增的id,而是拼接一些其他資訊:(安全性)
ID構造:時間戳+計數器(使用long型別,共八位元組,64bit)
-
符號位:1bit,永遠為0
-
時間戳:31bit,以秒為單位,可以使用約69年
-
序列號:32bit,秒內的計數器,這樣可以支持每秒產生2^32個不同的ID
-
4.2Redis實作全域唯一ID
(1)創建全域ID生成器RedisIdWorker
package com.hmdp.utils;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
/**
* @author 李
* @version 1.0
*/
@Component
public class RedisIdWorker {
//開始時間戳(1970-01-01T00:00:00到2022-01-01T00:00:00的秒數)
private static final long BEGIN_TIMESTAMP = 1640995200L;
//序列號的位數
private static final int COUNT_BITS = 32;
@Resource
private StringRedisTemplate stringRedisTemplate;
//public static void main(String[] args) {
// //開始時間
// LocalDateTime time = LocalDateTime.of(2022, 1, 1, 0, 0, 0);
// //得到1970-01-01T00:00:00Z.到指定時間為止的具體秒數
// long second = time.toEpochSecond(ZoneOffset.UTC);
// System.out.println(second);//1640995200L
//}
public long nextId(String keyPrefix) {
//1.生成時間戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
//開始時間到當前時間的 時間戳
long timeStamp = nowSecond - BEGIN_TIMESTAMP;
//2.生成序列號(keyPrefix代表業務前綴)
/*
* Redis的 Incr命令將 key 中儲存的數字值增1,如果key不存在,那么key的值會先被初始化為0,然后再執行INCR操作,
* 根據這個特性,我們每一天拼接不同的日期,當做key,也就是說同一天下單采用相同的key,不同天下單采用不同的key
* 這種方法不僅可以防止訂單號使用完(redis的的自增最多可以有2^64位,我們采取其中32位作計數器),
* 還可以根據不同的日期,統計該天的訂單數量
*/
//2.1獲取當前的日期(精確到天)
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
//2.2做自增長
Long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
//3.拼接并回傳
//將時間戳左移32位,空出來的右邊32位使用count填充,共64位
return timeStamp << COUNT_BITS | count;
}
}
(2)測驗類(部分代碼)
@Resource
private RedisIdWorker redisIdWorker;
private ExecutorService es = Executors.newFixedThreadPool(500);
@Test
public void testIdWorker() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(300);
//執行緒,生成100個id
Runnable task = () -> {
for (int i = 0; i < 100; i++) {
long id = redisIdWorker.nextId("order");
System.out.println("id" + id);
}
latch.countDown();
};
long start = System.currentTimeMillis();
//共執行300次任務
for (int i = 0; i < 300; i++) {
es.submit(task);
}
//讓所有執行緒執行完才計時
latch.await();
long end = System.currentTimeMillis();
System.out.println("共用時=" + (end - start));
}
關于countdownlatch
countdownlatch名為信號槍:主要的作用是同步協調在多執行緒的等待于喚醒問題,如果沒有CountDownLatch ,由于程式是異步的,當異步程式沒有執行完時,主執行緒可能就已經執行完了,如果期望的是分執行緒全部走完之后,主執行緒再走,此時就需要使用到CountDownLatch,CountDownLatch 中有兩個最重要的方法:1.countDown 2.await
await 方法是阻塞方法,使用await可以讓main執行緒阻塞,當CountDownLatch 內部維護的變數變為0時,就不再阻塞,直接放行,那么什么時候CountDownLatch 維護的變數變為0 呢?我們只需要呼叫一次countDown ,內部變數就減少1,
根據這個性質,讓分執行緒和變數系結, 執行完一個分執行緒就減少一個變數,當分執行緒全部走完,CountDownLatch 維護的變數就是0,此時await就不再阻塞,統計出來的時間也就是所有分執行緒執行完后的時間,
測驗結果:

查看redis中的資料:對應的key的自增值已經變為30000,說明生成了3w個id

4.2.1總結
全域唯一ID生成策略:
- UUID
- Redis自增
- snowflake演算法
- 資料庫自增(使用一張表來單獨記錄id)
Redis自增ID策略:
- 每天一個key,方便統計訂單量
- ID結構:時間戳+計數器
4.2實作優惠券秒殺下單
4.2.1需求分析&業務流程
每個店鋪都可以發布優惠券,分為平價券和特價券,平價券可以任意購買,而特價券需要秒殺搶購:

這兩張券對應的資料庫表結構如下:
-
tb_voucher:(優惠券表)優惠券的基本資訊、優惠金額、使用規則等(包括平價券和秒殺券)
-
tb_seckill_voucher:(秒殺優惠券表)優惠券的庫存、開始搶購時間、結束搶購時間,秒殺優惠券才需要填寫這些資訊,
要求在店鋪詳情中實作下單購買秒殺券:
下單時需要判斷兩點:
- 秒殺是否開始或者結束,如果尚未開始或者已經結束則無法下單
- 秒殺券的庫存是否充足,不足則無法下單

優惠券訂單表結構:

業務流程分析:

4.2.2代碼實作
(1)優惠券訂單物體:VoucherOrder.java
package com.hmdp.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 優惠券訂單物體
*
* @author 李
* @version 1.0
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_voucher_order")
public class VoucherOrder implements Serializable {
private static final long serialVersionUID = 1L;
//主鍵
@TableId(value = "https://www.cnblogs.com/liyuelian/p/id", type = IdType.INPUT)
private Long id;
//下單的用戶id
private Long userId;
//購買的代金券id
private Long voucherId;
//支付方式 1:余額支付;2:支付寶;3:微信
private Integer payType;
//訂單狀態,1:未支付;2:已支付;3:已核銷;4:已取消;5:退款中;6:已退款
private Integer status;
//下單時間
private LocalDateTime createTime;
//支付時間
private LocalDateTime payTime;
//核銷時間
private LocalDateTime useTime;
//退款時間
private LocalDateTime refundTime;
//更新時間
private LocalDateTime updateTime;
}
(2)mapper介面
package com.hmdp.mapper;
import com.hmdp.entity.VoucherOrder;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
/**
* Mapper 介面
*
* @author 李
* @version 1.0
*/
public interface VoucherOrderMapper extends BaseMapper<VoucherOrder> {
}
(3)IVoucherOrderService 服務類
package com.hmdp.service;
import com.hmdp.dto.Result;
import com.hmdp.entity.VoucherOrder;
import com.baomidou.mybatisplus.extension.service.IService;
/**
* 服務類
*
* @author 李
* @version 1.0
*/
public interface IVoucherOrderService extends IService<VoucherOrder> {
Result seckillVoucher(Long voucherId);
}
(4)VoucherOrderServiceImpl 服務實作類
package com.hmdp.service.impl;
import com.hmdp.dto.Result;
import com.hmdp.entity.SeckillVoucher;
import com.hmdp.entity.VoucherOrder;
import com.hmdp.mapper.VoucherOrderMapper;
import com.hmdp.service.ISeckillVoucherService;
import com.hmdp.service.IVoucherOrderService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.RedisIdWorker;
import com.hmdp.utils.UserHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.time.LocalDateTime;
/**
* 服務實作類
*
* @author 李
* @version 1.0
*/
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
//根據id查詢優惠券資訊
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
if (voucher == null) {
return Result.fail("該優惠券不存在,請重繪!");
}
//判斷秒殺券是否在有效時間內
//若不在有效期,則回傳例外結果
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒殺尚未開始!");
}
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒殺已經結束!");
}
//若在有效期,判斷庫存是否充足
if (voucher.getStock() < 1) {//庫存不足
return Result.fail("秒殺券庫存不足!");
}
//庫存充足,則扣減庫存(操作秒殺券表)
boolean success = seckillVoucherService.update().setSql("stock = stock -1").eq("voucher_id", voucherId).update();
if (!success) {//操作失敗
return Result.fail("秒殺券庫存不足!");
}
//扣減庫存成功,則創建訂單,回傳訂單id
VoucherOrder voucherOrder = new VoucherOrder();
//設定訂單id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
//設定用戶id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
//設定代金券id
voucherOrder.setVoucherId(voucherId);
//將訂單寫入資料庫(操作優惠券訂單表)
this.save(voucherOrder);
//回傳訂單id
return Result.ok(orderId);
}
}
(5)控制器 VoucherOrderController
package com.hmdp.controller;
import com.hmdp.dto.Result;
import com.hmdp.service.IVoucherOrderService;
import com.hmdp.service.IVoucherService;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
/**
* 秒殺券前端控制器
*
* @author 李
* @version 1.0
*/
@RestController
@RequestMapping("/voucher-order")
public class VoucherOrderController {
@Resource
private IVoucherOrderService voucherOrderService;
@PostMapping("seckill/{id}")
public Result seckillVoucher(@PathVariable("id") Long voucherId) {
return voucherOrderService.seckillVoucher(voucherId);
}
}
(6)測驗,在前端頁面點擊購買,顯示搶購成功,訂單號如下:

優惠券訂單表tb_voucher_order成功插入一條資料:

對應的秒殺券的庫存減一:

4.3超賣問題
4.3.1問題分析
4.2的代碼并沒有考慮到并發的問題:當有多個用戶同時對一個秒殺券進行搶購,并發會讓系統出現超賣問題:即賣出的秒殺券數量>實際的秒殺券庫存
我們使用jemeter測驗:



運行上述設定,測驗結果如下:
-
秒殺券表中,id=2的秒殺券庫存出現了負數:
-
訂單表中,對應的數量為104單,但是對應的秒殺券的庫存最多只有100張,也就是說:出現了超賣問題
出現超賣問題的原因:
4.2的代碼只是簡單地進行庫存判斷,并沒有考慮到執行緒并發,當有多個執行緒同時去判斷庫存時,如果當前庫存大于0,則這些執行緒都會去進行庫存扣減,從而發生并發安全問題:


4.3.2解決方案
超賣問題是典型的多執行緒安全問題,針對這一問題的常見解決方案就是加鎖:

這里使用樂觀鎖方案,樂觀鎖的關鍵是判斷之前查詢到的資料是否有被修改過:
常見的方式有兩種:
(1)版本號法:
表中設定一個版本號欄位,執行緒在修改表之前,先查詢一次版本號,對資料庫表操作時,再查詢一次版本號,如果值和之前的一致,說明此時表的資料在兩次查詢之間沒有被修改過,我們就可以進行業務操作,并設定新的版本號,
update陳述句會對當前修改的行進行鎖定操作(資料庫有行級鎖,不用擔心一行記錄被同時修改),
因此,進行表修改時,由于資料庫行鎖,其他執行緒會等待資料修改后再更新庫存
sql執行是交給資料庫的,如果開啟了事務的話,就是兩個事務的并發問題,此時將會啟動兩階段封鎖協議,保證事務并發安全

(2)CAS法:
這里為了簡化,使用庫存代替版本號,原理和方案1是一致的:執行緒在修改表之前,先查詢一次庫存的值,對資料庫表操作時,再查詢一次庫存值,如果值和之前的一致,說明此時表的資料在兩次查詢之間沒有被修改過,我們就可以進行業務操作,

CAS思想:Compare-And-Swap
CAS 有三個運算元:記憶體值 V、預期值 A、要修改的值 B,CAS 最核心的思路就是,僅當預期值 A 和當前的記憶體值 V 相同時,才將記憶體值修改為 B,
ABA問題
為了簡便,這里使用方案2,但實際的業務還是建議使用版本法來避免其他問題,
4.3.3代碼實作
(1)修改VoucherOrderServiceImpl,添加如下代碼:

(2)測驗:
清除之前的訂單資訊(tb_voucher_order):

還原tb_seckill_voucher表的測驗資料:

然后使用jemeter進行測驗:


測驗結果:
券沒有超賣,但是出現了新的問題:前幾個請求中就出現了下單失敗的情況,200個執行緒只有100-63=37個執行緒下單成功(理想情況下是100,即秒殺券全部賣出)

原因分析:這是因為,當有一個執行緒去修改資料時,其他很多的執行緒也來同時請求,它們都根據第一次查詢的stock值去判斷,發現stock值變化了,因此當第一個執行緒修改資料后,都沒有去對資料進行操作),導致發生了庫存充足,仍然搶不到券的情況(搶券失敗率偏高),
(3)改進:修改VoucherOrderServiceImpl,修改如下劃線處:
分析:執行緒A獲取stock值,通過業務判斷,然后去對庫存值進行update操作;因為update陳述句會對當前修改的行進行鎖定操作,因此,進行表修改時,由于資料庫行鎖,其他執行緒會等待資料修改后再更新庫存,當等待后獲取鎖,將where stock > 0作為update條件,這時,只要stock不小于0就仍可以售券,
update where 是先走where去拿鎖,拿不到就阻塞,等拿到鎖了再去執行update

再次對其測驗:可以看到200個執行緒并發,100張秒殺券全部售完,并且沒有出現超賣現象,同時解決了庫存充足卻搶不到券的問題,

4.3.4總結
超賣這樣的執行緒安全問題,解決方案有哪些?
- 悲觀鎖:添加同步鎖,讓執行緒串行執行
- 優點:簡答粗暴
- 缺點:性能一般
- 樂觀鎖:不加鎖,在更新時判斷是否有其他執行緒在修改
- 優點:性能好
- 缺點:成功率低
4.4一人一單
4.5分布式鎖
4.6Redis優化秒殺
4.7Redis訊息佇列實作異步秒殺
轉載請註明出處,本文鏈接:https://www.uj5u.com/shujuku/551133.html
標籤:NoSQL
上一篇:SQL優化處理
下一篇:返回列表