文章目录
- 一. 经典问题
- 问题一:锁不被释放
- 问题二:服务A释放了服务B的锁,导致问题
- 问题三:释放锁过程要保证原子性
- 问题四:多个服务同时获取到了锁
- 问题五:redis服务宕机,如何保证锁正常使用
- 二. 解决方案
- 2.1 问题1~问题4方案
- 2.1.1 手写方案
- 2.1.1.1 主线程
- 2.1.1.2 其他辅助类
- 2.1.2 redisson方案
- 2.1.2.1 redisson原生方式
- 2.1.2.2 springboot starter方式
- 2.1.2.3 方法说明
- 2.1.2.4 leaseTime特殊说明
- 2.2 问题5方案
- 2.3 redisson分布式锁失效场景
使用redis实现分布式锁,相对于使用数据库锁或者使用ZooKeeper,简单方便,相对可靠,是最常用的方式,本文上一个实现demo。
在写代码之前,先抛出几个常见问题,带着问题去实现代码,逻辑更清晰完整。
一. 经典问题
redis实现分布式锁,几个常见经典问题:
问题一:锁不被释放
就是说一个服务在获取到分布式锁后,在释放锁之前,由于某种原因比如服务挂掉了,导致锁一直不会被释放,那么其他服务自然也就再也拿不到锁了。针对这个问题。解决办法一般都是加锁时,同步设置锁的过期时间。
问题二:服务A释放了服务B的锁,导致问题
比如,服务A在拿到锁之后,设置过期时间1s,但是服务A由于自身某种原因,业务执行了2s才结束;那么,在锁过期后,1.5s的时候,服务B正好来拿锁,并且拿到了,然后执行B的业务1s,那么B业务还没执行结束,A结束了,然后去释放锁,这个时候释放的就是B拿到的锁。
为了避免这个问题,需要为每个服务拿锁的请求进行标记,避免分不清锁是谁的。释放锁的时候,判断此刻redis中的锁是不是自己档时获取到的。
问题三:释放锁过程要保证原子性
针对问题二,说到释放锁的时候,要进行判断是不是自己的锁,这个判断+释放的过程,必须是原子性的,否则同样会产生释放别人锁的问题。
比如,服务A解锁时刚判断锁是自己的,于是下一步就是释放锁,结果释放锁之前,锁正好过期,并且服务B刚好申请到了此锁,那么服务A接下来释放的锁,必然是服务B的。
问题四:多个服务同时获取到了锁
业务中,分布式锁的目的肯定是只希望同时只有一个服务拿到锁,不能多个服务同时拿到锁,不然就失去了锁的意义。
但是,有一种场景,比如A服务拿到了锁,由于A业务执行时间过长,在解锁之前锁早已经被释放,同时又被服务B获取到,这样实际上就是服务A和服务B都获取到了锁并且在执行业务逻辑,这是有问题的。
我们可能会想到,把锁的过期时间设置的足够长,比如1min,保证不少于服务A的业务执行时间,这样的确可以,但是这样又产生了别的问题,比如服务A挂掉了,那么其他服务就需要等1min的时间才能拿到锁,这个等待时间未免太久;
那么,过期时间到底设置多久呢,这个不好设定,只能说设置为服务A业务大多数执行的时长,比如服务A的业务大多数执行时间是200ms,那么就设置为1s,这个应该足够了,但是万一服务A某次业务由于特殊原因,执行了2s呢,还是会有上述问题。
那么,我们会想,既然服务执行时间不是那么稳定,这个锁的过期时间是否能根据业务执行时间动态变化呢?答案是肯定的,本问Demo中,我们使用守护线程来动态延长锁的过期时间。
问题五:redis服务宕机,如何保证锁正常使用
此问题是针对单机版的redis做分布式锁,如果此单机redis服务挂掉,那么redis锁将会不可用。解决方式是使用redis集群,但是,在集群环境下,我们的分布式锁的加锁策略是怎样的呢?
二. 解决方案
2.1 问题1~问题4方案
2.1.1 手写方案
对于问题1到问题4,在下面手写Demo中都有解决,并添加了注释,下面看代码。
2.1.1.1 主线程
public class RedisLockDemo {
//随便弄个key的名字
private static final String LOCK_KEY = "distributedLock:key";
//主线程
public static void main(String[] args) {
//获取redis客户端
RedisClient redisClient = RedisClient.getInstance();
//开启两个工作线程,模拟分布式服务中的两个服务
for (int i = 0; i < 1; i++) {
startAWork(redisClient, String.valueOf(i), 10);
}
}
/**
* 开启一个工作线程,模拟分布式中的一个服务,抢分布式锁
*
* @param redisClient redis客户端
* @param threadName 线程名称
* @param lengthOfWork 工作时长 秒
*/
public static void startAWork(RedisClient redisClient, String threadName, int lengthOfWork) {
new Thread(() -> {
try {
//生成并保存 获取分布式锁的 请求id,解决问题二
String requestId = UUID.randomUUID().toString();
RedisLockThreadLocalContext.getThreadLocal().set(requestId);
//获取分布式锁,设置过期时间2s,解决问题一
boolean result = RedisTool.tryGetDistributedLock(redisClient.getJedis(), LOCK_KEY, requestId, 2000);
if (result) {//如果成功获取到锁
//开一个守护线程延长锁的过期时间
Thread thread = new Thread(() -> {
while (true) {
Jedis jedis = redisClient.getJedis();
try {
TimeUnit.SECONDS.sleep(1);
System.out.println("守护线程延长锁的过期时间1s");
jedis.setex(LOCK_KEY, 1, requestId);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
if (jedis != null) {
jedis.close();
}
}
}
});
thread.setDaemon(true);
thread.start();
System.out.println("线程" + threadName + "拿到锁,干点事情");
//睡眠一定时间,模拟业务耗时
TimeUnit.SECONDS.sleep(lengthOfWork);
} else {
System.out.println("线程" + threadName + "没有拿到锁");
}
} catch (Exception e) {
//
} finally {
//释放分布式锁
String requestId = RedisLockThreadLocalContext.getThreadLocal().get();
boolean result = RedisTool.releaseDistributedLock(redisClient.getJedis(), LOCK_KEY, requestId);
if (result) {
System.out.println("线程" + threadName + "释放锁");
} else {
System.out.println("线程" + threadName + "释放锁失败");
}
}
System.out.println("线程" + threadName + "结束");
}).start();
}
}
主线程说明:
- 主线程比较简单,只开启了两个工作线程,模拟抢分布式锁的过程;
- 具体的startAWork()方法中,新建了工作线程,使用睡眠时间来模拟执行业务逻辑的耗时;
- 在 RedisTool#tryGetDistributedLock()方法中,传入了过期时间参数,方法内容看下问代码。这个参数解决了问题一;
- 在 RedisTool#tryGetDistributedLock()方法中,传入了requestId参数,这个是一个随机UUID,用来标识每一次加锁的线程,同时这个参数保存在了线程本地变量ThreadLocal中,解决了问题二。
- 在开启工作线程后,代码中紧接着又开启另外一个线程,并使用thread.setDaemon(true);标识为守护线程;这个守护线程的任务就是死循环延长锁的过期时间;当业务线程执行完毕后,这个守护线程会自动销毁。注意循环的时间间隔要小于锁的过期时间,一般设置为过期时间的一半即可。
2.1.1.2 其他辅助类
添加jedis依赖包:
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>2.9.0</version>
</dependency>
使用JedisPool初始化一个Jedis客户端:
/**
* Description:Redis客户端
*/
public class RedisClient {
private static final Logger LOGGER = LoggerFactory.getLogger(RedisClient.class);
private static RedisClient instance = new RedisClient();
private JedisPool pool;
private RedisClient() {
init();
}
public static RedisClient getInstance() {
return instance;
}
public Jedis getJedis() {
return pool.getResource();
}
/**
* 初始化redis连接池
*/
private void init() {
int maxTotal = 10;
String ip = "redis IP";
String pwd = "redis 密码";
int port = 6379;
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
jedisPoolConfig.setMaxTotal(maxTotal);
jedisPoolConfig.setMaxIdle(20);
jedisPoolConfig.setMaxWaitMillis(6000);
pool = new JedisPool(jedisPoolConfig, ip, port, 5000, pwd);
LOGGER.info("连接池初始化成功 ip={}, port={}, maxTotal={}", ip, port, maxTotal);
}
}
上述代码初始化了redis连接信息,属于固定代码,没啥好解释的,继续往下看代码。
/**
* Description:redis分布式锁访问工具类,提供具体的获取锁,释放锁方法
*/
public class RedisTool {
private static final String LOCK_SUCCESS = "OK";
private static final Long RELEASE_SUCCESS = 1L;
private static final String SET_IF_NOT_EXIST = "NX";
private static final String SET_WITH_EXPIRE_TIME = "PX";
/**
* 尝试获取分布式锁
*
* @param jedis Redis客户端
* @param lockKey 锁的key
* @param requestId 锁的Value,值是个唯一标识,用来标记加锁的线程请求;可以使用UUID.randomUUID().toString()方法生成
* @param expireTime 过期时间 ms
* @return 是否获取成功,成功返回true,否则false
*/
public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
String result = null;
try {
result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
} catch (Exception e) {
e.printStackTrace();
} finally {
if (jedis != null) {
jedis.close();
}
}
return LOCK_SUCCESS.equals(result);
}
/**
* 释放分布式锁
*
* @param jedis Redis客户端
* @param lockKey 锁
* @param requestId 请求标识,锁的Value
* @return 是否释放成功
*/
public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
Object result = null;
try {
//使用lua脚本保证原子性
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
} catch (Exception e) {
e.printStackTrace();
} finally {
if (jedis != null) {
jedis.close();
}
}
return RELEASE_SUCCESS.equals(result);
}
}
- RedisTool工具类,提供了加锁和解锁的两个方法;
- tryGetDistributedLock()加锁方法设置了过期时间,解决了问题一;
- releaseDistributedLock()解锁方法中使用了lua脚本,具备原子性,解锁时先判断key的value值,也就是当初加锁保存的requestId是不是和自己线程保存的一致,一致才说明是自己当初加的锁,方可进行解锁;不一致说明自己加锁已经自动过期,无需解锁;这个解决了问题二和问题三。
/**
* Description:保存redis分布式锁的请求id
*/
public class RedisLockThreadLocalContext {
private static ThreadLocal<String> threadLocal = new NamedThreadLocal<>("REDIS-LOCK-LOCAL-CONTEXT");
public static ThreadLocal<String> getThreadLocal() {
return threadLocal;
}
}
上述RedisLockThreadLocalContext中创建了一个threadLocal单例,用于保存加锁时设置的requestId。当然在使用线程池时,get完数据要注意清除里面的保存信息,这里就不写那么详细了。
2.1.2 redisson方案
对于问题1到问题4,上面手写的方案,实际Redisson框架已经帮我们实现了,只需要简单的几行代码。
Redisson实现了可重入锁,公平锁等各种java中定义的锁类型,相关资料可参考官方文档:https://github.com/redisson/redisson/wiki/目录
2.1.2.1 redisson原生方式
依赖包:
<!-- https://mvnrepository.com/artifact/org.redisson/redisson -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.12.0</version>
</dependency>
客户端配置:
@Bean
public Redisson redisson() {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379").setDatabase(0);
return (Redisson) Redisson.create(config);
}
使用:
public void test() {
RLock lock = redisson.getLock("keykeykey");
try {
boolean b = lock.tryLock(30, TimeUnit.SECONDS);
if(b){
//执行业务
}
//
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
2.1.2.2 springboot starter方式
依赖:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.10.6</version>
</dependency>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.10.6</version>
</dependency>
使用:
使用redisson-spring-boot-starter更简单,上面的@Bean Redisson 都不用配置,可直接在业务代码中注入RedissonClient:
@Autowired
private RedissonClient redissonClient;
RedissonClient是个接口,它的实现类就是Redisson,因此使用RedissonClient就是使用Redisson:
public void test() {
RLock lock = redissonClient.getLock("keykeykey");
try {
boolean b = lock.tryLock(30, TimeUnit.SECONDS);
if(b){
//执行业务
}
//
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
2.1.2.3 方法说明
上面两种方式得到的锁,都是RLock 类型,实现类是RedissonLock:
RLock lock = redissonClient.getLock("keykeykey");
或者:
RLock lock = redisson.getLock("keykeykey");
getLock源码如下:
下面针对RedissonLock中的常用方法,进行一些说明:
public void lock();
public void lock(long leaseTime, TimeUnit unit);
public boolean tryLock();
boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException;
boolean tryLock(long waitTime, TimeUnit unit) throws InterruptedException
- lock(long leaseTime, TimeUnit unit):阻塞获取锁,拿到锁之前,线程处于阻塞等待状态,拿到锁后,设置leaseTime:
- leaseTime:持有锁的时间,也就是设置的redis key 过期时间,超过此时间没有主动释放锁的话,会被redis释放;
- unit :单位;
- lock() :阻塞获取锁,拿到锁之前,线程处于阻塞等待状态;拿到锁之后,没有设置过期时间,除非主动释放锁,否则锁不会被释放;
- tryLock(long waitTime, long leaseTime, TimeUnit unit):尝试获取锁,最长等待waitTime,等待内拿到锁返回true,否则false;
- waitTime :阻塞等待获取锁的时间,超过此时间,则不继续等待;
- leaseTime :拿到锁后,设置redis key的过期时间;
- unit :时间单位;
- tryLock(long waitTime, TimeUnit unit):与第3个一样,不同指出是此方法设置的leaseTime=-1,也就是在线程存活期间,redis key 默认不会被redis释放;
- tryLock() :锁可用,就立马返回ture,否则立马返回false;
2.1.2.4 leaseTime特殊说明
上文中提到加锁的参数leaseTime,这里再对其进行进一步阐述,leaseTime的含义是持有锁的时间。
本文一开头提到的第4个问题,Redisson已经帮我们解决,就是子线程会对key的过期时间进行续期,那么是否续期不是必然的,而是通过leaseTime参数控制。
下面我们从lock(long leaseTime, TimeUnit unit)方法作为加锁入口,分析下leaseTime参数的具体作用:
先看下官方对leaseTime的解释:
* @param leaseTime the maximum time to hold the lock after granting it,
* before automatically releasing it if it hasn't already been released by invoking <code>unlock</code>.
* If leaseTime is -1, hold the lock until explicitly unlocked.
其中提到,If leaseTime is -1,如果leaseTime=-1,则锁会被一直持有,直到主动unlock,那么锁是如何一直被持有的?难道真的没有设置锁的过期时间吗?
从上图的调用栈,进入到第2步骤:
private RFuture<Boolean> tryAcquireOnceAsync(long leaseTime, TimeUnit unit, long threadId) {
//如果leaseTime不是-1,比如设置了30s,那么redis key的过期时间就是30秒
if (leaseTime != -1) {
return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_NULL_BOOLEAN);
}
// 如果leaseTime 等于 -1,代码继续
//注意tryLockInnerAsync第一个参数,跟进去发现值是lockWatchdogTimeout:
//private long lockWatchdogTimeout = 30 * 1000;
//也即是不设置过期时间,默认也是加了过期时间的,默认是30s
RFuture<Boolean> ttlRemainingFuture =
tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_NULL_BOOLEAN);
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
if (e != null) {
return;
}
// lock acquired
if (ttlRemaining) {
//这里进行锁过期时间续期
scheduleExpirationRenewal(threadId);
}
});
return ttlRemainingFuture;
}
直接看代码上的注释,
也就是不设置过期时间,默认也是加了过期时间的,默认是30s。然后通过 scheduleExpirationRenewal(threadId);方法进行锁过期时间的续期:
private void scheduleExpirationRenewal(long threadId) {
ExpirationEntry entry = new ExpirationEntry();
//需要续期的key和线程信息,放到map中,后面有单独的线程从此map中获取并进行续期
ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
if (oldEntry != null) {
oldEntry.addThreadId(threadId);
} else {
entry.addThreadId(threadId);
//执行续期
renewExpiration();
}
}
点renewExpiration()进去跟进:
//更新过期时间
private void renewExpiration() {
ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ee == null) {
return;
}
//使用定时任务进行更新,这里是一个新的线程,使用的是netty的定时工具HashedWheelTimer
Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ent == null) {
return;
}
Long threadId = ent.getFirstThreadId();
if (threadId == null) {
return;
}
//执行续期动作
RFuture<Boolean> future = renewExpirationAsync(threadId);
future.onComplete((res, e) -> {
if (e != null) {
log.error("Can't update lock " + getName() + " expiration", e);
return;
}
if (res) {
// reschedule itself
renewExpiration();
}
});
}
//定时任务执行时间internalLockLeaseTime / 3,internalLockLeaseTime默认是30s
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
ee.setTimeout(task);
}
renewExpirationAsync(threadId);执行了续期动作,跟进去:
protected RFuture<Boolean> renewExpirationAsync(long threadId) {
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return 1; " +
"end; " +
"return 0;",
Collections.<Object>singletonList(getName()),
//ARGV[1])是internalLockLeaseTime=30s
internalLockLeaseTime, getLockName(threadId));
}
总结:设置releaseTime=-1时,Redisson并不是不设置锁的持有时间,而是默认设置了30s,然后通过netty的定时任务每10s就去进行续期,续期长度是30s。 当然,如果拿到锁的主线程挂了,挂了分两种情况:
- 线程抛出异常:这种情况,我们会在try finaly中进行解锁处理;
- 整个机器挂了:那么续期任务的子线程自然也没了,也就不会对锁进行续期,锁等30s也就被redis释放了,不会产生锁不被释放的问题。
2.2 问题5方案
对于宕机的问题,redis作者已经给出了方案,那就是RedLock算法,原理是对redis集群的每个节点都加锁,然后判断超过半数的节点返回true,表示加锁成功。Redisson框架实现了RedLock算法,具体使用如下。
Demo:
RLock lock1 = redissonInstance1.getLock("lock1");
RLock lock2 = redissonInstance2.getLock("lock2");
RLock lock3 = redissonInstance3.getLock("lock3");
RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);
// 同时加锁:lock1 lock2 lock3
// 红锁在大部分节点上加锁成功就算成功。
lock.lock();
...
lock.unlock();
关键是使用了红锁:RedissonRedLock 。
2.3 redisson分布式锁失效场景
1.应用运行过程中发生了fullgc,导致系统长时间停滞,redisson锁守护线程无法自动进行锁的续期,导致锁过期被释放。
这种目前没有好的方案,只能从业务上来规避,或者建立完善的告警机制,及时发现问题。