【Redis場景4】單機環境下秒殺問題

2023-02-08 06:01:02

單機環境下的秒殺問題

全域性唯一ID

為什麼要使用全域性唯一ID:

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

  • 受單表資料量的限制
  • id的規律性太明顯

場景分析一:如果我們的id具有太明顯的規則,使用者或者說商業對手很容易猜測出來我們的一些敏感資訊,比如商城在一天時間內,賣出了多少單,這明顯不合適。

場景分析二:隨著我們商城規模越來越大,mysql的單表的容量不宜超過500W,資料量過大之後,我們要進行拆庫拆表,但拆分表了之後,他們從邏輯上講他們是同一張表,所以他們的id是不能一樣的, 於是乎我們需要保證id的唯一性。

場景分析三:如果全部使用資料庫自增長ID,那麼多張表都會出現相同的ID,不滿足業務需求。

在分散式系統下全域性唯一ID需要滿足的特點:

  1. 唯一性
  2. 遞增性
  3. 安全性
  4. 高可用(服務穩定)
  5. 高效能(生成速度夠快)

為了提高資料庫效能,這裡採用Java中的數值型別(Long--8(Byte)位元組,64位元),

  • ID的組成部分:符號位:1bit,永遠為0
  • 時間戳:31bit,以秒為單位,可以使用69年
  • 序列號:32bit,秒內的計數器,支援每秒產生2^32個不同ID

類雪花演演算法開發

我們的生成策略是基於redis的自增長,及序列號部分,在實現的時候需要傳入不同的字首(即不同業務不同序列號)

我們開始實現時間戳位數,先設定一個基準值,即某一時間的秒數,使用的時候用當前時間秒數-基準時間=所得秒數即時間戳;

基準值計算:這裡我是用2023/1/1 0:0:0;秒數為:1672531200

public static void main(String[] args) {
    LocalDateTime time = LocalDateTime.of(2023, 1, 1, 0, 0, 0);
    //設定時區
    long l = time.toEpochSecond(ZoneOffset.UTC);
    System.out.println(l);
}

開始生成時間戳:獲得當前時間的秒數-基準值(BEGIN_TIMESTAMP=1672531200)

LocalDateTime dateTime = LocalDateTime.now();
//秒數設定時區
long nowSecond = dateTime.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSecond - BEGIN_TIMESTAMP;

然後生成序列號,採用Redis的自增操作實現。keyPrefix業務Key(傳入的)

long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix);

這一行程式碼的使用問題是,同一個業務使用的同一個key,但是redis的自增上上限為2^64,總有時候會超過32位元,所以最好是讓其同一業務也要有不同的key值,這裡我們可以加上當前時間。

//獲取當日日期,精確到天
String date = dateTime.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
//自增長上限2^64
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);

這樣做的好處是:

  1. 在redis中快取是分層的,方便檢視,也方便統計每天、每月的訂單量或者其他資料等
  2. 不會超過Redis的自增長的值,安全性提高

最後將時間戳和序列號進行拼接即可,位運算。COUNT_BITS=32

timestamp << COUNT_BITS | count;

首先將時間戳左移32位元,低處補零,然後進行或運算(遇1得1),這樣實現整個的全域性唯一ID。

測試

在同一個業務中使用全域性唯一ID生成。

/**
 * 測試全域性唯一ID生成器
 * @throws InterruptedException
 */
@Test
public  void testIdWorker() throws InterruptedException {
    CountDownLatch countDownLatch = new CountDownLatch(300);
    ExecutorService executorService = Executors.newFixedThreadPool(300);
    Runnable task = ()->{
        for (int i = 0; i < 100; i++) {
            long id = redisIdWorker.nextId("order");
            System.out.println("id:"+id);
        }
        //計數-1
        countDownLatch.countDown();
    };
    long begin = System.currentTimeMillis();
    for (int i = 0; i < 300; i++) {
        executorService.submit(task);
    }
    //等待子執行緒結束
    countDownLatch.await();
    long endTime = System.currentTimeMillis();
    System.out.println("time= "+(endTime-begin));
}

time= 2608ms=2.68s,生成數量:30000

取兩個相近的十進位制轉為二進位制對比:

