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

語言: CN / TW / HK

theme: channing-cyan highlight: vs


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

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

前言

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

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

全域性唯一 ID

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

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

資料庫自增 id 的缺點

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

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

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

全域性 ID 生成器

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

image.png

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

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

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

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

但是——

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

舉個栗子:

image.png

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. 資料庫自增

超賣問題的產生

動畫.gif

解決方案

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

鎖有兩種:

一,悲觀鎖: 認為執行緒安全問題一定會發生,因此在操作資料之前先獲取鎖,確保執行緒序列執行。例如Synchronized、Lock都屬於悲觀鎖;

二,樂觀鎖: 認為執行緒安全問題不一定會發生,因此不加鎖,只是在更新資料時去判斷有沒有其它執行緒對資料做了修改。

如果沒有修改則認為是安全的,自己才更新資料。 如果已經被其它執行緒修改說明發生了安全問題,此時可以重試或異常。

樂觀鎖的兩種實現

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

第一種,新增版本號:

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

動畫2.gif

第二種,CAS法

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

動畫3.gif

下面給出 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 又有不同的鎖監視器,所以剛剛的設計在叢集模式下鎖住的其實還是不同的物件,即無法解決執行緒安全問題。

image.png

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

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

image.png

分散式鎖

分散式鎖:滿足分散式系統或叢集模式下多程序可見並且互斥的鎖。

image.png

分散式鎖的實現

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

| | 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. 設定過長,執行緒需要額外等待

鎖的誤刪

動畫4.gif

超時時間是一個非常不好把握的東西,因為業務執行緒的阻塞時間是不可預估的,在極端情況下,它總能阻塞到 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. 當前執行緒釋放鎖

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

分步執行,併發問題!

動畫5.gif

假設 執行緒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提供了主從叢集,主從同步存在延遲,當主宕機時,在主節點中的鎖資料並沒有及時同步到從節點中,則會導致其他執行緒也能獲得鎖,引發執行緒安全問題(延遲時間是在毫秒以下的,所以這種情況概率極低)