秒殺場景下的業務梳理——Redis分佈式鎖的優化

隨着互聯網的快速發展,商品秒殺的場景我們並不少見;秒殺是一種供不應求的,高併發的場景,它裏面包含了很多技術點,掌握了其中的技術點,雖不一定能讓你面試立馬成功,但那也必是一個閃耀的點!

前言

假設我們現在有一個商城系統,裏面上線了一個商品秒殺的模塊,那麼這個模塊我們要怎麼設計呢?

秒殺模塊又會有哪些不同的需求呢?

全局唯一 ID

商品秒殺本質上其實還是商品購買,所以我們需要準備一張訂單表來記錄對應的秒殺訂單。

這裏就涉及到了一個訂單 id 的問題了,我們是否可以像其他表一樣使用數據庫自身的自增 id 呢?

數據庫自增 id 的缺點

訂單表如果使用數據庫自增 id ,則會存在一些問題:

  1. id 的規律太明顯了 因爲我們的訂單 id 是需要回顯給用戶查看的,如果是 id 規律太明顯的話,會暴露一些信息,比如第一天下單的 id = 10 , 第二天下單的 id = 11,這就說明這兩單之間根本沒有其他用戶下單
  2. 受單表數據量的限制 在高併發場景下,產生上百萬個訂單都是有可能的,而我們都知道 MySQL 的單張表根本不可能容納這麼多數據(性能等原因的限制);如果是將單表拆成多表,還是用數據庫自增 id 的話,就存在了訂單 id 重複的情況了,很顯然這是業務不允許的。

基於以上兩個問題,我們可以知道訂單表的 id 需要是一個全局唯一的 ID,而且還不能存在明顯的規律。

全局 ID 生成器

全局ID生成器,是一種在分佈式系統下用來生成全局唯一ID的工具,一般要滿足下列特性:

這裏我們思考一下是否可以用 Redis 中的自增計數來作爲全局 id 生成器呢?

能不能主要是看它是否滿足上述 5 個條件:

  1. 唯一性,每個訂單都是來 Redis 這裏生成訂單 id 的,所以唯一性可以保證
  2. 高可用,Redis 可以由主從、集羣等模式保證可用性
  3. 高性能,Redis 是基於內存的,本來就是以性能自稱的
  4. 遞增性,increment 本來就是遞增的
  5. 安全性。。。這個就麻煩了點了,因爲 Redis 的 increment 也是遞增的,規律太明顯了。。。

綜上,Redis 的 increment 並不能滿足安全性,所以我們不能單純使用它來做全局 id 生成器。

但是——

我們可以使用它,再和其他東西拼接起來~

舉個栗子:

ID的組成部分:

  1. 符號位:1bit,永遠爲0
  2. 時間戳:31bit,以秒爲單位,可以使用69年
  3. 序列號:32bit,秒內的計數器,支持每秒產生2^32個不同ID

上面的時間戳就是用來增加複雜性的

下面給出代碼樣例:

public class RedisIdWorker {
    /**
     * 開始時間戳
     */
    private static final long BEGIN_TIMESTAMP = 1640995200L;
    /**
     * 序列號的位數
     */
    private static final int COUNT_BITS = 32;

    private StringRedisTemplate stringRedisTemplate;

    public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    public long nextId(String keyPrefix) {
        // 1.生成時間戳
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timestamp = nowSecond - BEGIN_TIMESTAMP;

        // 2.生成序列號
        // 2.1.獲取當前日期,精確到天
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        // 2.2.自增長
        // 每天一個key
        long count = stringRedisTemplate.opsForValue()
                                        .increment("icr:" + keyPrefix + ":" + date);

        // 3.拼接並返回
        return timestamp << COUNT_BITS | count;
    }
}

Redis自增ID策略:

  1. 每天一個key,方便統計訂單量
  2. ID構造是 時間戳 + 計數器

擴展

全局唯一ID生成策略:

  1. UUID
  2. Redis自增(需要額外拼接)
  3. snowflake算法
  4. 數據庫自增

超賣問題的產生