id : 148285184708444304

0010 0000 1110 1101 0000 1001 0111 0000 0000 0000 0000 0000 0000 1001 0000

id : 148285184708444305

0010 0000 1110 1101 0000 1001 0111 0000 0000 0000 0000 0000 0000 1001 0001

短碼生成策略

僅支援很小的呼叫量,用於生成活動設定類編號,保證全域性唯一

import java.util.Calendar;
import java.util.Random;

/**
 * @author xbhog
 * @describe:短碼生成策略,僅支援很小的呼叫量,用於生成活動設定類編號,保證全域性唯一
 * @date 2022/9/18
 */
@Slf4j
@Component
public class ShortCode implements IIdGenerator {
    @Override
    public synchronized long nextId() {
        Calendar calendar = Calendar.getInstance();
        int year = calendar.get(Calendar.YEAR);
        int week = calendar.get(Calendar.WEEK_OF_YEAR);
        int day = calendar.get(Calendar.DAY_OF_WEEK);
        int hour = calendar.get(Calendar.HOUR_OF_DAY);
        log.info("年:{},周:{},日:{},小時:{}",year, week,day,hour);
        //打亂順序:2020年為準 + 小時 + 週期 + 日 + 三位亂數
        StringBuilder idStr = new StringBuilder();
        idStr.append(year-2020);
        idStr.append(hour);
        idStr.append(String.format("%02d",week));
        idStr.append(day);
        idStr.append(String.format("%03d",new Random().nextInt(1000)));
        log.info("檢視拼接之後的值:{}",idStr);
        return Long.parseLong(idStr.toString());
    }

    public static void main(String[] args) {
        long l = new ShortCode().nextId();
        System.out.println(l);
    }
}

紀錄檔記錄:

14:40:22.336 [main] INFO ShortCode - 年:2023,周:5,日:7,小時:14
14:40:22.341 [main] INFO ShortCode - 檢視拼接之後的值:314057012
314057012

秒殺下單功能及並行測試

完整程式碼GitHubhttps://github.com/xbhog/hm-dianping/tree/20230130-xbhog-redisSpike

秒殺條件分析:

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

業務流程圖:

開發流程:

優惠卷訂單服務處理流程

  1. 查詢優惠卷

  2. 判斷使用者是否在秒殺時間段內

  3. 判斷是否庫存充足

    1. 不足:返回異常資訊
    2. 充足:執行步驟4
  4. 建立優惠卷訂單

  5. 落庫

  6. 返回訂單ID

流程比較簡單,這裡需要注意的點是在庫存扣減這部分

@Override
public Result seckillVoucher(Long voucherId) {
    // 1.查詢優惠券
    // 2.判斷秒殺是否開始
    // 3.判斷秒殺是否已經結束
    // 4.判斷庫存是否充足
    if (voucher.getStock() < 1) {
        // 庫存不足
        return Result.fail("庫存不足!");
    }
    //5,扣減庫
	//update tb_seckill_voucher set stock=stock -1  where voucher_id =  #{voucherId}
    boolean success  = seckillVoucherMapper.updateDateByVoucherId(voucherId);
    if (!success) {
        //扣減庫存
        return Result.fail("庫存不足!");
    }
    //6.建立訂單
    // 6.1.全域性唯一ID生成:訂單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);
}

jmeter進行測試:

條件:執行緒200,迴圈一次,檢視彙總報告可以看出:

預期結果應該為異常是50%,但是這裡顯示為0%,檢視資料庫可以看出生成訂單200個,庫存為-100;

原因分析:

假設執行緒1過來查詢庫存,判斷出來庫存大於1,正準備去扣減庫存,但是還沒有來得及去扣減,此時執行緒2過來,執行緒2也去查詢庫存,發現這個數量一定也大於1,那麼這兩個執行緒都會去扣減庫存,最終多個執行緒相當於一起去扣減庫存,由此就會出現庫存的超賣問題

鎖解決超賣問題

完整程式碼GitHubhttps://github.com/xbhog/hm-dianping/tree/20230130-xbhog-redisSpike

