为什么要使用分布式锁
在之前的单体架构中 , 面对线程安全的问题可能使用 Java 提供的 ReentrantLcok 或 Synchronized 即可。但是随着业务不断发展,这时单机满足不了,于是采用分布式部署的方式。虽然一定程度解决了性能的瓶颈 , 但是也带来了许多分布式相关的问题。就分布式锁而言,看如下图:
单体应用部署结构:
如上如所示,可以看出为什么需要分布式锁:
在电商业务采用分布式架构后,程序部署在3个tomcat容器中(1个tomcat容器代表一个服务器,3个tomcat可理解在北京上海深圳都有部署电商服务),成员变量A代表商品数量。在北京的Alice,上海的Bob,深圳的Tom,都分别发起了购买或取消iPhone12的用户请求,经过Nginx负载均衡将Alice的请求发给了北京服务器,Bob的请求发给了上海服务器,Tom的请求发给了深圳服务器,这时候每台服务器都会对iPhone12这个商品数量进行更改,Alice的请求是将商品数量加到200,Bob的请求是将商品数量减少100,Tom的请求是将商品数量加1,如果对于商品数量的修改没有任何限制,整体就会乱起来,可能Bob得先减少,Tom的在增加,数据就完全乱了,所以需要分布式锁解决方案。
1. 获取锁和释放锁的性能要好
2. 判断是否获得锁必须是原子性的,否则可能导致多个请求都获取到锁
3. 网络中断或宕机无法释放锁时,锁必须被清除,不然会发生死锁。即:具备锁失效机制。
4. 具备可重入特性
目前分布式锁的实现方案主要包括三种:
1. 基于数据库(唯一索引)
2. 基于缓存(Redis,memcached,tair)
3. 基于Zookeeper
基于数据库的分布式锁
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 = '锁定中的方法';
当我们想要锁住某个方法时,执行以下SQL:
insert into methodLock(method_name,desc) values (‘method_name’,‘desc’);
因为我们对method_name做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。
当方法执行完毕之后,想要释放锁的话,需要执行以下Sql:
delete from methodLock where method_name ='method_name';
上面这种简单的实现有以下几个问题:
(1)这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
(2)这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
(3)这把锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
(4)这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。
2、基于数据库排他锁
除了可以通过增删操作数据表中的记录以外,其实还可以借助数据中自带的锁来实现分布式的锁。
我们还用刚刚创建的那张数据库表,将需要加锁的方法执行插入sql初始化到数据库中。可以通过数据库的排他锁来实现分布式锁。 基于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;
}
在查询语句后面增加for update,数据库会在查询过程中给数据库表增加排他锁(InnoDB引擎在加锁的时候,只有通过索引进行检索的时候才会使用行级锁,否则会使用表级锁。这里我们希望使用行级锁,就要给method_name添加索引,值得注意的是,这个索引一定要创建成唯一索引,否则会出现多个重载方法之间无法同时被访问的问题。重载方法的话建议把参数类型也加上。)。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。
我们可以认为获得排它锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,再通过以下方法解锁:
public void unlock(){
connection.commit();
}
通过connection.commit()操作来释放锁。这种方法可以有效的解决上面提到的无法释放锁和阻塞锁的问题。
for update语句会对该行数据进行加锁操作,其他服务通过for update进行获取锁时会阻塞。
for update加的排它锁在服务器宕机后数据库会自己把锁释放掉。
但是还是无法直接解决数据库单点和可重入问题。
总结:
1. 使用数据库来实现分布式锁的方式,这两种方式都是依赖数据库的一张表,一种是通过表中的记录的存在情况确定当前是否有锁存在,另外一种是通过数据库的排他锁来实现分布式锁。
2. 数据库实现分布式锁的优点:直接借助数据库,容易理解。
3. 操作数据库需要一定的开销,性能问题需要考虑。
4. 使用数据库的行级锁并不一定靠谱,尤其是当我们的锁表并不大的时候
5. 分布式效果最差的实现方式,不建议使用
1.基于setnx、expire及del三个命令来实现
(1)通过redis setnx(set if not exist)方式,当缓存里key不存在时,才会去set,否则直接返回false。如果返回true则获取到锁,否则获取锁失败,为了防止死锁,我们再用expire命令对这个key设置一个超时时间来避免。非原子实现。
可以如下命令来实现设置key及设置过期时间的原子操作
SET key_name my_random_value NX PX 30000
操作可以看成setnx
和expire
的结合体,是原子性的。
NX 表示if not exist 就设置并返回True,否则不设置并返回False。
PX 表示过期时间用毫秒级, 30000 表示这些毫秒时间后此key过期。
(2)执行完业务操作之后,删除该锁。
简单的java实现:
获取锁:
/**
* 获得锁
*/
public boolean getLock(String lockId, long millisecond) {
//非原子操作
//Boolean result = redisTemplate.opsForValue().setIfAbsent(lockId, "lockValue");
//redisTemplate.expire("lockKey", millisecond, TimeUnit.SECONDS);
//原子操作
Boolean success = redisTemplate.opsForValue().setIfAbsent(lockId, "lock",
millisecond, TimeUnit.MILLISECONDS);
return success != null && success;
}
setIfAbsent方法,就是当键不存在的时候,设置,并且该方法可以设置键的过期时间。
释放锁:
public void releaseLock(String lockId) {
redisTemplate.delete(lockId);
}
释放锁的操作比较简单,直接删除之前设置的键即可。其实,基于redis实现分布式锁的方式,在释放锁的时候,是存在释放失败的风险的(比如网路抖动什么的),这也是为什么在设置锁的时候需要设置过期时间的原因,可以防止在出现异常的时候,锁会自动的消失掉。
但是这样设置的分布式锁并不是完美的,还是会有问题。举个例子:比如设置一个分布式锁的过期时间为10S,A线程过来拿到锁后执行该业务,需要执行15S(也说明锁过期时间没有遇预估好),那么在第10秒的时候锁就过期了,因此10S到15S期间,B线程进来就直接可以拿到锁了(此时A线程还没有释放锁,其实锁已经过期了),这时在B线程的执行期间,A线程执行完毕(15S到了),需要释放锁,但是此时的锁是B线程加的,结果被A线程给释放掉了,相当于B线程此时也没有锁了,那么如果其他线程进来也就可以直接拿到锁了。
因此,上述例子中有两个问题:1.如何确保加锁和解锁的唯一性 2.如何避免锁过期
如何确保加锁和解锁的唯一性
加锁时,value值设置为唯一值;在解锁的时候,需要验证value是和加锁的一致才删除key。
/**
* 获得锁
*/
public boolean getLock(String lockId, long millisecond) {
String value = UUID.randomUUID().toString();
//原子操作
Boolean success = redisTemplate.opsForValue().setIfAbsent(lockId, value,
millisecond, TimeUnit.MILLISECONDS);
return success != null && success;
}
/**
* 删除锁
*/
public void releaseLock(String lockId, String value) {
if(value.equals(redisTemplate.opsForValue().get(lockId))){
redisTemplate.delete(lockId);
}
}
如何避免锁过期
当某个线程获取到锁时,可以再开一个线程,该线程定时重新设置锁过期时间。定时时间可以设置为过期时间的三分之一,例如锁过期时间为30S,那么每隔10S就可以重新设置一次锁过期时间,还是设置为30S,避免出现锁过期的情况。
那么上诉这样过后是不是就不会有任何问题了呢,其实不是的。如果是单机版的redis,那么上诉操作基本上就没啥大问题了,但是如果单点故障了呢?那redis直接不可用了,锁也就没用了。但是如果redis在哨兵模式下呢?
上述方式只作用在一个redis节点上,redis在哨兵模式(单master节点)下如果这个master节点由于某些原因发生了主从切换,在redis的master节点上拿到了锁,但是这个加锁的key还没有同步到slave节点,master故障,发生故障转移,slave节点升级为master节点,导致锁丢失。
2.基于Redisson来实现
代码实现
引入maven依赖
org.redisson
redisson
3.15.5
单机模式下:
初始化Redisson配置:
package com.redistext;
import org.redisson.Redisson;
import org.redisson.config.Config;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
@SpringBootApplication
@EnableCaching
public class RedistextApplication {
public static void main(String[] args) {
SpringApplication.run(RedistextApplication.class, args);
}
@Bean
public Redisson redisson(){
//此为单机模式
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379").setDatabase(0);
return (Redisson)Redisson.create(config);
}
}
Redisson实现分布式锁:
package com.redistext.controller;
import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.*;
import java.util.concurrent.TimeUnit;
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private Redisson redisson;
@GetMapping(value = "/get")
public void userNum() {
//获取一把锁,只要锁的名字一样,就是同一把锁
RLock redissonLock = redisson.getLock("num");
try {
//尝试加锁,最多等待5秒,上锁以后10秒自动解锁。阻塞方法。
redissonLock.tryLock(5,10, TimeUnit.SECONDS);
int userNum = Integer.parseInt(stringRedisTemplate.opsForValue().get("num"));
if (userNum > 0){
int realUserNum = userNum -1;
stringRedisTemplate.opsForValue().set("num", realUserNum + "");
System.out.println("人数缩减成功, 当前人数:" + realUserNum);
}else {
System.out.println("人数不足,无法缩减");
}
} catch (Exception e) {
redissonLock.unlock();
}
}
}
Redisson看门狗机制
如果拿到分布式锁的节点宕机,且这个锁正好处于锁住的状态时,会出现锁死的状态,为了避免这种情况的发生,锁都会设置一个过期时间。这样也存在一个问题,假如一个线程拿到了锁设置了30s超时,在30s后这个线程还没有执行完毕,锁超时释放了,就会导致问题,Redisson给出了自己的答案,就是 watch dog 自动延期机制。
Redisson提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期,也就是说,如果一个拿到锁的线程一直没有完成逻辑,那么看门狗会帮助线程不断的延长锁超时时间,锁不会因为超时而被释放。
默认情况下,看门狗的续期时间是30s,也可以通过修改Config.lockWatchdogTimeout来另行指定。看门狗会每隔10S重新给锁设置过期时间。
3.redis非单机模式下的分布式锁解决方案
redis作者antirez基于分布式环境下提出了一种更高级的分布式锁的实现方式:Redlock。redis作者antirez提出的redlock算法大概是这样的:
在redis的分布式环境中,我们假设有N个redis master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。我们确保将在N个实例上使用与在redis单实例下相同方法获取和释放锁。现在我们假设有5个redis master节点,同时我们需要在5台服务器上面运行这些Redis实例,这样保证他们不会同时都宕掉。
这里需要说明一点:上面描述中的N个redis master并不是在集群环境下的N个master,对于Redlock而言,单机、主从、集群都只能代表一个节点(一个master),因此如果需要N个redis master节点,那么就需要N个单机、N个cluster或N个sentinel集群。
因此个人认为:能妥协一定可靠性的话,我会选择redis的set key value px 1000 ex实现分布式锁;否则,选zk。这种方式需要部署多套环境,比较麻烦。
为了取到锁,客户端应该执行以下操作:
代码实现
引入maven依赖
org.redisson
redisson
3.15.5
假设有3个redis节点:
Config config1 = new Config();
config1.useSingleServer().setAddress("redis://192.168.0.1:5378")
.setPassword("a123456").setDatabase(0);
RedissonClient redissonClient1 = Redisson.create(config1);
Config config2 = new Config();
config2.useSingleServer().setAddress("redis://192.168.0.1:5379")
.setPassword("a123456").setDatabase(0);
RedissonClient redissonClient2 = Redisson.create(config2);
Config config3 = new Config();
config3.useSingleServer().setAddress("redis://192.168.0.1:5380")
.setPassword("a123456").setDatabase(0);
RedissonClient redissonClient3 = Redisson.create(config3);
String resourceName = "REDLOCK_KEY";
RLock lock1 = redissonClient1.getLock(resourceName);
RLock lock2 = redissonClient2.getLock(resourceName);
RLock lock3 = redissonClient3.getLock(resourceName);
// 向3个redis实例尝试加锁
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
boolean isLock;
try {
// isLock = redLock.tryLock();
// 500ms拿不到锁, 就认为获取锁失败。10000ms即10s是锁失效时间。
isLock = redLock.tryLock(500, 10000, TimeUnit.MILLISECONDS);
System.out.println("isLock = "+isLock);
if (isLock) {
//TODO if get lock success, do something;
}
} catch (Exception e) {
} finally {
// 无论如何, 最后都要解锁
redLock.unlock();
}
关于zookeeper的介绍这里不做过多赘述。
使用zookeeper的临时有序节点,每个线程获取锁就在zookeeper创建一个临时有序的节点,比如在/lock目录下。
创建节点成功后,获取/lock目录下的所有临时节点,再判断当前线程创建的节点是否是所有的节点的序号最小的节点;如果当前线程创建的节点是所有节点序号最小的节点,则认为获取锁成功;如果当前线程创建的节点不是所有节点序号最小的节点,则对节点序号的前一个节点添加一个事件监听。
比如当前线程获取到的节点序号为/lock/003
,然后所有的节点列表为[/lock/001,/lock/002,/lock/003]
,则对/lock/002
这个节点添加一个事件监听器。如果锁释放了,会唤醒下一个序号的节点,然后重新执行判断是否自己的节点序号是最小。比如/lock/001
释放了,/lock/002
监听到事件,此时节点集合为[/lock/002,/lock/003]
,则/lock/002
为最小序号节点,获取到锁。
原生java API实现分布式锁,可看Zookeeper入门介绍_有个疙叫瘩儿的博客-CSDN博客
那么原生java API实现分布式锁存在的问题有哪些呢?
1. 会话连接是异步的,需要自己去处理。比如使用CountDownLatch。
2. Watch需要重复注册,不然不能生效。
3.开发复杂性较高。
4.不支持多节点删除和创建,需要自己去递归。
Curator是一个专门解决分布式锁的框架,解决了原生java API开发分布式遇到的问题。
java实现
pom依赖:
org.apache.curator
curator-framework
4.3.0
org.apache.curator
curator-recipes
4.3.0
org.apache.curator
curator-client
4.3.0
代码实现:
package com.redistext.controller;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.framework.recipes.locks.InterProcessMutex;
import org.apache.curator.retry.ExponentialBackoffRetry;
/**
* @author: maorui
* @description: TODO
* @date: 2021/10/21 15:01
* @description: V1.0
*/
public class CuratorTest {
public static void main(String[] args) {
InterProcessMutex lock1 = new InterProcessMutex(getCuratorFramework(),"/locks");
InterProcessMutex lock2 = new InterProcessMutex(getCuratorFramework(),"/locks");
new Thread(new Runnable() {
@Override
public void run() {
try {
lock1.acquire();
System.out.println("线程1,获取到锁");
lock1.acquire();
System.out.println("线程1,再次获取到锁");
Thread.sleep(1000);
lock1.release();
System.out.println("线程1,释放锁");
lock1.release();
System.out.println("线程1,再次释放锁");
} catch (Exception e) {
e.printStackTrace();
}
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
try {
lock2.acquire();
System.out.println("线程2,获取到锁");
lock2.acquire();
System.out.println("线程2,再次获取到锁");
Thread.sleep(1000);
lock2.release();
System.out.println("线程2,释放锁");
lock2.release();
System.out.println("线程2,再次释放锁");
} catch (Exception e) {
e.printStackTrace();
}
}
}).start();
}
private static CuratorFramework getCuratorFramework(){
ExponentialBackoffRetry exponentialBackoffRetry = new ExponentialBackoffRetry(3000, 3);
CuratorFramework client = CuratorFrameworkFactory.builder().connectString("10.192.140.1:2181")
.connectionTimeoutMs(2000)
.sessionTimeoutMs(2000)
.retryPolicy(exponentialBackoffRetry).build();
client.start();
System.out.println("zookeeper 启动成功");
return client;
}
}
测试结果:
对于redis的分布式锁而言,它有以下缺点:
对于zk分布式锁而言:
怎么选用要看具体的实际场景了,如果公司里面有zk集群条件,优先选用zk实现,但是如果说公司里面只有redis集群,没有条件搭建zk集群。那么其实用redis来实现也可以,另外还可能是系统设计者考虑到了系统已经有redis,但是又不希望再次引入一些外部依赖的情况下,可以选用redis
参考文档:
浅谈分布式锁--基于数据库实现篇_Eric的博客-CSDN博客
什么是分布式锁? 为啥需要分布式锁?_AKALXH的博客-CSDN博客_分布式锁是为了解决什么问题
Java分布式锁看这篇就够了 - seesun2012 - 博客园
Java基于redis实现分布式锁(SpringBoot) - happyjava - 博客园
Redlock:Redis分布式锁最牛逼的实现 - 简书
可不敢吹自己会Redis-02 - 三斤的博客 | ThreeJin Blog
Redis与Zookeeper实现分布式锁的区别 - __Meng - 博客园