分散式鎖實踐
安裝工具
正常是需要在linux安裝redis(官方推薦),為了方便在開發環境中,使用windows版本的redis
GitHub - redis-windows/redis-windows: Redis 6.0.20 6.2.14 7.0.15 for Windows
下載release版本,
根據readme,在服務中註冊,並啟動redis:redis-windows/README.zh_CN.md at main · redis-windows/redis-windows · GitHub
下載windows下的redis管理視覺化工具Releases · qishibo/AnotherRedisDesktopManager · GitHub
那麼,開始程式碼環節
程式碼實踐
匯入依賴
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
配置
工具類
因為是工具類,所以並不想將他強依賴於IOC,所以這裡使用beanFactory來對他進行bean的載入。
package com.example.redisdemo.config;
import com.example.redisdemo.utils.RedisLockUtil;
import org.springframework.context.annotation.Bean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
@Component
public class BeanFactory {
@Bean
public RedisLockUtil redisLockUtil(RedisTemplate<String, String> redisTemplate) {
return new RedisLockUtil(redisTemplate);
}
}
package com.example.redisdemo.utils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStringCommands;
import org.springframework.data.redis.connection.ReturnType;
import org.springframework.data.redis.core.RedisConnectionUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.types.Expiration;
import org.springframework.stereotype.Component;
import java.nio.charset.Charset;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import javax.annotation.Resource;
@Slf4j
public class RedisLockUtil {
private RedisTemplate<String,String> redisTemplate;
/**
* 解鎖指令碼,原子操作
*/
private static final String unlockScript =
"if redis.call(\"get\",KEYS[1]) == ARGV[1]\n"
+ "then\n"
+ " return redis.call(\"del\",KEYS[1])\n"
+ "else\n"
+ " return 0\n"
+ "end";
public RedisLockUtil(RedisTemplate<String,String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public RedisLockUtil() {
}
/**
* 加鎖,有阻塞
* @param name
* @param expire
* @param timeout
* @return
*/
public String lock(String name, long expire, long timeout){
long startTime = System.currentTimeMillis();
String token;
do{
token = tryLock(name, expire);
if(token == null) {
if((System.currentTimeMillis()-startTime) > (timeout-50))
break;
try {
Thread.sleep(50); //try 50 per sec
} catch (InterruptedException e) {
e.printStackTrace();
return null;
}
}
}while(token==null);
return token;
}
/**
* 加鎖,無阻塞
* @param name
* @param expire
* @return
*/
public String tryLock(String name, long expire) {
String token = UUID.randomUUID().toString();
RedisConnectionFactory factory = redisTemplate.getConnectionFactory();
RedisConnection conn = factory.getConnection();
try{
Boolean result = conn.set(name.getBytes(Charset.forName("UTF-8")), token.getBytes(Charset.forName("UTF-8")),
Expiration.from(expire, TimeUnit.MILLISECONDS), RedisStringCommands.SetOption.SET_IF_ABSENT);
if(result!=null && result)
return token;
}finally {
RedisConnectionUtils.releaseConnection(conn, factory);
}
return null;
}
/**
* 解鎖
* @param name
* @param token
* @return
*/
public boolean unlock(String name, String token) {
byte[][] keysAndArgs = new byte[2][];
keysAndArgs[0] = name.getBytes(Charset.forName("UTF-8"));
keysAndArgs[1] = token.getBytes(Charset.forName("UTF-8"));
RedisConnectionFactory factory = redisTemplate.getConnectionFactory();
RedisConnection conn = factory.getConnection();
try {
Long result = (Long)conn.scriptingCommands().eval(unlockScript.getBytes(Charset.forName("UTF-8")), ReturnType.INTEGER, 1, keysAndArgs);
if(result!=null && result>0)
return true;
}finally {
RedisConnectionUtils.releaseConnection(conn, factory);
}
return false;
}
}
嘗試獲取鎖
@Test
void redisLockTest() {
String token = redisLockUtil.tryLock("fuwu:123", 1000);
System.out.println("拿到鎖:"+token);
}
此時已有鎖,嘗試再次執行
token返回為null,說明獲取鎖失敗。使用10個執行緒進行等待嘗試,看是否能夠實現交替進入業務。
多執行緒競爭
package com.example.redisdemo;
import javax.annotation.Resource;
import com.example.redisdemo.utils.RedisLockUtil;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.ValueOperations;
@SpringBootTest
class RedisDemoApplicationTests {
@Resource
RedisLockUtil redisLockUtil;
@Test
void redisLockTest() throws InterruptedException {
System.out.println();
String name = "RedisLockTest";
for (int i = 0; i < 10; i++) {
int finalI = i;
new Thread(()->{
System.out.println("業務編號:" + finalI + "開始執行,嘗試獲取鎖...");
String token = redisLockUtil.lock(name, 60, 60 * 1000);
try {
Thread.sleep(50);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(finalI+" 拿到鎖:"+token);
System.out.println("業務執行中...");
System.out.println("業務執行完成\n開始解鎖中...");
if (redisLockUtil.unlock(name, token)) {
System.out.println("token:"+ token +" 解鎖成功");
}
}).start();
}
Thread.sleep(10000);
}
}
輸出
業務編號:0開始執行,嘗試獲取鎖...
業務編號:8開始執行,嘗試獲取鎖...
業務編號:3開始執行,嘗試獲取鎖...
業務編號:4開始執行,嘗試獲取鎖...
業務編號:2開始執行,嘗試獲取鎖...
業務編號:5開始執行,嘗試獲取鎖...
業務編號:6開始執行,嘗試獲取鎖...
業務編號:1開始執行,嘗試獲取鎖...
業務編號:7開始執行,嘗試獲取鎖...
業務編號:9開始執行,嘗試獲取鎖...
9 拿到鎖:a3059978-efff-4f06-847f-a21a0fd6a6dc
業務執行中...
業務執行完成
開始解鎖中...
token:a3059978-efff-4f06-847f-a21a0fd6a6dc 解鎖成功
1 拿到鎖:7aba4b40-77f1-448f-bbc3-60b0991d8b8d
業務執行中...
業務執行完成
開始解鎖中...
token:7aba4b40-77f1-448f-bbc3-60b0991d8b8d 解鎖成功
3 拿到鎖:df582f8a-3791-431c-8732-d64cb897dd47
業務執行中...
業務執行完成
開始解鎖中...
token:df582f8a-3791-431c-8732-d64cb897dd47 解鎖成功
7 拿到鎖:c771321a-707a-4b1d-8023-732b991cf9b1
業務執行中...
業務執行完成
開始解鎖中...
token:c771321a-707a-4b1d-8023-732b991cf9b1 解鎖成功
0 拿到鎖:ba041359-e7ef-4ed7-a59c-8d95e2b5b31d
業務執行中...
業務執行完成
開始解鎖中...
token:ba041359-e7ef-4ed7-a59c-8d95e2b5b31d 解鎖成功
5 拿到鎖:78f5eee0-e182-48fa-be70-09f1ec0853e4
業務執行中...
業務執行完成
開始解鎖中...
token:78f5eee0-e182-48fa-be70-09f1ec0853e4 解鎖成功
4 拿到鎖:3f821c1e-8c23-4062-8290-d184bd8b09ee
業務執行中...
業務執行完成
開始解鎖中...
token:3f821c1e-8c23-4062-8290-d184bd8b09ee 解鎖成功
2 拿到鎖:f861db53-f7cf-48e8-95ec-b08e86b26273
業務執行中...
業務執行完成
開始解鎖中...
token:f861db53-f7cf-48e8-95ec-b08e86b26273 解鎖成功
8 拿到鎖:0b50aa0b-3a72-47be-b62a-bb1ac50693b7
業務執行中...
業務執行完成
開始解鎖中...
token:0b50aa0b-3a72-47be-b62a-bb1ac50693b7 解鎖成功
6 拿到鎖:5524fe2a-1967-453e-a86e-bf35a49de5d9
業務執行中...
業務執行完成
開始解鎖中...
token:5524fe2a-1967-453e-a86e-bf35a49de5d9 解鎖成功
可以看到,鎖的獲取都是序列的,說明該鎖在多執行緒的情況下,能保持作用
使用setNX + lua指令碼解鎖作為分散式鎖解決方案的小結
首先,程式碼簡單,程式碼簡單意味著可維護性高,並且出了問題十分好定位。
但是其實僅實現了較為簡單的功能,僅使用lua指令碼解決了在解鎖的時候,獲取鎖的值跟目前執行緒持有鎖的值對比,然後對比成功再刪除,且該操作是原子性的,防止A執行緒在獲取自己的鎖的value的時候剛好超時,B執行緒進來獲取到鎖了,然後A執行緒把B執行緒的鎖給嘎了這種場景。
但是,A執行緒在正常執行的情況下,真的有必要釋放鎖嗎?雖然我們設定了過期時間是為了防止A執行緒因為當機,或者業務太長執行了太長時間了,導致一系列問題。但是如果是A執行緒跟B執行緒一起執行的話,勢必會導致各種問題呢?那麼A執行緒就得保持住自己的鎖,不讓B進來,為了防止當機問題,那麼過期時間必然是要設定的,所以此時需要續命了,如果A執行緒還在執行,那麼給他續個時間。這種系統的複雜度還是較高的,為了防止各種奇奇怪怪的BUG,我們這引入redisson來實現,redisson提供了看門狗機制,10s檢查一下A執行緒是否還活著,如果活著,給它續一次命,看門狗是單獨的執行緒。
因此,下面開始編寫redisson來實現分散式鎖的寫法。
Redisson實現分散式鎖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>2.7.0</version>
</dependency>
@Bean(name = "Redisson")
public Redisson redisson() {
Config config = new Config();
config.useSingleServer()
.setAddress("localhost:6379");
// 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");
return (Redisson) Redisson.create(config);
}
@Bean(name = "RedisLockUtilForRedisson")
public RedisLockUtilForRedisson redisLockUtil(Redisson redisson) {
return new RedisLockUtilForRedisson(redisson);
}
package com.example.redisdemo.utils;
import java.util.concurrent.TimeUnit;
import jodd.datetime.TimeUtil;
import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.redisson.spring.cache.RedissonSpringCacheManager;
public class RedisLockUtilForRedisson {
public RedisLockUtilForRedisson() {
}
private Redisson redisson;
public RedisLockUtilForRedisson(Redisson redisson) {
this.redisson = redisson;
}
public boolean acquire(String key) {
RLock lock = redisson.getLock(key);
lock.lock(1,TimeUnit.MINUTES);
return true;
}
}
如果指定了過期時間,那麼不會進入到scheduleExpirationRenewal,不會續命,使用預設時間30s會自動續命,續命的檢查時間為 30s / 3 = 10s
其他unlock、tryLock等自行看原始碼即可,較於自己實現,僅免去了寫lua指令碼,實現原理類似。
參考
SpringBoot實現Redis分散式鎖 - 簡書 (jianshu.com)