在对一些共享资源进行操作的时候,为了保证数据的安全性,我们经常会使用到锁,比如像synchronized、ReentrantLock等。这些是针对在同一个JVM中,但是在分布式情况下,程序的运行是在不同的服务器上,所以对应的也就是不同的JVM,用这些方法就不能够实现数据安全了。基于Redis、zookeeper则是可以实现在分布式情况下仍然能够保证数据安全性的分布式锁。
在用Redis实现分布式锁之前,我们先来一手简单的例子作为开篇:
正文
我们启动多个线程,让他们对同一个数据进行操作:
private static final Logger logger = LoggerFactory.getLogger(Test.class);
private static int size=2022;
private static final int ThreadNum=2022;
protected static ExecutorService executorService = new ThreadPoolExecutor(Runtime.getRuntime().availableProcessors() * 2, 50, 0L, TimeUnit.SECONDS
, new LinkedBlockingDeque<>(1024)
, new ThreadFactoryBuilder().setNameFormat("Thread-name-%d").setUncaughtExceptionHandler((thread, throwable)-> logger.error("ThreadPool {} got exception", thread,throwable)).build()
, new ThreadPoolExecutor.CallerRunsPolicy());
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch=new CountDownLatch(ThreadNum);
for (int i = 0; i <ThreadNum ; i++) {
executorService.execute(()->{
try {
Thread.sleep(1);
--size;
}catch (Exception e){
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+"执行完毕" );
countDownLatch.countDown();
});
}
executorService.shutdown();
countDownLatch.await();
System.out.println("剩余:"+size);
}
我们运行完程序后,发现结果并不为0,这是因为在多线程操作共享资源的时候,我们没有加锁出现了线程安全问题,解决的办法也很简单,加上个synchronized就可以了。
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch=new CountDownLatch(ThreadNum);
for (int i = 0; i <ThreadNum ; i++) {
executorService.execute(()->{
synchronized (""){
try {
Thread.sleep(1);
--size;
}catch (Exception e){
e.printStackTrace();
}
}
System.out.println(Thread.currentThread().getName()+"执行完毕" );
countDownLatch.countDown();
});
}
executorService.shutdown();
countDownLatch.await();
System.out.println("剩余:"+size);
}
加个synchronized锁,在同一个项目,也就是同一个JVM中,是不会有线程安全问题的。但是在分布式项目情况下就不行了。因为synchronized只能作用于当前JVM,对于别的JVM,它管不到啊。
因此需要一种锁,即使是在分布式多个JVM的情况下,也能够实现线程安全,这个时候Redis就派上用场了。
Redis实现分布式锁
我们在看Redis是如何实现分布式锁之前,先来看一个Redis的命令。
SET key value NX PX 1000
key、value就是键值对,可以根据key获取对应的value。
NX:当设置的键不存在的时候,才会设置key-value,当然了,这个也可以替换为setnx命令。
PX:设置键值对的过期时间为1000毫秒。
实现
首先导入下依赖
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
配置类:
public class JedisConfig {
//设置Redis的一些配置
public static JedisPool redisPoolFactory() {
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
jedisPoolConfig.setMaxTotal(8);
jedisPoolConfig.setMaxIdle(500);
jedisPoolConfig.setMinIdle(0);
return new JedisPool(jedisPoolConfig, "ip", 6379, 3000, "密码");
}
}
加锁的步骤就用到了我们在上面提到的SET命令,通过添加nx属性,可以保证整个set的过程是个原子性。
private static Logger logger = LoggerFactory.getLogger(RedisLock.class);
private static final long TIME = 10000;
private static final SetParams PARAMS = SetParams.setParams().nx().px(TIME);
private static final Long INTERNAL_LOCK_LEAST_TIME=1000l;
private static final String prefix="prefix-";
public boolean lock(String key){
Jedis jedis= JedisConfig.redisPoolFactory().getResource();
try {
long begin = System.currentTimeMillis();
while (true){
String result = jedis.set(prefix, key, PARAMS);
//如果返回值为Ok,那么就说明加锁成功
if(StringUtils.equals("OK",result)){
logger.info("分布式锁获取成功,realKey:"+prefix+key);
return true;
}
//自旋时间超过internalLockLeaseTime,那么就加锁失败
if ((System.currentTimeMillis() - begin)>INTERNAL_LOCK_LEAST_TIME){
logger.info("分布式锁获取失败,realKey:"+prefix+key);
return false;
}
}
}finally {
jedis.close();
}
}
我们想一下解锁的步骤:根据key从redis中获取到对应的value,如果这个value和我们代码中传入的值相等的话,那么就执行del删除操作。但是这里存在一个问题:这是两步操作,而不是原子性操作,因此是会出现线程安全问题的。所以在这里使用LUA脚本来完成解锁的实现。
/**
* 解锁
* */
public boolean unlock(String key){
Jedis jedis=JedisConfig.redisPoolFactory().getResource();
String script = "if redis.call('get',KEYS[1]) == ARGV[1] then" +
" return redis.call('del',KEYS[1]) " +
"else" +
" return 0 " +
"end";
try {
Object result = jedis.eval(script, Collections.singletonList(prefix),
Collections.singletonList(key));
if (StringUtils.equals("1",result.toString())){
logger.info("分布式锁删除成功,realKey:"+prefix+key);
return true;
}
logger.info("分布式锁删除失败,realKey:"+prefix+key);
return false;
}finally {
jedis.close();
}
}
这样就对加锁、解锁做了个简单的封装,来测试一下好不好使。
private static Logger logger = LoggerFactory.getLogger(RedisLockTest.class);
private static int SIZE=10;
private static int THREAD_NUM=10;
protected static ExecutorService executorService = new ThreadPoolExecutor(Runtime.getRuntime().availableProcessors() * 2, 50, 0L, TimeUnit.SECONDS
, new LinkedBlockingDeque<>(1024)
, new ThreadFactoryBuilder().setNameFormat("Thread-name-%d").setUncaughtExceptionHandler((thread, throwable)-> logger.error("ThreadPool {} got exception", thread,throwable)).build()
, new ThreadPoolExecutor.CallerRunsPolicy());
public static void main(String[] args){
RedisLock redisLock=new RedisLock();
for (int i = 0; i <THREAD_NUM ; i++) {
executorService.execute(()->{
String s = UUID.randomUUID().toString();
try {
redisLock.lock(s);
System.out.println(Thread.currentThread().getName()+"=======加锁:" + s);
System.out.println("当前剩余:"+--SIZE);
}finally {
redisLock.unlock(s);
System.out.println(Thread.currentThread().getName()+"========解锁:" + s);
}
});
}
executorService.shutdown();
}
从这个结果可以看到,数据的准确性还是可以保证的。
以上就是我们基于redis实现了一个简单的分布式锁。为了保证分布式锁的正常使用,我们要考虑以下几点:
- 互斥性:在任意时刻,只有一个客户端能持有锁,如果多个客户端都可以持有锁,那么数据还是会出现线程安全问题。
- 不会发生死锁:即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
- 具有容错性:只要大部分的Redis节点正常运行,客户端就可以加锁和解锁。
- 解铃还须系铃人:加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。
以我们写的简单的例子还是无法实现上面四点的,好在这里有一个现成的供我们使用:Redisson。
Redisson
网上是这么介绍Redisson的:
Redisson在基于NIO的Netty框架上,充分的利用了Redis键值数据库提供的一系列优势,在Java实用工具包中常用接口的基础上,为使用者提供了一系列具有分布式特性的常用工具类。使得原本作为协调单机多线程并发程序的工具包获得了协调分布式多机多线程并发系统的能力,大大降低了设计和研发大规模分布式系统的难度。同时结合各富特色的分布式服务,更进一步简化了分布式环境中程序相互之间的协作。
说的倒是有点多,我们只需要知道他功能很强大就行啦。
我们来个简单的例子看下redisson是怎么用的:
还是先导入下依赖:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.10.1</version>
</dependency>
public static void main(String[] args) {
Config config = new Config();
config.useSingleServer().setAddress("redis://ip:6379");
config.useSingleServer().setPassword("密码");
final RedissonClient client = Redisson.create(config);
RLock lock = client.getLock("lock");
try{
lock.lock();
System.out.println("线程一加锁,开始执行逻辑.......");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程一逻辑执行完毕.......");
}finally{
lock.unlock();
System.out.println("线程一释放锁");
}
System.out.println("线程一执行结束");
}
可以看到,这个redisson已经帮我们封装了加锁、解锁的过程,我们只需要调用api就可以了,可以说是相当的方便。基于上面我们已经写出了一个简单的分布式锁,那么我们看下redisson他是怎么实现加锁与解锁的。
获取Lock对象:
public RLock getLock(String name) {
return new RedissonLock(connectionManager.getCommandExecutor(), name);
}
public RedissonLock(CommandAsyncExecutor commandExecutor, String name) {
super(commandExecutor, name);
//命令执行器
this.commandExecutor = commandExecutor;
//UUID,可以看到getId的返回值是UUID
this.id = commandExecutor.getConnectionManager().getId();
//锁过期时间
this.internalLockLeaseTime = commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout();
this.entryName = id + ":" + name;
}
加锁整体步骤
public void lock() {
try {
lockInterruptibly();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
public void lockInterruptibly() throws InterruptedException {
lockInterruptibly(-1, null);
}
public void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException {
//当前线程id
long threadId = Thread.currentThread().getId();
//获取锁
Long ttl = tryAcquire(leaseTime, unit, threadId);
// lock acquired
//说明获取到了锁
if (ttl == null) {
return;
}
//获取锁失败就一直循环去获取
RFuture<RedissonLockEntry> future = subscribe(threadId);
commandExecutor.syncSubscription(future);
try {
while (true) {
ttl = tryAcquire(leaseTime, unit, threadId);
// lock acquired
//获取成功
if (ttl == null) {
break;
}
// waiting for message
//如果ttl为正数,那么就等待ttl时间后再获取
if (ttl >= 0) {
getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
//如果没有key或者key没有设置过期时间,那么就走这里
getEntry(threadId).getLatch().acquire();
}
}
} finally {
unsubscribe(future, threadId);
}
}
获取锁有带过期时间与不带过期时间两种情况,所以需要进行一个区分:
private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, final long threadId) {
//如果带有过期时间,则按照普通方式获取锁
if (leaseTime != -1) {
return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
}
//先按照30秒的过期时间来执行获取锁的方法
RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
//如果还持有这个锁,则开启定时任务不断刷新该锁的过期时间
ttlRemainingFuture.addListener(new FutureListener<Long>() {
@Override
public void operationComplete(Future<Long> future) throws Exception {
if (!future.isSuccess()) {
return;
}
Long ttlRemaining = future.getNow();
// lock acquired
if (ttlRemaining == null) {
scheduleExpirationRenewal(threadId);
}
}
});
return ttlRemainingFuture;
}
加锁的核心方法也是执行了一段LUA代码,不过要比我们写的要复杂点:
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
//获取到过期时间
internalLockLeaseTime = unit.toMillis(leaseTime);
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
//如果锁不存在,则通过hset设置它的值,并设置过期时间
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
//如果锁已存在,并且锁的是当前线程,则通过hincrby给数值递增1
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
//如果锁已存在,但并非本线程,则返回过期时间ttl
"return redis.call('pttl', KEYS[1]);",
Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}
加锁成功后,在redis的内存数据中,就有一条hash结构的数据。Key为锁的名称;field为随机字符串+线程ID;值为1。如果同一线程多次调用lock
方法,值递增1。他就长这个样子:
解锁:
public void unlock() {
try {
get(unlockAsync(Thread.currentThread().getId()));
} catch (RedisException e) {
if (e.getCause() instanceof IllegalMonitorStateException) {
throw (IllegalMonitorStateException)e.getCause();
} else {
throw e;
}
}
}
public RFuture<Void> unlockAsync(final long threadId) {
final RPromise<Void> result = new RedissonPromise<Void>();
//解锁方法
RFuture<Boolean> future = unlockInnerAsync(threadId);
future.addListener(new FutureListener<Boolean>() {
@Override
public void operationComplete(Future<Boolean> future) throws Exception {
if (!future.isSuccess()) {
cancelExpirationRenewal(threadId);
result.tryFailure(future.cause());
return;
}
//获取返回值
Boolean opStatus = future.getNow();
//如果返回空,则证明解锁的线程和当前锁不是同一个线程,抛出异常
if (opStatus == null) {
IllegalMonitorStateException cause = new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: "
+ id + " thread-id: " + threadId);
result.tryFailure(cause);
return;
}
//解锁成功,取消刷新过期时间的那个定时任务
if (opStatus) {
cancelExpirationRenewal(null);
}
result.trySuccess(null);
}
});
return result;
}
解锁核心方法:
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; " +
"end;" +
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; "+
"end; " +
"return nil;",
Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.unlockMessage, internalLockLeaseTime, getLockName(threadId));
}
这里的解锁LUA代码明显是要比我们的要复杂点,在这里我给出这段代码都表达了些什么
- 如果锁已经不存在,通过publish发布锁释放的消息,解锁成功
- 如果解锁的线程和当前锁的线程不是同一个,解锁失败,抛出异常
- 通过hincrby递减1,先释放一次锁。若剩余次数还大于0,则证明当前锁是重入锁,刷新过期时间;若剩余次数小于0,删除key并发布锁释放的消息,解锁成功
后续
Redisson封装的已经很好了,我们可以开箱即用。但是上面我们使用的是单节点redis,在集群或者是哨兵模式情况下,我们知道主节点将数据同步给从节点的过程是异步的,也就是说,先返回是否加锁成功的结果,然后才会将信息同步给从节点。在这里就会存在节点挂掉的时候丢失锁的问题。
如果可以接受上面出现的问题的话,那么用Redisson也是没有问题的,如果不能够接受的话,倒是可以用这个Redlock,Redlock在加锁时,它会向过半的节点发送 set指令,只要大部分节点 set 成功,那就认为加锁成功。释放锁时,需要向所有节点发送 del 指令。不过 Redlock 算法还需要考虑出错重试、时钟漂移等很多细节问题,同时因为 Redlock 需要向多个节点进行读写,意味着相比单实例 Redis 性能会下降一些。
关于Redlock更多的信息,可以参考:https://redis.io/topics/distlock,在这里我就不多介绍了。