Redis解決優惠券秒殺應用案例

雖然本文是針對黑馬點評的優惠券秒殺業務的實現,但是是適用於各種搶購活動,保證線程安全。

摘要:本文先講瞭搶購問題,指出其中會出現的多線程問題,提出解決方案采用悲觀鎖和樂觀鎖兩種方式進行實現,然後發現在搶購過程中容易出現一人多單現象,為保證優惠券不會被【黃牛】搶到,因此我們在保證多線程安全的情況下實現瞭一人一單業務,最後指出本文的實現在集群情況下的不足之處。在本專欄的另一篇文章中提出集群或者分佈式系統的解決方案

【前端頁面】

 在代金券發放後,多個用戶會進行優惠券搶購,在搶購時需要判斷兩點:

下單時需要判斷兩點:

  • 秒殺是否開始或結束,如果尚未開始或已經結束則無法下單 
  • 庫存是否充足,不足則無法下單

下單核心邏輯分析:

當用戶開始進行下單,我們應當去查詢優惠卷信息,查詢到優惠卷信息,判斷是否滿足秒殺條件

比如時間是否充足,如果時間充足,則進一步判斷庫存是否足夠,如果兩者都滿足,則扣減庫存,創建訂單,然後返回訂單id,如果有一個條件不滿足則直接結束。

【邏輯圖】

 【代碼實現】

@Override
public Result seckillVoucher(Long voucherId) {
    // 1.查詢優惠券
    SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
    // 2.判斷秒殺是否開始
    if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
        // 尚未開始
        return Result.fail("秒殺尚未開始!");
    }
    // 3.判斷秒殺是否已經結束
    if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
        // 尚未開始
        return Result.fail("秒殺已經結束!");
    }
    // 4.判斷庫存是否充足#######
    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("庫存不足!");
    }
    //6.創建訂單
    VoucherOrder voucherOrder = new VoucherOrder();
    // 6.1.訂單id
    long orderId = redisIdWorker.nextId("order");
    voucherOrder.setId(orderId);
    // 6.2.用戶id
    Long userId = UserHolder.getUser().getId();
    voucherOrder.setUserId(userId);
    // 6.3.代金券id
    voucherOrder.setVoucherId(voucherId);
    save(voucherOrder);
 
    return Result.ok(orderId);
 
}

【分析代碼】

  • 從上述的邏輯圖中我們可以知道,要扣減庫存,並且要保存訂單,因此需要事務業務
  • 在第4步判斷庫存是否充足處,會出現多線程問題。出現訂單超賣現象

問題代碼如下:

 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("庫存不足!");
    }

 【采用鎖】解決上述超賣問題。

悲觀鎖:

悲觀鎖可以實現對於數據的串行化執行,比如syn,和lock都是悲觀鎖的代表,同時,悲觀鎖中又可以再細分為公平鎖,非公平鎖,可重入鎖,等等

樂觀鎖:

樂觀鎖:會有一個版本號,每次操作數據會對版本號+1,再提交回數據時,會去校驗是否比之前的版本大1 ,如果大1 ,則進行操作成功,這套機制的核心邏輯在於,如果在操作過程中,版本號隻比原來大1 ,那麼就意味著操作過程中沒有人對他進行過修改,他的操作就是安全的,如果不大1,則數據被修改過,當然樂觀鎖還有一些變種的處理方式比如cas

樂觀鎖的典型代表:就是cas,利用cas進行無鎖化機制加鎖,var5 是操作前讀取的內存值,while中的var1+var2 是預估值,如果預估值 == 內存值,則代表中間沒有被人修改過,此時就將新值去替換 內存值

其中do while 是為瞭在操作失敗時,再次進行自旋操作,即把之前的邏輯再操作一次。

修改代碼方案

我們的樂觀鎖保證stock大於0 即可,如果查詢邏輯stock不能保證大於0,則會出現 success為false我們在後文進行判斷即可。

boolean success = seckillVoucherService.update()
            .setSql("stock= stock -1")
            .eq("voucher_id", voucherId).update().gt("stock",0); //where id = ? and stock > 0
   if (!success) {
        //扣減庫存
        return Result.fail("庫存不足!");
    }

代碼寫到這裡,我們就解決瞭多線程安全問題(優惠券超賣)

一人一單

但是我們在檢查數據庫數據時,我們發現一個人可以購買多個優惠券。

因此我們可以在搶購前,判斷該用戶是否已經購買過該優惠券,如果購買過則直接返回。

