業務場景
- 防止用戶重複下單
- MQ消息去重
- 訂單操作變更
- 庫存超賣
分析:
業務場景共性:
共享資源:用戶id、訂單id、商品id。。。
解決方案
- 共享資源互斥
- 共享資源串行化
問題轉化
鎖的問題 (將需求抽象後得到問題的本質)
鎖應用
單應用中使用鎖:(單進程多線程)synchronized、ReentrantLock
分佈式應用中使用鎖:(多進程多線程)分佈式鎖是控制分佈式系統之間同步訪問共享資源的一種方式。
解決分佈式下併發訪問:
try{
if(getLock()){
扣減庫存
}
}finally{
unlock();
}
分佈式鎖特性:
- 互斥性:我持有則別人不能持有
- 同一性:我加鎖,別人不能解鎖
- 可重入性:我加鎖,鎖超時,則鎖失效,別人可以加鎖;我加鎖,還可再次加鎖(多次持有)
Redis實現分佈式鎖
原理:利用Redis的單線程特性對共享資源進行串行化處理
獲取鎖:
方式一
/**
* 使用redis的set命令實現獲取分佈式鎖 推薦使用
* @param lockKey 可以就是鎖
* @param requestId 請求ID,保證同一性 uuid+threadID
* @param expireTime 過期時間,避免死鎖
* @return
*/
public boolean getLock(String lockKey,String requestId,int expireTime) {
//NX:保證互斥性
// hset 原子性操作
String result = jedis.set(lockKey, requestId, "NX", "EX", expireTime);
if("OK".equals(result)) {
return true;
}
return false;
}
方式二 (使用setnx命令實現) -- 併發會產生問題
public boolean getLock(String lockKey,String requestId,int expireTime) {
Long result = jedis.setnx(lockKey, requestId);
if(result == 1) {
//成功設置 失效時間
jedis.expire(lockKey, expireTime);
return true;
}
return false;
}
釋放鎖
方式1(del命令實現) -- 併發
/
**
* 釋放分佈式鎖
* @param lockKey
* @param requestId
*/
public static void releaseLock(String lockKey,String requestId) {
if (requestId.equals(jedis.get(lockKey))) {
jedis.del(lockKey);
}
}
方式2(redis+lua腳本實現)--推薦
public static boolean releaseLock(String lockKey, String requestId) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object result = jedis.eval(script, Collections.singletonList(lockKey),
Collections.singletonList(requestId));
if (result.equals(1L)) {
return true;
}
return false;
}
存在問題
- 單機:無法保證高可用
- 主--從:無法保證數據的強一致性,在主機宕機時會造成鎖的重複獲得
- 無法續租:超過expireTime後,不能繼續使用
本質分析
CAP模型分析
在分佈式環境下不可能滿足三者共存,只能滿足其中的兩者共存,在分佈式下P不能捨棄(捨棄P就是單機了)。所以只能是CP(強一致性模型)和AP(高可用模型)。
分佈式鎖是CP模型,Redis集羣是AP模型。 (base)
Redis集羣不能保證數據的隨時一致性,只能保證數據的最終一致性。
爲什麼還可以用Redis實現分佈式鎖?
與業務有關
當業務不需要數據強一致性時,比如:社交場景,就可以使用Redis實現分佈式鎖
當業務必須要數據的強一致性,即不允許重複獲得鎖,比如金融場景(重複下單,重複轉賬)就不要使用
可以使用CP模型實現,比如:zookeeper和etcd。
分佈式鎖的實現方式
- 基於Redis的set實現分佈式鎖
- 基於 zookeeper 臨時節點的分佈式鎖
- 基於etcd實現
三者的對比,如下表
生產環境中的分佈式鎖
落地生產環境用分佈式鎖,一般採用開源框架,比如Redisson。
POM
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>2.7.0</version>
</dependency
配置Redisson
public class RedissonManager {
private static Config config = new Config();
//聲明redisso對象
private static Redisson redisson = null;
//實例化redisson
static{
config.useClusterServers()
// 集羣狀態掃描間隔時間,單位是毫秒
.setScanInterval(2000)
//cluster方式至少6個節點(3主3從,3主做sharding,3從用來保證主宕機後可以高可用)
.addNodeAddress("redis://127.0.0.1:6379" )
.addNodeAddress("redis://127.0.0.1:6380")
.addNodeAddress("redis://127.0.0.1:6381")
.addNodeAddress("redis://127.0.0.1:6382")
.addNodeAddress("redis://127.0.0.1:6383")
.addNodeAddress("redis://127.0.0.1:6384");
//得到redisson對象
redisson = (Redisson) Redisson.create(config);
}
//獲取redisson對象的方法
public static Redisson getRedisson(){
return redisson;
}
}
鎖的獲取和釋放
public class DistributedRedisLock {
//從配置類中獲取redisson對象
private static Redisson redisson = RedissonManager.getRedisson();
private static final String LOCK_TITLE = "redisLock_";
//加鎖
public static boolean acquire(String lockName){
//聲明key對象
String key = LOCK_TITLE + lockName;
//獲取鎖對象
RLock mylock = redisson.getLock(key);
//加鎖,並且設置鎖過期時間3秒,防止死鎖的產生 uuid+threadId
mylock.lock(2,3,TimeUtil.SECOND);
//加鎖成功
return true;
}
//鎖的釋放
public static void release(String lockName){
//必須是和加鎖時的同一個key
String key = LOCK_TITLE + lockName;
//獲取所對象
RLock mylock = redisson.getLock(key);
//釋放鎖(解鎖)
mylock.unlock();
}
}
業務邏輯中使用分佈式鎖
public String discount(){
String key = "test123";
//加鎖
DistributedRedisLock.acquire(key);
//執行具體業務邏輯
dosoming
//釋放鎖
DistributedRedisLock.release(key);
//返回結果
return soming;
}
Redisson分佈式鎖的實現原理
加鎖機制
如果該客戶端面對的是一個redis cluster集羣,他首先會根據hash節點選擇一臺機器。發送lua腳本到redis服務器上,腳本如下:
"if (redis.call('exists',KEYS[1])==0) then "+
"redis.call('hset',KEYS[1],ARGV[2],1) ; "+
"redis.call('pexpire',KEYS[1],ARGV[1]) ; "+
"return nil; end ;" +
"if (redis.call('hexists',KEYS[1],ARGV[2]) ==1 ) then "+
"redis.call('hincrby',KEYS[1],ARGV[2],1) ; "+
"redis.call('pexpire',KEYS[1],ARGV[1]) ; "+
"return nil; end ;" +
"return redis.call('pttl',KEYS[1]) ;"
lua的作用:保證這段複雜業務邏輯執行的原子性。
lua的解釋:
KEYS[1]) : 加鎖的key
ARGV[1] : key的生存時間,默認爲30秒
ARGV[2] : 加鎖的客戶端ID (UUID.randomUUID()) + “:” + threadId)
第一段if判斷語句,就是用“exists myLock”命令判斷一下,如果你要加鎖的那個鎖key不存在的話,你就進行加鎖。如何加鎖呢?很簡單,用下面的命令:
hset myLock 8743c9c0-0795-4907-87fd-6c719a6b4586:1 1
通過這個命令設置一個hash數據結構,這行命令執行後,會出現一個類似下面的數據結構:
myLock :{"8743c9c0-0795-4907-87fd-6c719a6b4586:1":1 }
上述就代表“8743c9c0-0795-4907-87fd-6c719a6b4586:1”這個客戶端對“myLock”這個鎖key完成了加鎖。
鎖互斥機制
那麼在這個時候,如果客戶端2來嘗試加鎖,執行了同樣的一段lua腳本,會咋樣呢?
很簡單,第一個if判斷會執行“exists myLock”,發現myLock這個鎖key已經存在了。
接着第二個if判斷,判斷一下,myLock鎖key的hash數據結構中,是否包含客戶端2的ID,但是明顯不是的,因爲那裏包含的是客戶端1的ID。所以,客戶端2會獲取到pttl myLock返回的一個數字,這個數字代表了myLock這個鎖key的剩餘生存時間。比如還剩15000毫秒的生存時間。此時客戶端2會進入一個while循環,不停的嘗試加鎖。
自動延時機制
只要客戶端1一旦加鎖成功,就會啓動一個watch dog看門狗,他是一個後臺線程,會每隔10秒檢查一下,如果客戶端1還持有鎖key,那麼就會不斷的延長鎖key的生存時間。
可重入鎖機制
第一個if判斷肯定不成立,“exists myLock”會顯示鎖key已經存在了。
第二個if判斷會成立,因爲myLock的hash數據結構中包含的那個ID,就是客戶端1的那個ID,也就是“8743c9c0-0795-4907-87fd-6c719a6b4586:1”此時就會執行可重入加鎖的邏輯,他會用:incrby myLock 8743c9c0-0795-4907-87fd-6c71a6b4586:1 1 通過這個命令,對客戶端1的加鎖次數,累加1。數據結構會變成:myLock :{"8743c9c0-0795-4907-87fd-6c719a6b4586:1":2 }
釋放鎖機制
執行lua腳本如下:
#如果key已經不存在,說明已經被解鎖,直接發佈(publish)redis消息
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; " +
"end;" +
# key和field不匹配,說明當前客戶端線程沒有持有鎖,不能主動解鎖。
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
# 將value減1
"local counter = redis.call('hincrby', KEYS[1], ARGV[3],-1); " +
# 如果counter>0說明鎖在重入,不能刪除key
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
# 刪除key並且publish 解鎖消息
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; "+
"end; " +
"return nil;",
– KEYS[1] :需要加鎖的key,這裏需要是字符串類型。
– KEYS[2] :redis消息的ChannelName,一個分佈式鎖對應唯一的一個channelName:“redisson_lockchannel{” + getName() + “}”
– ARGV[1] :reids消息體,這裏只需要一個字節的標記就可以,主要標記redis的key已經解鎖,再結合redis的Subscribe,能喚醒其他訂閱解鎖消息的客戶端線程申請鎖。
– ARGV[2] :鎖的超時時間,防止死鎖
– ARGV[3] :鎖的唯一標識,也就是剛纔介紹的 id(UUID.randomUUID()) + “:” + threadId
如果執行lock.unlock(),就可以釋放分佈式鎖,此時的業務邏輯也是非常簡單的。
每次都對myLock數據結構中的那個加鎖次數減1。如果發現加鎖次數是0了,說明這個客戶端已經不再持有鎖了,此時就會用:
“del myLock”命令,從redis裏刪除這個key。然後呢,另外的客戶端2就可以嘗試完成加鎖了
常見緩存問題
數據讀
- 緩存穿透:對不存在的key進行高併發訪問,導致數據庫壓力瞬間增大,這就叫做【緩存穿透】
- 緩存雪崩:突然間大量的key失效或redis重啓,大量訪問數據庫,導致數據庫壓力瞬間增加,叫做【緩存雪崩】
- 緩存擊穿:當某一key恰好在失效時,發生了高併發訪問,導致數據庫壓力瞬間增大,叫做【緩存擊穿】
緩存穿透
一般的緩存系統,都是按照key去緩存查詢,如果不存在對應的value,就應該去後端系統查找(比如DB)。如果key對應的value是一定不存在的,並且對該key併發請求量很大,就會對後端系統造成很大的壓力。也就是說,對不存在的key進行高併發訪問,導致數據庫壓力瞬間增大,這就叫做【緩存穿透】。
解決方案:
對查詢結果爲空的情況也進行緩存,緩存時間設置短一點,或者該key對應的數據insert了之後清理緩存。
緩存雪崩
當緩存服務器重啓或者大量緩存集中在某一個時間段失效,這樣在失效的時候,也會給後端系統(比如DB)帶來很大壓力。突然間大量的key失效了或redis重啓,大量訪問數據庫
解決方案:
1、 key的失效期分散開 不同的key設置不同的有效期
2、設置二級緩存
3、高可用
緩存擊穿
對於一些設置了過期時間的key,如果這些key可能會在某些時間點被超高併發地訪問,是一種非常“熱點”的數據。這個時候,需要考慮一個問題:緩存被“擊穿”的問題,這個和緩存雪崩的區別在於這裏針對某一key緩存,前者則是很多key。
緩存在某個時間點過期的時候,恰好在這個時間點對這個Key有大量的併發請求過來,這些請求發現緩存過期一般都會從後端DB加載數據並回設到緩存,這個時候大併發的請求可能會瞬間把後端DB壓垮。
解決方案:
1、 用分佈式鎖控制訪問的線程使用redis的setnx互斥鎖先進行判斷,這樣其他線程就處於等待狀態,保證不會有大併發操作去操作數據庫。if(redis.sexnx()==1){ //先查詢緩存 //查詢數據庫 //加入緩存 }
2、不設超時時間,但是會有寫一致問題
數據寫
數據不一致的根源 : 數據源不一樣
如何解決?強一致性很難,追求最終一致性
互聯網業務數據處理的特點:
- 高吞吐量
- 低延遲
- 數據敏感性低於金融業
時序控制是否可行?
先更新數據庫,再更新緩存 或者 先更新緩存,再更新數據庫;本質上不是一個原子操作,所以時序控制不可行
保證數據的最終一致性(延時雙刪)
1、先更新數據庫同時刪除緩存項(key),等讀的時候再填充緩存
2、2秒後再刪除一次緩存項(key)
3、設置緩存過期時間 Expired Time 比如 10秒 或1小時
4、將緩存刪除失敗記錄到日誌中,利用腳本提取失敗記錄再次刪除(緩存失效期過長 7*24)
升級方案
通過數據庫的binlog來異步淘汰key,利用工具(canal)將binlog日誌採集發送到MQ中,然後通過ACK機制確認處理刪除緩存。