一、什么是分布式锁
在一个进程中,多线程去竞争资源时,可以通过synchronized或者Lock锁进行同步执行,保证多线程情况下,资源的调用是安全的,那么多进程中或者多节点机器中如何去保证对相同资源的调用是安全的,此时就引出了分布式锁解决方案。分布式锁就是用来保证在分布式系统中对共享资源调用时保证其一致性。
二、分布式锁实现
在实现分布式锁过程中需要考虑如下几点:
- 加锁和释放锁的原理
- 怎么保证一次只有一个节点拿到锁
- 锁的可重入性
- 怎么预防死锁问题
- 没有获得锁的节点应该怎么处理
需要实现分布式锁,就得借助于第三方软件,比如数据库、Redis、ZooKeeper等,本文就分别从这三种软件着手,来看看是怎样的实现过程。
1、基于Mysql的分布式锁
数据库中我们可以通过主键唯一性特点来进行加锁和解锁过程,主键唯一性就表示当前节点中只能有一个节点创建成功,其余的都是得到创建异常,那么创建成功的节点就表示获得了锁,其余节点只能等待获得锁。此时保证了第一步和第二步,那么怎么实现锁的可重入性呢?此时我们可以增加一列来存储当前节点的当前线程信息(可以用节点的应用名称+ip+线程名),重入次数加上一个计数器,因为数据库没有一个过期时间的设置,所以需要开启一个定时任务去判断当前锁是不是已经过期。所以我们还需要一个更新时间。
表如下:
DROP TABLE IF EXISTS `locks`;
CREATE TABLE `locks` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`lock_key` varchar(255) NOT NULL COMMENT '需要锁定的资源',
`repeat_key` varchar(255) NOT NULL COMMENT '可重入标识',
`repeat_time` int(11) DEFAULT NULL COMMENT '重入次数',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `lock_key` (`lock_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
代码实现如下,
import javax.sql.DataSource;
import java.io.Closeable;
import java.io.IOException;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.concurrent.TimeUnit;
/**
* Created by Administrator on 2022/1/13.
*/
public class MyLockFromMysql implements MyLock{
private DataSource dataSource;
public MyLockFromMysql(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public void lock(String key) {
if (!tryLock(key)){
throw new RuntimeException();
}
}
@Override
public void unlock(String key) {
String repeatKey= getRepeatKey();
if (hasRepeatLock(key,repeatKey)){
updateLock(key,repeatKey,-1);
}else if (!deleteLock(key,repeatKey)){
throw new RuntimeException();
}
}
@Override
public boolean tryLock(String key) {
String repeatKey= getRepeatKey();
if (hasLock(key,repeatKey)){
return updateLock(key,repeatKey,1);
}
for (;;){
if (addLock(key,repeatKey)){
break;
}
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
}
return true;
}
private String getRepeatKey() {
String host= "";
try {
host = InetAddress.getLocalHost().getHostAddress();
} catch (UnknownHostException e) {
e.printStackTrace();
}
return host+Thread.currentThread().getName();//采用节点ip+线程名来做重入判断
}
/**
* 根据key和repeatKey判断当前是否已经获得锁
* @param key 锁定的资源
* @param repeatKey 可重入标识
* @return
*/
private boolean hasLock(String key,String repeatKey){
Connection connection=null;
PreparedStatement statement=null;
ResultSet rs=null;
try {
connection=dataSource.getConnection();
statement=connection.prepareStatement("SELECT repeat_time FROM locks WHERE lock_key=? AND repeat_key=?");
statement.setString(1,key);
statement.setString(2,repeatKey);
rs=statement.executeQuery();
return rs.next();
} catch (Exception e) {
return false;
}finally {
close(rs);
close(statement);
close(connection);
}
}
/**
* 判断当前是否有重入锁
* @param key
* @param repeatKey
* @return
*/
private boolean hasRepeatLock(String key,String repeatKey){
Connection connection=null;
PreparedStatement statement=null;
ResultSet rs=null;
try {
connection=dataSource.getConnection();
statement=connection.prepareStatement("SELECT repeat_time FROM locks WHERE lock_key=? AND repeat_key=? AND repeat_time>1 ");
statement.setString(1,key);
statement.setString(2,repeatKey);
rs=statement.executeQuery();
return rs.next();
} catch (Exception e) {
return false;
}finally {
close(rs);
close(statement);
close(connection);
}
}
/**
* 如果当前线程没有获得锁直接添加一条数据去竞争锁
* @param key 锁定的资源
* @param repeatKey 可重入标识
* @return
*/
private boolean addLock(String key,String repeatKey){
Connection connection=null;
PreparedStatement statement=null;
try {
connection=dataSource.getConnection();
statement=connection.prepareStatement("INSERT INTO locks (lock_key,repeat_key,repeat_time,update_time) VALUES (?,?,1,now())");
statement.setString(1,key);
statement.setString(2,repeatKey);
return statement.executeUpdate()>0;
} catch (Exception e) {
return false;
}finally {
close(statement);
close(connection);
}
}
private boolean updateLock(String key,String repeatKey,int upDown){
Connection connection=null;
PreparedStatement statement=null;
try {
connection=dataSource.getConnection();
statement=connection.prepareStatement("UPDATE locks set repeat_time=repeat_time+?,update_time=now() WHERE lock_key=? AND repeat_key=?");
statement.setInt(1,upDown);
statement.setString(2,key);
statement.setString(3,repeatKey);
return statement.executeUpdate()>0;
} catch (Exception e) {
return false;
}finally {
close(statement);
close(connection);
}
}
private boolean deleteLock(String key,String repeatKey){
Connection connection=null;
PreparedStatement statement=null;
try {
connection=dataSource.getConnection();
statement=connection.prepareStatement("DELETE FROM locks WHERE lock_key=?");
statement.setString(1,key);
return statement.executeUpdate()>0;
} catch (Exception e) {
e.printStackTrace();
return false;
}finally {
close(statement);
close(connection);
}
}
private void close(AutoCloseable close){
if (close!=null){
try {
close.close();
} catch (Exception e) {
}
}
}
}
以上代码只是简单的去实现了用mysql来做分布式锁的过程(性能不是太友好,也会存在很多问题),逻辑就是通过设定需要锁定的资源为主键,加锁的时候往数据库中添加数据,此时只会有添加成功的节点会获得锁,当调用完成之后删掉数据,也就是表明释放锁。针对重入锁,采用了一个重入key和重入次数两个字段来实现重入,如果当前节点已经获得锁,需要再次获得锁的时候,直接对次数+1操作,释放锁的时候做-1操作。当为1的时候再释放就需要删除节点。
其中的缺点也可想而知:
- 获得锁节点,不能正确的释放锁,那么锁记录就会一直存在数据库中,其它节点就不能获得锁,此时就需要人工干预
- 高并发时,会给系统和数据库系统带来压力
- 没有唤醒操作,其它线程只能是循环去获得锁
2、基于Redis实现分布式锁
Redis中有一个setnx命令,这个命令key不存在添加成功放回1,存在返回0,所以采用redis实现分布式锁就是基于这个命令来实现,也就是只有在创建成功放回1的节点就是获得锁的节点,释放锁的时候,删除该节点即可,redis中可以采用过期时间策略来保证,客户端释放锁失败后,也能在规定时间内释放锁,锁的可重入性在于value的设计过程,value我们可以保存当前节点的唯一标识和可重入次数。
简单代码如下所示:
import com.alibaba.fastjson.JSONObject;
import redis.clients.jedis.Jedis;
import java.net.InetAddress;
import java.net.UnknownHostException;
/**
* Created by Administrator on 2022/1/14.
*/
public class MyLockForRedis implements MyLock {
private final long TIME_WAIT=50L;
@Override
public void lock(String key) {
tryLock(key);
}
@Override
public void unlock(String key) {
Jedis redis=new Jedis("127.0.0.1",6379);
String json=redis.get(key);
LockValue lockValue= JSONObject.parseObject(json,LockValue.class);
if (getRepeatKeyV().equals(lockValue.getRepeatKey())){
if (lockValue.getTime()>1){
lockValue.setTime(lockValue.getTime()-1);
redis.set(key,JSONObject.toJSONString(lockValue));
}else {
redis.del(key);
}
}
redis.close();
}
@Override
public boolean tryLock(String key) {
Jedis redis=new Jedis("127.0.0.1",6379);
try {
for (;;){
if ("OK".equals(redis.set(key,JSONObject.toJSONString(new LockValue()),"NX","EX",200000))){
return true;
}else {
String json=redis.get(key);
LockValue lockValue= JSONObject.parseObject(json,LockValue.class);
if (lockValue!=null&&getRepeatKeyV().equals(lockValue.getRepeatKey())){
lockValue.setTime(lockValue.getTime()+1);
redis.set(key,JSONObject.toJSONString(lockValue));
return true;
}
}
try {
Thread.sleep(TIME_WAIT);
} catch (InterruptedException e) {
}
}
}finally {
redis.close();
}
}
private static class LockValue{
private int time=1;
private String repeatKey=getRepeatKeyV();
public int getTime() {
return time;
}
public void setTime(int time) {
this.time = time;
}
public String getRepeatKey() {
return repeatKey;
}
public void setRepeatKey(String repeatKey) {
this.repeatKey = repeatKey;
}
@Override
public String toString() {
return "{time=" + time +", repeatKey=\"" + repeatKey+ "\"}";
}
}
private static String getRepeatKeyV() {
String host= "";
try {
host = InetAddress.getLocalHost().getHostAddress();
} catch (UnknownHostException e) {
e.printStackTrace();
}
return host+Thread.currentThread().getName();//采用节点ip+线程名来做重入判断
}
}
以上只是简单的去实现了基于Redis的分布式锁功能(肯定存在很多问题),其中value保存的是json格式的数据来实现锁的可重入性,这里就会存在一个性能的开销(不断的解析json格式),这里存在一个问题是过期时间的设置,如果我们设置的是10,如果某节点获得锁之后,执行的很慢超过了十秒,此时别的节点就可以获得锁,一种解决方案就是在获得锁的节点中开启一个线程去更新锁的过期时间,当节点执行完成之后关掉这个线程,如果获取锁的节点宕机之后,也可以通过过期时间来解决。
3、基于Zookeeper实现分布式锁
ZooKeeper中实现分布式锁方式有两种方案:
- 创建临时节点,多个客户端同时去创建相同节点,只能一个创建成功,其余的都会创建失败,那么创建成功的节点就获得锁,失败的就继续等待释放锁
- 创建临时有序节点,每个客户端都去创建一个临时有序节点,然后获取到所有节点,判断自己是不是最小的节点,如果是则获得锁,否则监听自己前一个节点锁的释放。
基于这种方式实现的分布式锁,可以查看Curator客户端连接中的InterProcessMutex实现,它的实现逻辑就是通过创建临时顺序子节点,具体实现过程如下:
- 首先在InterProcessMutex中维护一个ConcurrentMap集合,集合的键值是当前线程,值是LockData(他是由当前线程,锁路径、以及重入次数组成)
- 获取锁时,先从集合中取出当前的LockData,如果存在说明当前线程已经获取锁,只需要重入次数加1操作,否则就尝试获得锁
- 尝试获得锁时,先创建属于自己的临时顺序节点,然后先获取到当前锁中所有的顺序子节点(排序后的),然后判断当前节点是否是最小节点,如果是则获取锁,返回,否则找到前一个节点,然后在前一个节点上注册一个wathcer,然后调用wait方法进行阻塞(如果设置的等待时间为负数,表示当前节点没有获得锁时,立即退出,然后会删除当前节点)
- 当前节点释放锁时,当前节点就会收到watcher通知,然后调用notifyAll()方法进行唤醒,此时当前节点再次去竞争锁
- 当前获得锁之后,会把当前节点和LockData保存在集合中,用来处理重入性
- 锁释放,根据当前节点线程找到需要释放的路径(不存在抛出异常),然后先判断当前的重入性,如果大于0,表示当前是重入锁,否则如果等于0,则进行锁的释放,小于0 ,抛出异常
- 锁的释放过程就是,删除当前节点,从集合中移除当前线程,然后移除watcher
以上有任何不对的地方,请留言指正,谢谢!