- 分布式锁之Redis实现
- 一、分布式锁
- 加锁
- 解锁
- 锁超时
- 二、redis设置分布式锁
- 1.加锁
- 2.解锁
- 3.实现
- 4.如果在setnx之后执行expire之前进程意外crash或者要重启维护了,那会怎么样?
- 多线程下设置分布式锁
- 三、Redisson
- 1.可重入锁
- 2.获取锁实例
- 3.加锁
分布式锁之Redis实现
在Java中,关于锁我想大家都很熟悉。在并发编程中,我们通过锁,来避免由于竞争而造成的数据不一致问题。通常,我们以synchronized 、Lock来使用它。
但是Java中的锁,只能保证在同一个JVM进程内中执行。如果在分布式集群环境下呢?
一、分布式锁
分布式锁,是一种思想,它的实现方式有很多。比如,我们将沙滩当做分布式锁的组件,那么它看起来应该是这样的:
加锁
在沙滩上踩一脚,留下自己的脚印,就对应了加锁操作。其他进程或者线程,看到沙滩上已经有脚印,证明锁已被别人持有,则等待。
解锁
把脚印从沙滩上抹去,就是解锁的过程。
锁超时
为了避免死锁,我们可以设置一阵风,在单位时间后刮起,将脚印自动抹去。
分布式锁的实现有很多,比如基于数据库、memcached、Redis、系统文件、zookeeper等。它们的核心的理念跟上面的过程大致相同。
二、redis设置分布式锁
1.加锁
加锁实际上就是在redis中,给Key键设置一个值,为避免死锁,并给定一个过期时间。
SET lock_key random_value NX PX 5000
值得注意的是: random_value 是客户端生成的唯一的字符串。 NX 代表只在键不存在时,才对键进行设置操作。 PX 5000 设置键的过期时间为5000毫秒。
这样,如果上面的命令执行成功,则证明客户端获取到了锁。
2.解锁
解锁的过程就是将Key键删除。但也不能乱删,不能说客户端1的请求将客户端2的锁给删除掉。这时候random_value的作用就体现出来。
为了保证解锁操作的原子性,我们用LUA脚本完成这一操作。先判断当前锁的字符串是否与传入的值相等,是的话就删除Key,解锁成功。
if redis.call('get',KEYS[1]) == ARGV[1] then
return redis.call('del',KEYS[1])
else
return 0
end
3.实现
- 首先,我们在pom文件中,引入Jedis。在这里,这里用的是最新版本,注意由于版本的不同,API可能有所差异。
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>3.0.1</version>
</dependency>
- 加锁的过程很简单,就是通过SET指令来设置值,成功则返回;否则就循环等待,在timeout时间内仍未获取到锁,则获取失败。
@Service
public class RedisLock {
Logger logger = LoggerFactory.getLogger(this.getClass());
private String lock_key = "redis_lock"; //锁键
protected long internalLockLeaseTime = 30000;//锁过期时间
private long timeout = 999999; //获取锁的超时时间
//SET命令的参数
SetParams params = SetParams.setParams().nx().px(internalLockLeaseTime);
/**
* 加锁
* @param id
* @return
*/
public boolean lock(String id){
JedisPool jedisPool=new JedisPool();
Jedis jedis = jedisPool.getResource();
Long start = System.currentTimeMillis();
try{
for(;;){
//SET命令返回OK ,则证明获取锁成功
String lock = jedis.set(lock_key, id, params);
if("OK".equals(lock)){
return true;
}
//否则循环等待,在timeout时间内仍未获取到锁,则获取失败
long l = System.currentTimeMillis() - start;
if (l>=timeout) {
return false;
}
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}finally {
jedis.close();
}
}
}
- 解锁我们通过jedis.eval来执行一段LUA就可以。将锁的Key键和生成的字符串当做参数传进来。
/**
* 解锁
* @param id
* @return
*/
public boolean unlock(String id){
Jedis jedis = jedisPool.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(lock_key),
Collections.singletonList(id));
if("1".equals(result.toString())){
return true;
}
return false;
}finally {
jedis.close();
}
}
- 测试
import com.example.springdemo01.service.Impl.RedisLock;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
@RunWith(SpringRunner.class)
@SpringBootTest
public class RedisLockTest {
@Autowired
RedisLock redisLock;
@Test
public void addLock(){
boolean lock = redisLock.lock("123");
System.out.println("---------------"+lock);
//测试同一个锁键能否添加俩次 若已存在此锁键 则在获取锁的周期时间内继续等待获取锁
//若超过获取锁的周期时间,则添加锁失败, 返回false
boolean lock1 = redisLock.lock("123");
System.out.println("---------------"+lock1);
}
@Test
public void unLock(){
boolean unlock = redisLock.unlock("123");
System.out.println("-----------"+unlock);
//测试如果没有锁键,或者已经被删除,则解锁失败 返回false
boolean unlock2 = redisLock.unlock("123");
System.out.println("-----------"+unlock);
}
}
4.如果在setnx之后执行expire之前进程意外crash或者要重启维护了,那会怎么样?
- setnx用来争抢锁
- expire给锁加一个过期时间防止锁忘记了释放。
- set指令有非常复杂的参数,这个应该是可以同时把setnx和expire合成一条指令来用的
- 当设置setnx的同时时,expire也可以进行设置,所以可以避免上述产生的问题 jedis.set(String key, String value, String nx, String expx, int time),这个set()方法一共有五个形参:
第一个为key,我们使用key来当锁,因为key是唯一的。
第二个为value,我们传的是requestId,很多童鞋可能不明白,有key作为锁不就够了吗,为什么还要用到value?原因就是我们在上面讲到可靠性时,分布式锁要满足第四个条件解铃还须系铃人,通过给value赋值为requestId,我们就知道这把锁是哪个请求加的了,在解锁的时候就可以有依据。requestId可以使用UUID.randomUUID().toString()方法生成。
第三个为nx,这个参数我们填的是NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作;
第四个为expx,这个参数我们传的是PX,意思是我们要给这个key加一个过期的设置,具体时间由第五个参数决定。
第五个为time,与第四个参数相呼应,代表key的过期时间。
总的来说,执行上面的set()方法就只会导致两种结果:
- 当前没有锁(key不存在),那么就进行加锁操作,并对锁设置个有效期,同时value表示加锁的客户端。
- 已有锁存在,不做任何操作。
多线程下设置分布式锁
- 我们可以在多线程环境下测试一下。我们开启1000个线程,对count进行累加。调用的时候,关键是唯一字符串的生成。这里,笔者使用的是Snowflake算法。
@Controller
public class IndexController {
@Autowired
RedisLock redisLock;
int count = 0;
@RequestMapping("/index")
@ResponseBody
public String index() throws InterruptedException {
int clientcount =1000;
CountDownLatch countDownLatch = new CountDownLatch(clientcount);
ExecutorService executorService = Executors.newFixedThreadPool(clientcount);
long start = System.currentTimeMillis();
for (int i = 0;i<clientcount;i++){
executorService.execute(() -> {
//通过Snowflake算法获取唯一的ID字符串
String id = IdUtil.getId();
try {
redisLock.lock(id);
count++;
}finally {
redisLock.unlock(id);
}
countDownLatch.countDown();
});
}
countDownLatch.await();
long end = System.currentTimeMillis();
logger.info("执行线程数:{},总耗时:{},count数为:{}",clientcount,end-start,count);
return "Hello";
}
}
三、Redisson
Redisson是架设在Redis基础上的一个Java驻内存数据网格(In-Memory
Data Grid)。 充分的利用了Redis键值数据库提供的一系列优势,基于Java实用工具包中 常用接口,为使用者提供了一系列具有分布式特性的常用工具类。使得原 本作为协调单机多线程并发程序的工具包获得了协调分布式多机多线程并 发系统的能力,大大降低了设计和研发大规模分布式系统的难度。同时结 合各富特色的分布式服务,更进一步简化了分布式环境中程序相互之间的 协作。
相对于Jedis而言,Redisson强大的一批。当然了,随之而来的就是它的复杂性。它里面也实现了分布式锁,而且包含多种类型的锁,更多请参阅分==布式锁和同步器==
1.可重入锁
上面我们自己实现的Redis分布式锁,其实不具有可重入性。那么下面我们先来看看Redisson中如何调用可重入锁。
- 导入依赖包
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.10.1</version>
</dependency>
- 首先,通过配置获取RedissonClient客户端的实例,然后getLock获取锁的实例,进行操作即可。
public static void main(String[] args) {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
config.useSingleServer().setPassword("redis1234");
final RedissonClient client = Redisson.create(config);
RLock lock = client.getLock("lock1");
try{
lock.lock();
}finally{
lock.unlock();
}
}
2.获取锁实例
我们先来看RLock lock = client.getLock("lock1"); 这句代码就是为了获取锁的实例,然后我们可以看到它返回的是一个RedissonLock对象。
public RLock getLock(String name) {
return new RedissonLock(connectionManager.getCommandExecutor(), name);
}
在RedissonLock构造方法中,主要初始化一些属性。
public RedissonLock(CommandAsyncExecutor commandExecutor, String name) {
super(commandExecutor, name);
//命令执行器
this.commandExecutor = commandExecutor;
//UUID字符串
this.id = commandExecutor.getConnectionManager().getId();
//内部锁过期时间
this.internalLockLeaseTime = commandExecutor.
getConnectionManager().getCfg().getLockWatchdogTimeout();
this.entryName = id + ":" + name;
}
3.加锁
当我们调用lock方法,定位到lockInterruptibly。在这里,完成了加锁的逻辑。
public void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException {
//当前线程ID
long threadId = Thread.currentThread().getId();
//尝试获取锁
Long ttl = tryAcquire(leaseTime, unit, threadId);
// 如果ttl为空,则证明获取锁成功
if (ttl == null) {
return;
}
//如果获取锁失败,则订阅到对应这个锁的channel
RFuture<RedissonLockEntry> future = subscribe(threadId);
commandExecutor.syncSubscription(future);
try {
while (true) {
//再次尝试获取锁
ttl = tryAcquire(leaseTime, unit, threadId);
//ttl为空,说明成功获取锁,返回
if (ttl == null) {
break;
}
//ttl大于0 则等待ttl时间后继续尝试获取
if (ttl >= 0) {
getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
getEntry(threadId).getLatch().acquire();
}
}
} finally {
//取消对channel的订阅
unsubscribe(future, threadId);
}
//get(lockAsync(leaseTime, unit));
}
如上代码,就是加锁的全过程。先调用tryAcquire来获取锁,如果返回值ttl为空,则证明加锁成功,返回;如果不为空,则证明加锁失败。这时候,它会订阅这个锁的Channel,等待锁释放的消息,然后重新尝试获取锁。流程如下: