一、前言
一年前,寫了一篇有瑕疵的博文 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