分布式锁-基于Redis实现
互斥性:作为锁,需要保证任何时刻只能有一个客户端(用户)持有锁
可重入: 同一个客户端在获得锁后,可以再次进行加锁
高可用:获取锁和释放锁的效率较高,不会出现单点故障
自动重试机制:当客户端加锁失败时,能够提供一种机制让客户端自动重试
setnx 是『SET if Not eXists』(如果不存在,则 SET)的简写。 命令格式:SETNX key value;使用:只在键 key 不存在的情况下,将键 key 的值设置为 value 。若键 key 已经存在, 则 SETNX 命令不做任何动作。返回值:命令在设置成功时返回 1 ,设置失败时返回 0 。
getset 命令格式:GETSET key value,将键 key 的值设为 value ,并返回键 key 在被设置之前的旧的value。返回值:如果键 key 没有旧值, 也即是说, 键 key 在被设置之前并不存在, 那么命令返回 nil 。当键 key 存在但不是字符串类型时,命令返回一个错误。
expire 命令格式:EXPIRE key seconds,使用:为给定 key 设置生存时间,当 key 过期时(生存时间为 0 ),它会被自动删除。返回值:设置成功返回 1 。 当 key 不存在或者不能为 key 设置生存时间时(比如在低于 2.1.3 版本的 Redis 中你尝试更新 key 的生存时间),返回 0 。
del 命令格式:DEL key [key …],使用:删除给定的一个或多个 key ,不存在的 key 会被忽略。返回值:被删除 key 的数量。
实现原理一:
过程分析:
缺陷:
如果客户端A,在获取锁以后,也就是在执行setnx(lockkey,currenttime+timeout)命令成功以后,redis宕机或者程序异常终止,未执行expire(lockkey)命令,那么锁就一直存在,别的客户端就一直获取不到锁,造成阻塞。
解决方法:
关闭Tomcat有两种方式,一种通过温柔的执行shutdown关闭,一种通过kill杀死进程关闭
//通过温柔的执行shutdown关闭时,以下的方法会在关闭前执行,即可以释放锁,而对于通过kill杀死进程关闭时,以下方法不会执行,即不会释放锁
//这种方式释放锁的缺点在于,如果关闭的锁过多,将造成关闭服务器耗时过长
@PreDestroy
public void delLock() {
RedisShardedPoolUtil.del(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
}
为解决以上设计存在的弊端,优化设计,采用双重防死锁解决死锁问题
实现原理二:
过程分析:
优化点:
加入了超时时间判断锁是否超时了,及时A在成功设置了锁之后,服务器就立即出现宕机或是重启,也不会出现死锁问题;因为B在尝试获取锁的时候,如果不能setnx成功,会去获取redis中锁的超时时间与当前的系统时间做比较,如果当前的系统时间已经大于锁超时时间,说明A已经对锁的使用权失效,B能继续判断能否获取锁,解决了redis分布式锁的死锁问题。
我们看到lockkey的value值为时间戳,所以要在多客户端情况下,保证锁有效,一定要同步各服务器的时间,如果各服务器间,时间有差异。时间不一致的客户端,在判断锁超时,就会出现偏差,从而产生竞争条件。
锁的超时与否,严格依赖时间戳,时间戳本身也是有精度限制,假如我们的时间精度为秒,从加锁到执行操作再到解锁,一般操作肯定都能在一秒内完成。这样的话,我们上面的CASE,就很容易出现。所以,最好把时间精度提升到毫秒级。这样的话,可以保证毫秒级别的锁是安全的。分布式锁,多客户端的时间戳不能保证严格意义的一致性,所以在某些特定因素下,有可能存在锁串的情况。要适度的机制,可以承受小概率的事件产生。
必要的超时机制:获取锁的客户端一旦崩溃,一定要有过期机制,否则其他客户端都降无法获取锁,造成死锁问题。
只对关键处理节点加锁,良好的习惯是,把相关的资源准备好,比如连接数据库后,调用加锁机制获取锁,直接进行操作,然后释放,尽量减少持有锁的时间。
在持有锁期间要不要CHECK锁,如果需要严格依赖锁的状态,最好在关键步骤中做锁的CHECK检查机制,但是根据我们的测试发现,在大并发时,每一次CHECK锁操作,都要消耗掉几个毫秒,而我们的整个持锁处理逻辑才不到10毫秒,玩客没有选择做锁的检查。
为了减少对Redis的压力,获取锁尝试时,循环之间一定要做sleep操作。但是sleep时间是多少是门学问。需要根据自己的Redis的QPS,加上持锁处理时间等进行合理计算。
pom.xml
org.springframework.boot
spring-boot-starter-data-redis
redis.clients
jedis
application.yml
server:
port: 8000
spring:
redis:
database: 5
host: 114.55.34.44
password: root
port: 6379
timeout: 3000ms
jedis:
pool:
max-active: 2000
max-idle: 500
max-wait: 1000ms
min-idle: 50
lock.lua 获得分布式锁lua脚本
-- 获取参数
local requestIDKey = KEYS[1]
local currentRequestID = ARGV[1]
local expireTimeTTL = ARGV[2]
-- setnx 尝试加锁
local lockSet = redis.call('hsetnx',KEYS[1],'lockKey',currentRequestID)
if lockSet == 1
then
-- 加锁成功 设置过期时间和重入次数=1
redis.call('expire',KEYS[1],expireTimeTTL)
redis.call('hset',KEYS[1],'lockCount',1)
return 1
else
-- 判断是否是重入加锁
local oldRequestID = redis.call('hget',KEYS[1],'lockKey')
if currentRequestID == oldRequestID
then
-- 是重入加锁
redis.call('hincrby',KEYS[1],'lockCount',1)
-- 重置过期时间
redis.call('expire',KEYS[1],expireTimeTTL)
return 1
else
-- requestID不一致,加锁失败
return 0
end
end
unlock.lua 释放分布式锁lua脚本
-- 获取参数
local requestIDKey = KEYS[1]
local currentRequestID = ARGV[1]
-- 判断requestID一致性
if redis.call('hget',KEYS[1],'lockKey') == currentRequestID
then
-- requestID相同,重入次数自减
local currentCount = redis.call('hincrby',KEYS[1],'lockCount',-1)
if currentCount == 0
then
-- 重入次数为0,删除锁
redis.call('del',KEYS[1])
return 1
else
return 0 end
else
return 0 end
LuaScript
使用redis实现分布式锁时,加锁操作必须是原子操作,否则多客户端并发操作时会导致各种各样的问题
由于我们实现的是可重入锁,加锁过程中需要判断客户端ID的正确与否。而redis原生的简单接口没法保证一系列逻辑的原子性执行,因此采用了lua脚本来实现加锁操作。lua脚本可以让redis在执行时将一连串的操作以原子化的方式执行。
public class LuaScript {
/**
* 加锁脚本 lock.lua
* 1. 判断key是否存在
* 2. 如果存在,判断requestID是否相等
* 相等,则删除掉key重新创建新的key值,重置过期时间
* 不相等,说明已经被抢占,加锁失败,返回null
* 3. 如果不存在,说明恰好已经过期,重新生成key
*/
public static String LOCK_SCRIPT;
/**
* 解锁脚本 unlock.lua
*/
public static String UN_LOCK_SCRIPT;
public static void initLockScript() throws IOException {
if (StringUtils.isEmpty(LOCK_SCRIPT)) {
InputStream inputStream = Objects.requireNonNull(
LuaScript.class.getClassLoader().getResourceAsStream("lock.lua"));
LOCK_SCRIPT = readFile(inputStream);
}
}
public static void initUnLockScript() throws IOException {
if (StringUtils.isEmpty(UN_LOCK_SCRIPT)) {
InputStream inputStream = Objects.requireNonNull(
LuaScript.class.getClassLoader().getResourceAsStream("unlock.lua"));
UN_LOCK_SCRIPT = readFile(inputStream);
}
}
private static String readFile(InputStream inputStream) throws IOException {
try (
BufferedReader br = new BufferedReader(new InputStreamReader(inputStream))
) {
String line;
StringBuilder stringBuilder = new StringBuilder();
while ((line = br.readLine()) != null) {
stringBuilder.append(line)
.append(System.lineSeparator());
}
return stringBuilder.toString();
}
}
}
DistributeLock
/**
* 分布式锁 api接口
*/
public interface DistributeLock {
/**
* 尝试加锁
*
* @param lockKey 锁的key
* @return 加锁成功 返回uuid
* 加锁失败 返回null
*/
String lock(String lockKey);
/**
* 尝试加锁 (requestID相等 可重入)
*
* @param lockKey 锁的key
* @param expireTime 过期时间 单位:秒
* @return 加锁成功 返回uuid
* 加锁失败 返回null
*/
String lock(String lockKey, int expireTime);
/**
* 尝试加锁 (requestID相等 可重入)
*
* @param lockKey 锁的key
* @param requestID 用户ID
* @return 加锁成功 返回uuid
* 加锁失败 返回null
*/
String lock(String lockKey, String requestID);
/**
* 尝试加锁 (requestID相等 可重入)
*
* @param lockKey 锁的key
* @param requestID 用户ID
* @param expireTime 过期时间 单位:秒
* @return 加锁成功 返回uuid
* 加锁失败 返回null
*/
String lock(String lockKey, String requestID, int expireTime);
/**
* 尝试加锁,失败自动重试 会阻塞当前线程
*
* @param lockKey 锁的key
* @return 加锁成功 返回uuid
* 加锁失败 返回null
*/
String lockAndRetry(String lockKey);
/**
* 尝试加锁,失败自动重试 会阻塞当前线程 (requestID相等 可重入)
*
* @param lockKey 锁的key
* @param requestID 用户ID
* @return 加锁成功 返回uuid
* 加锁失败 返回null
*/
String lockAndRetry(String lockKey, String requestID);
/**
* 尝试加锁 (requestID相等 可重入)
*
* @param lockKey 锁的key
* @param expireTime 过期时间 单位:秒
* @return 加锁成功 返回uuid
* 加锁失败 返回null
*/
String lockAndRetry(String lockKey, int expireTime);
/**
* 尝试加锁 (requestID相等 可重入)
*
* @param lockKey 锁的key
* @param expireTime 过期时间 单位:秒
* @param retryCount 重试次数
* @return 加锁成功 返回uuid
* 加锁失败 返回null
*/
String lockAndRetry(String lockKey, int expireTime, int retryCount);
/**
* 尝试加锁 (requestID相等 可重入)
*
* @param lockKey 锁的key
* @param requestID 用户ID
* @param expireTime 过期时间 单位:秒
* @return 加锁成功 返回uuid
* 加锁失败 返回null
*/
String lockAndRetry(String lockKey, String requestID, int expireTime);
/**
* 尝试加锁 (requestID相等 可重入)
*
* @param lockKey 锁的key
* @param expireTime 过期时间 单位:秒
* @param requestID 用户ID
* @param retryCount 重试次数
* @return 加锁成功 返回uuid
* 加锁失败 返回null
*/
String lockAndRetry(String lockKey, String requestID, int expireTime, int retryCount);
/**
* 释放锁
*
* @param lockKey 锁的key
* @param requestID 用户ID
* @return true 释放自己所持有的锁 成功
* false 释放自己所持有的锁 失败
*/
boolean unLock(String lockKey, String requestID);
}
RedisDistributeLock
调用lockAndRetry方法进行加锁时,如果加锁失败,则当前客户端线程会短暂的休眠一段时间,并进行重试。在重试了一定的次数后,会终止重试加锁操作,从而加锁失败。
需要注意的是,加锁失败之后的线程休眠时长是"固定值 + 随机值",引入随机值的主要目的是防止高并发时大量的客户端在几乎同一时间被唤醒并进行加锁重试,给redis服务器带来周期性的、不必要的瞬时压力。
@Component("distributeLock")
public final class RedisDistributeLock implements DistributeLock {
private static final Logger LOGGER = LoggerFactory.getLogger(RedisDistributeLock.class);
/**
* 无限重试
*/
public static final int UN_LIMIT_RETRY_COUNT = -1;
private RedisDistributeLock() {
try {
LuaScript.initLockScript();
LuaScript.initUnLockScript();
} catch (IOException e) {
throw new RuntimeException("LuaScript init error!", e);
}
}
/**
* 持有锁 成功标识
*/
private static final Long ADD_LOCK_SUCCESS = 1L;
/**
* 释放锁 失败标识
*/
private static final Long RELEASE_LOCK_SUCCESS = 1L;
/**
* 默认过期时间 单位:秒
*/
private static final int DEFAULT_EXPIRE_TIME_SECOND = 300;
/**
* 默认加锁重试时间 单位:毫秒
*/
private static final int DEFAULT_RETRY_FIXED_TIME = 100;
/**
* 默认的加锁浮动时间区间 单位:毫秒
*/
private static final int DEFAULT_RETRY_TIME_RANGE = 10;
/**
* 默认的加锁重试次数
*/
private static final int DEFAULT_RETRY_COUNT = 30;
@Resource
private RedisClient redisClient;
//===========================================api=======================================
@Override
public String lock(String lockKey) {
String uuid = UUID.randomUUID().toString();
return lock(lockKey, uuid);
}
@Override
public String lock(String lockKey, int expireTime) {
String uuid = UUID.randomUUID().toString();
return lock(lockKey, uuid, expireTime);
}
@Override
public String lock(String lockKey, String requestID) {
return lock(lockKey, requestID, DEFAULT_EXPIRE_TIME_SECOND);
}
@Override
public String lock(String lockKey, String requestID, int expireTime) {
List keyList = Collections.singletonList(lockKey);
List argsList = Arrays.asList(
requestID,
expireTime + ""
);
Long result = (Long) redisClient.eval(LuaScript.LOCK_SCRIPT, keyList, argsList);
if (result.equals(ADD_LOCK_SUCCESS)) {
return requestID;
} else {
return null;
}
}
@Override
public String lockAndRetry(String lockKey) {
String uuid = UUID.randomUUID().toString();
return lockAndRetry(lockKey, uuid);
}
@Override
public String lockAndRetry(String lockKey, String requestID) {
return lockAndRetry(lockKey, requestID, DEFAULT_EXPIRE_TIME_SECOND);
}
@Override
public String lockAndRetry(String lockKey, int expireTime) {
String uuid = UUID.randomUUID().toString();
return lockAndRetry(lockKey, uuid, expireTime);
}
@Override
public String lockAndRetry(String lockKey, int expireTime, int retryCount) {
String uuid = UUID.randomUUID().toString();
return lockAndRetry(lockKey, uuid, expireTime, retryCount);
}
@Override
public String lockAndRetry(String lockKey, String requestID, int expireTime) {
return lockAndRetry(lockKey, requestID, expireTime, DEFAULT_RETRY_COUNT);
}
@Override
public String lockAndRetry(String lockKey, String requestID, int expireTime, int retryCount) {
if (retryCount <= 0) {
// retryCount小于等于0 无限循环,一直尝试加锁
while (true) {
String result = lock(lockKey, requestID, expireTime);
if (result != null) {
return result;
}
LOGGER.info("加锁失败,稍后重试:lockKey={},requestID={}", lockKey, requestID);
redisClient.increment("retryCount", 1);
// 休眠一会
sleepSomeTime();
}
} else {
// retryCount大于0 尝试指定次数后,退出
for (int i = 0; i < retryCount; i++) {
String result = lock(lockKey, requestID, expireTime);
if (result != null) {
return result;
}
// 休眠一会
sleepSomeTime();
}
return null;
}
}
@Override
public boolean unLock(String lockKey, String requestID) {
List keyList = Collections.singletonList(lockKey);
List argsList = Collections.singletonList(requestID);
Object result = redisClient.eval(LuaScript.UN_LOCK_SCRIPT, keyList, argsList);
// 释放锁成功
return RELEASE_LOCK_SUCCESS.equals(result);
}
//==============================================私有方法========================================
/**
* 获得最终的获得锁的重试时间
*/
private int getFinallyGetLockRetryTime() {
Random ra = new Random();
// 最终重试时间 = 固定时间 + 浮动时间
return DEFAULT_RETRY_FIXED_TIME + ra.nextInt(DEFAULT_RETRY_TIME_RANGE);
}
/**
* 当前线程 休眠一段时间
*/
private void sleepSomeTime() {
// 重试时间 单位:毫秒
int retryTime = getFinallyGetLockRetryTime();
try {
Thread.sleep(retryTime);
} catch (InterruptedException e) {
throw new RuntimeException("redis锁重试时,出现异常", e);
}
}
}
public interface RedisClient {
/**
* 执行脚本
*/
Object eval(String script, List keys, List args);
/**
* get
*/
Object get(String key);
/**
* set
*/
void set(String key, Object value);
/**
* set
*/
void set(String key, Object value, long expireTime, TimeUnit timeUnit);
/**
* setNX
*/
Boolean setNX(String key, Object value);
/**
* 设置过期时间
*/
Boolean expire(String key, long time, TimeUnit type);
/**
* 移除过期时间
*/
Boolean persist(String key);
/**
* 增加
*/
Long increment(String key, long number);
/**
* 增加
*/
Double increment(String key, double number);
/**
* 删除
*/
Boolean delete(String key);
// ==========================hash========================
void hset(String key, String hashKey, Object value);
void hsetAll(String key, Map map);
Boolean hsetNX(String key, String hashKey, Object value);
Object hget(String key, String hashKey);
Map hgetAll(String key);
}
@Component("redisClient")
public class RedisClientImpl implements RedisClient {
@Autowired
private RedisTemplate redisTemplate;
@Override
public Object eval(String script, List keys, List args) {
DefaultRedisScript redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(script);
redisScript.setResultType(Integer.class);
Object result = redisTemplate.execute((RedisCallback
@RestController
@RequestMapping("distributelock")
public class DistributeLockController {
private static final String TEST_REDIS_LOCK_KEY = "lock_key";
private static final int EXPIRE_TIME = 100;
@Autowired
private RedisDistributeLock redisDistributeLock;
@RequestMapping("/getlock")
public String test() throws ExecutionException, InterruptedException {
int threadNum = 100;
ThreadFactory namedThreadFactory = new ThreadFactoryBuilder()
.setNameFormat("demo-pool-%d").build();
ExecutorService executorService = new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue(1024), namedThreadFactory, new ThreadPoolExecutor.AbortPolicy());
List futureList = new ArrayList<>();
for (int i = 0; i <= threadNum; i++) {
int currentThreadNum = i;
Future future = executorService.submit(() -> {
System.out.println("线程尝试获得锁 i=" + currentThreadNum);
String requestID = redisDistributeLock.lockAndRetry(TEST_REDIS_LOCK_KEY, EXPIRE_TIME);
if (!StringUtils.isEmpty(requestID)) {
System.out.println("获得锁,开始执行任务 requestID=" + requestID + "i=" + currentThreadNum);
}
// 模拟 宕机事件 不释放锁
/* if (currentThreadNum == 1) {
System.out.println("模拟 宕机事件 不释放锁,直接返回 currentThreadNum=" + currentThreadNum);
return;
}*/
try {
// 休眠完毕
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("任务执行完毕" + "i=" + currentThreadNum);
redisDistributeLock.unLock(TEST_REDIS_LOCK_KEY, requestID);
System.out.println("释放锁完毕");
redisDistributeLock.lockAndRetry(TEST_REDIS_LOCK_KEY, requestID, EXPIRE_TIME);
System.out.println("重入获得锁,开始执行任务 requestID=" + requestID + "i=" + currentThreadNum);
redisDistributeLock.unLock(TEST_REDIS_LOCK_KEY, requestID);
System.out.println("释放重入锁完毕");
});
futureList.add(future);
}
for (Future future : futureList) {
future.get();
}
return "ok";
}
}
RedisLock
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RedisLock {
/**
* redis锁,重试次数-1代表无限重试
*/
int unLimitRetryCount = RedisDistributeLock.UN_LIMIT_RETRY_COUNT;
/**
* redis锁对应的key 会拼接此参数,用于进一步区分,避免redis的key被覆盖
*/
String lockKey() default "";
/**
* redis锁过期时间(单位:秒)
*/
int expireTime() default 10;
/**
* redis锁,加锁失败重试次数 默认30次,大约3s
* 超过指定次数后,抛出加锁失败异常,可以由调用方自己补偿
*
* @see RedisLockFailException
*/
int retryCount() default 30;
}
RedisLockAspect
@Component
@Aspect
public class RedisLockAspect {
private static final Logger logger = LoggerFactory.getLogger(RedisLockAspect.class);
private final RequestIDMap REQUEST_ID_MAP = new RequestIDMap();
@Autowired
private Environment environment;
@Autowired
private DistributeLock distributeLock;
/**
* 将ThreadLocal包装成一个对象方便使用
*/
private class RequestIDMap {
private ThreadLocal
https://github.com/FocusProgram/springcloud-redis-lock
主从同步可能导致锁的互斥性失效
在redis主从结构下,出于性能的考虑,redis采用的是主从异步复制的策略,这会导致短时间内主库和从库数据短暂的不一致。
试想,当某一客户端刚刚加锁完毕,redis主库还没有来得及和从库同步就挂了,之后从库中新选拔出的主库是没有对应锁记录的,这就可能导致多个客户端加锁成功,破坏了锁的互斥性。
休眠并反复尝试加锁效率较低
lockAndRetry方法在客户端线程加锁失败后,会休眠一段时间之后再进行重试。当锁的持有者持有锁的时间很长时,其它客户端会有大量无效的重试操作,造成系统资源的浪费。
进一步优化时,可以使用发布订阅的方式。这时加锁失败的客户端会监听锁被释放的信号,在锁真正被释放时才会进行新的加锁操作,从而避免不必要的轮询操作,以提高效率。
不是一个公平的锁
当前实现版本中,多个客户端同时对锁进行抢占时,是完全随机的,既不遵循先来后到的顺序,客户端之间也没有加锁的优先级区别。
后续优化时可以提供一个创建公平锁的接口,能指定加锁的优先级,内部使用一个优先级队列维护加锁客户端的顺序。公平锁虽然效率稍低,但在一些场景能更好的控制并发行为。