1、为什么要使用分布式锁
我们在开发应用的时候,如果需要对某一个共享变量进行多线程同步访问的时候,可以使用我们学到的Java多线程的18般武艺进行处理,并且可以完美的运行,毫无Bug
!但是这是单机的应用,也就是所有的请求都会分配到当前服务器的JVM
内部,然后映射为操作系统的线程进行处理!而这个共享变量只是在这个JVM
内部的一块内存空间!
后来业务发展,需要做集群,一个应用需要部署到几台机器上然后做负载均衡:
如上图所示,变量A存在JVM1
、JVM2
、JVM3
三个JVM内存中(这个变量A主要体现是在一个类中的一个成员变量,是一个有状态的对象,例如:UserController
控制器中的一个整形类型的成员变量),如果不加任何控制的话,变量A同时都会在JVM
分配一块内存,三个请求发过来同时对这个变量操作,操作的结果不对。即使不是同时发过来,三个请求分别操作三个不同JVM
内存区域的数据,变量A之间不存在共享,也不具有可见性,处理的结果也是不对的!
集群环境中存在各个服务器之间数据不共享、不可见的问题
- 单机环境: 为了保证一个方法或属性在高并发情况下的同一时间只能被同一个线程执行,在传统单体应用单机部署的情况下,可以使用Java并发处理相关的
API
(如ReentrantLock
或Synchronized
)进行互斥控制。在单机环境中,Java中提供了很多并发处理相关的API
。 - 集群环境: 随着业务发展的需要,原单体单机部署的系统被演化成分布式集群系统后,由于分布式系统多线程、多进程并且分布在不同机器上,这将使原单机部署情况下的并发控制锁策略失效,单纯的
Java API
并不能提供分布式锁的能力。为了解决这个问题就需要一种跨JVM
的互斥机制来控制共享资源的访问,这就是分布式锁要解决的问题!
2、分布式锁应该具备哪些条件?
- 在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行;
- 高可用的获取锁与释放锁;
- 高性能的获取锁与释放锁;
- 具备可重入特性;
- 具备锁失效机制,防止死锁;
- 具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。
3、分布式锁的三种实现方式
在很多场景中,我们为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等。有的时候,我们需要保证一个方法在同一时间内只能被同一个线程执行。
- 基于缓存(
Redis
等)实现分布式锁; - 基于数据库实现分布式锁;
- 基于
Zookeeper
实现分布式锁。
4、基于 Redis 的分布式锁
4.1、利用 SETNX 和 SETEX
- setnx+lua: 当且仅当
Key
不存在时,则可以设置,否则不做任何动作; - SETEX(set key value px milliseconds nx): 可以设置超时时间。
# 获取锁(unique_value可以是UUID等)
SET resource_name unique_value NX PX 30000
# 释放锁(lua脚本中,一定要比较value,防止误解锁)
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
4.1.1、原理
通过 SETNX
设置 Key-Value
来获得锁,随即进入死循环,每次循环判断,如果存在 Key
则继续循环,如果不存在 Key
,则跳出循环,当前任务执行完成后,删除 Key
以释放锁。这种方式可能会导致死锁,为了避免这种情况,需要设置超时时间。
4.1.2、实现注意事项
-
set
命令要用set key value px milliseconds nx
; -
value
要具有唯一性; - 释放锁时要验证
value
值,不能误解锁;
4.1.3、集群Redis常见问题
事实上这类琐最大的缺点就是它加锁时只作用在一个Redis
节点上,即使Redis
通过sentinel
保证高可用,如果这个master
节点由于某些原因发生了主从切换,那么就会出现锁丢失的情况:
- 在
Redis
的master
节点上拿到了锁; - 但是这个加锁的
key
还没有同步到slave
节点; -
master
故障,发生故障转移,slave节点升级为master
节点; - 导致锁丢失。
4.2、实现步骤
4.2.1、创建一个Maven工程,并在pom.xml中加入下述依赖:
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- 开启web-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
</dependencies>
4.2.2、添加配置文件 application.yml
点击查看代码
server:
port: 8080
spring:
redis:
host: localhost
port: 6379
4.2.3、创建一个全局锁类Lock.java
/**
* 全局锁,包括锁的名称
*/
public class Lock {
private String name;
private String value;
public Lock(String name, String value) {
this.name = name;
this.value = value;
}
public String getName() {
return name;
}
public String getValue() {
return value;
}
}
4.2.4、创建分布式锁类 DistributedLockHandler.java
@Component
public class DistributedLockHandler {
private static final Logger logger = LoggerFactory.getLogger(DistributedLockHandler.class);
private final static long LOCK_EXPIRE = 30 * 1000L;//单个业务持有锁的时间30s,防止死锁
private final static long LOCK_TRY_INTERVAL = 30L;//默认30ms尝试一次
private final static long LOCK_TRY_TIMEOUT = 20 * 1000L;//默认尝试20s
@Autowired
private StringRedisTemplate template;
/**
* 尝试获取全局锁
*
* @param lock 锁的名称
* @return true 获取成功,false获取失败
*/
public boolean tryLock(Lock lock) {
return getLock(lock, LOCK_TRY_TIMEOUT, LOCK_TRY_INTERVAL, LOCK_EXPIRE);
}
/**
* 尝试获取全局锁
*
* @param lock 锁的名称
* @param timeout 获取超时时间 单位ms
* @return true 获取成功,false获取失败
*/
public boolean tryLock(Lock lock, long timeout) {
return getLock(lock, timeout, LOCK_TRY_INTERVAL, LOCK_EXPIRE);
}
/**
* 尝试获取全局锁
*
* @param lock 锁的名称
* @param timeout 获取锁的超时时间
* @param tryInterval 多少毫秒尝试获取一次
* @return true 获取成功,false获取失败
*/
public boolean tryLock(Lock lock, long timeout, long tryInterval) {
return getLock(lock, timeout, tryInterval, LOCK_EXPIRE);
}
/**
* 尝试获取全局锁
*
* @param lock 锁的名称
* @param timeout 获取锁的超时时间
* @param tryInterval 多少毫秒尝试获取一次
* @param lockExpireTime 锁的过期
* @return true 获取成功,false获取失败
*/
public boolean tryLock(Lock lock, long timeout, long tryInterval, long lockExpireTime) {
return getLock(lock, timeout, tryInterval, lockExpireTime);
}
/**
* 操作redis获取全局锁
*
* @param lock 锁的名称
* @param timeout 获取的超时时间
* @param tryInterval 多少ms尝试一次
* @param lockExpireTime 获取成功后锁的过期时间
* @return true 获取成功,false获取失败
*/
public boolean getLock(Lock lock, long timeout, long tryInterval, long lockExpireTime) {
try {
if (StringUtils.isEmpty(lock.getName()) || StringUtils.isEmpty(lock.getValue())) {
return false;
}
long startTime = System.currentTimeMillis();
do{
if (!template.hasKey(lock.getName())) {
ValueOperations<String, String> ops = template.opsForValue();
ops.set(lock.getName(), lock.getValue(), lockExpireTime, TimeUnit.MILLISECONDS);
return true;
} else {//存在锁
logger.debug("lock is exist!!!");
}
if (System.currentTimeMillis() - startTime > timeout) {//尝试超过了设定值之后直接跳出循环
return false;
}
Thread.sleep(tryInterval);
}
while (template.hasKey(lock.getName())) ;
} catch (InterruptedException e) {
logger.error(e.getMessage());
return false;
}
return false;
}
/**
* 释放锁
*/
public void releaseLock(Lock lock) {
if (!StringUtils.isEmpty(lock.getName())) {
template.delete(lock.getName());
}
}
}
4.2.5、创建 HelloController 来测试分布式锁
点击查看代码
@RestController
public class HelloController {
@Autowired
private DistributedLockHandler distributedLockHandler;
@RequestMapping("index")
public String index(){
Lock lock=new Lock("lynn","min");
if(distributedLockHandler.tryLock(lock)){
try {
//为了演示锁的效果,这里睡眠5000毫秒
System.out.println("执行方法");
Thread.sleep(5000);
}catch (Exception e){
e.printStackTrace();
}
distributedLockHandler.releaseLock(lock);
}
return "hello world!";
}
}
4.2.6、测试
启动项目,并在浏览器输入http://localhost:8080/index
,连续刷新界面,控制台在打印了一次“执行方法”,5秒之后再次打印。
4.3、SETNX 和 SETEX的缺点
- 高并发的情况下,如果两个线程同时进入循环,可能导致加锁失败;
-
SETNX
是一个耗时操作,因为它需要判断Key
是否存在,因为会存在性能问题; - 如果这个master节点由于某些原因发生了主从切换,那么就会出现锁丢失的情况。
4.4、红锁(RedLock)
4.4.1、红锁的前置条件
在Redis
的分布式环境中,我们假设有N个Redis master
。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。我们确保将在N个实例上使用与在Redis
单实例下相同方法获取和释放锁。现在我们假设有5个Redis master
节点,同时我们需要在5台服务器上面运行这些Redis
实例,这样保证他们不会同时都宕掉。
4.4.2、红锁的执行流程
- 获取当前
Unix
时间,以毫秒为单位。 - 依次尝试从5个实例,使用相同的
key
和具有唯一性的value
(例如UUID
)获取锁。当向Redis
请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10
秒,则超时时间应该在5-50
毫秒之间。这样可以避免服务器端Redis
已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个Redis
实例请求获取锁。 - 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(
N/2+1
,这里是3个节点)的Redis
节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。 - 如果取到了锁,
key
的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。 - 如果因为某些原因,获取锁失败(没有在至少
N/2+1
个Redis
实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis
实例上进行解锁(即便某些Redis
实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)。
4.5、红锁代码实现
4.4.1、pom.xml添加依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.7.0</version>
</dependency>
4.4.2、增加几个类
/**
* 获取锁后需要处理的逻辑
*/
public interface AquiredLockWorker<T> {
T invokeAfterLockAquire() throws Exception;
}
/**
* 获取锁管理类
*/
public interface DistributedLocker {
/**
* 获取锁
* @param resourceName 锁的名称
* @param worker 获取锁后的处理类
* @param <T>
* @return 处理完具体的业务逻辑要返回的数据
* @throws UnableToAquireLockException
* @throws Exception
*/
<T> T lock(String resourceName, AquiredLockWorker<T> worker) throws UnableToAquireLockException, Exception;
<T> T lock(String resourceName, AquiredLockWorker<T> worker, int lockTime) throws UnableToAquireLockException, Exception;
}
/**
* 异常类
*/
public class UnableToAquireLockException extends RuntimeException {
public UnableToAquireLockException() {
}
public UnableToAquireLockException(String message) {
super(message);
}
public UnableToAquireLockException(String message, Throwable cause) {
super(message, cause);
}
}
/**
* 获取RedissonClient连接类
*/
@Component
public class RedissonConnector {
RedissonClient redisson;
@PostConstruct
public void init(){
redisson = Redisson.create();
}
public RedissonClient getClient(){
return redisson;
}
}
/**
* 实现分布式锁
*/
@Component
public class RedisLocker implements DistributedLocker{
private final static String LOCKER_PREFIX = "lock:";
@Autowired
RedissonConnector redissonConnector;
@Override
public <T> T lock(String resourceName, AquiredLockWorker<T> worker) throws InterruptedException, UnableToAquireLockException, Exception {
return lock(resourceName, worker, 100);
}
@Override
public <T> T lock(String resourceName, AquiredLockWorker<T> worker, int lockTime) throws UnableToAquireLockException, Exception {
RedissonClient redisson= redissonConnector.getClient();
RLock lock = redisson.getLock(LOCKER_PREFIX + resourceName);
// Wait for 100 seconds seconds and automatically unlock it after lockTime seconds
boolean success = lock.tryLock(100, lockTime, TimeUnit.SECONDS);
if (success) {
try {
return worker.invokeAfterLockAquire();
} finally {
lock.unlock();
}
}
throw new UnableToAquireLockException();
}
}
4.4.3、修改HelloController
@RestController
public class HelloController {
@Autowired
private DistributedLocker distributedLocker;
@RequestMapping("index")
public String index()throws Exception{
distributedLocker.lock("test",new AquiredLockWorker<Object>() {
@Override
public Object invokeAfterLockAquire() {
try {
System.out.println("执行方法!");
Thread.sleep(5000);
}catch (Exception e){
e.printStackTrace();
}
return null;
}
});
return "hello world!";
}
}
4.4.4、测试
启动项目,并在浏览器输入http://localhost:8080/index
,连续刷新界面,控制台在打印了一次“执行方法”,5秒之后再次打印。
5、基于数据库的分布式锁
5.1、基于数据库表
它的基本原理和 Redis
的 SETNX
类似,其实就是创建一个分布式锁表,加锁后,我们就在表增加一条记录,释放锁即把该数据删掉,具体实现,我这里就不再一一举出。
缺点
- 没有失效时间,容易导致死锁;
- 依赖数据库的可用性,一旦数据库挂掉,锁就马上不可用;
- 这把锁只能是非阻塞的,因为数据的
insert
操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作; - 这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据库中数据已经存在了。
5.2、乐观锁
乐观锁一般通过 version
来实现,也就是在数据库表创建一个 version
字段,每次更新成功,则 version + 1
,读取数据时,我们将 version
字段一并读出,每次更新时将会对版本号进行比较,如果一致则执行此操作,否则更新失败!
5.3、悲观锁
5.3.1、创建一个表
CREATE TABLE `methodLock` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
`method_name` varchar(64) NOT NULL DEFAULT '' COMMENT '锁定的方法名',
`desc` varchar(1024) NOT NULL DEFAULT '备注信息',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存数据时间,自动生成',
PRIMARY KEY (`id`),
UNIQUE KEY `uidx_method_name` (`method_name `) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';
5.3.2、通过数据库的排他锁实现分布式
基于Mysql
的InnoDB
引擎的设计:
public boolean lock(){
connection.setAutoCommit(false)
while(true){
try{
result = select * from methodLock where method_name=xxx for update;
if(result==null){
return true;
}
}catch(Exception e){
}
sleep(1000);
}
return false;
}
5.3.3、释放锁
我们可以认为获得排它锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,再通过以下方法解锁:
public void unlock(){
connection.commit();
}
6、基于Zookeeper的分布式锁
6.1、Zookeeper的简介
ZooKeeper
是一个分布式的,开放源码的分布式应用程序协调服务,是 Google Chubby
的一个开源实现,是 Hadoop
和 Hbase
的重要组件。它是一个为分布式应用提供一致性服务的软件,提供的功能包括:配置维护、域名服务、分布式同步、组服务等。
6.2、zk分布式锁实现的原理
- 建立一个节点,假如名为
lock
。节点类型为持久节点(Persistent
); - 每当进程需要访问共享资源时,会调用分布式锁的
lock()
或tryLock()
方法获得锁,这个时候会在第一步创建的lock
节点下建立相应的顺序子节点,节点类型为临时顺序节点(EPHEMERAL_SEQUENTIAL
),通过组成特定的名字name+lock+顺序号
; - 在建立子节点后,对
lock
下面的所有以name
开头的子节点进行排序,判断刚刚建立的子节点顺序号是否是最小的节点,假如是最小节点,则获得该锁对资源进行访问; - 假如不是该节点,就获得该节点的上一顺序节点,并监测该节点是否存在注册监听事件。同时在这里阻塞。等待监听事件的发生,获得锁控制权。
- 当调用完共享资源后,调用
unlock()
方法,关闭ZooKeeper
,进而可以引发监听事件,释放该锁。
6.3、代码实现
6.3.1、创建 DistributedLock 类
public class DistributedLock implements Lock, Watcher{
private ZooKeeper zk;
private String root = "/locks";//根
private String lockName;//竞争资源的标志
private String waitNode;//等待前一个锁
private String myZnode;//当前锁
private CountDownLatch latch;//计数器
private CountDownLatch connectedSignal=new CountDownLatch(1);
private int sessionTimeout = 30000;
/**
* 创建分布式锁,使用前请确认config配置的zookeeper服务可用
* @param config localhost:2181
* @param lockName 竞争资源标志,lockName中不能包含单词_lock_
*/
public DistributedLock(String config, String lockName){
this.lockName = lockName;
// 创建一个与服务器的连接
try {
zk = new ZooKeeper(config, sessionTimeout, this);
connectedSignal.await();
Stat stat = zk.exists(root, false);//此去不执行 Watcher
if(stat == null){
// 创建根节点
zk.create(root, new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
}
} catch (IOException e) {
throw new LockException(e);
} catch (KeeperException e) {
throw new LockException(e);
} catch (InterruptedException e) {
throw new LockException(e);
}
}
/**
* zookeeper节点的监视器
*/
public void process(WatchedEvent event) {
//建立连接用
if(event.getState()== Event.KeeperState.SyncConnected){
connectedSignal.countDown();
return;
}
//其他线程放弃锁的标志
if(this.latch != null) {
this.latch.countDown();
}
}
public void lock() {
try {
if(this.tryLock()){
System.out.println("Thread " + Thread.currentThread().getId() + " " +myZnode + " get lock true");
return;
}
else{
waitForLock(waitNode, sessionTimeout);//等待锁
}
} catch (KeeperException e) {
throw new LockException(e);
} catch (InterruptedException e) {
throw new LockException(e);
}
}
public boolean tryLock() {
try {
String splitStr = "_lock_";
if(lockName.contains(splitStr))
throw new LockException("lockName can not contains \\u000B");
//创建临时子节点
myZnode = zk.create(root + "/" + lockName + splitStr, new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE,CreateMode.EPHEMERAL_SEQUENTIAL);
System.out.println(myZnode + " is created ");
//取出所有子节点
List<String> subNodes = zk.getChildren(root, false);
//取出所有lockName的锁
List<String> lockObjNodes = new ArrayList<String>();
for (String node : subNodes) {
String _node = node.split(splitStr)[0];
if(_node.equals(lockName)){
lockObjNodes.add(node);
}
}
Collections.sort(lockObjNodes);
if(myZnode.equals(root+"/"+lockObjNodes.get(0))){
//如果是最小的节点,则表示取得锁
System.out.println(myZnode + "==" + lockObjNodes.get(0));
return true;
}
//如果不是最小的节点,找到比自己小1的节点
String subMyZnode = myZnode.substring(myZnode.lastIndexOf("/") + 1);
waitNode = lockObjNodes.get(Collections.binarySearch(lockObjNodes, subMyZnode) - 1);//找到前一个子节点
} catch (KeeperException e) {
throw new LockException(e);
} catch (InterruptedException e) {
throw new LockException(e);
}
return false;
}
public boolean tryLock(long time, TimeUnit unit) {
try {
if(this.tryLock()){
return true;
}
return waitForLock(waitNode,time);
} catch (Exception e) {
e.printStackTrace();
}
return false;
}
private boolean waitForLock(String lower, long waitTime) throws InterruptedException, KeeperException {
Stat stat = zk.exists(root + "/" + lower,true);//同时注册监听。
//判断比自己小一个数的节点是否存在,如果不存在则无需等待锁,同时注册监听
if(stat != null){
System.out.println("Thread " + Thread.currentThread().getId() + " waiting for " + root + "/" + lower);
this.latch = new CountDownLatch(1);
this.latch.await(waitTime, TimeUnit.MILLISECONDS);//等待,这里应该一直等待其他线程释放锁
this.latch = null;
}
return true;
}
public void unlock() {
try {
System.out.println("unlock " + myZnode);
zk.delete(myZnode,-1);
myZnode = null;
zk.close();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (KeeperException e) {
e.printStackTrace();
}
}
public void lockInterruptibly() throws InterruptedException {
this.lock();
}
public Condition newCondition() {
return null;
}
public class LockException extends RuntimeException {
private static final long serialVersionUID = 1L;
public LockException(String e){
super(e);
}
public LockException(Exception e){
super(e);
}
}
}
6.3.2、修改HelloController
@RestController
public class HelloController {
@RequestMapping("index")
public String index()throws Exception{
DistributedLock lock = new DistributedLock("localhost:2181","lock");
lock.lock();
//共享资源
if(lock != null){
System.out.println("执行方法");
Thread.sleep(5000);
lock.unlock();
}
return "hello world!";
}
}
6.3.3、测试
启动项目,并在浏览器输入http://localhost:8080/index
,连续刷新界面,控制台在打印了一次“执行方法”,5秒之后再次打印。
7、总结
- 通过数据库实现分布式锁是最不可靠的一种方式,对数据库依赖较大,性能较低,不利于处理高并发的场景。
- 通过
Redis
的Redlock
和ZooKeeper
来加锁,性能有了比较大的提升。 - 针对
Redlock
,曾经有位大神对其实现的分布式锁提出了质疑,但是Redis
官方却不认可其说法,所谓公说公有理婆说婆有理,对于分布式锁的解决方案,没有最好,只有最适合的,根据不同的项目采取不同方案才是最合理的。
Redis、Mysql和zk实现方案的对比
- 从理解的难易程度角度(从低到高): 数据库 > 缓存 >
Zookeeper
; - 从实现的复杂性角度(从低到高):
Zookeeper
>= 缓存 > 数据库; - 从性能角度(从高到低): 缓存 >
Zookeeper
>= 数据库; - 从可靠性角度(从高到低):
Zookeeper
> 缓存 > 数据库。