秒殺場景下的業務梳理——Redis分散式鎖的優化
theme: channing-cyan highlight: vs
秒殺場景下的業務梳理——Redis分散式鎖的優化
隨著網際網路的快速發展,商品秒殺的場景我們並不少見;秒殺是一種供不應求的,高併發的場景,它裡面包含了很多技術點,掌握了其中的技術點,雖不一定能讓你面試立馬成功,但那也必是一個閃耀的點!
前言
假設我們現在有一個商城系統,裡面上線了一個商品秒殺的模組,那麼這個模組我們要怎麼設計呢?
秒殺模組又會有哪些不同的需求呢?
全域性唯一 ID
商品秒殺本質上其實還是商品購買,所以我們需要準備一張訂單表來記錄對應的秒殺訂單。
這裡就涉及到了一個訂單 id 的問題了,我們是否可以像其他表一樣使用資料庫自身的自增 id 呢?
資料庫自增 id 的缺點
訂單表如果使用資料庫自增 id ,則會存在一些問題:
- id 的規律太明顯了 因為我們的訂單 id 是需要回顯給使用者檢視的,如果是 id 規律太明顯的話,會暴露一些資訊,比如第一天下單的 id = 10 , 第二天下單的 id = 11,這就說明這兩單之間根本沒有其他使用者下單
- 受單表資料量的限制 在高併發場景下,產生上百萬個訂單都是有可能的,而我們都知道 MySQL 的單張表根本不可能容納這麼多資料(效能等原因的限制);如果是將單表拆成多表,還是用資料庫自增 id 的話,就存在了訂單 id 重複的情況了,很顯然這是業務不允許的。
基於以上兩個問題,我們可以知道訂單表的 id 需要是一個全域性唯一的 ID,而且還不能存在明顯的規律。
全域性 ID 生成器
全域性ID生成器,是一種在分散式系統下用來生成全域性唯一ID的工具,一般要滿足下列特性:
這裡我們思考一下是否可以用 Redis 中的自增計數來作為全域性 id 生成器呢?
能不能主要是看它是否滿足上述 5 個條件:
- 唯一性,每個訂單都是來 Redis 這裡生成訂單 id 的,所以唯一性可以保證
- 高可用,Redis 可以由主從、叢集等模式保證可用性
- 高效能,Redis 是基於記憶體的,本來就是以效能自稱的
- 遞增性,increment 本來就是遞增的
- 安全性。。。這個就麻煩了點了,因為 Redis 的 increment 也是遞增的,規律太明顯了。。。
綜上,Redis 的 increment 並不能滿足安全性,所以我們不能單純使用它來做全域性 id 生成器。
但是——
我們可以使用它,再和其他東西拼接起來~
舉個栗子:
ID的組成部分:
- 符號位:1bit,永遠為0
- 時間戳:31bit,以秒為單位,可以使用69年
- 序列號: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策略:
- 每天一個key,方便統計訂單量
- ID構造是 時間戳 + 計數器
擴充套件
全域性唯一ID生成策略:
- UUID
Redis自增
(需要額外拼接)snowflake演算法
- 資料庫自增
超賣問題的產生
解決方案
超賣問題是典型的多執行緒安全問題,針對這一問題的常見解決方案就是加鎖:
鎖有兩種:
一,悲觀鎖: 認為執行緒安全問題一定會發生,因此在操作資料之前先獲取鎖,確保執行緒序列執行。例如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();
併發安全問題
因為上述的實現是分成兩步的:
- 判斷當前使用者在資料庫中並沒有訂單
- 執行扣除操作,並生成訂單
也正因為是分成了兩步,所以才引發了執行緒安全問題: 可以是同一個使用者的多個請求執行緒都同時判斷沒有訂單,後續則大家都執行了扣除操作。
要解決這個問題,也很簡單,只要讓這兩步序列執行即可,也就是加鎖!
在方法頭上加 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
命令(如果不存在,則設定)
主要要實現兩個功能:
- 獲取鎖(設定一個 key)
- 釋放鎖 (刪除 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 設計一個超時時間,讓它超時失效;但是這個超時時間的長短卻不好確定:
- 設定過短,會導致其他執行緒提前獲得鎖,引發執行緒安全問題
- 設定過長,執行緒需要額外等待
鎖的誤刪
超時時間是一個非常不好把握的東西,因為業務執行緒的阻塞時間是不可預估的,在極端情況下,它總能阻塞到 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 已經判斷當前鎖是它的鎖了,正準備釋放鎖,可偏偏這時候它阻塞了(可能是 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 設計出一種生產可用的分散式鎖:
實現思路:
- 利用
set nx ex
獲取鎖,並設定過期時間,儲存執行緒標示- 釋放鎖時先判斷執行緒標示是否與自己一致,一致則刪除鎖 (Lua 指令碼保證原子性)
有哪些特性?
- 利用
set nx
滿足互斥性- 利用
set ex
保證故障時鎖依然能釋放,避免死鎖,提高安全性- 利用
Redis
叢集保證高可用和高併發特性
目前還有待完善的點:
- 不可重入,同一個執行緒無法多次獲取同一把鎖
- 不可重試,獲取鎖只嘗試一次就返回false,沒有重試機制
- 超時釋放,鎖超時釋放雖然可以避免死鎖,但如果是業務執行耗時較長,也會導致鎖釋放,存在安全隱患(雖然已經解決了誤刪問題,但是仍然可能存在未知問題)
- 主從一致性,如果Redis提供了主從叢集,主從同步存在延遲,當主宕機時,在主節點中的鎖資料並沒有及時同步到從節點中,則會導致其他執行緒也能獲得鎖,引發執行緒安全問題(延遲時間是在毫秒以下的,所以這種情況概率極低)