day05-優惠券秒殺01

2023-04-25 06:01:41

功能03-優惠券秒殺01

4.功能03-優惠券秒殺

4.1全域性唯一ID

4.1.1全域性ID生成器

每個店鋪都可以釋出優惠券:

image-20230423154152138

當用戶搶購時,就會生成訂單,並儲存到tb_voucher_order這張表中。訂單表如果使用資料庫的自增id就存在一些問題:

  1. id的規律性太明顯:使用者可以根據id猜測一些資訊,從而非法得到資料
  2. 受單表資料量的限制:由於單張表的資料限制,需要進行分表,而如果每張表都採取自增長,容易出現id重複,會影響訂單之後的業務,比如說售後服務(因為售後服務一般是根據訂單id來進行的)

解決方案:使用全域性ID生成器。

(1)全域性ID生成器是一種在分散式系統下用來生成全域性唯一ID的工具(也稱為分散式唯一ID),一般要滿足下列特性:

  • 唯一性

  • 高可用

  • 高效能

  • 遞增性

  • 安全性

(2)全域性唯一ID生成策略:

  • UUID
  • Redis自增
  • snowflake演演算法
  • 資料庫自增

(3)我們這裡使用redis作為全域性唯一生成器的實現方案,原因如下:

  1. redis是獨立於資料庫之外的,它只有一個,當所有人都來存取redis時,它的自增一定是唯一的(唯一性)

  2. 使用redis的叢集、主從方案、哨兵功能,可以維持它的高可用性(高可用)

  3. redis具有高效能(高效能)

  4. 可以使用redis的String型別,具有自增性(如:incr命令)(自增性)

    Redis Incr 命令將 key 中儲存的數位值增一

    如果 key 不存在,那麼 key 的值會先被初始化為 0 ,然後再執行 INCR 操作

  5. 為了增加id的安全性,我們不會直接使用自增redis自增的id,而是拼接一些其他資訊:(安全性)

    ID構造:時間戳+計數器(使用long型別,共八位元組,64bit)

    • 符號位:1bit,永遠為0

    • 時間戳:31bit,以秒為單位,可以使用約69年

    • 序列號:32bit,秒內的計數器,這樣可以支援每秒產生2^32個不同的ID

      image-20230423175259846

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就不再阻塞,統計出來的時間也就是所有分執行緒執行完後的時間。

測試結果:

image-20230423191756438

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

image-20230423195906103

4.2.1總結

全域性唯一ID生成策略:

  • UUID
  • Redis自增
  • snowflake演演算法
  • 資料庫自增(使用一張表來單獨記錄id)

Redis自增ID策略:

  • 每天一個key,方便統計訂單量
  • ID結構:時間戳+計數器

4.2實現優惠券秒殺下單

4.2.1需求分析&業務流程

每個店鋪都可以釋出優惠券,分為平價券和特價券。平價券可以任意購買,而特價券需要秒殺搶購:

image-20230423201358147

這兩張券對應的資料庫表結構如下:

  • tb_voucher:(優惠券表)優惠券的基本資訊、優惠金額、使用規則等(包括平價券和秒殺券)

    image-20230424161312210
  • tb_seckill_voucher:(秒殺優惠券表)優惠券的庫存、開始搶購時間、結束搶購時間。秒殺優惠券才需要填寫這些資訊。

    image-20230424161158004

要求在店鋪詳情中實現下單購買秒殺券:

下單時需要判斷兩點:

  1. 秒殺是否開始或者結束,如果尚未開始或者已經結束則無法下單
  2. 秒殺券的庫存是否充足,不足則無法下單
image-20230424152833646

優惠券訂單表結構:

image-20230424161458352

業務流程分析:

image-20230424153759552

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 = "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)測試,在前端頁面點選購買,顯示搶購成功,訂單號如下:

image-20230424163828781

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

image-20230424164101927

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

image-20230424164300622

4.3超賣問題

4.3.1問題分析

4.2的程式碼並沒有考慮到並行的問題:當有多個使用者同時對一個秒殺券進行搶購,並行會讓系統出現超賣問題:即賣出的秒殺券數量>實際的秒殺券庫存