解決方案

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

鎖有兩種:

一,悲觀鎖: 認爲線程安全問題一定會發生,因此在操作數據之前先獲取鎖,確保線程串行執行。例如Synchronized、Lock都屬於悲觀鎖;

二,樂觀鎖: 認爲線程安全問題不一定會發生,因此不加鎖,只是在更新數據時去判斷有沒有其它線程對數據做了修改。

如果沒有修改則認爲是安全的,自己才更新數據。 如果已經被其它線程修改說明發生了安全問題,此時可以重試或異常。

樂觀鎖的兩種實現

下面介紹樂觀鎖的兩種實現:

第一種,添加版本號:

每扣減一次就更改一下版本號,每次進行扣減之前需要查詢一下版本號,只有在扣減時的版本號和之前的版本號相同時,才進行扣減。

第二種,CAS法

因爲每扣減一次,庫存量都會發生改變的,所以我們完全可以用庫存量來做標誌,標誌當前庫存量是否被其他線程更改過(在這種情況下,庫存量的功能和版本號類似)

下面給出 CAS 法扣除庫存時,針對超賣問題的解決方案:

   // 扣減庫存
   boolean success = seckillVoucherService.update()
                    .setSql("stock = stock - 1") // set stock = stock - 1
                    .eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
                    .update();

請注意上述的 CAS 判斷有所優化了的,並不是判斷剛查詢的庫存和扣除時的庫存是否相等,而是判斷當前庫存是否大於 0。

因爲 判斷剛查詢的庫存和扣除時的庫存是否相等會出現問題:假如多個線程都判斷到不相等了,那它們都停止了扣減,這時候就會出現沒辦法買完了。

判斷當前庫存是否大於 0,則可以很好地解決上述問題!

一人一單的需求

一般來說秒殺的商品都是優惠力度很大的,所以可能存在一種需求——平臺只允許一個用戶購買一個商品。

對於秒殺場景下的這種需求,我們應該怎麼去設計呢?

很明顯,我們需要在執行扣除庫存的操作之前,先去查查數據庫是否已經有了該用戶的訂單了;如果有了,說明該用戶已經下單過了,不能再購買;如果沒有,則執行扣除操作並生成訂單。

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

// 扣減庫存
boolean success = seckillVoucherService.update()
        .setSql("stock = stock - 1") // set stock = stock - 1
        .eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
        .update();

併發安全問題

因爲上述的實現是分成兩步的:

  1. 判斷當前用戶在數據庫中並沒有訂單
  2. 執行扣除操作,並生成訂單

也正因爲是分成了兩步,所以才引發了線程安全問題: 可以是同一個用戶的多個請求線程都同時判斷沒有訂單,後續則大家都執行了扣除操作。

要解決這個問題,也很簡單,只要讓這兩步串行執行即可,也就是加鎖!

在方法頭上加 synchronized

很顯然這種會鎖住整個方法,鎖的範圍太大了,而且會對所有請求線程作出限制;而我們的需求只是同一個用戶的請求線程串行就可以了;顯然有些大材小用了~

