大家好,我是walking,原文首發(fā)于公眾號(hào)編程大道。感謝你打開這篇文章,請認(rèn)真閱讀下去吧。
今天我們聊聊redis的一個(gè)實(shí)際開發(fā)的使用場景,那就是大名鼎鼎的分布式鎖。
啥是分布式鎖?
我們學(xué)習(xí) Java 都知道鎖的概念,例如基于 JVM 實(shí)現(xiàn)的同步鎖 synchronized,以及 jdk 提供的一套代碼級(jí)別的鎖機(jī)制 lock,我們在并發(fā)編程中會(huì)經(jīng)常用這兩種鎖去保證代碼在多線程環(huán)境下運(yùn)行的正確性。但是這些鎖機(jī)制在分布式場景下是不適用的,原因是在分布式業(yè)務(wù)場景下,我們的代碼都是跑在不同的JVM甚至是不同的機(jī)器上,synchronized 和 lock 只能在同一個(gè) JVM 環(huán)境下起作用。所以這時(shí)候就需要用到分布式鎖了。
例如,現(xiàn)在有個(gè)場景就是整點(diǎn)搶消費(fèi)券(疫情的原因,支付寶最近在8點(diǎn)、12點(diǎn)整點(diǎn)開放搶消費(fèi)券),消費(fèi)券有一個(gè)固定的量,先到先得,搶完就沒了,線上的服務(wù)都是部署多個(gè)的,大致架構(gòu)如下:
所以這個(gè)時(shí)候我們就得用分布式鎖來保證共享資源的訪問的正確性。
為什么要用分布式鎖嗯?
假設(shè)不使用分布式鎖,我們看看 synchronized 能不能保證?其實(shí)是不能的,我們來演示一下。
下面我寫了一個(gè)簡單的 springboot 項(xiàng)目來模擬這個(gè)搶消費(fèi)券的場景,代碼很簡單,大致意思是先從 Redis 獲取剩余消費(fèi)券數(shù),然后判斷大于0,則減一模擬被某個(gè)用戶搶到一個(gè),然后減一后再修改 Redis 的剩余消費(fèi)券數(shù)量,打印扣減成功,剩余還有多少,否則扣減失敗,就沒搶到。整塊代碼被 synchronized 包裹,Redis 設(shè)置的庫存數(shù)量為50。
//假設(shè)庫存編號(hào)是00001
private String key = "stock:00001";
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
* 扣減庫存 synchronized同步鎖
*/
@RequestMapping("/deductStock")
public String deductStock(){
synchronized (this){
//獲取當(dāng)前庫存
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get(key));
if(stock>0){
int afterStock = stock-1;
stringRedisTemplate.opsForValue().set(key,afterStock+"");//修改庫存
System.out.println("扣減庫存成功,剩余庫存"+afterStock);
}else {
System.out.println("扣減庫存失敗");
}
}
return "ok";
}
然后啟動(dòng)兩個(gè)springboot項(xiàng)目,端口分別為8080,8081,然后在nginx里配置負(fù)載均衡
upstream redislock{
server 127.0.0.1:8080;
server 127.0.0.1:8081;
}
server {
listen 80;
server_name 127.0.0.1;
location / {
root html;
index index.html index.htm;
proxy_pass http://redislock;
}
}
然后用jmeter壓測工具進(jìn)行測試
然后我們看一下控制臺(tái)輸出,可以看到我們運(yùn)行的兩個(gè)web實(shí)例,很多同樣的消費(fèi)券被不同的線程搶到,證明synchronized在這樣的情況下是不起作用的,所以就需要使用分布式鎖來保證資源的正確性。
如何用Redis實(shí)現(xiàn)分布式鎖?
在實(shí)現(xiàn)分布式鎖之前,我們先考慮如何實(shí)現(xiàn),以及都要實(shí)現(xiàn)鎖的哪些功能。
1、分布式特性(部署在多個(gè)機(jī)器上的實(shí)例都能夠訪問這把鎖)
2、排他性(同一時(shí)間只能有一個(gè)線程持有鎖)
3、超時(shí)自動(dòng)釋放的特性(持有鎖的線程需要給定一定的持有鎖的最大時(shí)間,防止線程死掉無法釋放鎖而造成死鎖)
4、...
基于以上列出的分布式鎖需要擁有的基本特性,我們思考一下使用Redis該如何實(shí)現(xiàn)?
1、第一個(gè)分布式的特性Redis已經(jīng)支持,多個(gè)實(shí)例連同一個(gè)Redis即可
2、第二個(gè)排他性,也就是要實(shí)現(xiàn)一個(gè)獨(dú)占鎖,可以使用Redis的setnx命令實(shí)現(xiàn)
3、第三個(gè)超時(shí)自動(dòng)釋放特性,Redis可以針對某個(gè)key設(shè)置過期時(shí)間
4、執(zhí)行完畢釋放分布式鎖
科普時(shí)間
Redis Setnx 命令
Redis Setnx(SET if Not eXists) 命令在指定的 key 不存在時(shí),為 key 設(shè)置指定的值
語法
redis Setnx 命令基本語法如下:
redis 127.0.0.1:6379> SETNX KEY_NAME VALUE
可用版本:>= 1.0.0
返回值:設(shè)置成功,返回1, 設(shè)置失敗,返回0
@RequestMapping("/stock_redis_lock")
public String stock_redis_lock(){
//底層使用setnx命令
Boolean aTrue = stringRedisTemplate.opsForValue().setIfAbsent(lock_key, "true");
stringRedisTemplate.expire(lock_key,10, TimeUnit.SECONDS);//設(shè)置過期時(shí)間10秒
if (!aTrue) {//設(shè)置失敗則表示沒有拿到分布式鎖
return "error";//這里可以給用戶一個(gè)友好的提示
}
//獲取當(dāng)前庫存
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get(key));
if(stock>0){
int afterStock = stock-1;
stringRedisTemplate.opsForValue().set(key,afterStock+"");
System.out.println("扣減庫存成功,剩余庫存"+afterStock);
}else {
System.out.println("扣減庫存失敗");
}
stringRedisTemplate.delete(lock_key);//執(zhí)行完畢釋放分布式鎖
return "ok";
}
仍然設(shè)置庫存數(shù)量為50,我們再用jmeter測試一下,把jmeter的測試地址改為127.0.0.1/stock_redis_lock,同樣的設(shè)置再來測一次。
測試了5次沒有出現(xiàn)臟數(shù)據(jù),把發(fā)送時(shí)間改為0,測了5次也沒問題,然后又把線程數(shù)改為600,時(shí)間為0 ,循環(huán)4次,測了幾次也是正常的。
上面實(shí)現(xiàn)分布式鎖的代碼已經(jīng)是一個(gè)較為成熟的分布式鎖的實(shí)現(xiàn)了,對大多數(shù)軟件公司來說都已經(jīng)滿足需求了。但是上面代碼還是有優(yōu)化的空間,例如:
1)上面的代碼我們是沒有考慮異常情況的,實(shí)際情況下代碼沒有這么簡單,可能還會(huì)有別的很多復(fù)雜的操作,都有可能會(huì)出現(xiàn)異常,所以我們釋放鎖的代碼需要放在finally塊里來保證即使是代碼拋異常了釋放鎖的代碼他依然會(huì)被執(zhí)行。
2)還有,你有沒有注意到,上面我們的分布式鎖的代碼的獲取和設(shè)置過期時(shí)間的代碼是兩步操作第4行和第5行,即非原子操作,就有可能剛執(zhí)行了第4行還沒來得及執(zhí)行第5行這臺(tái)機(jī)器掛了,那么這個(gè)鎖就沒有設(shè)置超時(shí)時(shí)間,其他線程就一直無法獲取,除非人工干預(yù),所以這是一步優(yōu)化的地方,Redis也提供了原子操作,那就是SET key value EX seconds NX
科普時(shí)間
SET key value [EX seconds] [PX milliseconds] [NX|XX] 將字符串值 value 關(guān)聯(lián)到 key
可選參數(shù)
從 Redis 2.6.12 版本開始, SET 命令的行為可以通過一系列參數(shù)來修改:
- EX second :設(shè)置鍵的過期時(shí)間為 second 秒。SET key value EX second 效果等同于 SETEX key second value
- PX millisecond :設(shè)置鍵的過期時(shí)間為 millisecond 毫秒。SET key value PX millisecond 效果等同于 PSETEX key millisecond value
- NX :只在鍵不存在時(shí),才對鍵進(jìn)行設(shè)置操作。SET key value NX 效果等同于 SETNX key value
- XX :只在鍵已經(jīng)存在時(shí),才對鍵進(jìn)行設(shè)置操作
SpringBoot的StringRedisTemplate也有對應(yīng)的方法實(shí)現(xiàn),如下代碼:
//假設(shè)庫存編號(hào)是00001
private String key = "stock:00001";
private String lock_key = "lock_key:00001";
@Autowired
private StringRedisTemplate stringRedisTemplate;
@RequestMapping("/stock_redis_lock")
public String stock_redis_lock() {
try {
//原子的設(shè)置key及超時(shí)時(shí)間
Boolean aTrue = stringRedisTemplate.opsForValue().setIfAbsent(lock_key, "true", 30, TimeUnit.SECONDS);
if (!aTrue) {
return "error";
}
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get(key));
if (stock > 0) {
int afterStock = stock - 1;
stringRedisTemplate.opsForValue().set(key, afterStock + "");
System.out.println("扣減庫存成功,剩余庫存" + afterStock);
} else {
System.out.println("扣減庫存失敗");
}
} catch (NumberFormatException e) {
e.printStackTrace();
} finally {
//釋放鎖
stringRedisTemplate.delete(lock_key);
}
return "ok";
}
這樣實(shí)現(xiàn)是否就完美了呢?嗯,對于并發(fā)量要求不高或者非大并發(fā)的場景的話這樣實(shí)現(xiàn)已經(jīng)可以了。但是對于搶購 ,秒殺這樣的場景,當(dāng)流量很大,這時(shí)候服務(wù)器網(wǎng)卡、磁盤IO、CPU負(fù)載都可能會(huì)達(dá)到極限,那么服務(wù)器對于一個(gè)請求的的響應(yīng)時(shí)間勢必變得比正常情況下慢很多,那么假設(shè)就剛才設(shè)置的鎖的超時(shí)時(shí)間為10秒,如果某一個(gè)線程拿到鎖之后因?yàn)槟承┰驔]能在10秒內(nèi)執(zhí)行完畢鎖就失效了,這時(shí)候其他線程就會(huì)搶占到分布式鎖去執(zhí)行業(yè)務(wù)邏輯,然后之前的線程執(zhí)行完了,會(huì)去執(zhí)行 finally 里的釋放鎖的代碼就會(huì)把正在占有分布式鎖的線程的鎖給釋放掉,實(shí)際上剛剛正在占有鎖的線程還沒執(zhí)行完,那么其他線程就又有機(jī)會(huì)獲得鎖了...這樣整個(gè)分布式鎖就失效了,將會(huì)產(chǎn)生意想不到的后果。如下圖模擬了這個(gè)場景。
所以這個(gè)問題總結(jié)一下,就是因?yàn)殒i的過期時(shí)間設(shè)置的不合適或因?yàn)槟承┰驅(qū)е麓a執(zhí)行時(shí)間大于鎖過期時(shí)間而導(dǎo)致并發(fā)問題以及鎖被別的線程釋放,以至于分布式鎖混亂。在簡單的說就是兩個(gè)問題,1)自己的鎖被別人釋放 2)鎖超時(shí)無法續(xù)時(shí)間。
第一個(gè)問題很好解決,在設(shè)置分布式鎖時(shí),我們在當(dāng)前線程中生產(chǎn)一個(gè)唯一串將value設(shè)置為這個(gè)唯一值,然后在finally塊里判斷當(dāng)前鎖的value和自己設(shè)置的一樣時(shí)再去執(zhí)行delete,如下:
String uuid = UUID.randomUUID().toString();
try {
//原子的設(shè)置key及超時(shí)時(shí)間,鎖唯一值
Boolean aTrue = stringRedisTemplate.opsForValue().setIfAbsent(lock_key,uuid,30,TimeUnit.SECONDS);
//...
} finally {
//是自己設(shè)置的鎖再執(zhí)行delete
if(uuid.equals(stringRedisTemplate.opsForValue().get(lock_key))){
stringRedisTemplate.delete(lock_key);//避免死鎖
}
}
問題一解決了(設(shè)想一下上述代碼還有什么問題,一會(huì)兒講),那鎖的超時(shí)時(shí)間就很關(guān)鍵了,不能太大也不能太小,這就需要評(píng)估業(yè)務(wù)代碼的執(zhí)行時(shí)間,比如設(shè)置個(gè)10秒,20秒。即使是你的鎖設(shè)置了合適的超時(shí)時(shí)間,也避免不了可能會(huì)發(fā)生上述分析的因?yàn)槟承┰虼a沒在正常評(píng)估的時(shí)間內(nèi)執(zhí)行完畢,所以這時(shí)候的解決方案就是給鎖續(xù)超時(shí)時(shí)間。大致思路就是,業(yè)務(wù)線程單獨(dú)起一個(gè)分線程,定時(shí)去監(jiān)聽業(yè)務(wù)線程設(shè)置的分布式鎖是否還存在,存在就說明業(yè)務(wù)線程還沒執(zhí)行完,那么就延長鎖的超時(shí)時(shí)間,若鎖已不存在則業(yè)務(wù)線程執(zhí)行完畢,然后就結(jié)束自己。
“鎖續(xù)命”的這套邏輯屬實(shí)有點(diǎn)復(fù)雜啊,要考慮的問題太多了,稍不注意就會(huì)有bug。不要看上面實(shí)現(xiàn)分布式鎖的代碼沒有幾行,就認(rèn)為實(shí)現(xiàn)起來很簡單,如果說自己去實(shí)現(xiàn)的時(shí)候沒有實(shí)際高并發(fā)的經(jīng)驗(yàn),肯定也會(huì)踩很多坑,例如,
1)鎖的設(shè)置和過期時(shí)間的設(shè)置是非原子操作的,就可能會(huì)導(dǎo)致死鎖。
2)還有上面遺留的一個(gè),在finally塊里判斷鎖是否是自己設(shè)置的,是的話再刪除鎖,這兩步操作也不是原子的,假設(shè)剛判斷完為true服務(wù)就掛了,那么刪除鎖的代碼不會(huì)執(zhí)行,就會(huì)造成死鎖,即使是設(shè)置了過期時(shí)間,在沒過期這段時(shí)間也會(huì)死鎖。所以這里也是一個(gè)注意的點(diǎn),要保證原子操作的話,Redis提供了執(zhí)行Lua腳本的功能來保證操作的原子性,具體怎么使用不再展開。
所以,“鎖續(xù)命”的這套邏輯實(shí)現(xiàn)起來還是有點(diǎn)復(fù)雜的,好在市面上已經(jīng)有現(xiàn)成的開源框架幫我們實(shí)現(xiàn)了,那就是Redisson。
Redisson分布式鎖的實(shí)現(xiàn)原理
實(shí)現(xiàn)原理:
1、首先Redisson會(huì)嘗試進(jìn)行加鎖,加鎖的原理也是使用類似Redis的setnx命令原子的加鎖,加鎖成功的話其內(nèi)部會(huì)開啟一個(gè)子線程
2、子線程主要負(fù)責(zé)監(jiān)聽,其實(shí)就是一個(gè)定時(shí)器,定時(shí)監(jiān)聽主線程是否還持有鎖,持有則將鎖的時(shí)間延時(shí),否則結(jié)束線程
3、如果加鎖失敗則自旋不斷嘗試加鎖
4、執(zhí)行完代碼主線程主動(dòng)釋放鎖
那我們看一下使用后Redisson后的代碼是什么樣的。
1、首先在pom.xml文件添加Redisson的maven坐標(biāo)
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.12.5</version>
</dependency>
2、我們要拿到Redisson的這個(gè)對象,如下配置Bean
@SpringBootApplication
public class RedisLockApplication {
public static void main(String[] args) {
SpringApplication.run(RedisLockApplication.class, args);
}
@Bean
public Redisson redisson(){
Config config = new Config();
config.useSingleServer().setAddress("redis://localhost:6379")
.setDatabase(0);
return (Redisson) Redisson.create(config);
}
}
3、然后我們獲取Redisson的實(shí)例,使用其API進(jìn)行加鎖釋放鎖操作
//假設(shè)庫存編號(hào)是00001
private String key = "stock:00001";
private String lock_key = "lock_key:00001";
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
* 使用Redisson實(shí)現(xiàn)分布式鎖
* @return
*/
@RequestMapping("/stock_redisson_lock")
public String stock_redisson_lock() {
RLock redissonLock = redisson.getLock(lock_key);
try {
redissonLock.lock();
int stock = Integer.parseInt(stringRedisTemplate.opsForValue().get(key));
if (stock > 0) {
int afterStock = stock - 1;
stringRedisTemplate.opsForValue().set(key, afterStock + "");
System.out.println("扣減庫存成功,剩余庫存" + afterStock);
} else {
System.out.println("扣減庫存失敗");
}
} catch (NumberFormatException e) {
e.printStackTrace();
} finally {
redissonLock.unlock();
}
return "ok";
}
看這個(gè)Redisson的分布式鎖提供的API是不是非常的簡單?就像Java并發(fā)變成里AQS那套Lock機(jī)制一樣,如下獲取一把RedissonLock
RLock redissonLock = redisson.getLock(lock_key);
默認(rèn)返回的是RedissonLock的對象,該對象實(shí)現(xiàn)了RLock接口,而RLock接口繼承了JDK并發(fā)編程報(bào)包里的Lock接口
在使用Redisson加鎖時(shí),它也提供了很多API,如下
現(xiàn)在我們選擇使用的是最簡單的無參lock方法,簡單的點(diǎn)進(jìn)去跟一下看看他的源碼,我們找到最終的執(zhí)行加鎖的代碼如下:
我們可以看到其底層使用了Lua腳本來保證原子性,使用Redis的hash結(jié)構(gòu)實(shí)現(xiàn)的加鎖,以及可重入鎖。
比我們自己實(shí)現(xiàn)分布式鎖看起來還要簡單,但是我們自己寫的鎖功能他都有,我們沒有的他也有。比如,他實(shí)現(xiàn)的分布式鎖是支持可重入的,也支持可等待,即嘗試等待一定時(shí)間,沒拿到鎖就返回false。上述代碼中的redissonLock.lock();是一直等待,內(nèi)部自旋嘗試加鎖。
Distributed Java locks and synchronizers
Lock
FairLock
MultiLock
RedLock
ReadWriteLock
Semaphore
PermitExpirableSemaphore
CountDownLatch
redisson.org
Redisson提供了豐富的API,內(nèi)部運(yùn)用了大量的Lua腳本保證原子操作,篇幅原因redisson實(shí)現(xiàn)鎖的代碼暫不分析了。
*注意:在上述示例代碼中,為了方便演示,查詢r(jià)edis庫存、修改庫存并非原子操作,實(shí)際這兩部操作也得保證原子行,可以用redis自帶的Lua腳本功能去實(shí)現(xiàn) *
結(jié)語
到這里,Redis分布式鎖實(shí)戰(zhàn)基本就講完了,總結(jié)一下Redis分布式鎖吧。
1、如果說是自己實(shí)現(xiàn)的話,需要特別注意四點(diǎn):
- 原子加鎖
- 設(shè)置鎖超時(shí)時(shí)間
- 誰加的鎖誰釋放,且釋放時(shí)的原子操作
- 鎖續(xù)命問題。
2、如果使用現(xiàn)成的分布式鎖框架Redisson,就需要熟悉一下其常用的API以及實(shí)現(xiàn)原理,或者選擇其他開源的分布式鎖框架,充分考察,選擇適合自己業(yè)務(wù)需求的即可。
** 參考:**
http://doc.redisfans.com/string/set.html
https://www.runoob.com/redis/strings-setnx.html
https://github.com/redisson/redisson/wiki/8.-distributed-locks-and-synchronizers#81-lock
如果覺得本文對你有幫助的話,請不要吝嗇你的贊哦。
更多干貨文章歡迎關(guān)注公眾號(hào):編程大道
公眾號(hào)也有好多關(guān)于Redis的技術(shù)文,歡迎關(guān)注哦
另外walking本人呢也在整理Redis相關(guān)的知識(shí)點(diǎn),做成思維導(dǎo)圖的形式,不過還沒最終整理完,已經(jīng)整理了好幾天啦,關(guān)注公眾號(hào),整理好了會(huì)通過公眾號(hào)推送給大家~