1、分佈式鎖常見實現方式
分佈式鎖一般有三種實現方式:1. 數據庫樂觀鎖;2. 基於Redis的分佈式鎖;3. 基於ZooKeeper的分佈式鎖。
本文是基於redis實現,爲了方便使用,也提供了封裝成spring boot starter方式的redis分佈式鎖。
2、實現分佈式鎖需要滿足的條件
爲了確保分佈式鎖可用,我們至少要確保鎖的實現同時滿足以下四個條件:
1)互斥性。在任意時刻,只有一個客戶端能持有鎖。
2)可重入性,即不會發生死鎖。即使有一個客戶端在持有鎖的期間崩潰而沒有主動解鎖,也能保證後續其他客戶端能加鎖。
3)具有容錯性。只要大部分的Redis節點正常運行,客戶端就可以加鎖和解鎖。
4)同一性。加鎖和解鎖必須是同一個客戶端,客戶端自己不能把別人加的鎖給解了。
3、基於lua腳本方式實現
鎖是針對某個資源,保證其訪問的互斥性,在實際使用當中,這個資源一般是一個字符串。使用 Redis 實現鎖,主要是將資源放到 Redis 當中,利用其原子性,當其他線程訪問時,如果 Redis 中已經存在這個資源,就不允許之後的一些操作。spring boot使用 Redis 的操作主要是通過 RedisTemplate 來實現,一般步驟如下:
1)將鎖資源放入 Redis (注意是當key不存在時才能放成功,所以使用 setIfAbsent 方法):
redisTemplate.opsForValue().setIfAbsent("key", "value");
2)設置過期時間
redisTemplate.expire("key", 30000, TimeUnit.MILLISECONDS);
3)釋放鎖
redisTemplate.delete("key");
一般情況下,這樣的實現就能夠滿足鎖的需求了,但是如果在調用 setIfAbsent 方法之後線程掛掉了,即沒有給鎖定的資源設置過期時間,默認是永不過期,那麼這個鎖就會一直存在。所以需要保證設置鎖及其過期時間兩個操作的原子性,spring data的 RedisTemplate 當中並沒有這樣的方法。
但是在jedis當中是有這種原子操作的方法的,需要通過 RedisTemplate 的 execute 方法獲取到jedis裏操作命令的對象,代碼如下:
String result = redisTemplate.execute(new RedisCallback<String>() {
@Override
public String doInRedis(RedisConnection connection) throws DataAccessException {
JedisCommands commands = (JedisCommands) connection.getNativeConnection();
return commands.set(key, "鎖定的資源", "NX", "PX", expire);
}
});
注意: Redis 從2.6.12版本開始 set 命令支持 NX 、 PX 這些參數來達到 setnx 、 setex 、 psetex 命令的效果。
文檔參見: http://doc.redisfans.com/string/set.html
NX: 表示只有當鎖定資源不存在的時候才能 SET 成功。利用 Redis 的原子性,保證了只有第一個請求的線程才能獲得鎖,而之後的所有線程在鎖定資源被釋放之前都不能獲得鎖。
PX: expire 表示鎖定的資源的自動過期時間,單位是毫秒。具體過期時間根據實際場景而定。
這樣在獲取鎖的時候就能夠保證設置 Redis 值和過期時間的原子性,避免前面提到的兩次 Redis 操作期間出現意外而導致的鎖不能釋放的問題。但是這樣還是可能會存在一個問題,考慮如下的場景順序:
- 線程T1獲取鎖
- 線程T1執行業務操作,由於某些原因阻塞了較長時間
- 鎖自動過期,即鎖自動釋放了
- 線程T2獲取鎖
- 線程T1業務操作完畢,釋放鎖(其實是釋放的線程T2的鎖)
按照這樣的場景順序,線程T2的業務操作實際上就沒有鎖提供保護機制了。所以,每個線程釋放鎖的時候只能釋放自己的鎖,即鎖必須要有一個擁有者的標記,並且也需要保證釋放鎖的原子性操作。
因此在獲取鎖的時候,可以生成一個隨機不唯一的串放入當前線程中,然後再放入 Redis 。釋放鎖的時候先判斷鎖對應的值是否與線程中的值相同,相同時才做刪除操作。
Redis 從2.6.0開始通過內置的 Lua 解釋器,可以使用 EVAL 命令對 Lua 腳本進行求值,文檔參見: http://doc.redisfans.com/script/eval.html
因此我們可以通過 Lua 腳本來達到釋放鎖的原子操作,定義 Lua 腳本如下:
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
使用 RedisTemplate 執行的代碼如下:
// 使用Lua腳本刪除Redis中匹配value的key,可以避免由於方法執行時間過長而redis鎖自動過期失效的時候誤刪其他線程的鎖
// spring自帶的執行腳本方法中,集羣模式直接拋出不支持執行腳本的異常,所以只能拿到原redis的connection來執行腳本
Long result = redisTemplate.execute(new RedisCallback<Long>() {
public Long doInRedis(RedisConnection connection) throws DataAccessException {
Object nativeConnection = connection.getNativeConnection();
// 集羣模式和單機模式雖然執行腳本的方法一樣,但是沒有共同的接口,所以只能分開執行
// 集羣模式
if (nativeConnection instanceof JedisCluster) {
return (Long) ((JedisCluster) nativeConnection).eval(UNLOCK_LUA, keys, args);
}
// 單機模式
else if (nativeConnection instanceof Jedis) {
return (Long) ((Jedis) nativeConnection).eval(UNLOCK_LUA, keys, args);
}
return 0L;
}
});
代碼中分爲集羣模式和單機模式,並且兩者的方法、參數都一樣,原因是spring封裝的執行腳本的方法中( RedisConnection 接口繼承於 RedisScriptingCommands 接口的 eval 方法),集羣模式的方法直接拋出了不支持執行腳本的異常(雖然實際是支持的),所以只能拿到 Redis 的connection來執行腳本,而 JedisCluster 和 Jedis 中的方法又沒有實現共同的接口,所以只能分開調用。
spring封裝的集羣模式執行腳本方法源碼:
# JedisClusterConnection.java
/**
* (non-Javadoc)
* @see org.springframework.data.redis.connection.RedisScriptingCommands#eval(byte[], org.springframework.data.redis.connection.ReturnType, int, byte[][])
*/
@Override
public <T> T eval(byte[] script, ReturnType returnType, int numKeys, byte[]... keysAndArgs) {
throw new InvalidDataAccessApiUsageException("Eval is not supported in cluster environment.");
}
至此,我們就完成了一個相對可靠的 Redis 分佈式鎖,但是,在集羣模式的極端情況下,還是可能會存在一些問題,比如如下的場景順序( 本文暫時不深入開展 ):
- 線程T1獲取鎖成功
- Redis 的master節點掛掉,slave自動頂上
- 線程T2獲取鎖,會從slave節點上去判斷鎖是否存在,由於Redis的master slave複製是異步的,所以此時線程T2可能成功獲取到鎖
代碼實現如下:
3.1 組件依賴
首先我們要通過Maven引入Jedis開源組件,在pom.xml文件加入下面的代碼:
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>2.9.0</version>
</dependency>
3.2 具體代碼
第一種方式:
import com.alibaba.fastjson.JSON;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisCluster;
import redis.clients.jedis.JedisCommands;
import java.util.ArrayList;
import java.util.List;
/**
* @description redis分佈式鎖工具類
* @date 2019/5/15 9:29
*/
@Component
public class RedisDistributedLock {
private final Logger logger = LoggerFactory.getLogger(RedisDistributedLock.class);
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 將key的值設爲value,當且僅當key不存在,等效於SETNX
*/
private static final String SET_IF_NOT_EXIST = "NX";
/**
* 以毫秒爲單位設置key的過期時間,等效於EXPIRE key milliseconds
*/
private static String SET_WITH_EXPIRE_TIME = "PX";
/**
* 以秒爲單位設置key的過期時間,等效於EXPIRE key seconds
*/
private static String SET_WITH_EXPIRE_TIME_E = "EX";
/**
* 調用嘗試獲取分佈式鎖後的返回值
*/
public static final String OK = "OK";
/**
* 使用lua腳本刪除redis中匹配value的key
* 使用lua腳本原因:保證操作的原子性
*/
private static final String UNLOCK_LUA = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
/**
* 嘗試獲取分佈式鎖
* @param key 鎖標識
* @param value 一般爲請求id(可以使用隨機生成的uuid,保證加鎖和解鎖是同一個客戶端)
* @param expire 過期時間
*/
public boolean lock(String key, String value, long expire) {
try {
RedisCallback<String> callback = (connection) -> {
JedisCommands commands = (JedisCommands) connection.getNativeConnection();
return commands.set(key, value, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expire);
};
String result = redisTemplate.execute(callback);
// 設置成功爲OK,否則爲null
return OK.equals(result);
} catch (Exception e) {
logger.error("redis加鎖失敗,key值:" + key, e);
}
return false;
}
/**
* 獲取鎖key的值
*/
public String get(String key) {
try {
RedisCallback<String> callback = (connection) -> {
JedisCommands commands = (JedisCommands) connection.getNativeConnection();
return commands.get(key);
};
String result = redisTemplate.execute(callback);
return result;
} catch (Exception e) {
logger.error("redis獲取值失敗,key值:" + key, e);
}
return "";
}
/**
* 釋放鎖
* @param key 鎖標識
* @param value 一般爲請求id(可以使用隨機生成的uuid,保證加鎖和解鎖是同一個客戶端)
*/
public boolean unLock(String key,String value) {
// 釋放鎖的時候,有可能因爲持鎖之後方法執行時間大於鎖的有效期,此時有可能已經被另外一個線程持有鎖,所以不能直接刪除
try {
List<String> keys = new ArrayList<>();
keys.add(key);
List<String> values = new ArrayList<>();
values.add(value);
// 使用lua腳本刪除redis中匹配value的key,可以避免由於方法執行時間過長而redis鎖自動過期失效的時候誤刪其他線程的鎖
// spring自帶的執行腳本方法中,集羣模式直接拋出不支持執行腳本的異常,所以只能拿到原redis的connection來執行腳本
RedisCallback<Long> callback = (connection) -> {
Object nativeConnection = connection.getNativeConnection();
// 集羣模式和單機模式雖然執行腳本的方法一樣,但是沒有共同的接口,所以只能分開執行
// 集羣模式
if (nativeConnection instanceof JedisCluster) {
return (Long) ((JedisCluster) nativeConnection).eval(UNLOCK_LUA, keys, values);
}
// 單機模式
else if (nativeConnection instanceof Jedis) {
return (Long) ((Jedis) nativeConnection).eval(UNLOCK_LUA, keys, values);
}
return 0L;
};
Long result = redisTemplate.execute(callback);
return result != null && result >= 0;
} catch (Exception e) {
logger.error("redis解鎖失敗,key值:" + key, e);
}
return false;
}
public boolean unLockList(List<String> keys,List<String> values) {
// 釋放鎖的時候,有可能因爲持鎖之後方法執行時間大於鎖的有效期,此時有可能已經被另外一個線程持有鎖,所以不能直接刪除
try {
// 使用lua腳本刪除redis中匹配value的key,可以避免由於方法執行時間過長而redis鎖自動過期失效的時候誤刪其他線程的鎖
// spring自帶的執行腳本方法中,集羣模式直接拋出不支持執行腳本的異常,所以只能拿到原redis的connection來執行腳本
RedisCallback<Long> callback = (connection) -> {
Object nativeConnection = connection.getNativeConnection();
// 集羣模式和單機模式雖然執行腳本的方法一樣,但是沒有共同的接口,所以只能分開執行
// 集羣模式
if (nativeConnection instanceof JedisCluster) {
return (Long) ((JedisCluster) nativeConnection).eval(UNLOCK_LUA, keys, values);
}
// 單機模式
else if (nativeConnection instanceof Jedis) {
return (Long) ((Jedis) nativeConnection).eval(UNLOCK_LUA, keys, values);
}
return 0L;
};
Long result = redisTemplate.execute(callback);
return result != null && result > 0;
} catch (Exception e) {
logger.error("redis解鎖失敗,key值:" + JSON.toJSONString(keys), e);
}
return false;
}
}
第二種方式:
爲了可以以後擴展爲使用其他方式來實現分佈式鎖,定義了接口和抽象類,代碼如下:
/**
* 分佈式鎖接口
* @Author xf
* @Date 2019/5/22 20:39
*/
public interface DistributedLock {
public static final long TIMEOUT_MILLIS = 30000;
public static final int RETRY_TIMES = Integer.MAX_VALUE;
public static final long SLEEP_MILLIS = 500;
public boolean lock(String key);
public boolean lock(String key, int retryTimes);
public boolean lock(String key, int retryTimes, long sleepMillis);
public boolean lock(String key, long expire);
public boolean lock(String key, long expire, int retryTimes);
public boolean lock(String key, long expire, int retryTimes, long sleepMillis);
public boolean releaseLock(String key);
}
/**
* 分佈式鎖抽象類,實現基本的方法,關鍵方法由子類去實現
* @Author xf
* @Date 2019/5/22 20:41
*/
public abstract class AbstractDistributedLock implements DistributedLock{
@Override
public boolean lock(String key) {
return lock(key, TIMEOUT_MILLIS, RETRY_TIMES, SLEEP_MILLIS);
}
@Override
public boolean lock(String key, int retryTimes) {
return lock(key, TIMEOUT_MILLIS, retryTimes, SLEEP_MILLIS);
}
@Override
public boolean lock(String key, int retryTimes, long sleepMillis) {
return lock(key, TIMEOUT_MILLIS, retryTimes, sleepMillis);
}
@Override
public boolean lock(String key, long expire) {
return lock(key, expire, RETRY_TIMES, SLEEP_MILLIS);
}
@Override
public boolean lock(String key, long expire, int retryTimes) {
return lock(key, expire, retryTimes, SLEEP_MILLIS);
}
}
/**
* 分佈式鎖實現類
* @Author xf
* @Date 2019/5/22 20:43
*/
public class RedisDistributedLock extends AbstractDistributedLock{
private final Logger logger = LoggerFactory.getLogger(RedisDistributedLock.class);
private RedisTemplate<Object, Object> redisTemplate;
private ThreadLocal<String> lockFlag = new ThreadLocal<String>();
public static final String UNLOCK_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();
}
public RedisDistributedLock(RedisTemplate<Object, Object> redisTemplate) {
super();
this.redisTemplate = redisTemplate;
}
@Override
public boolean lock(String key, long expire, int retryTimes, long sleepMillis) {
boolean result = setRedis(key, expire);
// 如果獲取鎖失敗,按照傳入的重試次數進行重試
while((!result) && retryTimes-- > 0){
try {
logger.debug("lock failed, retrying..." + retryTimes);
Thread.sleep(sleepMillis);
} catch (InterruptedException e) {
return false;
}
result = setRedis(key, expire);
}
return result;
}
private boolean setRedis(String key, long expire) {
try {
String result = redisTemplate.execute(new RedisCallback<String>() {
@Override
public String doInRedis(RedisConnection connection) throws DataAccessException {
JedisCommands commands = (JedisCommands) connection.getNativeConnection();
String uuid = UUID.randomUUID().toString();
lockFlag.set(uuid);
return commands.set(key, uuid, "NX", "PX", expire);
}
});
return !StringUtils.isEmpty(result);
} catch (Exception e) {
logger.error("set redis occured an exception", e);
}
return false;
}
@Override
public boolean releaseLock(String key) {
// 釋放鎖的時候,有可能因爲持鎖之後方法執行時間大於鎖的有效期,此時有可能已經被另外一個線程持有鎖,所以不能直接刪除
try {
List<String> keys = new ArrayList<String>();
keys.add(key);
List<String> args = new ArrayList<String>();
args.add(lockFlag.get());
// 使用lua腳本刪除redis中匹配value的key,可以避免由於方法執行時間過長而redis鎖自動過期失效的時候誤刪其他線程的鎖
// spring自帶的執行腳本方法中,集羣模式直接拋出不支持執行腳本的異常,所以只能拿到原redis的connection來執行腳本
Long result = redisTemplate.execute(new RedisCallback<Long>() {
public Long doInRedis(RedisConnection connection) throws DataAccessException {
Object nativeConnection = connection.getNativeConnection();
// 集羣模式和單機模式雖然執行腳本的方法一樣,但是沒有共同的接口,所以只能分開執行
// 集羣模式
if (nativeConnection instanceof JedisCluster) {
return (Long) ((JedisCluster) nativeConnection).eval(UNLOCK_LUA, keys, args);
}
// 單機模式
else if (nativeConnection instanceof Jedis) {
return (Long) ((Jedis) nativeConnection).eval(UNLOCK_LUA, keys, args);
}
return 0L;
}
});
return result != null && result > 0;
} catch (Exception e) {
logger.error("release lock occured an exception", e);
}
return false;
}
}
4、基於AOP方式實現
在實際的使用過程中,分佈式鎖可以封裝好後使用在方法級別,這樣就不用每個地方都去獲取鎖和釋放鎖,使用起來更加方便。
首先定義個註解:
/**
* @Author xf
* @Date 2019/5/22 20:50
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface RedisLock {
/** 鎖的資源,redis的key*/
String value() default "default";
/** 持鎖時間,單位毫秒*/
long keepMills() default 30000;
/** 當獲取失敗時候動作*/
LockFailAction action() default LockFailAction.CONTINUE;
public enum LockFailAction{
/** 放棄 */
GIVEUP,
/** 繼續 */
CONTINUE;
}
/** 重試的間隔時間,設置GIVEUP忽略此項*/
long sleepMills() default 200;
/** 重試次數*/
int retryTimes() default 5;
}
裝配分佈式鎖的bean
/**
* @Author xf
* @Date 2019/5/22 20:55
*/
@Configuration
@AutoConfigureAfter(RedisAutoConfiguration.class)
public class DistributedLockAutoConfiguration {
@Bean
@ConditionalOnBean(RedisTemplate.class)
public DistributedLock redisDistributedLock(RedisTemplate<Object, Object> redisTemplate){
return new RedisDistributedLock(redisTemplate);
}
}
定義切面(spring boot配置方式)
/**
* @Author xf
* @Date 2019/5/22 20:57
*/
@Aspect
@Configuration
@ConditionalOnClass(DistributedLock.class)
@AutoConfigureAfter(DistributedLockAutoConfiguration.class)
public class DistributedLockAspectConfiguration {
private final Logger logger = LoggerFactory.getLogger(DistributedLockAspectConfiguration.class);
@Autowired
private DistributedLock distributedLock;
private ExpressionParser parser = new SpelExpressionParser();
private LocalVariableTableParameterNameDiscoverer discoverer = new LocalVariableTableParameterNameDiscoverer();
@Pointcut("@annotation(com.itcode.lock.redis.spring.boot.autoconfigure.annotations.LockAction)")
private void lockPoint(){
}
@Around("lockPoint()")
public Object around(ProceedingJoinPoint pjp) throws Throwable{
Method method = ((MethodSignature) pjp.getSignature()).getMethod();
LockAction lockAction = method.getAnnotation(LockAction.class);
String key = lockAction.value();
Object[] args = pjp.getArgs();
key = parse(key, method, args);
int retryTimes = lockAction.action().equals(LockFailAction.CONTINUE) ? lockAction.retryTimes() : 0;
boolean lock = distributedLock.lock(key, lockAction.keepMills(), retryTimes, lockAction.sleepMills());
if(!lock) {
logger.debug("get lock failed : " + key);
return null;
}
//得到鎖,執行方法,釋放鎖
logger.debug("get lock success : " + key);
try {
return pjp.proceed();
} catch (Exception e) {
logger.error("execute locked method occured an exception", e);
throw e;
} finally {
boolean releaseResult = distributedLock.releaseLock(key);
logger.debug("release lock : " + key + (releaseResult ? " success" : " failed"));
}
}
/**
* @description 解析spring EL表達式
* @author xf
* @date 2019/5/22 20:57
* @param key 表達式
* @param method 方法
* @param args 方法參數
* @return
*/
private String parse(String key, Method method, Object[] args) {
String[] params = discoverer.getParameterNames(method);
EvaluationContext context = new StandardEvaluationContext();
for (int i = 0; i < params.length; i ++) {
context.setVariable(params[i], args[i]);
}
return parser.parseExpression(key).getValue(context, String.class);
}
}
spring boot starter還需要在 resources/META-INF 中添加 spring.factories 文件
# Auto Configure
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.itcode.lock.redis.spring.boot.autoconfigure.DistributedLockAutoConfiguration,\
com.itcode.lock.redis.spring.boot.autoconfigure.DistributedLockAspectConfiguration
這樣封裝之後,使用spring boot開發的項目,直接依賴這個starter,就可以在方法上加 RedisLock 註解來實現分佈式鎖的功能了,當然如果需要自己控制,直接注入分佈式鎖的bean即可
@Autowired
private DistributedLock distributedLock;
如果需要使用其他的分佈式鎖實現,繼承 AbstractDistributedLock 後實現獲取鎖和釋放鎖的方法即可。
參考:
https://www.cnblogs.com/linjiqin/p/8003838.html
http://www.cnblogs.com/number7/p/8320259.html