@Transactional
public synchronized Result createVoucherOrder(Long voucherId) {
    // 一人一單
    Long userId = UserHolder.getUser().getId
     // 查詢訂單
     int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
     // 判斷是否存在
     if (count > 0) {
         // 用戶已經購買過了
         return Result.fail("用戶已經購買過一次!");

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

     // 創建訂單
     VoucherOrder voucherOrder = new VoucherOrder();
     .....
     return Result.ok(orderId);
}

鎖住同一用戶 id 的 String 對象

@Transactional
public Result createVoucherOrder(Long voucherId) {
    // 一人一單
    Long userId = UserHolder.getUser().getId

    // 鎖住同一用戶 id 的 String 對象
    synchronized (userId.toString().intern()) {
        // 查詢訂單
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        // 判斷是否存在
        ......

        // 扣減庫存
        ......

        // 創建訂單
        ......
     }
     return Result.ok(orderId);
}

上述方法開啓了事務,但是synchronized (userId.toString().intern())鎖住的卻不是整個方法(先釋放鎖,再提交事務,寫入訂單),那就存在一個問題——假如一個線程的事務還沒提交(也就是還沒寫入訂單),這時候其他線程來了卻可以獲得鎖,它判斷數據庫中訂單爲0 ,又可以再次創建訂單。。。。

爲了解決這個問題,我們需要先提交事務,再釋放鎖:

 // 鎖住同一用戶 id 的 String 對象
 synchronized (userId.toString().intern()) {
     ......
    createVoucherOrder(voucherId);
     ......
 }

@Transactional
public Result createVoucherOrder(Long voucherId) {
    // 一人一單
    Long userId = UserHolder.getUser().getId

        // 查詢訂單
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        // 判斷是否存在
        ......

        // 扣減庫存
        ......

        // 創建訂單
        ......

     return Result.ok(orderId);
}

集羣模式下的併發安全問題

剛剛討論的那些都默認是單機結點的,可是現在如果放在了集羣模式下的話就會出現一下問題。

剛剛的加鎖已經解決了單機節點下的線程安全問題,但是卻不能解決集羣下多節點的線程安全問題:

因爲 synchronized 鎖的是對應 JVM 內的鎖監視器,可是不同的結點有不同的 JVM,不同的 JVM 又有不同的鎖監視器,所以剛剛的設計在集羣模式下鎖住的其實還是不同的對象,即無法解決線程安全問題。

知道問題產生的原因,我們應該很快就想到了解決辦法了:

既然是因爲集羣導致了鎖不同,那我們就重新設計一下,讓他們都使用同一把鎖即可!

分佈式鎖

分佈式鎖:滿足分佈式系統或集羣模式下多進程可見並且互斥的鎖。

分佈式鎖的實現

分佈式鎖的核心是實現多進程之間互斥,而滿足這一點的方式有很多,常見的有三種:

MySQL Redis Zookeeper
互斥 利用mysql本身的互斥鎖機制 利用setnx這樣的互斥命令 利用節點的唯一性和有序性實現互斥
高可用
高性能 一般 一般
安全性 斷開連接,自動釋放鎖 利用鎖超時時間,到期釋放 臨時節點,斷開連接自動釋放

基於 Redis 的分佈式鎖

用 Redis 實現分佈式鎖,主要應用到的是 SETNX key value命令(如果不存在,則設置)

主要要實現兩個功能:

  1. 獲取鎖(設置一個 key)
  2. 釋放鎖 (刪除 key)

基本思想是執行了 SETNX命令的線程獲得鎖,在完成操作後,需要刪除 key,釋放鎖。

加鎖:

@Override
public boolean tryLock(long timeoutSec) {
    // 獲取線程標示
    String threadId = ID_PREFIX + Thread.currentThread().getId();
    // 獲取鎖
    Boolean success = stringRedisTemplate.opsForValue()
            .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
    return Boolean.TRUE.equals(success);
}

釋放鎖:

@Override
public void unlock() {
    // 獲取線程標示
    String threadId = ID_PREFIX + Thread.currentThread().getId();
    // 獲取鎖中的標示
    String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
    // 釋放鎖
    stringRedisTemplate.delete(KEY_PREFIX + name);
}

可是這裏會存在一個隱患——假設該線程發生阻塞(或者其他問題),一直不釋放鎖(刪除 key)這可怎麼辦?

爲了解決這個問題,我們需要爲 key 設計一個超時時間,讓它超時失效;但是這個超時時間的長短卻不好確定:

  1. 設置過短,會導致其他線程提前獲得鎖,引發線程安全問題
  2. 設置過長,線程需要額外等待

鎖的誤刪

超時時間是一個非常不好把握的東西,因爲業務線程的阻塞時間是不可預估的,在極端情況下,它總能阻塞到 lock 超時失效,正如上圖中的線程1,鎖超時釋放了,導致線程2也進來了,這時候 lock 是 線程2的鎖了(key 相同,value不同,value一般是線程唯一標識);假設這時候,線程1突然不阻塞了,它要釋放鎖,如果按照剛剛的代碼邏輯的話,它會釋放掉線程2的鎖;線程2的鎖被釋放掉之後,又會導致其他線程進來(線程3),如此往復。。。

爲了解決這個問題,需要在釋放鎖時多加一個判斷,每個線程只釋放自己的鎖,不能釋放別人的鎖!

釋放鎖

@Override
public void unlock() {
    // 獲取線程標示
    String threadId = ID_PREFIX + Thread.currentThread().getId();
    // 獲取鎖中的標示
    String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);

    // 判斷標示是否一致
    if(threadId.equals(id)) {
        // 釋放鎖
        stringRedisTemplate.delete(KEY_PREFIX + name);
    }
}

