單個Redis實例下的鎖的使用

一、前言
一年前,寫了一篇有瑕疵的博文 Redis分佈式鎖原理及實現 。這篇博文最後雖然給出了redis實現分佈式鎖的方式,但是在併發相當高的情況下,比如Requests per second: 1453.85 [#/sec] (mean)情況下,如果出現了一次鎖超時,那麼,之後的請求會有極大的概率一直持續處在被鎖的狀態,即出現死鎖。
經過不斷查資料以及實踐檢測,最終,得出了Redis使用單個實例下鎖的正確實現。

二、原理
有效方式使用分佈式鎖所需的最低保證:

安全屬性:相互排斥。在任何給定時刻,只有一個客戶端可以持有鎖。
活力屬性A:無死鎖。最終,即使鎖定資源的客戶端崩潰或被分區,也始終可以獲取鎖定。
活力屬性B:容錯。只要大多數Redis節點啓動,客戶端就能夠獲取和釋放鎖。
由於介紹的是單實例下的Redis鎖,所以 活力屬性B 暫不考慮。

獲得鎖定
要獲得鎖定,可以採用以下方法:
 

 SET resource_name my_random_value NX PX 30000

該命令僅在密鑰尚不存在時才設置密鑰(NX選項),到期時間爲30000毫秒(PX選項)。密鑰設置爲“我的隨機值”值。此值必須在所有客戶端和所有鎖定請求中都是唯一的。

以安全的方式釋放鎖
使用一個告訴Redis的腳本:僅當密鑰存在且存儲在密鑰上的值恰好是我期望的值時才刪除密鑰。這是通過以下Lua腳本完成的:
 

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

這一點很重要,以避免刪除由另一個客戶端創建的鎖。僅使用DEL是不安全的,因爲客戶端可能會刪除另一個客戶端的鎖定。

三、java 實現

老話說得好,Talk is cheap ,show me the code! 接下來看看其java實現。

導入依賴並配置基礎信息

導入依賴:

		<!--  本項目是  springboot 2.0.4.RELEASE 下得實現 -->
     	<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

配置基礎信息:

spring:
  redis:
    # Redis數據庫索引(默認爲0)
    database: 0
    # Redis服務器地址
    host: 127.0.0.1
    # Redis服務器連接端口
    port: 6379
    # Redis 密碼
    password: require_pass
    jedis:
      pool:
        # 連接池中的最小空閒連接
        min-idle: 100
        # 連接池中的最大空閒連接
        max-idle: 500
        # 連接池最大連接數(使用負值表示沒有限制)
        max-active: 2000
        # 連接池最大阻塞等待時間(使用負值表示沒有限制)
        max-wait: 10000
    # 連接超時時間(毫秒)
    timeout: 0

配置JedisPool

新建類RedisConfig如下:


import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;

@Configuration
@EnableCaching
public class RedisConfig extends CachingConfigurerSupport {

    @Value("${spring.redis.host}")
    private String host;

    @Value("${spring.redis.port}")
    private int port;
    
    @Value("${spring.redis.password}")
    private String auth;

    @Value("${spring.redis.timeout}")
    private int timeout;

    @Value("${spring.redis.jedis.pool.max-idle}")
    private int maxIdle;

    @Value("${spring.redis.jedis.pool.max-wait}")
    private long maxWaitMillis;

    @Bean
    public JedisPool redisPoolFactory() {
        JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
        jedisPoolConfig.setMaxIdle(maxIdle);
        jedisPoolConfig.setMaxWaitMillis(maxWaitMillis);
        if(auth ==null || auth.equals("")){
        	return new JedisPool(jedisPoolConfig, host, port, timeout);
        }else{
        	return new JedisPool(jedisPoolConfig, host, port, timeout,auth);
        }
    }

}

核心加鎖和解鎖方法

創建以下抽象RedisService :

import cc.mrbird.common.domain.RedisInfo;

import java.util.List;
import java.util.Map;
import java.util.Set;

public interface RedisService {


    /**
     *  setnx expire
     * @param key
     * @param value
     * @param nxxx setnx
     * @param expx expire
     * @param time
     * @return
     */
    String set(String key, String value, String nxxx, String expx, int time);

    /**
     * 執行redis腳本
     * @param script  腳本
     * @param keys 鍵值
     * @param args 參數
     * @return
     */
    Object eval(String script, List<String> keys, List<String> args);
}

具體實現如下:

@Service("redisService")
public class RedisServiceImpl implements RedisService {

    @Autowired
    JedisPool jedisPool;


    /**
     * 處理jedis請求
     *
     * @param f 處理邏輯,通過lambda行爲參數化
     * @return 處理結果
     */
    private Object excuteByJedis(Function<Jedis, Object> f) {
        try (Jedis jedis = jedisPool.getResource()) {
            return f.apply(jedis);
        } catch (Exception e) {
            log.error(e.getMessage());
            return null;
        }
    } 

    @Override
    public String set(String key, String value, String nxxx, String expx, int time) {
        return (String) this.excuteByJedis(j -> j.set(key, value, nxxx, expx, time));
    }

    @Override
    public Object eval(String script, List<String> keys, List<String> args) {
        return this.excuteByJedis(j -> j.eval(script, keys, args));
    }
}

有了以上準備,下面就可以創建一個工具類,專門用來處理Redis鎖,如下:

@Component
@Slf4j
public class RedisLockUtils {

    /**
     * 用來表示 setnx 的參數
     */
    private static final String SET_IF_NOT_EXIST = "NX";
    /**
     * EX = seconds(秒); PX = milliseconds(毫秒)
     */
    private static final String SET_WITH_EXPIRE_TIME = "EX";
    /**
     * 釋放鎖成功返回值
     */
    private static final Long RELEASE_SUCCESS = 1L;
    /**
     * 加鎖成功返回值
     */
    private static final String LOCK_SUCCESS = "OK";
    /**
     * 超時時間 10s,單位是由 {@code SET_WITH_EXPIRE_TIME }
     */
    public static final int TIMEOUT = 10;

    /**
     * 常量前綴
     */
    private static final String REDIS_LOCK_KEY_PREFIX = "redis_lock_key_prefix";

    /**
     * 常量連接符
     */
    private static final String REDIS_LOCK_PLUS = "@";

    /**
     * 可用 key前綴
     */
    public static final String REDIS_LOCK_KEY = REDIS_LOCK_KEY_PREFIX + REDIS_LOCK_PLUS;

    @Autowired
    RedisService redisService;


    /**
     * 生成分佈式鎖密鑰
     *
     * @param key
     * @return
     */
    public String generateLockKey(String key) {
        if (StringUtils.isBlank(key)) {
            return "";
        }
        return REDIS_LOCK_KEY + MD5Utils.encrypt(key);
    }


    /**
     * 嘗試獲取分佈式鎖
     *
     * @param lockKey    鎖
     * @param requestId  請求標識
     * @param expireTime 超期時間
     * @return 是否獲取成功  true:成功獲取鎖;false:未獲取鎖資格
     */
    public boolean tryGetDistributedLock(String lockKey, String requestId, int expireTime) {

        String result = redisService.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);

        if (LOCK_SUCCESS.equals(result)) {
            return true;
        }
        return false;
    }

    /**
     * 釋放分佈式鎖
     *
     * @param lockKey   鎖
     * @param requestId 請求標識
     * @return 是否釋放成功  true:手動解鎖成功;false:手動解鎖失敗
     */
    public boolean releaseDistributedLock(String lockKey, String requestId) {

        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        Object result = redisService.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
        if (RELEASE_SUCCESS.equals(result)) {
            return true;
        }
        log.info("解鎖失敗,解鎖用戶:{}, 鎖值爲:{}", requestId, lockKey);
        return false;
    }

}

