分佈式鎖(Redisson)- 從零開始,深入理解與不斷優化
作者:大程子的技術成長路
鏈接:https://www.jianshu.com/p/bc4ff4694cf3
分佈式鎖場景
-
互聯網秒殺
-
搶優惠卷
-
接口冪等性校驗
案例 1
如下代碼模擬了下單減庫存的場景,我們分析下在高併發場景下會存在什麼問題
package com.wangcp.redisson;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class IndexController {
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
* 模擬下單減庫存的場景
* @return
*/
@RequestMapping(value = "/duduct_stock")
public String deductStock(){
// 從redis 中拿當前庫存的值
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
if(stock > 0){
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock",realStock + "");
System.out.println("扣減成功,剩餘庫存:" + realStock);
}else{
System.out.println("扣減失敗,庫存不足");
}
return "end";
}
}
假設在 redis 中庫存(stock)初始值是 100。
現在有 5 個客戶端同時請求該接口,可能就會存在同時執行
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
這行代碼,獲取到的值都爲 100,緊跟着判斷大於 0 後都進行 - 1 操作,最後設置到 redis 中的值都爲 99。但正常執行完成後 redis 中的值應爲 95。
案例 2 - 使用 synchronized 實現單機鎖
在遇到案例 1 的問題後,大部分人的第一反應都會想到加鎖來控制事務的原子性,如下代碼所示:
@RequestMapping(value = "/duduct_stock")
public String deductStock(){
synchronized (this){
// 從redis 中拿當前庫存的值
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
if(stock > 0){
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock",realStock + "");
System.out.println("扣減成功,剩餘庫存:" + realStock);
}else{
System.out.println("扣減失敗,庫存不足");
}
}
return "end";
}
現在當有多個請求訪問該接口時,同一時刻只有一個請求可進入方法體中進行庫存的扣減,其餘請求等候。
但我們都知道,synchronized 鎖是屬於 JVM 級別的,也就是我們俗稱的 “單機鎖”。但現在基本大部分公司使用的都是集羣部署,現在我們思考下以上代碼在集羣部署的情況下還能保證庫存數據的一致性嗎?
答案是不能,如上圖所示,請求經 Nginx 分發後,可能存在多個服務同時從 Redis 中獲取庫存數據,此時只加 synchronized (單機鎖)是無效的,併發越高,出現問題的幾率就越大。
案例 3 - 使用 SETNX 實現分佈式鎖
setnx:將 key 的值設爲 value,當且僅當 key 不存在。
若給定 key 已經存在,則 setnx 不做任何動作。
使用 setnx 實現簡單的分佈式鎖:
/**
* 模擬下單減庫存的場景
* @return
*/
@RequestMapping(value = "/duduct_stock")
public String deductStock(){
String lockKey = "product_001";
// 使用 setnx 添加分佈式鎖
// 返回 true 代表之前redis中沒有key爲 lockKey 的值,並已進行成功設置
// 返回 false 代表之前redis中已經存在 lockKey 這個key了
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "wangcp");
if(!result){
// 代表已經加鎖了
return "error_code";
}
// 從redis 中拿當前庫存的值
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
if(stock > 0){
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock",realStock + "");
System.out.println("扣減成功,剩餘庫存:" + realStock);
}else{
System.out.println("扣減失敗,庫存不足");
}
// 釋放鎖
stringRedisTemplate.delete(lockKey);
return "end";
}
我們知道 Redis 是單線程執行,現在再看案例 2 中的流程圖時,哪怕高併發場景下多個請求都執行到了 setnx 的代碼,redis 會根據請求的先後順序進行排列,只有排列在隊頭的請求才能設置成功。其它請求只能返回 “error_code”。
當 setnx 設置成功後,可執行業務代碼對庫存扣減,執行完成後對鎖進行釋放。
我們再來思考下以上代碼已經完美實現分佈式鎖了嗎?能夠支撐高併發場景嗎?答案並不是,上面的代碼還是存在很多問題的,離真正的分佈式鎖還差的很遠。我們分析下以上代碼存在的問題:
死鎖:假如第一個請求在 setnx 加鎖完成後,執行業務代碼時出現了異常,那釋放鎖的代碼就無法執行,後面所有的請求也都無法進行操作了。
針對死鎖的問題,我們對代碼再次進行優化,添加 try-finally,在 finally 中添加釋放鎖代碼,這樣無論如何都會執行釋放鎖代碼,如下所示:
/**
* 模擬下單減庫存的場景
* @return
*/
@RequestMapping(value = "/duduct_stock")
public String deductStock(){
String lockKey = "product_001";
try{
// 使用 setnx 添加分佈式鎖
// 返回 true 代表之前redis中沒有key爲 lockKey 的值,並已進行成功設置
// 返回 false 代表之前redis中已經存在 lockKey 這個key了
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "wangcp");
if(!result){
// 代表已經加鎖了
return "error_code";
}
// 從redis 中拿當前庫存的值
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
if(stock > 0){
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock",realStock + "");
System.out.println("扣減成功,剩餘庫存:" + realStock);
}else{
System.out.println("扣減失敗,庫存不足");
}
}finally {
// 釋放鎖
stringRedisTemplate.delete(lockKey);
}
return "end";
}
經過改進後的代碼是否還存在問題呢?我們思考正常執行的情況下應該是沒有問題,但我們假設請求在執行到業務代碼時服務突然宕機了,或者正巧你的運維同事重新發版,粗暴的 kill -9 掉了呢,那代碼還能執行 finally 嗎?
案例 4 - 加入過期時間
針對想到的問題,對代碼再次進行優化,加入過期時間,這樣即便出現了上述的問題,在時間到期後鎖也會自動釋放掉,不會出現 “死鎖” 的情況。
@RequestMapping(value = "/duduct_stock")
public String deductStock(){
String lockKey = "product_001";
try{
Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockKey,"wangcp",10,TimeUnit.SECONDS);
if(!result){
// 代表已經加鎖了
return "error_code";
}
// 從redis 中拿當前庫存的值
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
if(stock > 0){
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock",realStock + "");
System.out.println("扣減成功,剩餘庫存:" + realStock);
}else{
System.out.println("扣減失敗,庫存不足");
}
}finally {
// 釋放鎖
stringRedisTemplate.delete(lockKey);
}
return "end";
}
現在我們再思考一下,給鎖加入過期時間後就可以了嗎?就可以完美運行不出問題了嗎?
超時時間設置的 10s 真的合適嗎?如果不合適設置多少秒合適呢?如下圖所示
假設同一時間有三個請求。
請求 1 首先加鎖後需執行 15 秒,但在執行到 10 秒時鎖失效釋放。
請求 2 進入後加鎖執行,在請求 2 執行到 5 秒時,請求 1 執行完成進行鎖釋放,但此時釋放掉的是請求 2 的鎖。
請求 3 在請求 2 執行 5 秒時開始執行,但在執行到 3 秒時請求 2 執行完成將請求 3 的鎖進行釋放。
我們現在只是模擬 3 個請求便可看出問題,如果在真正高併發的場景下,可能鎖就會面臨 “一直失效” 或“永久失效”。
那麼具體問題出在哪裏呢?總結爲以下幾點:
-
- 存在請求釋放鎖時釋放掉的並不是自己的鎖
-
- 超時時間過短,存在代碼未執行完便自動釋放
針對問題我們思考對應的解決方法:
-
針對問題 1,我們想到在請求進入時生成一個唯一 id,使用該唯一 id 作爲鎖的 value 值,釋放時先進行獲取比對,比對相同時再進行釋放,這樣就可以解決釋放掉其它請求鎖的問題。
-
針對問題 2,我們思考不斷的延長過期時間真的合適嗎?設置短了存在超時自動釋放的問題,設置長了又會出現宕機後一段時間鎖無法釋放的問題,雖然不會再出現 “死鎖”。針對這個問題,如何解決呢?
案例 5-Redisson 分佈式鎖
SpringBoot 集成 Redisson 步驟
引入依賴
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.6.5</version>
</dependency>
初始化客戶端
@Bean
public RedissonClient redisson(){
// 單機模式
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.3.170:6379").setDatabase(0);
return Redisson.create(config);
}
Redisson 實現分佈式鎖
package com.wangcp.redisson;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class IndexController {
@Autowired
private RedissonClient redisson;
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
* 模擬下單減庫存的場景
* @return
*/
@RequestMapping(value = "/duduct_stock")
public String deductStock(){
String lockKey = "product_001";
// 1.獲取鎖對象
RLock redissonLock = redisson.getLock(lockKey);
try{
// 2.加鎖
redissonLock.lock(); // 等價於 setIfAbsent(lockKey,"wangcp",10,TimeUnit.SECONDS);
// 從redis 中拿當前庫存的值
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get("stock"));
if(stock > 0){
int realStock = stock - 1;
stringRedisTemplate.opsForValue().set("stock",realStock + "");
System.out.println("扣減成功,剩餘庫存:" + realStock);
}else{
System.out.println("扣減失敗,庫存不足");
}
}finally {
// 3.釋放鎖
redissonLock.unlock();
}
return "end";
}
}
Redisson 分佈式鎖實現原理圖
Redisson 底層源碼分析
我們點擊 lock() 方法,查看源碼,最終看到以下代碼
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
internalLockLeaseTime = unit.toMillis(leaseTime);
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
"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]);",
Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}
沒錯,加鎖最終執行的就是這段 lua 腳本語言。
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;
腳本的主要邏輯爲:
-
exists 判斷 key 是否存在
-
當判斷不存在則設置 key
-
然後給設置的 key 追加過期時間
這樣來看其實和我們前面案例中的實現方法好像沒什麼區別,但實際上並不是。
這段 lua 腳本命令在 Redis 中執行時,會被當成一條命令來執行,能夠保證原子性,故要不都成功,要不都失敗。
我們在源碼中看到 Redssion 的許多方法實現中很多都用到了 lua 腳本,這樣能夠極大的保證命令執行的原子性。
Redisson 鎖自動 “續命” 源碼
private void scheduleExpirationRenewal(final long threadId) {
if (expirationRenewalMap.containsKey(getEntryName())) {
return;
}
Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
RFuture<Boolean> future = commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return 1; " +
"end; " +
"return 0;",
Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
future.addListener(new FutureListener<Boolean>() {
@Override
public void operationComplete(Future<Boolean> future) throws Exception {
expirationRenewalMap.remove(getEntryName());
if (!future.isSuccess()) {
log.error("Can't update lock " + getName() + " expiration", future.cause());
return;
}
if (future.getNow()) {
// reschedule itself
scheduleExpirationRenewal(threadId);
}
}
});
}
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
if (expirationRenewalMap.putIfAbsent(getEntryName(), task) != null) {
task.cancel();
}
}
這段代碼是在加鎖後開啓一個守護線程進行監聽。Redisson 超時時間默認設置 30s,線程每 10s 調用一次判斷鎖還是否存在,如果存在則延長鎖的超時時間。
現在,我們再回過頭來看看案例 5 中的加鎖代碼與原理圖,其實完善到這種程度已經可以滿足很多公司的使用了,並且很多公司也確實是這樣用的。但我們再思考下是否還存在問題呢?例如以下場景:
-
衆所周知 Redis 在實際部署使用時都是集羣部署的,那在高併發場景下我們加鎖,當把 key 寫入到 master 節點後,master 還未同步到 slave 節點時 master 宕機了,原有的 slave 節點經過選舉變爲了新的 master 節點,此時可能就會出現鎖失效問題。
-
通過分佈式鎖的實現機制我們知道,高併發場景下只有加鎖成功的請求可以繼續處理業務邏輯。那就出現了大夥都來加鎖,但有且僅有一個加鎖成功了,剩餘的都在等待。其實分佈式鎖與高併發在語義上就是相違背的,我們的請求雖然都是併發,但 Redis 幫我們把請求進行了排隊執行,也就是把我們的並行轉爲了串行。串行執行的代碼肯定不存在併發問題了,但是程序的性能肯定也會因此受到影響。
針對這些問題,我們再次思考解決方案
-
在思考解決方案時我們首先想到 CAP 原則(一致性、可用性、分區容錯性),那麼現在的 Redis 就是滿足 AP(可用性、分區容錯性),如果想要解決該問題我們就需要尋找滿足 CP(一致性、分區容錯性) 的分佈式系統。首先想到的就是 zookeeper,zookeeper 的集羣間數據同步機制是當主節點接收數據後不會立即返回給客戶端成功的反饋,它會先與子節點進行數據同步,半數以上的節點都完成同步後纔會通知客戶端接收成功。並且如果主節點宕機後,根據 zookeeper 的 Zab 協議(Zookeeper 原子廣播)重新選舉的主節點一定是已經同步成功的。
那麼問題來了,Redisson 與 zookeeper 分佈式鎖我們如何選擇呢?答案是如果併發量沒有那麼高,可以用 zookeeper 來做分佈式鎖,但是它的併發能力遠遠不如 Redis。如果你對併發要求比較高的話,那就用 Redis,偶爾出現的主從架構鎖失效的問題其實是可以容忍的。
-
關於第二個提升性能的問題,我們可以參考 ConcurrentHashMap 的鎖分段技術的思想,例如我們代碼的庫存量當前爲 1000,那我們可以分爲 10 段,每段 100,然後對每段分別加鎖,這樣就可以同時執行 10 個請求的加鎖與處理,當然有要求的同學還可以繼續細分。但其實 Redis 的 Qps 已經達到 10W + 了,沒有特別高併發量的場景下也是完全夠用的。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/xx4RHUOlIrtg_hwd5E7jOw