文章目錄
- ⛅引言
- 一、秒殺優化 - 異步秒殺思路
- 二、秒殺優化 - 基于Redis完成秒殺資格判斷
- 三、基于阻塞隊列完成異步秒殺下單
- 四、測試程式
- 五、源碼位址
- ⛵小結
⛅引言
本章節,介紹使用阻塞隊列實作秒殺的優化,采用異步秒殺完成下單的優化!
一、秒殺優化 - 異步秒殺思路
當使用者發起請求,此時會請求nginx,nginx會通路到tomcat,而tomcat中的程式,會進行串行操作,分成如下幾個步驟
- 查詢優惠卷
- 判斷秒殺庫存是否足夠
- 查詢訂單
- 校驗是否是一人一單
- 扣減庫存
- 建立訂單,完成
在以上6個步驟中,我們可以采用怎樣的方式來優化呢?
整體思路:當使用者下單之後,判斷庫存是否充足隻需要導redis中去根據key找對應的value是否大于0即可,如果不充足,則直接結束,如果充足,繼續在redis中判斷使用者是否可以下單,如果set集合中沒有這條資料,說明他可以下單,如果set集合中沒有這條記錄,則将userId和優惠卷存入到redis中,并且傳回0,整個過程需要保證是原子性的,我們可以使用Lua來操作
當以上邏輯走完後,我們可以根據傳回的結果來判斷是否是0,如果是0,則可以下單,可以存入 queue 隊列中,然後傳回,前端可以通過傳回的訂單id來判斷是否下單成功。

二、秒殺優化 - 基于Redis完成秒殺資格判斷
需求:
- 新增秒殺優惠卷的同時,需要将優惠卷資訊儲存在redis中
- 基于Lua腳本實作,判斷秒殺庫存、一人一單,決定使用者是否搶購成功
- 如果搶購成功,将優惠卷id和使用者id封裝後存入阻塞隊列
- 開啟線程任務,不斷從阻塞隊列中擷取資訊,實作異步下單功能
新增優惠卷時,将優惠卷資訊存入Redis
VoucherService
@Override
@Transactional
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);
// 儲存秒殺庫至redis seckill:stock
stringRedisTemplate.opsForValue().set(RedisConstants.SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString());
}
新增優惠卷時,可存入redis資訊
編寫 Lua 腳本,實作秒殺資格判斷
seckill Lua 秒殺腳本
-- 1.參數清單
-- 1.1 優惠卷id
local voucherId = ARGV[1]
-- 1.2 使用者id
local userId = ARGV[2]
-- 2. 資料key
-- 2.1 庫存key 拼接 ..
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2 訂單key 拼接 ..
local orderKey = "seckill:order" .. voucherId
-- 3. 腳本業務
-- 3.1 判斷庫存是否充足
if (tonumber(redis.call('get', stockKey)) <= 0) then
-- 3.2 庫存不足
return 1
end
-- 3.2 判斷使用者是否下單 SISMEMBER orderKey userId
if (redis.call('sismember', orderKey, userId) == 1) then
-- 3.3 存在,證明是重複下單
return 2
end
-- 3.4 扣庫存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5 下單 儲存使用者 sadd orderKey userId
redis.call('sadd', orderKey, userId)
return 0
三、基于阻塞隊列完成異步秒殺下單
基于阻塞隊列實作異步秒殺下單
核心思路:将請求存入阻塞隊列中 進行緩存,開啟線程池讀取任務并依次處理。
VoucherOrderService
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
private BlockingQueue<VoucherOrder> orderTasks =new ArrayBlockingQueue<>(1024 * 1024);
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
//項目啟動後執行該方法
@PostConstruct
private void init() {
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
// 用于線程池處理的任務
// 當初始化完畢後 就會去從對列中去拿資訊
private class VoucherOrderHandler implements Runnable {
@Override
public void run() {
while (true){
try {
// 1.擷取隊列中的訂單資訊
VoucherOrder voucherOrder = orderTasks.take();
// 2.建立訂單
handleVoucherOrder(voucherOrder);
} catch (Exception e) {
log.error("處理訂單異常", e);
}
}
}
}
private void handleVoucherOrder(VoucherOrder voucherOrder) {
//1.擷取使用者
Long userId = voucherOrder.getUserId();
// 2.建立鎖對象
RLock lock = redissonClient.getLock("lock:order:" + userId);
// 3.嘗試擷取鎖
boolean isLock = lock.tryLock();
// 4.判斷是否獲得鎖成功
if (!isLock) {
// 擷取鎖失敗,直接傳回失敗或者重試
log.error("不允許重複下單!");
return;
}
try {
//注意:由于是spring的事務是放在threadLocal中,此時的是多線程,事務會失效
proxy.createVoucherOrder(voucherOrder);
} finally {
// 釋放鎖
lock.unlock();
}
}
// 代理對象
private IVoucherOrderService proxy;
@Override
public Result seckillVoucher(Long voucherId) {
// 擷取使用者
Long userId = UserHolder.getUser().getId();
// 擷取訂單id
long orderId = redisIdWorker.nextId("order");
// 1. 執行lua 腳本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(),
userId.toString(), String.valueOf(orderId)
);
int r = result.intValue();
// 2. 判斷結果是否為0
if (r != 0) {
// 2.1 不為0 代表沒有購買資格
return Result.fail(r == 1 ? "庫存不足" : "不允許重複下單");
}
// 2.2 為0,有購買資格 把下單資訊儲存到阻塞隊列
// 2.2 有購買的資格,建立訂單放入阻塞隊列中
VoucherOrder voucherOrder = new VoucherOrder();
// 2.3.訂單id
voucherOrder.setId(orderId);
// 2.4.使用者id
voucherOrder.setUserId(userId);
// 2.5.代金券id
voucherOrder.setVoucherId(voucherId);
// 2.6.放入阻塞隊列
orderTasks.add(voucherOrder);
//3.擷取代理對象
proxy = (IVoucherOrderService)AopContext.currentProxy();
// 2.3 傳回訂單id
return Result.ok(orderId);
}
@Transactional
public void createVoucherOrder (VoucherOrder voucherOrder){
// 5.一人一單邏輯
// 5.1.使用者id
Long userId = voucherOrder.getUserId();
// 判斷是否存在
int count = query().eq("user_id", userId)
.eq("voucher_id", voucherOrder.getId()).count();
// 5.2.判斷是否存在
if (count > 0) {
// 使用者已經購買過了
log.error("使用者已經購買過了");
}
//6,扣減庫存
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1") //set stock = stock -1
.eq("voucher_id", voucherOrder.getVoucherId()).gt("stock",0).update(); //where id = ? and stock > 0
// .eq("voucher_id", voucherId).eq("stock",voucher.getStock()).update(); //where id = ? and stock = ?
if (!success) {
//扣減庫存
log.error("庫存不足!");
}
save(voucherOrder);
}
四、測試程式
ApiFox 測試程式
測試成功,檢視Redis
成功添加訂單資訊
庫存資訊
資料庫資訊
Jmeter 進行壓力測試
恢複資料,進行壓力測試
關于測試:新增了1000條使用者資訊,存入資料庫和Redis,token,Jmeter使用Tokens檔案測試1000條并發
相關資料見下文
進行壓測
經過檢測,性能提升了幾十倍!
資料庫
五、源碼位址
源碼位址及 Jmeter測試檔案: 公衆号進行擷取網盤位址,後續我會上傳至百度網盤