功能03-優惠券秒殺02 4.功能03-優惠券秒殺 4.4一人一單 4.4.1需求分析 要求:修改秒殺業務,要求同一個優惠券,一個用戶只能下一單。 在之前的做法中,加入一個對用戶id和優惠券id的判斷,如果在優惠券下單表中已經存在,則表示該用戶對於這張優惠券已經下過單了,不允許重覆購買 4.4.2代 ...
功能03-優惠券秒殺02
4.功能03-優惠券秒殺
4.4一人一單
4.4.1需求分析
要求:修改秒殺業務,要求同一個優惠券,一個用戶只能下一單。
在之前的做法中,加入一個對用戶id和優惠券id的判斷,如果在優惠券下單表中已經存在,則表示該用戶對於這張優惠券已經下過單了,不允許重覆購買
4.4.2代碼實現
(1)修改VoucherOrderServiceImpl的seckillVoucher方法,在扣減庫存之前,加入如下邏輯:
//一人一單
Long userId = UserHolder.getUser().getId();
//查詢訂單
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) {//說明已經該用戶已經對該優惠券下過單了
return Result.fail("用戶已經購買過一次!");
}
(2)使用jemeter進行測試:由同一個用戶發起200個併發線程,進行下單請求
測試結果:查看資料庫發現,秒殺券原本有100張,現在只剩下94張,也就是說一個用戶搶購了多張同樣的券
(3)原因分析:
因為是多線程併發操作,假設當前資料庫中沒有某個用戶的對應券的訂單,這時,有100個線程來執行(1)代碼的邏輯,大家都來查詢訂單,都發現該用戶沒有下過訂單,因此都進行之後的下單操作,於是一個用戶就連續插入了多條訂單記錄。根本原因還是線程併發的安全問題。
(4)解決方案:使用悲觀鎖。
修改VoucherOrderServiceImpl:
我們將查詢用戶是否購買過某個優惠券的功能,以及扣減庫存、下單功能抽取到一個方法createVoucherOrder()中,在seckillVoucher方法中,通過synchronized鎖定對象(用戶id),這樣同一個用戶發起多個線程時,多個線程同時只能有一個線程進入到createVoucherOrder()中(不同用戶的不同線程不受影響),然後去判斷是否符合業務,從而實現一人一單的問題。
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.aop.framework.AopContext;
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
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("秒殺券庫存不足!");
}
Long userId = UserHolder.getUser().getId();
//即使是同一個userId,在不同線程中調用toString得到的是不同的字元串對象,synchronized無法鎖定
//因此這裡還要使用intern()方法:
//調用intern()時,如果常量池中已經包含一個等於這個String對象(由equals(Object)方法確定)的字元串,
//則返回池中的字元串。否則將此String對象添加到常量池中並返回該String對象的引用
//先獲取鎖,然後提交createVoucherOrder()的事務,再釋放鎖,才能確保線程是安全的
synchronized (userId.toString().intern()) {
//spring聲明式事務的原理,通過aop的動態代理實現,獲取到這個動態代理,讓動態代理去調用方法
IVoucherOrderService proxy =(IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
}
@Transactional
public Result createVoucherOrder(Long voucherId) {
//一人一單
Long userId = UserHolder.getUser().getId();
//查詢訂單
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) {//說明已經該用戶已經對該優惠券下過單了
return Result.fail("用戶已經購買過一次!");
}
//庫存充足,則扣減庫存(操作秒殺券表)
boolean success = seckillVoucherService.update()
.setSql("stock = stock -1")//set stock = stock -1
//where voucher_id =? and stock>0
.gt("stock", 0).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);
//將訂單寫入資料庫(操作優惠券訂單表)
save(voucherOrder);
return Result.ok(orderId);
}
}
(5)引入依賴
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
(6)主程式中添加註解@EnableAspectJAutoProxy:
(7)IVoucherOrderService中添加方法聲明:
(8)重新進行(2)的測試。可以看到,同一個用戶對一種優惠券同時發起200個線程請求下單,結果是:成功下單,且只能下單一次
4.5分散式鎖
4.5.1問題提出(集群模式下的線程併發問題)
通過加鎖,可以解決在單機情況下的一人一單安全問題,但是在集群模式下就不行了:
(1)我們將服務啟動兩份,埠分別為8081,8082:
View--Tool Windows--Services
點擊add service,選擇Run Configuration Type,選擇SpringBoot
按如下步驟配置,然後點擊apply
點擊啟動新的項目,形成一個集群:
(2)然後修改nginx的conf目錄下的nginx.conf文件,配置反向代理和負載均衡:
命令行重新載入nginx配置:nginx.exe -s reload
(3)測試集群情況下,4.4實現的“一人一單”功能是否生效:
在VoucherOrderServiceImpl如下位置打上斷點:
以debug方式啟動兩個服務端:
我們用一個用戶發起兩次請求:
測試結果如下:同一個用戶的兩個線程同時進入了對象鎖中,對象鎖失效了!
原來的數據:
現在:
說明在集群模式下出現了線程併發的安全問題。
4.5.2原因分析
在單機伺服器的情況下:
利用互斥鎖解決了一人一單問題,確保了串列執行
在集群伺服器的情況下:
如上圖,在JVM1中,synchronized修飾的是對象(UserId),synchronized依賴於monitor對象—監視器鎖來實現鎖機制。由於userId相同,鎖的監視器對象相同,因此當線程1來獲取鎖的時候,鎖監視器會記錄獲取鎖的對象。當線程2再來獲取鎖的時候,此時鎖監視器發現不是記錄的線程,於是線程2獲取互斥鎖失敗。
但是當我們做集群部署的時候,一個節點意味著一個新的tomcat,同時也意味著一個新的JVM。不同的JVM擁有各自的堆、棧、方法區。
JVM2中,synchronized修飾的是也是對象(UserId),它的鎖監視器和JVM1的不是同一個對象,當線程3來獲取鎖的時候,JVM2的鎖監視器是空的,線程3可以獲取互斥鎖。
綜上,鎖監視器在JVM的內部可以監視到線程,實現互斥。但是,如果有多個JVM,就會有多個鎖監視器,那麼每一個JVM內部都會有一個線程獲取互斥鎖成功。這意味著在集群的情況下,可能出現線程的併發安全問題。
要解決上述問題,我們需要想辦法,讓多個JVM只能使用同一把鎖。
4.5.3解決方案
經過上述分析,我們已經知道在集群模式下,synchronized的鎖失效了,要想解決這個問題,需要使用分散式鎖。
分散式鎖:滿足分散式系統或集群模式下多進程可見並且互斥的鎖。
不同的分散式鎖的實現方案:
分散式鎖的核心是實現多線程之間互斥,滿足這一點的方式有很多,常見的有三種:
這裡利用redis來實現分散式鎖。
4.5.4實現思路(基於Redis的分散式鎖)
實現分散式鎖時需要實現的兩個基本方法:
a. 獲取鎖:
- 互斥,確保只能有一個線程獲取鎖
- 非阻塞式:嘗試一次,成功返回true,失敗返回false
#添加鎖,利用setnx的互斥特性
SETNX lock thread1
#添加鎖過期時間,避免伺服器宕機(非redis服務宕機)引起的死鎖
EXPIRE lock 10
此外,還要保證senx lock value
和expire lock
,兩個操作是原子性的,否則可能會出現添加鎖之候服務宕機的情況,這樣就會出現死鎖。因此,最好使用set命令一次性添加“鎖”和設置過期時間。
操作說明:
127.0.0.1:6379> help SET
SET key value [EX seconds] [PX milliseconds] [NX|XX]
summary: Set the string value of a key
since: 1.0.0
group: string
#獲取鎖的最終方案:添加鎖,NX是互斥,EX是設置超時時間
SET lock thread1 EX 10 NX
b. 釋放鎖:
- 手動釋放
- 超時釋放:獲取鎖時添加一個超時時間
#釋放鎖,刪除即可
DEL key
整個流程:
4.5.5基於Redis實現分散式鎖(初級版本)
(1)定義一個類,實現下麵介面,利用Redis實現分散式鎖功能
package com.hmdp.utils;
/**
* @author 李
* @version 1.0
*/
public interface ILock {
/**
* 嘗試獲取鎖
*
* @param timeoutSec 鎖持有的時間,過期後自動釋放
* @return true代表獲取鎖成功,false代表獲取鎖失敗
*/
public boolean tryLock(long timeoutSec);
/**
* 釋放鎖
*/
public void unLock();
}
(2)創建SimpleRedisLock.java
使用redis的setnx來實現分散式互斥鎖
package com.hmdp.utils;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;
/**
* @author 李
* @version 1.0
*/
public class SimpleRedisLock implements ILock {
private String name;
private StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
private static final String KEY_PREFIX = "lock:";
@Override
public boolean tryLock(long timeoutSec) {
//獲取線程標識
long threadId = Thread.currentThread().getId();
//獲取鎖
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);//防止空指針
}
@Override
public void unLock() {
//釋放鎖
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
(3)修改VoucherOrderServiceImpl的seckillVoucher()方法:
package com.hmdp.service.impl;
import ...
/**
* 服務實現類
*
* @author 李
* @version 1.0
*/
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
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("秒殺券庫存不足!");
}
Long userId = UserHolder.getUser().getId();
//--------------start---------------------
//創建鎖對象
SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
//獲取鎖
boolean isLock = lock.tryLock(1200);
//判斷是否獲取鎖成功
if (!isLock) {//獲取鎖失敗
//直接返回錯誤,不阻塞
return Result.fail("不允許重覆下單!");
}
try {
//獲取代理對象(事務)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
//這裡應該先獲取鎖,然後提交createVoucherOrder()的事務,再釋放鎖,才能確保線程是安全的
return proxy.createVoucherOrder(voucherId);
} finally {
//釋放鎖
lock.unLock();
}
//--------------end---------------------
}
@Transactional
public Result createVoucherOrder(Long voucherId) {
...
}
}
(4)測試:以debug方式啟動兩個服務端:
在如下位置打上斷點:
仍使用postman測試:用一個用戶發起兩次請求
測試結果:在集群模式下,只有一個請求獲取鎖成功了
redis存儲的數據:1025號用戶,線程id為29