解決方式

  1. 悲觀鎖:可以實現對於資料的序列化執行,比如syn,和lock都是悲觀鎖的代表,同時,悲觀鎖中又可以再細分為公平鎖,非公平鎖,可重入鎖,等等
  2. 樂觀鎖:會有一個版本號,每次運算元據會對版本號+1,再提交回資料時,會去校驗是否比之前的版本大1 ,如果大1 ,則進行操作成功,這套機制的核心邏輯在於,如果在操作過程中,版本號只比原來大1 ,那麼就意味著操作過程中沒有人對他進行過修改,他的操作就是安全的,如果不大1,則資料被修改過,當然樂觀鎖還有一些變種的處理方式比如cas

採用樂觀鎖解決超賣問題:

在操作時,對版本號進行+1 操作,然後要求version 如果是1 的情況下,才能操作,那麼第一個執行緒在操作後,資料庫中的version變成了2,但是他自己滿足version=1 ,所以沒有問題,此時執行緒2執行,執行緒2 最後也需要加上條件version =1 ,但是現在由於執行緒1已經操作過了,所以執行緒2,操作時就不滿足version=1 的條件了,所以執行緒2無法執行成功。

修改上述程式碼有兩種修改方式:

  1. 只要我扣減庫存時的庫存和之前我查詢到的庫存是一樣的,就意味著沒有人在中間修改過庫存,那麼此時就是安全的。
  2. 判斷條件為庫存數stock>0即可(解決問題)

測試第一種方式:100執行緒並行;資料庫訂單數為1,庫存99(預期時庫存0)。

通過測試發現會有99%失敗的情況,跟我們預計的0%失敗率來說相差很遠,失敗的原因在於:在使用樂觀鎖過程中假設100個執行緒同時都拿到了100的庫存,然後大家一起去進行扣減,但是100個人中只有1個人能扣減成功,其他的人在處理時,他們在扣減時,庫存已經被修改過了,所以此時其他執行緒都會失敗。

解決方式就是修改庫存數條件為stock>0

一人一單秒殺並行問題

完整程式碼GitHubhttps://github.com/xbhog/hm-dianping/tree/20230130-xbhog-redisSpike

上述秒殺訂單有一個問題,一個使用者可以秒殺多次;優惠卷是為了引流,但是目前的情況是,一個人可以無限制的搶這個優惠卷,所以我們應當增加一層邏輯,讓一個使用者只能下一個單,而不是讓一個使用者下多個單。

相關流程圖如下:

在原來的程式碼上增加使用者判斷:

// 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("使用者已經購買過一次!");
}

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

當前注意點:

  1. 執行緒安全實現
  2. 鎖的範圍(顆粒度)
  3. 事務問題

處理執行緒安全問題,將對資料庫更新和插入的操作單獨作為一個方法進行封裝:

@Transactional
public synchronized Result createVoucherOrder(Long voucherId) {

    Long userId = UserHolder.getUser().getId();
         // 5.1.查詢訂單
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        // 5.2.判斷是否存在
        if (count > 0) {
            // 使用者已經購買過了
            return Result.fail("使用者已經購買過一次!");
        }

        // 6.扣減庫存
        //開始扣減庫存(通過樂觀鎖--->對應資料庫中行鎖實現)
        boolean success  = seckillVoucherMapper.updateDateByVoucherId(voucherId);
        if (!success) {
            // 扣減失敗
            return Result.fail("庫存不足!");
        }

        // 7.建立訂單
        VoucherOrder voucherOrder = new VoucherOrder();
        // 7.1.訂單id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        // 7.2.使用者id
        voucherOrder.setUserId(userId);
        // 7.3.代金券id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);

        // 7.返回訂單id
        return Result.ok(orderId);
}

當前操作雖然可以解決執行緒安全,但是效率太低,每個進來的執行緒都要鎖一下,這裡我們可以嘗試以使用者ID來作為鎖條件,但是使用userId.toString(),是重新new了一個物件,這就造成每個執行緒進來都不一樣,鎖不住。

public static String toString(long i) {
    if (i == Long.MIN_VALUE)
        return "-9223372036854775808";
    int size = (i < 0) ? stringSize(-i) + 1 : stringSize(i);
    char[] buf = new char[size];
    getChars(i, size, buf);
    return new String(buf, true);
}