原子性問題

剛剛我們談論的釋放鎖的邏輯:

  1. 判斷當前鎖是當前線程的鎖
  2. 當前線程釋放鎖

可以看到釋放鎖是分兩步完成的,如果你是對併發比較有感覺的話,應該一下子就知道這裏會存在問題了。

分步執行,併發問題!

假設 線程1 已經判斷當前鎖是它的鎖了,正準備釋放鎖,可偏偏這時候它阻塞了(可能是 FULL GC 引起的),鎖超時失效,線程2來加鎖,這時候鎖是線程2的了;可是如果線程1這時候醒過來,因爲它已經執行了步驟1了的,所以這時候它會直接直接步驟2,釋放鎖(可是此時的鎖不是線程1的了)

其實這就是一個原子性的問題,剛剛釋放鎖的兩步應該是原子的,不可分的!

要使得其滿足原子性,則需要在 Redis 中使用 Lua 腳本了。

引入 Lua 腳本保持原子性

lua 腳本:

-- 比較線程標示與鎖中的標示是否一致
if(redis.call('get', KEYS[1]) ==  ARGV[1]) then
    -- 釋放鎖 del key
    return redis.call('del', KEYS[1])
end
return 0

Java 中調用執行:

public class SimpleRedisLock implements ILock {

    private String name;
    private StringRedisTemplate stringRedisTemplate;

    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    private static final String KEY_PREFIX = "lock:";
    private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        UNLOCK_SCRIPT.setResultType(Long.class);
    }

    @Override
    public boolean tryLock(long timeoutSec) {
        // 獲取線程標示
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        // 獲取鎖
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success);
    }

    @Override
    public void unlock() {
        // 調用lua腳本
        stringRedisTemplate.execute(
                UNLOCK_SCRIPT,
                Collections.singletonList(KEY_PREFIX + name),
                ID_PREFIX + Thread.currentThread().getId());
    }
}

到了目前爲止,我們設計的 Redis 分佈式鎖已經是生產可用的,相對完善的分佈式鎖了。

總結

這一次我們從秒殺場景的業務需求出發,一步步地利用 Redis 設計出一種生產可用的分佈式鎖:

實現思路:

  1. 利用set nx ex獲取鎖,並設置過期時間,保存線程標示
  2. 釋放鎖時先判斷線程標示是否與自己一致,一致則刪除鎖 (Lua 腳本保證原子性)

有哪些特性?

  1. 利用set nx滿足互斥性
  2. 利用set ex保證故障時鎖依然能釋放,避免死鎖,提高安全性
  3. 利用Redis集羣保證高可用和高併發特性

目前還有待完善的點:

  1. 不可重入,同一個線程無法多次獲取同一把鎖
  2. 不可重試,獲取鎖只嘗試一次就返回false,沒有重試機制
  3. 超時釋放,鎖超時釋放雖然可以避免死鎖,但如果是業務執行耗時較長,也會導致鎖釋放,存在安全隱患(雖然已經解決了誤刪問題,但是仍然可能存在未知問題)
  4. 主從一致性,如果Redis提供了主從集羣,主從同步存在延遲,當主宕機時,在主節點中的鎖數據並沒有及時同步到從節點中,則會導致其他線程也能獲得鎖,引發線程安全問題(延遲時間是在毫秒以下的,所以這種情況概率極低)
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章