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

單機環境下的秒殺問題

全局唯一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

img

類雪花算法開發

我們的生成策略是基於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的自增長的值,安全性提高

img

最後將時間戳和序列號進行拼接即可,位運算。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

秒殺條件分析:

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

業務流程圖:

img

開發流程:

優惠卷訂單服務處理流程

  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,循環一次,查看彙總報告可以看出:

img

預期結果應該爲異常是50%,但是這裏顯示爲0%,查看數據庫可以看出生成訂單200個,庫存爲-100;

img

原因分析:

img

假設線程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

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

img

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

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

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

測試第一種方式:100線程併發;數據庫訂單數爲1,庫存99(預期時庫存0)。

img

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

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

一人一單秒殺併發問題

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

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

相關流程圖如下:

img

在原來的代碼上增加用戶判斷:

// 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次,查看結果樹和彙總報告可以看出;

img

查看數據庫,一個用戶秒殺成功一個訂單,對比異常率,滿足我們的需求。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章