文章目录
- 什么是集群环境下的并发安全问题
- 什么是`Tomcat`
- 为什么会出现集群下的并发安全问题
- 什么是分布式锁
- 分布式锁的特点
- 实现原理与方式
- 使用数据库实现
- 基于`Redis`的实现:
- 基于`Redis`实现一个分布式锁
- `Redis`分布式锁的实现核心思路
- 实现一个分布式锁
- 分布式锁的接口
- 锁的基本实现
- 分布式锁的误删
- 解决Redis分布式锁误删问题
- 分布式锁的原子性问题
- 解决分布式锁的原子性问题
- `Lua`脚本
- 利用`Java`代码调用`Lua`脚本改造分布式锁
什么是集群环境下的并发安全问题
什么是Tomcat
Tomcat是一个开源的、轻量级的Web服务器和Servlet容器.
注意:
Tomcat本身是一个Java应用程序,它运行在JVM中,并管理着其内部所有Web应用程序的生命周期.
Tomcat的主要功能:
Servlet容器:Tomcat作为一个Servlet容器,能够执行Java Servlet。Servlet是一种用于扩展Web服务器功能的Java类,它能够对来自Web客户端(如浏览器)的请求进行响应。JSP容器:Tomcat还支持JSP技术,允许在HTML页面中嵌入Java代码,以动态生成Web页面。当JSP页面首次被访问时,Tomcat会将其翻译成一个等效的Java Servlet,然后编译并执行。- 静态资源服务:
Tomcat还可以提供静态资源服务,如HTML、CSS、JavaScript和图像文件等。这使得它不仅能够用于动态Web应用,也能够用于简单的静态网站服务。
Tomcat是如何运行的:
当Tomcat启动时,它会创建一个或多个JVM实例(通常是一个),并加载所有必要的类和库。然后,Tomcat会在这个JVM实例中运行,并管理其内部的所有Web应用程序。这些Web应用程序共享同一个JVM实例,因此它们也共享JVM提供的内存管理、垃圾回收、线程管理等核心功能。
需要注意的是:虽然同一个Tomcat下的Java EE项目使用同一个JVM,但每个项目都有自己的类加载器和命名空间,以确保它们在逻辑上是彼此分开的。这减少了不同应用程序之间的意外进程内串扰的可能性。
总结:部署在同一个
Tomcat中的Web服务,是运行在同一个JVM实例中的,但是每个项目都有自己的类加载器和命名空间,保证在逻辑上是彼此分开的.
为什么会出现集群下的并发安全问题
所以,如果我们将服务部署在集群环境中,那么就说明我们要将服务放入到多个Tomcat中进行运行,也就是运行在多个JVM实例中;在同一个Tomcat中,多个线程并发执行时,syn锁会生效;但当存服务部署在多个Tomcat中时,也就代表了服务运行在不同的JVM实例中,那么此时syn锁就会失效了,这就导致了集群下的并发安全问题.
在同一JVM实例运行中,多线程并发安全:

在多个JVM实例中运行的Web服务,出现线程安全问题:

我们发现,线程1和线程3同时获取到同一个锁对象.发生并发问题.
所以,我们多个
JVM应该同时获取同一个锁监视器,这样能够保证多个JVM进程中的线程都实现互斥性.
什么是分布式锁
在前面的学习中,我们了解到:JVM内部的锁监视器(syn锁),只能 用于在单个机器上,保证线程之间的互斥和数据安全****.但在集群模式下,多个JVM中存在多个锁监视器**,所以无法使用JVM内部的锁监视器,来保证在集群中的线程之间数据的安全性.
那么分布式锁,就应运而生了.
分布式锁:满足分布式系统或者集群模式下多进程可见并且互斥的锁.

