当多个事务[并发]操作同一批数据的时候,如果不加锁,就无法保证事务的隔离性,最后导致数据错乱。加锁是为了保证并发操作下数据的正确性。
什么是共享锁(share Lock):允许事务读一行数据,具有锁兼容性质,允许多个事务同时获得该锁。共享锁(share lock):共享锁又称读锁,简称S锁;**当一个事务为数据加上读锁之后,其他事务只能对该数据加读锁,而不能对数据加写锁,直到所有的读锁释放之后其他事务才能对其进行加持写锁**
。共享锁的特性主要是为了支持[并发]的读取数据,读取数据的时候不支持修改,避免出现重复读的问题。在select语句末尾加上lock in share mode关键字
。
# 共享锁
select * from tb_user for share;
select * from tb_user lock in share mode ;
什么是排它锁(exclusive Lock):排他锁又称写锁,简称×锁;当**一个事务为数据加上写锁时,其他请求将不能再为数据加任何锁,直到该锁释放之后,其他事务才能对数据进行加锁**
。排他锁的目的是在数据修改时候,不允许其他人同时修改
,也不允许其他人读取,避免了出现脏数据和脏读的问题
。 在select语句末尾加上for update
关键字。
# 排他锁
select * from tb_user where id = 6 for update;
Mysql innodb引擎锁的默认操作: 当我们对某一行数据进行查询是会默认使用S锁加锁
**锁住的是表的某一行或多行记录**
,其他事务访问同一张表时,只有被锁住的记录不能访问,其他的记录可正常访问,特点:粒度小,加锁比表锁麻烦,不容易冲突,相比表锁支持的并发要高,产生死锁。**锁住的是整个表**
,当下一个事务访问该表的时候,必须等前一个事务释放了锁才能进行对表进行访问;特点:粒度大,加锁简单,容易冲突,不产生死锁,并发度低;**介于行级锁和表级锁中间的一种锁**
.表级锁速度快,但冲突多,行级冲突少,但速度慢。所以取了折衷的页级
,一次锁定相邻的一组记录
。特点:开销和加锁时间界于表锁和行锁之间,会出现死锁;锁定粒度界于表锁和行锁之间,并发度一般。**锁的范围只是表中的某一条记录**
,记录锁是说事务在加锁后锁住的只是表的某一条记录
,加了记录锁之后数据可以避免数据在查询的时候被修改的重复读问题,也避免了在修改的事务未提交前被其他事务读取的脏读问题。悲观锁的设计理念是悲观的,认为总是有线程并发问题导致数据不安全,所以在整个数据处理过程中,将数据处于锁定状态。悲观锁的实现,往往依靠数据库提供的锁机制。通常是在 select语句后面增加 for update
来锁定数据。
注:要使用悲观锁,我们必须关闭mysql数据库的自动提交属性,因为MySQL默认使用autocommit模式,也就是说,当你执行一个更新操作后,MySQL会立刻将结果进行提交。可以使用命令设置MySQL为非autocommit模式:set autocommit=0
。
乐观锁是相对悲观锁而言的,乐观锁假设数据一般情况下不会造成冲突,在数据处理的过程中会不加锁,在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测
,如果发现冲突了,则返回给用户错误
的信息,让用户决定如何去做
。
直接用:表中添加一个时间戳或者版本号的字段来实现,update account set version = version + 1 where id = #{id} and version = #{oldVersion}
当更新不成功,客户端重试,重新读取最新的版本号或时间戳,再次尝试更新,类似 CAS 机制,推荐使用。
伪代码实现:
Resrouce resource = exeSql("select * from resource where resource_name = xxx");
boolean succ = exeSql("update resource set version= version + 1 where resource_name = xxx and version =#{version}");
if (!succ) {
// 发起重试
}
实际代码中可以写个while循环不断重试,版本号不一致,更新失败,重新获取新的版本号,直到更新成功。
就是在在分布式/集群 环境下,保证某个公共资源只能在同一时间被多进程应用的某个进程的某一个线程访问时使用锁。
基于数据库操作(基于主键或唯一索引)
基于redis缓存(setnx)
基于zookeeper 临时顺序节点+watch
基于数据库基本不用,zk或redis要根据项目情况来决定,如果你项目本来就用到zk,就使用zk,否则redis。
Redis提供了一个命令setnx
可以来实现分布式锁,该命令只在键 key
不存在的情况下 将键 key
的值设置为 value
,若键 key
已经存在, 则 SETNX
命令不做任何动作。根据这一特性我们就可以制定Redis实现分布式锁的方案了。
如果获取到锁的服务在释放锁的时候宕机了,那么Redis中lock-stock就永远存在,那锁就不能被释放不了,别的服务也就没办法获取到锁,就造成了死锁,为了解决这个问题,我们需要设置锁的自动超时也就是Key的超时自动删除,即使服务宕机没有调用del释放锁,那么锁本身也有超时时间,可以自动删除锁,别的服务就可以获取锁了,Redis中Key的过期时间可以使用Redis的 expire(lock_stock,30)命令实现。
使用setnx
获取锁和expire不是原子性操作,假设有一极端情况,当线程通过setnx(lock_stock,30)
获取到锁,还没来得及执行expire(lock_stock,30)设置锁的过期时间,服务就宕机了,那是不是锁也永远得不到释放呢???又变成了死锁,这个问题可以使用set命令解决,我们先来看一下这个命令的语法
SET key value [EX seconds] [PX milliseconds] [NX|XX]
EX seconds
: 将键的过期时间设置为 seconds 秒。 执行 SET key value EX seconds
的效果等同于执行 SETEX key seconds value
。
PX milliseconds
: 将键的过期时间设置为 milliseconds
毫秒。 执行 SET key value PX milliseconds
的效果等同于执行 PSETEX key milliseconds value 。
NX
: 只在键不存在时, 才对键进行设置操作。 执行 SET key value NX 的效果等同于执行 SETNX key value 。
XX
: 只在键已经存在时, 才对键进行设置操作。
就是说该命令可以当做setnx
和expire
的组合命令来使用,而且是原子性的
就是在del
释放锁的时候可能会误删除别人加的锁,例如服务A获取到锁lock_stock,过期时间为 10s,如果在服务A执行业务逻辑的这一段时间内,锁到期自动删除,且别的服务获取到了锁lock_stock,那么服务A业务执行完成执行del(lock_stock)
就会把别人的锁给删除掉。
解决:
可以在删除锁的时候先判断一下要删除的锁是不是自己上的锁,比如可以把锁的值使用一个UUID,在释放锁的时候先获取一下锁的值和当前业务中创建的UUID是不是同一个,如果是才执行·del
删除锁,当然也可以使用线程的ID替代UUID。
但是依然有问题,就是判断锁的代码和删除锁的代码也不是原子性的,依然可能会导致锁的误删除问题,比如服务A在判断锁成功准备删除锁时,锁自动过期,别的服务B获取到了锁,然后服务A执行DEL就可能会把服务B的锁给删除掉,所以,我们必须保证 获取锁 -> 判断锁 -> 删除锁 的操作是原子性的才可以,解决方案可以使用Redis+Lua脚本来解决一致性问题。
String script = "if redis.call('get', KEYS[1]) == ARGV[1]
then return redis.call('del', KEYS[1]) else return 0 end";
上面是一段Lua
脚本,可以保证多个命令的原子性
redis.call(‘get’, KEYS[1]) :是调用redis的get命令,key可以通过参数传入
== ARGV[1] :意思是是否和 某个值相等,这里的值也可以参数传入
then return redis.call(‘del’, KEYS[1]) :如果相等就执行 redis.call('del', KEYS[1]) 删除操作
else return 0 end :否则就返回 0
例如把数据带入KEYS[1]的值为“lock_stock”,ARGV[1]的值为ThreadID如“111”,所以大概的含义是如果调用get(“lock_stock”)获取到的值 等于 “111” ,那就调用 del(“lock_stock”),否则就返回 0 。 说白了就是把我们上面的判断锁和删除锁的动作使用Lua脚本去执行而已,现在代码可以这样写
String threadId = Thread.currentThread().getId();
if(jedis.set(lock_stock,threadId,"NX","EX",5) == 1){ //获取锁并设置超时
try {
业务代码
} finally {
//lua脚本
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
//执行脚本
jedis.eval(script, Collections.singletonList("lock_stock"),Collections.singletonList(threadId));// 集合表示传值
}
}
Collections.singletonList("lock_stock")
传给 KEYS[1]
Collections.singletonList(threadId)
转给 ARGV[1]
上面的代码是不完整的,如果某个线程没有获取到锁是不是就不会进入 IF 呢?如果是这样的话未获取到锁的线程就执行失败了,啥也没做,这是不可行的,需要让未获取到锁的线程等待片刻之后再次尝试获取锁呢?如下:
public void method(){
String threadId = Thread.currentThread().getId();
if(jedis.set(lock_stock,threadId,"NX","EX",5) == 1){ //获取锁并设置超时
try {
业务代码
} finally {
//lua脚本
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
//执行脚本
jedis.eval(script, Collections.singletonList("lock_stock"),Collections.singletonList(threadId));
}
}else{
//休眠一会儿,重入方法,尝试获取锁
Thread.sleep(100);
method(); //自旋,重新进入方法
}
}
上面的代码增加了else获取锁失败的逻辑,休眠一会儿后重入方法尝试重新获取锁
,休眠时间结合业务逻辑的执行时间设定
Redisson是一个实现的Java操作Redis的工具包,它不仅提供了一系列常用的操作Redis的API,还提供了许多分布式服务。其中包括(BitSet, Set, Multimap, SortedSet, Map, List, Queue, BlockingQueue, Deque, BlockingDeque, Semaphore, Lock, AtomicLong, CountDownLatch, Publish / Subscribe, Bloom filter, Remote service, Spring cache, Executor service, Live Object service, Scheduler service) Redisson提供了使用Redis的最简单和最便捷的方法,Redisson的宗旨是促进使用者对Redis的关注分离,从而让使用者能够将精力更集中地放在处理业务逻辑上。
<dependency>
<groupId>org.redissongroupId>
<artifactId>redissonartifactId>
<version>3.13.6version>
dependency>
@Configuration
public class RedissonConfig {
//创建客户端
@Bean
public RedissonClient redissonClient(){
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");//.setPassword("123456");
return Redisson.create(config);
}
}
Redisson
加锁自动有过期时间30s,监控锁的看门狗发现业务没执行完,会自动进行锁的续期(重回30s),这样做的好处是防止在程序执行期间锁自动过期被删除问题基于Redis的Redisson分布式可重入锁RLock Java对象实现了java.util.concurrent.locks.Lock
接口。同时还提供了异步(Async)、反射式(Reactive)和RxJava2标准的接口
案例
@Autowired
private RedissonClient redissonClient;
@Test
public void testLock1(){
RLock rLock = redissonClient.getLock("lock_stock");
rLock.lock(); //阻塞式等待,过期时间30s
try{
System.out.println("加锁成功....");
System.out.println("执行业务....");
}finally {
rLock.unlock();
System.out.println("释放锁....");
}
}
另外Redisson还通过加锁的方法提供了leaseTime的参数来指定加锁的时间。超过这个时间后锁便自动解开了,如下
@Test
public void testLock2(){
RLock rLock = redissonClient.getLock("lock_stock");
// 加锁以后10秒钟自动解锁
// 无需调用unlock方法手动解锁
rLock.lock(10, TimeUnit.SECONDS);
try{
System.out.println("加锁成功....");
System.out.println("执行业务....");
}finally {
rLock.unlock();
System.out.println("释放锁....");
}
}
如果没有设置过期时间,Redisson
以 30s 作为锁的默认过期时间,获取锁成功后(底层也用到了Lua
脚本保证原子性)会开启一个定时任务定时进行锁过期时间续约,即每次都把过期时间设置成 30s,定时任务 10s执行一次(看门狗)
如果设置了过期时间,直接把设定的过期时间作为锁的过期时间,然后使用Lua脚本获取锁,没获取到锁的线程会while自旋重入不停地尝试获取锁。
注意:,rLock.lock(10, TimeUnit.SECONDS)
指定了解锁时间,Redisson
就不会再自动续期,那么如果在线程A业务还没执行完就自动解锁了,这时候线程B获取到锁,继续执行业务,那么等线程A业务执行完释放锁就可能会把线程B的锁删除,当然这种情况Redisson会报异常,但是这种情况是没有把所有线程都锁住的,所以如果要手动设定过期时间需要让过期时间比业务逻辑执行的时间长才对。
基于Redis的Redisson分布式可重入公平锁也是实现了java.util.concurrent.locks.Lock接口的一种RLock对象。同时还提供了异步(Async)、反射式(Reactive)和RxJava2标准的接口。它保证了当多个Redisson客户端线程同时请求加锁时,优先分配给先发出请求的线程。所有请求线程会在一个队列中排队,当某个线程出现宕机时,Redisson会等待5秒后继续下一个线程,也就是说如果前面有5个线程都处于等待状态,那么后面的线程会等待至少25秒。
@Test
public void testFairLock() {
RLock fairLock= redissonClient.getFairLock("anyLock");
try{
// 最常见的使用方法
fairLock.lock();
}finally {
fairLock.unlock();
System.out.println("释放锁....");
}
}
另外Redisson还通过加锁的方法提供了leaseTime的参数来指定加锁的时间。超过这个时间后锁便自动解开了
// 10秒钟以后自动解锁
// 无需调用unlock方法手动解锁
fairLock.lock(10, TimeUnit.SECONDS);
// 尝试加锁,最多等待100秒,上锁以后10秒自动解锁
boolean res = fairLock.tryLock(100, 10, TimeUnit.SECONDS);
...
fairLock.unlock();
基于Redis的Redisson分布式联锁RedissonMultiLock对象可以将多个RLock对象关联为一个联锁,每个RLock对象实例可以来自于不同的Redisson实例。
RLock lock1 = redissonInstance1.getLock("lock1");
RLock lock2 = redissonInstance2.getLock("lock2");
RLock lock3 = redissonInstance3.getLock("lock3");
RedissonMultiLock lock = new RedissonMultiLock(lock1, lock2, lock3);
// 同时加锁:lock1 lock2 lock3
// 所有的锁都上锁成功才算成功。
lock.lock();
...
lock.unlock();
Redis常用的方式有单节点、主从模式、哨兵模式、集群模式,在后三种模式中可能会出现 ,异步数据丢失,脑裂问题,Redis官方提供了解决方案:RedLock, RedLock是基于redis实现的分布式
锁,它能够保证以下特性:
容错性:只要多数节点的redis实例正常运行就能够对外提供服务,加锁释放锁
互斥性:只能有一个客户端能获取锁,即使发生了网络分区或者客户端宕机,也不会发生死锁
基于Redis的Redisson红锁RedissonRedLock
对象实现了Redlock介绍的加锁算法。该对象也可以用来将多个RLock对象关联为一个红锁,每个RLock对象实例可以来自于不同的Redisson实例
RLock lock1 = redissonInstance1.getLock("lock1");
RLock lock2 = redissonInstance2.getLock("lock2");
RLock lock3 = redissonInstance3.getLock("lock3");
RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);
// 同时加锁:lock1 lock2 lock3
// 红锁在大部分节点上加锁成功就算成功。
lock.lock();
...
lock.unlock();
基于Redis的Redisson的分布式信号量(Semaphore)Java对象RSemaphore采用了与java.util.concurrent.Semaphore相似的接口和用法。同时还提供了异步(Async)、反射式(Reactive)和RxJava2标准的接口。
信号量可以看做是在Redis中保存了一个数字,然后可以实现原子性的加或者减,比如说有一商品需要拿100个做秒杀,我们就可以把这个库存数量做成信号量,然后实现原子性加减操作:
@Test
public void testReadLock5() throws InterruptedException {
//获得到一个信号量
RSemaphore semaphore = redissonClient.getSemaphore("semaphore");
//设置信号量的值
boolean setPermits = semaphore.trySetPermits(1000);
System.out.println(setPermits);
System.out.println("可用数量:"+semaphore.availablePermits());
}
@Test
public void testReadLock6() throws InterruptedException {
//获得到一个信号量
RSemaphore semaphore = redissonClient.getSemaphore("semaphore");
//获取 2 个信号量 , 值会减去 2 , 如果获取不到,方法会阻塞
semaphore.acquire(2);
System.out.println("可用数量:"+semaphore.availablePermits());
//尝试获取 2 个信号量 , 值会减去 2 , 如果获取不到,方法不会
boolean tryAccquireSuccess = semaphore.tryAcquire(2);
System.out.println(tryAccquireSuccess);
System.out.println("可用数量:"+semaphore.availablePermits());
}
@Test
public void testReadLock7() throws InterruptedException {
//获得到一个信号量
RSemaphore semaphore = redissonClient.getSemaphore("semaphore");
//释放2个值,数量会加回去
semaphore.release(2);
System.out.println("可用数量:"+semaphore.availablePermits());
}
基于Redisson的Redisson分布式闭锁(CountDownLatch
)Java对象RCountDownLatch采用了与java.util.concurrent.CountDownLatch相似的接口和用法。
闭锁可以实现多个线程都执行完才是完成的效果,否则闭锁会等待。
RCountDownLatch latch = redisson.getCountDownLatch("anyCountDownLatch");
//设置2个数量
latch.trySetCount(2);
//await方法会等待,等待其他线程 countDown 完成所有的trySetCount(2)次就结束闭锁
latch.await();
// 在其他线程或其他JVM里
RCountDownLatch latch = redisson.getCountDownLatch("anyCountDownLatch");
//完成第1个
latch.countDown();
// 在其他线程或其他JVM里
RCountDownLatch latch = redisson.getCountDownLatch("anyCountDownLatch");
//完成第2个 , 闭锁完成
latch.countDown();
ZooKeeper 节点是有生命周期的这取决于节点的类型,在 ZooKeeper 中,节点类型可以分为持久节点(PERSISTENT )、临时节点(EPHEMERAL),以及时序节点(SEQUENTIAL ),具体在节点创建过程中,一般是组合使用,可以生成以下 4 种节点类型。
持久节点(PERSISTENT)
所谓持久节点,是指在节点创建后,就一直存在,直到有删除操作来主动清除这个节点——不会因为创建该节点的客户端会话失效而消失。
持久顺序节点(PERSISTENT_SEQUENTIAL)
这类节点的基本特性和上面的节点类型是一致的。额外的特性是,在ZK中,每个父节点会为他的第一级子节点维护一份时序,会记录每个子节点创建的先后顺序。基于这个特性,在创建子节点的时候,可以设置这个属性,那么在创建节点过程中,ZK会自动为给定节点名加上一个数字后缀,作为新的节点名。这个数字后缀的范围是整型的最大值。
临时节点(EPHEMERAL)
和持久节点不同的是,临时节点的生命周期和客户端会话绑定。也就是说,如果客户端会话失效,那么这个节点就会自动被清除掉。注意,这里提到的是会话失效,而非连接断开。另外,在临时节点下面不能创建子节点。
临时顺序节点(EPHEMERAL_SEQUENTIAL)
在临时几点的基础上增加了顺序,可以用来实现分布式锁
顺序节点可以用来为所有的事件进行全局排序,这样客户端可以通过序号推断事件的顺序。
临时节点+重试:根据Zookeeper的临时节点的特性实现分布式锁,先执行的线程在zookeeper创建一个临时节点,代表获取到锁,后执行的线程需要等待,直到临时节点被删除说明锁被释放,第二个线程可以尝试获取锁。
临时顺序节点+watch:
1.每个要获取锁的线程都创建顺序临时节点,第一个节点获取锁;
2.其他不是第一个节点的节点拿不到锁,需要监听上一个节点;
3.获取到锁后,当业务执行成功,释放锁,第一个节点被删除;
4.第二个节点会监听到第一个节点被删除,成为第一个节点,获取锁;
5.后面的节点以此类推。