目前越來越多的應用使用負載均衡,以往傳統(tǒng)單體應用單機部署的情況下使用的JAVA并發(fā)處理資源競爭方式(J.U.C或synchronized等)在集群部署中已經(jīng)無法保證資源的安全訪問。
為什么需要分布式鎖
需要考慮以下情況——
- 只允許一個客戶端操作共享資源:這種情況下紊馏,對共享資源的操作一般是非冪等性操作银受。在這種情況下沧烈,如果出現(xiàn)多個客戶端操作共享資源履羞,就可能意味著數(shù)據(jù)不一致枣申,數(shù)據(jù)丟失嚷缭。
- 允許多個客戶端操作共享資源:對共享資源的操作一定是冪等性操作饮亏,無論你操作多少次都不會出現(xiàn)不同結果耍贾。在這里使用鎖,無外乎就是為了避免重復操作共享資源從而提高效率路幸。
為了解決分布式應用中對資源的安全訪問于是便有了分布式鎖荐开。
實現(xiàn)分布式鎖一般基于Zookeeper或者Redis,前者可靠性高而后者效率高简肴。
如果并發(fā)量不大追求可靠性選擇Zookeeper晃听,反之選擇Redis。
Redis實現(xiàn)分布式鎖
分布式鎖一大特點就是排他性砰识,臨界條件下僅有獲得鎖的調(diào)用者才能訪問資源能扒。
而Redis是基于內(nèi)存
設計K-V數(shù)據(jù)庫且單線程
執(zhí)行命令。
因此使用Redis作為分布式鎖的中間件滿足兩個重要條件——
- 基于內(nèi)部:加鎖/解鎖效率高
- 單線程:請求先后順序執(zhí)行沒有并發(fā)沖突辫狼,所以任意時刻只有一個調(diào)用者能成功獲取鎖初斑。
而且 Redis支持集群部署(sentinel, cluster)保障了可用性。
加鎖
使用SETNX()便可以完成加鎖操作膨处。SETNX表示如果Key不存在則寫入见秤,如果存在則什么都不做。
setnx key value
這樣便只允許一個調(diào)用者可以訪問真椿。等等似乎少了什么鹃答?沒錯我們沒有設置KEY的過期時間,如果此時調(diào)用者宕機這把鎖就無法釋放了突硝。
我們使用EXPIRE加上過期時間测摔,默認單位為秒。
EXPIRE key seconds
但是這樣做就完了嗎解恰?SETNX跟EXPIRE是兩個獨立的操作锋八,即加鎖操作不具備原子性。假如加鎖調(diào)用成功修噪,但是設置過期時間的時候調(diào)用服務宕機查库,依然存在鎖無法正常釋放的問題路媚。
于是我們還需要把這兩條命令合為一條命令黄琼。
SET key value [expiration EX seconds|PX milliseconds] [NX|XX]
- expiration:過期時間,EX表示秒整慎,PX表示毫秒
- NX:表示如果不存在則寫入(顯然我們需要這條語句)
- XX:表示如果存在則寫入
目前終于算是完成了原子加鎖命令并且鎖存在過期時間即使調(diào)用者宕機到期之后也會自動釋放脏款。
解鎖
使用DEL便可以刪除鎖。
DEL key [key ...]
不過事情真的有這么簡單嗎裤园?當然不會(套路啊)
- 假設調(diào)用者A加鎖成功并且設置鎖超時時間為30秒撤师。但是因為某些原因導致調(diào)用者A處理業(yè)務邏輯所花費的時間超過了30秒。
- 鎖超時自動釋放之后調(diào)用者B剛好加鎖成功拧揽,這時調(diào)用者A使用DEL語句釋放了調(diào)用者B的鎖剃盾。
- 因為鎖被調(diào)用者A釋放導致后續(xù)的調(diào)用者可以參與鎖競爭腺占,例如調(diào)用者C獲取到鎖。
- 從而發(fā)送在同一個時間內(nèi)調(diào)用者B與調(diào)用者C同時運行業(yè)務邏輯從而破壞了資源的安全訪問痒谴。
別繞暈了畫張圖更直觀的感受下——
從圖中可以發(fā)現(xiàn)A衰伯、B有并行,B积蔚、C有并行不符合我們的要求意鲸。
那么怎樣才能讓調(diào)用者只能釋放自己占用的鎖呢?這個時候K-V中的V就發(fā)揮作用了尽爆。如果我們的Value都是唯一(例如:UUID)的并且調(diào)用者釋放鎖需要驗證Value便可以避免釋放其他調(diào)用者占用的鎖資源怎顾。
SET key uuid EX times NX
那么在釋放鎖的時候就跟需要分三步——
- 根據(jù)KEY獲取鎖
- 對比VALUE與預期值是否一致
- 如果一致則釋放(刪除)鎖
可是這三個步驟是獨立非原子操作,如果在2漱贱、3步驟之間鎖因為超時自動釋放且在高并發(fā)情況下別其他調(diào)用者加鎖成功槐雾,在執(zhí)行步驟3時則仍然存在刪除其他調(diào)用者的鎖。
因此我們?nèi)匀恍枰硬僮鞣ā_z憾的是Redis的命令并不提供該操作蚜退。
別慌Redis雖然沒有提供,但是Redis支持LUA腳本彪笼,LUA腳本可以幫助原子性的完成這一系列復合操作钻注。
以下為Redis官方提供腳本——
if redis.call('get', KEYS[1]) == ARGV[1]
then
return redis.call('del', KEYS[1])
else
return 0
end
這個腳本即使你不會LUA大概也猜得到是什么意思吧。
- 傳入的鍵名參數(shù)調(diào)用get操作獲取到的值與附加參數(shù)比較
- 如果值一致則調(diào)用del操作(刪除操作成功返回1)
- 如果值不一致則返回0
執(zhí)行腳本如下——
eval "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end" 1 lock value
現(xiàn)在釋放鎖的原子操作也滿足了配猫,讓我們看看代碼實踐幅恋。
代碼示例
筆者這里使用springboot來演示分布式鎖。
因為分布式鎖的實現(xiàn)可以是多種方式建議抽象一個接口泵肄,例如DistributedLock捆交。
如果使用Redis作為分布式鎖中間件則創(chuàng)建RedisDistributedLock。
如果使用Zookeeper作為分布式鎖的中間件則創(chuàng)建ZookeeperDistributedLock腐巢。
可以根據(jù)@ConditionalOnBean以及spring優(yōu)先級選擇加載那種分布式鎖品追。
-
分布式鎖配置
spring: redis: lettuce: pool: max-active: 100 max-idle: 50 min-idle: 30 max-wait: 2000ms # 哨兵模式 sentinel: master: mymaster nodes: ${ARGS_REDIS_NODES} # 集群模式 # cluster: # nodes: ${ARGS_REDIS_NODES} password: ${ARGS_REDIS_PASSWORD} # 分布式配置 distributed: lock: expire: 30000 # connection-timeout-ms: 15000 # session-timeout-ms: 60000
-
分布式鎖接口
public interface DistributedLock { /** * 獲取鎖 * * @param key * @param value * @return */ public boolean tryLock(String key, String value); /** * 釋放鎖 * * @param key * @param value * @return */ public boolean releaseLock(String key, String value); }
-
分布式鎖實現(xiàn)
/** * 分布式鎖:使用Redis實現(xiàn) */ @AutoConfigureAfter({RedisAutoConfiguration.class}) @ConditionalOnBean(RedisAutoConfiguration.class) @Service @Slf4j public class RedisDistributedLock implements DistributedLock { private final static String OK = "OK"; private static final String UNLOCK_LUA; /** * Lua腳本來釋放鎖 */ static { StringBuilder sb = new StringBuilder(); sb.append("if redis.call('get', KEYS[1]) == ARGV[1] "); sb.append("then "); sb.append(" return redis.call('del', KEYS[1]) "); sb.append("else "); sb.append(" return 0 "); sb.append("end"); UNLOCK_LUA = sb.toString(); } /** * 缺省超時時間 */ @Value("${distributed.lock.expire}") private long expire; @Resource private StringRedisTemplate stringRedisTemplate; @Override public boolean tryLock(String key, String value) { return tryLock(key, value, TimeUnit.MILLISECONDS, expire); } @Override public boolean releaseLock(String key, String value) { try { return stringRedisTemplate.execute( (RedisCallback<Boolean>) connection -> connection.eval( UNLOCK_LUA.getBytes(), ReturnType.BOOLEAN, 1, key.getBytes(Charset.forName("UTF-8")), value.getBytes(Charset.forName("UTF-8")))); } catch (Exception ex) { log.error("釋放redis鎖失敗", ex); } return false; } /** * 獲取鎖 * * @param key * @param value * @param timeUnit * @param time * @return */ private boolean tryLock(String key, String value, TimeUnit timeUnit, long time) { try { return stringRedisTemplate.execute( (RedisCallback<Boolean>) connection -> connection.set( key.getBytes(Charset.forName("UTF-8")), value.getBytes(Charset.forName("UTF-8")), Expiration.milliseconds(timeUnit.toMillis(time)), RedisStringCommands.SetOption.SET_IF_ABSENT) ); } catch (Exception ex) { log.error("獲取redis鎖失敗", ex); } return false; } }
-
調(diào)用示例
boolean lock = distributedLock.tryLock(LOCK_NAME, lockValue); try { if (!lock) { log.info("已存在分布式鎖:[{}]", LOCK_NAME); return; } } finally { if (lock) { distributedLock.releaseLock(LOCK_NAME, lockValue); } }
以上就是對分布式鎖的代碼演示。
分布式鎖的疑問
Redis實現(xiàn)分布式鎖還有哪些問題呢冯丙?
-
自動續(xù)期
當業(yè)務邏輯處理時間超過鎖的過期時間需要有監(jiān)控線程在過期之前進續(xù)期肉瓦。你可以將其稱之為Monitor或者Watchdog。
-
可重入
分布式鎖能夠支持一個線程對資源的重復加鎖嗎胃惜?
-
讀寫鎖
所謂讀寫鎖即:讀讀不互斥泞莉,讀寫互斥,寫寫互斥船殉。例如:ReentrantReadWriteLock鲫趁。能否想使用J.U.C包下面的類一樣使用分布式鎖呢?
-
集群
Redis集群環(huán)境是一個復雜的邏輯結構利虫,節(jié)點的上線下線挨厚、主從復制堡僻、從節(jié)點的提升導致的數(shù)據(jù)丟失考慮了嗎?
是不是感覺頭都大了疫剃。
好在Redis官方給我們指了一條明路苦始。這就是接下來筆者要介紹的內(nèi)容了。
Redisson介紹
Redisson正式筆者要介紹的Redis分布式鎖終極解決方案慌申。什么你沒聽說過陌选?沒關系跟著筆者進入新世界的大門。
什么是Redisson?
Redisson是一個在Redis的基礎上實現(xiàn)的Java駐內(nèi)存數(shù)據(jù)網(wǎng)格(In-Memory Data Grid)蹄溉。它不僅提供了一系列的分布式的Java常用對象咨油,還提供了許多分布式服務。其中包括(
BitSet
,Set
,Multimap
,SortedSet
,Map
,List
,Queue
,BlockingQueue
,Deque
,BlockingDeque
,Semaphore
,Lock
,AtomicLong
,CountDownLatch
,Publish / Subscribe
,Bloom filter
,Remote service
,Spring cache
,Executor service
,Live Object service
,Scheduler service
) Redisson提供了使用Redis的最簡單和最便捷的方法柒爵。Redisson的宗旨是促進使用者對Redis的關注分離(Separation of Concern)役电,從而讓使用者能夠將精力更集中地放在處理業(yè)務邏輯上。
關于Redisson項目的詳細介紹可以在官方網(wǎng)站找到棉胀。每個Redis服務實例都能管理多達1TB的內(nèi)存法瑟。
能夠完美的在云計算環(huán)境里使用,并且支持AWS ElastiCache主備版唁奢,AWS ElastiCache集群版霎挟,Azure Redis Cache和阿里云(Aliyun)的云數(shù)據(jù)庫Redis版
一言以蔽之可以像使用本地J.U.C一樣使用Redis分布式鎖。
Redisson示例
-
新增依賴支持
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.13.2</version> </dependency>
-
YAML文件配置
redisson: client: sentinel-address: - redis://${redis.senntinel.node1} - redis://${redis.senntinel.node2} - redis://${redis.senntinel.node3} master-name: mymaster database: 0 password: ${redis.password}
-
新增RedissonConfig配置類
@Configuration @ConfigurationProperties(prefix = "redisson.client") @Getter @Setter public class RedissonConfig { private String masterName; private int database; private String password; private String[] sentinelAddress; @Bean(destroyMethod = "shutdown") public RedissonClient redissonClient() { Config config = new Config(); config.useSentinelServers() .setMasterName(masterName) .setPassword(password) .setDatabase(database) .setCheckSentinelsList(false) .addSentinelAddress(sentinelAddress); return Redisson.create(config); } }
-
測試方法
@GetMapping(value = "/api/lock") public String testRedisson() throws InterruptedException { // 鎖對象 RLock rLock = redissonClient.getLock("lock"); // rLock.tryLock(); // 阻塞式獲取鎖 rLock.lock(); try { log.info("the lock for {}-{}", Thread.currentThread().getName(), Thread.currentThread().getId()); // 超過默認的30秒鎖過期時間 Thread.sleep(1000 * 120); } finally { rLock.unlock(); log.info("release the lock for {}-{}", Thread.currentThread().getName(), Thread.currentThread().getId()); } return ""; }
驗證結果如下所示——
由此可以Redisson幫我們自動續(xù)期麻掸。抓取一下Redis的請求信息看看——
"EVAL" "if (redis.call('exists', KEYS[1]) == 0) then redis.call('hincrby', 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]);" "1" "lock" "30000" "edabe2d0-b3d5-4526-b69a-952f1fc994cc:97" "EVAL" "if (redis.call('exists', KEYS[1]) == 0) then redis.call('hincrby', 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]);" "1" "lock" "30000" "edabe2d0-b3d5-4526-b69a-952f1fc994cc:91"
Redisson代碼
觀察代碼發(fā)現(xiàn)使用LUA腳本續(xù)期酥夭,Redisson中大量使用LUA腳本保證其命令具有原子性。
1594739476.909029 [0 101.88.95.75:25242] "EVAL" "if (redis.call('exists', KEYS[1]) == 0) then redis.call('hincrby', 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]);" "1" "lock" "30000" "edabe2d0-b3d5-4526-b69a-952f1fc994cc:97"
1594739476.909135 [0 lua] "exists" "lock"
1594739476.909158 [0 lua] "hincrby" "lock" "edabe2d0-b3d5-4526-b69a-952f1fc994cc:97" "1"
1594739476.909176 [0 lua] "pexpire" "lock" "30000"
1594739476.909201 [0 101.88.95.75:25242] "WAIT" "2" "1000"
1594739477.166176 [0 10.10.10.101:36452] "PING"
1594739477.452893 [0 10.10.10.101:36468] "PING"
1594739477.619219 [0 10.10.10.101:36468] "PUBLISH" "__sentinel__:hello" "172.17.0.3,26380,2d8a2463c5242b26b0fc58a07b444ed28fca45c6,0,mymaster,10.10.10.101,6379,0"
1594739477.766886 [0 10.10.10.101:36484] "PING"
1594739477.870853 [0 10.10.10.101:36484] "PUBLISH" "__sentinel__:hello" "172.17.0.3,26381,037ccf65d8178a1aaac14bbb40fc28b2cebd4eac,0,mymaster,10.10.10.101,6379,0"
1594739478.017627 [0 101.88.95.75:25247] "EVAL" "if (redis.call('exists', KEYS[1]) == 0) then redis.call('hincrby', 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]);" "1" "lock" "30000" "edabe2d0-b3d5-4526-b69a-952f1fc994cc:91"
1594739478.017727 [0 lua] "exists" "lock"
1594739478.017738 [0 lua] "hexists" "lock" "edabe2d0-b3d5-4526-b69a-952f1fc994cc:91"
1594739478.017747 [0 lua] "pttl" "lock"
1594739478.017758 [0 101.88.95.75:25247] "WAIT" "2" "1000"
1594739478.047182 [0 101.88.95.75:25263] "SUBSCRIBE" "redisson_lock__channel:{lock}"
1594739478.065068 [0 101.88.95.75:25251] "EVAL" "if (redis.call('exists', KEYS[1]) == 0) then redis.call('hincrby', 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]);" "1" "lock" "30000" "edabe2d0-b3d5-4526-b69a-952f1fc994cc:91"
1594739478.065226 [0 lua] "exists" "lock"
1594739478.065241 [0 lua] "hexists" "lock" "edabe2d0-b3d5-4526-b69a-952f1fc994cc:91"
1594739478.065256 [0 lua] "pttl" "lock"
1594739478.065270 [0 101.88.95.75:25251] "WAIT" "2" "1000"
MONITOR日志中也證實了Redisson操作使用了大量的LUA腳本脊奋。
Redisson的鎖集成了J.U.C
下的Lock
接口熬北,提供分布式的重入鎖
、讀寫鎖
等等诚隙,讀者完全可以放心大膽的使用Redisson實現(xiàn)分布式鎖讶隐。
Redisson的時候場景非常多,由于篇幅現(xiàn)在這里筆者就點到為止久又。建議去Redisson官網(wǎng)去進一步學習巫延。
尾聲
一個合格的Redis分布式鎖考慮到多少問題。在實際使用中還需要考慮到KEY的設計以及鎖的粒度問題籽孙。粒度越低影響越少烈评,只鎖定訪問共享數(shù)據(jù)的代碼盡可能的降低鎖的粒度火俄。
Redis分布式鎖就介紹到這里犯建,看到這里的你是否有所收獲呢?