分布式锁的特点
- 互斥性:分布式锁要保证在多个客户端之间的互斥,即同一时间只有一个客户端能持有锁。
- 可重入性:同一客户端的相同线程,允许重复多次加锁。
- 锁超时:支持锁超时机制,防止死锁的发生。如果持有锁的客户端在指定时间内没有释放锁,锁将自动过期并被释放。
- 高性能:分布式锁需要适应高并发场景,具备高性能和可扩展性。
实现原理与方式
分布式锁的实现方式有多种,常见的包括基于数据库、Redis、ZooKeeper等中间件的实现。
使用数据库实现
- 使用专用的数据表来存储锁信息。
- 通过
SQL语句来加锁和解锁。
这种方式实现起来相对简单,但性能可能较差,且需要自行处理锁超时和死锁等问题。
基于Redis的实现:
- 利用
Redis的原子操作(如SETNX)来实现分布式锁。 - 通过设置锁的过期时间(
TTL)来防止死锁。
Redis分布式锁常用在高并发场景下,能够确保在分布式环境中,某个时刻只有一个客户端可以对共享资源进行修改。
基于Redis实现一个分布式锁
Redis是如何保证获取锁资源的互斥呢?
- 利用
setnx这样的互斥命令setnx是指只有key不存在时, 才会set成功,否则会set失败- 利用设置锁的超时时间,到期释放,保证锁的安全性,防止死锁的现象.
Redis分布式锁的实现核心思路
实现分布式锁时需要实现两个基本方法:
- 获取锁
- 互斥:确保只能有一个线程获取锁
- 非阻塞:尝试一次,成功返回
true,失败返回false
SET lock thread01 NX EX 10
- 释放锁
- 手动释放
- 超时释放:获取锁的时候添加一个超时时间
DEL lock
我们利用Redis的SETNX方法,当有多个线程进入时,我们就利用tryAcquire()方法来获取锁。第一个线程进入时,Redis 中就有这个key了,返回了true,如果结果是true,则表示他抢到了锁,那么他去执行业务,然后再删除锁,退出锁逻辑,没有抢到锁(返回了false)的线程,等待一定时间之后重试
实现一个分布式锁
分布式锁的接口
设计两大基本接口:
tryLock(long timeoutSec):获取锁,如果获取锁成功,持有锁的最长时间timeoutSecunLock():释放锁
public interface ILock {/*** 获取锁* long timeoutSec:表示获取锁的时间* */boolean tryLock(long timeoutSec);/*** 释放锁* */boolean unLock();
}
锁的基本实现
@Slf4j
public class SimpleRedisLock implements ILock{//这里不是@Autowired注入,采用的是构造器注入,在创建SimpleRedisLock时,//将RedisTemplate作为参数传入private StringRedisTemplate redisTemplate;private String name;//具体的业务名称String KEY_PREFIX="Lock: ";//锁的前缀/*** 构造方法* */* public SimpleRedisLock(StringRedisTemplate redisTemplate,String name){this.redisTemplate=redisTemplate;this.name=name;}/*** 获取锁* setnx key value expire timeoutSec** setnx lock thread expire timeoutSec** */public boolean tryLock(long timeoutSec) {long threadId = Thread.currentThread().getId();//获取到当前的线程id//获取锁,使用SETNX方法进行加锁,同时设置过期时间,防止死锁boolean success=redisTemplate.opsForValue().setIfAbsent(KEY_PREFIX+name,threadId+"",timeoutSec,TimeUnit.SECONDS);if(success){log.info("当前线程:"+threadId+"-->获取到锁: "+name);return true;}log.info("当前线程:"+threadId+"-->获取锁失败: "+name);return false;}/*** 释放锁** del key* */public boolean unLock() {Boolean success = redisTemplate.delete(KEY_PREFIX+name);log.info("释放锁成功");return Boolean.TRUE.equals(success);}}
分布式锁的误删
我们上面使用Redis进行的分布式锁的简单实现,在某些情境下,是否会出现问题呢?
-
逻辑说明
- 持有锁的线程
1在锁的内部出现了阻塞,导致他的锁TTL到期,自动释放 - 此时线程
2也来尝试获取锁,由于线程1已经释放了锁,所以线程2可以拿到 - 但是现在线程
1阻塞完了,继续往下执行,要开始释放锁了 - 那么此时就会将属于线程
2的锁释放,这就是误删别人锁的情况
- 持有锁的线程
-
解决方案
解决方案就是在每个线程释放锁的时候,都判断一下这个锁是不是自己的,如果不属于自己,则不进行删除操作。
假设还是上面的情况,线程1阻塞,锁自动释放,线程2进入到锁的内部执行逻辑,此时线程1阻塞完了,继续往下执行,开始删除锁,但是线程1发现这把锁不是自己的,所以不进行删除锁的逻辑,当线程2执行到删除锁的逻辑时,如果TTL还未到期,则判断当前这把锁是自己的,于是删除这把锁

解决Redis分布式锁误删问题
- 需求:修改之前的分布式锁实现
- 满足:在获取锁的时候存入线程标识
注意:这里建议使用
UUID标识不同的线程.因为,在一个JVM中,ThreadId一般不会重复,但是我们现在是集群模式,有多个JVM,多个JVM之间可能会出现ThreadId重复的情况,在释放锁的时候先获取锁的线程标识,判断是否与当前线程标识一致
- 如果一致则释放锁
- 如果不一致则不释放锁
- 核心逻辑:在存入锁的时候,放入自己的线程标识,在删除锁的时候,判断当前这把锁是不是自己存入的
- 如果是,则进行删除
- 如果不是,则不进行删除
private static final String ID_PREFIX= UUID.randomUUID().toString()+"-";//生成UUID标识
/**
*获取锁
*/
public boolean tryLock(long timeoutSec) {long threadId = Thread.currentThread().getId();//获取到当前的线程idboolean success=redisTemplate.opsForValue().setIfAbsent(KEY_PREFIX+name,ID_PREFIX+threadId+"",timeoutSec,TimeUnit.SECONDS);if(success){log.info("当前线程:"+threadId+"-->获取到锁: "+name);return true;}log.info("当前线程:"+threadId+"-->获取锁失败: "+name);return false;}/*** 释放锁** del key* */public boolean unLock() {String threadId =ID_PREFIX + Thread.currentThread().getId()+"";//获取到当前的线程名称//1.校验是否是一个线程if(threadId.equals(redisTemplate.opsForValue().get(KEY_PREFIX+name))){//2.说明一致,可以删除Boolean success = redisTemplate.delete(KEY_PREFIX+name);if(Boolean.TRUE.equals(success)) {log.info("释放锁成功");return true;}}log.info(threadId+"释放锁失败");return false;}
分布式锁的原子性问题
更为极端的误删逻辑说明
假设线程
1已经获取了锁,在判断标识一致之后,准备释放锁的时候,又出现了阻塞(例如JVM垃圾回收机制)
- 于是锁的
TTL到期了,自动释放了 - 那么现在线程
2趁虚而入,拿到了一把锁 - 但是线程
1的逻辑还没执行完,那么线程1就会执行删除锁的逻辑 - 但是在阻塞前线程
1已经判断了标识一致,所以现在线程1把线程2的锁给删了
那么就相当于判断标识那行代码没有起到作用
这就是删锁时的原子性问题:
因为线程
1的拿锁,判断标识,删锁不是原子操作,所以我们要防止刚刚的情况

解决分布式锁的原子性问题
Lua脚本
Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。
Lua是一种编程语言,它的基本语法可以上菜鸟教程看看,链接:菜鸟教程[Lua脚本]
这里重点介绍Redis提供的调用函数,我们可以使用Lua去操作Redis,而且还能保证它的原子性,这样就可以实现拿锁,判断标识,删锁是一个原子性动作了
- 在
Lua脚本中,Redis提供的调用函数语法如下
redis.call('命令名称','key','其他参数', ...)
比如,我们要使用Lua脚本,执行set key1 value1的Redis命令
redis.call('set', 'key1', 'value1')
比如,我们要执行set key1 value1,再执行get key1,则脚本如下:
redis.call('set','key1','value1')
local value=redis.call('get','key1')
return value
- 写好脚本之后,我们需要使用
Redis来调用脚本
EVAL script numkeys key [key ...] arg [arg ...]
比如,我们要插入(key11,value11)
EVAL "return redis.call('set','key11','value11')" 0
- 如果脚本中的
key和value不想写死,也可以作为参数进行传递,key类型参数会放入KEYS数组,其他参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组中获取这些参数
注意:在Lua中,数组下标从1开始
比如:
EVAL "return redis.call('set', KEYS[1], ARGV[1])" 1 name Lucy
使用脚本语言设计的检验唯一的线程标识,释放锁:
--锁的key
--local key=KEYS[1]
--当前线程标识
--local cur_thread_id=ARGV[1]--得到当前获取锁的线程标识,即获取到key的value
local thread_id=redis.call('get',KEYS[1])--将当前线程id与获取锁的线程进行比较
if(thread_id==ARGV[1]) then--释放锁 del keyredis.call('del',key)
end
return 0
利用Java代码调用Lua脚本改造分布式锁
- 在
RedisTemplate中,可以利用execute方法去执行lua脚本
public <T> T execute(RedisScript<T> script, List<K> keys, Object... args) {return this.scriptExecutor.execute(script, keys, args);
}
参数:
-
script:传入脚本文件(或者直接传递脚本指令,但这样导致代码的可移植性不强)

我们看到RedisScript的落地实现类是DefaultRedisScript -
keys:传入keys参数数组 -
args:传入values参数数组
为了避免每一次执行释放锁的过程,都要读取脚本文件,我们可以先将脚本文件读取出来,然后作为一个静态成员变量
@Slf4j
public class SimpleRedisLock implements ILock{
//.......//脚本文件的读取,作为静态变量进行存储private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;static{UNLOCK_SCRIPT=new DefaultRedisScript<>();//根据文件路径(resourse文件下)进行读取)UNLOCK_SCRIPT.setLocation(new ClassPathResource("redis_lock.lua"));UNLOCK_SCRIPT.setResultType(Long.class);//设置返回值}
//......
}