這裡我們使用userId.toString().intern()從常數池中查詢資料。解決鎖物件不一致的問題。

Long userId = UserHolder.getUser().getId();
synchronized(userId.toString().intern()){
    .......
}
@Transactional
public Result createVoucherOrder(Long voucherId) {
    Long userId = UserHolder.getUser().getId();
	synchronized(userId.toString().intern()){
        log.info("開始進行使用者秒殺活動:{}",userId);
        //一人一單邏輯
        Integer count = voucherOrderService.query().eq("voucher_id", voucherId).eq("user_id", userId).count();
        if(count > 0){
            return Result.fail("該使用者已參加活動。");
        }
        //開始扣減庫存(通過樂觀鎖--->對應資料庫中行鎖實現)
        boolean success  = seckillVoucherMapper.updateDateByVoucherId(voucherId);
        if(!success){
            return Result.fail("庫存不足,正在補充!");
        }
        //建立訂單
        VoucherOrder voucherOrder = new VoucherOrder();
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        voucherOrder.setUserId(userId);
        voucherOrder.setVoucherId(voucherId);
        voucherOrderService.save(voucherOrder);
        return Result.ok(orderId);
    }
//這裡事務還沒有提交事務,但是鎖已經釋放了。
}

但是! 以上程式碼還是存在問題;

問題的原因在於當前方法被spring的事務控制,如果你在方法內部加鎖,可能會導致當前方法事務還沒有提交,但是鎖已經釋放也會導致問題.

解決:把使用者ID放入外部.將當前方法整體包裹起來,確保事務不會出現問題

@Slf4j
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
    @Resource
    private ISeckillVoucherService seckillVoucherService;
    @Resource
    private SeckillVoucherMapper seckillVoucherMapper;
    @Resource
    private IVoucherOrderService voucherOrderService;
    @Resource
    private RedisIdWorker redisIdWorker;


    @Override
    public Result seckillVoucher(Long voucherId) {
        //查詢優惠卷庫存資訊
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        log.info("查詢秒殺優惠卷:{}",voucher);
        //判斷秒殺是否開始:開始時間,結束時間
        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();
    	//這一步有問題
        synchronized (userId.toString().intern()){
            return this.createVoucherOrder(voucherId);
        }
    }
    @Override
    @Transactional
    public Result createVoucherOrder(Long voucherId) {
        Long userId = UserHolder.getUser().getId();
        log.info("開始進行使用者秒殺活動:{}",userId);
        //一人一單邏輯
        Integer count = voucherOrderService.query().eq("voucher_id", voucherId).eq("user_id", userId).count();
        if(count > 0){
            return Result.fail("該使用者已參加活動。");
        }
        //開始扣減庫存(通過樂觀鎖--->對應資料庫中行鎖實現)
        boolean success  = seckillVoucherMapper.updateDateByVoucherId(voucherId);
        if(!success){
            return Result.fail("庫存不足,正在補充!");
        }
        //建立訂單
        VoucherOrder voucherOrder = new VoucherOrder();
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        voucherOrder.setUserId(userId);
        voucherOrder.setVoucherId(voucherId);
        voucherOrderService.save(voucherOrder);
        return Result.ok(orderId);
    }
}

但是但是!還是有問題。

因為我們呼叫的方法,其實是this.的方式呼叫的,事務想要生效,還得利用代理來生效,所以這個地方,我們需要獲得原始的事務物件, 來操作事務。

代理使用需要進行設定和包的引入:

<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
</dependency>

在啟動類中加入:@EnableAspectJAutoProxy(exposeProxy = true);暴露代理物件,不設定無法獲取代理物件;

在呼叫時,通過AopContext來獲取當前代理物件。

synchronized (userId.toString().intern()){
    //獲取原始事務代理物件
    IVoucherOrderService iVoucherOrderService = (IVoucherOrderService) AopContext.currentProxy();
    return iVoucherOrderService.createVoucherOrder(voucherId);
}

Jmeter測試條件:100執行緒,迴圈1次,檢視結果樹和彙總報告可以看出;

檢視資料庫,一個使用者秒殺成功一個訂單,對比異常率,滿足我們的需求。