【邏輯圖】紅框內的是新增邏輯。

 @Override
public Result seckillVoucher(Long voucherId) {
    // 1.查詢優惠券
    SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
    // 2.判斷秒殺是否開始
    if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
        // 尚未開始
        return Result.fail("秒殺尚未開始!");
    }
    // 3.判斷秒殺是否已經結束
    if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
        // 尚未開始
        return Result.fail("秒殺已經結束!");
    }
    // 4.判斷庫存是否充足
    if (voucher.getStock() < 1) {
        // 庫存不足
        return Result.fail("庫存不足!");
    }
    // 5.一人一單邏輯
    // 5.1.用戶id
    Long userId = UserHolder.getUser().getId();
    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();
    // 7.1.訂單id
    long orderId = redisIdWorker.nextId("order");
    voucherOrder.setId(orderId);
 
    voucherOrder.setUserId(userId);
    // 7.3.代金券id
    voucherOrder.setVoucherId(voucherId);
    save(voucherOrder);
 
    return Result.ok(orderId);
 
}

 【分析代碼】—仍然會出現多線程問題。

        存在問題:現在的問題還是和之前一樣,並發過來,查詢數據庫,都不存在訂單,所以我們還是需要加鎖,但是樂觀鎖比較適合更新數據,而現在是插入數據,所以我們需要使用悲觀鎖操作

【註意事項】

  • 事務應該包含在鎖的內部。
  • 鎖的粒度,鎖的對象應該是用戶級別的,而不是整個搶購優惠券級別的,因此我們不會直接將synchronized加到方法上。
  • 鎖對象的細節處理,使用userId.toString().intern()保證對象唯一。
  • 獲取代理對象調用切入事務
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.RedisWorker;
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;
 
/**
 * <p>
 * 服務實現類
 * </p>
 *
 * @author msf
 * @since 2022-10-29
 */
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
 
    @Resource
    private ISeckillVoucherService seckillVoucherService;
 
    @Resource
    private RedisWorker redisWorker;
 
 
    @Override
 
    public Result seckillVoucher(Long voucherId) {
        // 1. 查詢優惠券信息
        SeckillVoucher voucherOrder = seckillVoucherService.getById(voucherId);
        // 2.判斷秒殺是否開始
        if (voucherOrder.getBeginTime().isAfter(LocalDateTime.now())) {
            return Result.fail("搶購尚未開始");
        }
        if (voucherOrder.getEndTime().isBefore(LocalDateTime.now())) {
            return Result.fail("搶購已經結束");
        }
        // 3.判斷庫存是否充足
        if (voucherOrder.getStock() < 1) {
            return Result.fail("您來晚瞭,票已被搶完");
        }
        Long userId = UserHolder.getUser().getId();
        // 事務應該在synchronized裡面
        synchronized (userId.toString().intern()) {
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId,userId);
        }
    }
 
 
    @Transactional
    public Result createVoucherOrder(Long voucherId,Long userId) {
            // 4. 一人一單邏輯
            // 4.1 根據優惠券id和用戶id查詢訂單
            Integer count = query().eq("user_id", userId)
                    .eq("voucher_id", voucherId).count();
            // 4.2 訂單存在,直接返回
            if (count > 0) {
                return Result.fail("用戶已經購買一次");
            }
 
            // 5. 扣減庫存
            boolean success = seckillVoucherService.update()
                    .setSql("stock = stock - 1")
                    .gt("stock", 0)
                    .eq("voucher_id", voucherId).update();
            if (!success) {
                return Result.fail("庫存不足");
            }
 
            // 6.創建訂單
            VoucherOrder order = new VoucherOrder();
            // 6.1 設置id
            order.setId(redisWorker.nextId("order"));
            // 6.2 設置訂單id
            order.setVoucherId(voucherId);
            // 6.3 設置用戶id
            order.setUserId(userId);
            save(order);
 
            // 7. 返回訂單id
            return Result.ok(order);
 
    }
}

展望

雖然我們利用鎖和事務解決單體系統下的秒殺功能,但是現在的業務一般是在集群和分佈式系統協作完成,因此我們在測試系統在集群部署時,仍會出現一人多單問題,稍後我們將更新文章,分析問題出現原因,並利用分佈式鎖的方式解決該問題。

到此這篇關於Redis解決優惠券秒殺的文章就介紹到這瞭,更多相關Redis優惠券秒殺內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: