分散式鎖實踐

發表於2024-03-09

分散式鎖實踐

安裝工具

正常是需要在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

image-20240307161346528

image-20240307161212936

image-20240307161300241

那麼,開始程式碼環節

程式碼實踐

匯入依賴

        <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);
    }

image-20240307171805606

此時已有鎖,嘗試再次執行

image-20240307171838148

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

image-20240307232352180

其他unlock、tryLock等自行看原始碼即可,較於自己實現,僅免去了寫lua指令碼,實現原理類似。

參考

SpringBoot實現Redis分散式鎖 - 簡書 (jianshu.com)

相關文章