什么是分布式锁
分布式锁是控制分布式系统或不同系统之间共同访问共享资源的一种锁实现,如果不同的系统或同一个系统的不同主机之间共享了某个资源时,往往需要互斥来防止彼此干扰来保证一致性。
常用的分布式锁的实现有三种方式。
- 基于mysql实现(利用mysql的innodb的行锁来实现,有两种方式, 悲观锁与乐观锁)
- 基于redis实现(利用redis的原子性操作setnx来实现)
- 基于Zookeeper实现(利用zk的临时顺序节点来实现)
一、mysql实现
1.1 乐观锁
1.1.1 乐观锁原理
乐观锁:采取了更加宽松的加锁机制,大多是基于数据版本( Version )及时间戳来实现。适合于读比较多,不会阻塞读,读取数据时不上锁,更新时检查是否数据已经被更新过,如果是则取消当前更新进行重试。version 或者 时间戳(CAS思想)。
1.1.2 操作案例
使用数据版本(Version)记录机制实现,这是乐观锁最常用的实现 方式。一般是通过为数据库表增加一个数字类型的 “version” 字段来实现。当读取数据时,将version字段的值一同读出,数据每更新一次,对此version值加一。当我们提交更新的时候,判断数据库表对应记录 的当前版本信息与第一次取出来的version值进行比对,如果数据库表当前版本号与第一次取出来的version值相等,则予以更新。
更新sql:
select * from db_stock where product_code='1001'
update db_stock set count=4996,version=version+1 where id=1 and version=0;
1.1.3 乐观锁存在的缺点
优点:
在检测数据冲突时并不依赖数据库本身的锁机制,不会影响请求的性能,当产生并发且并发量较小的时候只有少部分请求会失败。
缺点:
1.缺点是需要对表的设计增加额外的字段,增加了数据库的冗余,
2.另外,当应用并发量高的时候,version值在频繁变化,则会导致大量请求失败,影响系统的可用性。
3.高并发情况下,性能比较低下,并发量越小,性能越高。
4.读写情况下,乐观锁不可靠。
1.2 悲观锁
1.2.1 悲观锁原理
在select的时候就会加锁,采用先加锁后处理的模式,虽然保证了数据处理的安全性,但也会阻塞其他线程的写操作。在读取数据时锁住那几行,其他对这几行的更新需要等到悲观锁结束时才能继续 。
select ... for update
悲观锁适用于写多读少的场景,因为拿不到锁的线程,会将线程挂起,交出CPU资源,可以把CPU给其他线程使用,提高了CPU的利用率。
1.2.2 使用悲观锁的优缺点
优点:
1.简单容易理解;
2.可以严格保证数据访问的安全;
缺点:
1.即每次请求都会额外产生加锁的开销且未获取到锁的请求将会阻塞等待锁的获取,在高并发环境下,容易造成大量请求阻塞,影响系统可用性。另外,悲观锁使用不当还可能产生死锁的情况。
2.性能一般。
1.2.3 使用悲观锁的使用—行锁&表锁
1.使用悲观锁时,查询条件必须添加索引,成为索引字段,才走行锁。
2.使用悲观锁是,查询条件不加索引,走表锁。
二、redis
2.1 setnx
基于 Redis 实现的锁机制,主要是依赖 Redis 自身的原子操作
实现思想:
1. 获取锁的时候,使用setnx
加锁,并使用expire
命令为锁添加一个超时时间,超过该时间则自动释放锁,锁的 value 值为一个随机生成的 UUID +ThreadId
,通过此在释放锁的时候进行判断
2. 获取锁的时候还设置一个获取的超时时间,若超过这个时间则放弃获取锁
3. 释放锁的时候,通过 UUID + ThreadId
判断是不是该锁,若是该锁,则执行del进行锁释放
import org.springframework.data.redis.core.RedisTemplate;
import java.util.concurrent.TimeUnit;
public class RedisDistributedLock {
private RedisTemplate<String, String> redisTemplate;
private String lockKey;
private int expireTime; // 锁的过期时间,单位秒
public RedisDistributedLock(RedisTemplate<String, String> redisTemplate, String lockKey, int expireTime) {
this.redisTemplate = redisTemplate;
this.lockKey = lockKey;
this.expireTime = expireTime;
}
public boolean lock() {
Boolean result = redisTemplate.opsForValue().setIfAbsent(lockKey, "locked", expireTime, TimeUnit.SECONDS);
return result != null && result;
}
public void unlock() {
redisTemplate.delete(lockKey);
}
}
- 通过
SetNx(key,value,timeOut)
这个结合加锁与设置过期时间的原子命令就能完整的实现基于Redis的分布式锁的加锁步骤。 - value赋值为
UUID + ThreadId
,代表加锁的客户端请求标识,那么在客户端在解锁的时候就可以进行校验是否是同一个客户端。这样就避免了锁被别的线程删除,保证了安全性。
2.1.2 命令缺陷
1、死锁问题
根据上图流程,线程在获取锁成功以后,在执行业务的时候突然服务器宕机了,但是此刻依旧没有释放锁,导致锁无法释放就会导致死锁。
2、代码未执行完但锁时间到期了
如果添加了锁的过期时间,会出现业务代码未执行完就会释放锁。这是因为锁的时间难以预估出现代码未执行完出现锁提前释放,无法保证代码的原子性。
3、可重入问题
重入问题是指 获得锁的线程可以再次进入到相同的锁的代码块中, 可重入锁的意义在于防止死锁,比如HashTable这样的代码中,他的方法都是使用synchronized修饰的,假如他在一个方法内,调用另一个方法,那么此时如果是不可重入的,不就死锁了吗?所以可重入锁他的主要意义是防止死锁,我们的synchronized和Lock锁都是可重入的。
2.2 redisson
setnx可能存在锁过期释放,业务没执行完的问题。有些小伙伴认为,稍微把锁过期时间设置长一些就可以啦。其实问题的关键就在于我们不确定要设置多长时间,时间太短就会导致锁过期释放,业务没执行完,时间太长就会使系统运行效率下降. 我们设想一下,是否可以给获得锁的线程,开启一个定时守护线程,每隔一段时间检查锁是否还存在,存在则对锁的过期时间延长,防止锁过期提前释放。Redission框架帮我们实现了此功能,名为看门狗
底层是setnx和lua脚本(保证原子性)
只要线程一加锁成功,就会启动一个watch dog看门狗,它是一个后台线程,会每隔10秒检查一下,如果线程1还持有锁,那么就会不断的延长锁key的生存时间。因此,Redisson就是使用Redisson解决了锁过期释放,业务没执行完问题。
redisson代码见下:
2.3 引入lua脚本实现原子删除操作
lua脚本
是一个非常轻量级的脚本语言,Redis底层天生支持lua脚本的执行,一个lua脚本中可以包含多条Redis命令,Redis会将整个lua脚本当作原子操作来执行,从而实现聚合多条Redis指令的原子操作,其原理如下图所示:
2.4 RedLock
如果线程一在Redis的master节点上拿到了锁,但是加锁的key还没同步到slave节点。恰好这时,master节点发生故障,一个slave节点就会升级为master节点。线程二就可以获取同个key的锁啦,但线程一也已经拿到锁了,锁的安全性就没了。
这个时候我们就可以使用RedLock,
思路:在多个Redis服务器上保存锁,只需要超过半数的Redis服务器获取到锁,那么就真的获取到锁了,这样就算挂掉一部分节点,也能保证正常运行,保证了容错性。
三、ZooKeeper——todo
ZooKeeper是一个为分布式应用提供一致性服务的开源组件,它内部是一个分层的文件系统目录树结构,规定同一个目录下只能有一个唯一文件名。基于ZooKeeper实现分布式锁的步骤如下:
(1)创建一个目录mylock;
(2)线程A想获取锁就在mylock目录下创建临时顺序节点;
(3)获取mylock目录下所有的子节点,然后获取比自己小的兄弟节点,如果不存在,则说明当前线程顺序号最小,获得锁;
(4)线程B获取所有节点,判断自己不是最小节点,设置监听比自己次小的节点;
(5)线程A处理完,删除自己的节点,线程B监听到变更事件,判断自己是不是最小的节点,如果是则获得锁。
这里推荐一个Apache的开源库Curator
,它是一个ZooKeeper客户端,Curator提供的InterProcessMutex是分布式锁的实现,acquire方法用于获取锁,release方法用于释放锁。
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.retry.RetryNTimes;
import org.apache.zookeeper.CreateMode;
import org.apache.zookeeper.data.Stat;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;
/**
* 分布式锁Zookeeper实现
*
*/
@Slf4j
@Component
public class ZkLock implements DistributionLock {
private String zkAddress = "zk_adress";
private static final String root = "package root";
private CuratorFramework zkClient;
private final String LOCK_PREFIX = "/lock_";
@Bean
public DistributionLock initZkLock() {
if (StringUtils.isBlank(root)) {
throw new RuntimeException("zookeeper 'root' can't be null");
}
zkClient = CuratorFrameworkFactory
.builder()
.connectString(zkAddress)
.retryPolicy(new RetryNTimes(2000, 20000))
.namespace(root)
.build();
zkClient.start();
return this;
}
public boolean tryLock(String lockName) {
lockName = LOCK_PREFIX+lockName;
boolean locked = true;
try {
Stat stat = zkClient.checkExists().forPath(lockName);
if (stat == null) {
log.info("tryLock:{}", lockName);
stat = zkClient.checkExists().forPath(lockName);
if (stat == null) {
zkClient
.create()
.creatingParentsIfNeeded()
.withMode(CreateMode.EPHEMERAL)
.forPath(lockName, "1".getBytes());
} else {
log.warn("double-check stat.version:{}", stat.getAversion());
locked = false;
}
} else {
log.warn("check stat.version:{}", stat.getAversion());
locked = false;
}
} catch (Exception e) {
locked = false;
}
return locked;
}
public boolean tryLock(String key, long timeout) {
return false;
}
public void release(String lockName) {
lockName = LOCK_PREFIX+lockName;
try {
zkClient
.delete()
.guaranteed()
.deletingChildrenIfNeeded()
.forPath(lockName);
log.info("release:{}", lockName);
} catch (Exception e) {
log.error("删除", e);
}
}
public void setZkAddress(String zkAddress) {
this.zkAddress = zkAddress;
}
}
四、对比
数据库分布式锁缺点:
1、db操作性能较差,并且有锁表的风险。
2、非阻塞操作失败后,需要轮询,占用cpu资源。
3、长时间不commit或者长时间轮询,可能会占用较多连接资源。
Redis(缓存)分布式锁缺点:
1、锁删除失败,过期时间不好控制。
2、非阻塞,操作失败后,需要轮询,占用cpu资源。
ZK分布式锁缺点:
性能不如redis实现,主要原因是写操作(获取锁释放锁)都需要在leader上执行,然后同步到follower。同时需要频繁的创建和删除节点,
总之:ZooKeeper有较好的性能和可靠性。
从理解的难易程度角度(从低到高):数据库 > 缓存 > Zookeeper
从实现的复杂性角度(从低到高):Zookeeper >= 缓存 > 数据库
从性能角度(从高到低):缓存 > Zookeeper >= 数据库
从可靠性角度(从高到低):Zookeeper > 缓存 > 数据库