我們使用jemeter測試:

image-20230424165758548 image-20230424165828025 image-20230424170309980

執行上述設定,測試結果如下:

  1. 秒殺券表中,id=2的秒殺券庫存出現了負數:

    image-20230424170139020
  2. 訂單表中,對應的數量為104單,但是對應的秒殺券的庫存最多隻有100張。也就是說:出現了超賣問題

    image-20230424170544754

出現超賣問題的原因:

4.2的程式碼只是簡單地進行庫存判斷,並沒有考慮到執行緒並行。當有多個執行緒同時去判斷庫存時,如果當前庫存大於0,則這些執行緒都會去進行庫存扣減,從而發生並行安全問題:

image-20230424171155754 image-20230424171918982

4.3.2解決方案

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

image-20230424172233037

這裡使用樂觀鎖方案。樂觀鎖的關鍵是判斷之前查詢到的資料是否有被修改過:

常見的方式有兩種:

(1)版本號法:

表中設定一個版本號欄位,執行緒在修改表之前,先查詢一次版本號。對資料庫表操作時,再查詢一次版本號,如果值和之前的一致,說明此時表的資料在兩次查詢之間沒有被修改過,我們就可以進行業務操作,並設定新的版本號。

update語句會對當前修改的行進行鎖定操作(資料庫有行級鎖,不用擔心一行記錄被同時修改)。

因此,進行表修改時,由於資料庫行鎖,其他執行緒會等待資料修改後再更新庫存

sql執行是交給資料庫的,如果開啟了事務的話,就是兩個事務的並行問題,此時將會啟動兩階段封鎖協定,保證事務並行安全

image-20230424180914306

image-20230424181018721 image-20230424181058145

(2)CAS法:

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

image-20230424183341487

image-20230424183557589 image-20230424183641411

CAS思想:Compare-And-Swap

CAS 有三個運算元:記憶體值 V、預期值 A、要修改的值 B。CAS 最核心的思路就是,僅當預期值 A 和當前的記憶體值 V 相同時,才將記憶體值修改為 B。

ABA問題

為了簡便,這裡使用方案2,但實際的業務還是建議使用版本法來避免其他問題。

4.3.3程式碼實現

(1)修改VoucherOrderServiceImpl,新增如下程式碼:

image-20230424191811284

(2)測試:

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

image-20230424192136997

還原tb_seckill_voucher表的測試資料:

image-20230424192255563

然後使用jemeter進行測試:

image-20230424204420148 image-20230424204442421

測試結果:

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

image-20230424204357434

原因分析:這是因為,當有一個執行緒去修改資料時,其他很多的執行緒也來同時請求,它們都根據第一次查詢的stock值去判斷,發現stock值變化了,因此當第一個執行緒修改資料後,都沒有去對資料進行操作),導致發生了庫存充足,仍然搶不到券的情況(搶券失敗率偏高)。

(3)改進:修改VoucherOrderServiceImpl,修改如下劃線處:

分析:執行緒A獲取stock值,通過業務判斷,然後去對庫存值進行update操作;因為update語句會對當前修改的行進行鎖定操作,因此,進行表修改時,由於資料庫行鎖,其他執行緒會等待資料修改後再更新庫存。當等待後獲取鎖,將where stock > 0作為update條件,這時,只要stock不小於0就仍可以售券。

update where 是先走where去拿鎖,拿不到就阻塞,等拿到鎖了再去執行update

image-20230424205453393

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

image-20230424212235502

4.3.4總結

超賣這樣的執行緒安全問題,解決方案有哪些?

  1. 悲觀鎖:新增同步鎖,讓執行緒序列執行
    • 優點:簡答粗暴
    • 缺點:效能一般
  2. 樂觀鎖:不加鎖,在更新時判斷是否有其他執行緒在修改
    • 優點:效能好
    • 缺點:成功率低

4.4一人一單

4.5分散式鎖

4.6Redis優化秒殺

4.7Redis訊息佇列實現非同步秒殺