到這,就可以愉快得擼代碼了。

四、拓展
以上方法實現的redis鎖,每次只有一個用戶佔用鎖資源,這樣會照成當一個用戶A獲取鎖執行業務邏輯時,其他的用戶都不能執行這塊的業務邏輯,只有當A執行完成後,其他用戶再次請求過來時,纔有機會和獲取到鎖資格去執行相應的業務邏輯。

下面我有一個業務場景,比如發紅包,如果用上面這樣的鎖機制,先點擊“搶紅包”按鈕的用戶,也不一定能比後點擊“搶紅包”按鈕的用戶先搶到紅包。因爲先點擊的用戶可能此時鎖正被佔用,而後點擊的後點擊的用戶 可能這時候鎖正好釋放被他給碰上了。所以這樣情況,以上的鎖機制是不符合這個搶紅包的邏輯的。

那麼應該怎麼辦呢???其實也很簡單。利用redis的單線程特性和其api中的 DECR 或是INCR可以實現。

下面來看一看基礎 命令:

INCR key
簡述:

爲鍵 key 儲存的數字值加上一。

如果鍵 key 不存在, 那麼它的值會先被初始化爲 0 , 然後再執行 INCR 命令。

如果鍵 key 儲存的值不能被解釋爲數字, 那麼 INCR 命令將返回一個錯誤。

本操作的值限制在 64 位(bit)有符號數字表示之內。

INCR 命令是一個針對字符串的操作。 因爲 Redis 並沒有專用的整數類型, 所以鍵 key 儲存的值在執行 INCR 命令時會被解釋爲十進制 64 位有符號整數。

返回值: INCR 命令會返回鍵 key 在執行加一操作之後的值。

代碼示例:
 

redis> SET page_view 20
OK

redis> INCR page_view
(integer) 21

redis> GET page_view    # 數字值在 Redis 中以字符串的形式保存
"21"

DECR key
簡述:

爲鍵 key 儲存的數字值減去一。

如果鍵 key 不存在, 那麼鍵 key 的值會先被初始化爲 0 , 然後再執行 DECR 操作。

如果鍵 key 儲存的值不能被解釋爲數字, 那麼 DECR 命令將返回一個錯誤。

本操作的值限制在 64 位(bit)有符號數字表示之內。

返回值: DECR 命令會返回鍵 key 在執行減一操作之後的值。

代碼示例:
對儲存數字值的鍵 key 執行 DECR 命令:
 

redis> SET failure_times 10
OK

redis> DECR failure_times
(integer) 9

對不存在的鍵執行 DECR 命令:

redis> EXISTS count
(integer) 0

redis> DECR count
(integer) -1

測試

在上面的RedisServiceImpl 類中添加一個方法:

    public Long incr(String key) {
        return (Long)this.excuteByJedis(j -> j.incr(key));
    }

測試方法邏輯如下:

    public String testIncr() {
        long tmp = 0;
        if ((tmp = redisService.incr("numkey")) > 100) {  //100是紅包數量,假定限制只能 發100個

            return "已經發完了!!!";
        }

        System.out.println("第" + tmp + “搶到紅包的幸運兒!”) ;
        return "成功發出一個紅包!";
    }

這樣處理後,那麼可以保證先點擊的前一百個用戶可以搶到紅包,後面點擊的用戶則不會搶到紅包了。

ps:你也可以使用 DECR 執行減法操作來計數,同時還可以利用set方法設置一個初值,並給定一個紅包過期時間,留給讀者發揮,這裏就不贅述了。

參考文檔:

https://redis.io/topics/distlock
https://www.cnblogs.com/linjiqin/p/8003838.html
 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章