三种情况导致JVM本地锁失效的情况
1.多例模式,不同线程运行同一段代码。同一个锁只能锁住当前线程而不能锁住其他线程,导致本地锁失效
2.事务,在同一线程中,在一个请求中通过事务获得了锁并修改了数据库数据后事务未提交,但是此时释放了锁,导致其他获得了锁并读取数据库数据。但此时前一个请求的事务并未提交。最终导致了数据库数据错误的情况。出现这种的错误的根本原因是数据库默认是读已提交。一个请求读取数据的时候另一个请求还未将修改后的数据提交。使用读未提交可以解决该问题。但是读未提交可能会产生脏读现象。
3.集群部署:情况跟多例模式相似。本地锁只能锁住一台机器中的一个线程,而无法控制其他机器。
解决方案
1.使用一条sql语句完成
mysql本身对update insert delete 写操作本身就会加锁
update db_stock set count=count- #{count} where product_code = #{productCode} and count >= #{count}
问题:
- 锁的的范围 表级锁还是行级锁? mysql正常不加索引的情况是表级锁,修改一条数据将整张表都锁住了,这是我们不能接受的。
如何使用行级锁?
1.锁的查询或更新条件必须是索引字段
2.查询或更新条件必须是具体值不能是模糊查询或者是!=条件
- 同一个商品有多个库存记录
- 无法记录库存前后变化的状态
2.悲观锁
select … for update查询
select * from db_stock where product_code=#{productCode} for update
select查询是不加锁的,select…for update是会加锁的,而且是悲观锁,但是在不同查询条件时候加的锁的类型(行锁,表锁)是不同的。
在where 后面查询条件是主键索引,唯一索引时候是行锁
查询条件是普通字段时候加的是表锁
吞吐量比JVM的锁要稍好一点,但是比一条sql语句的吞吐量要低,原因是锁的粒度变大了。一条sql语句只需要锁一条sql语句。select ... for update 语句由多条sql语句组成
问题总结:
1.性能问题
2.死锁问题:对多条数据加锁时,加锁顺序要统一
3.库存操作要统一:所有库存操作统一使用select... for update
3.乐观锁
通过添加版本号或者时间戳机制实现乐观锁
CAS机制:比较并交换变量X Compare-And-Swap
CAS指令执行更新一个变量的时候,只有当旧预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B。这样的一个过程属于原子操作(依赖硬件) ,执行期间不会被其他线程打断。
乐观锁每次在执行数据的修改操作时,都会带上一个版本号,一旦版本号和数据的版本号一致就可以执行修改操作并对版本号执行+1操作,否则就执行失败,重新执行。
update db_stock set count=count-1,version=version+1 where id=1 and version=0;
//@Transactional
public void deduct(){
//1.查询库存信息并锁定库存信息
List<Stock> stocks = this.stockMapper.selectList(new QueryWrapper<Stock>().eq("product_code","1001"));
//取第一个库存
Stock stock = stocks.get(0);
//2.判断库存是否充足
if(stock!=null && stock.getCount()>0){
//3.扣减库存
stock.setCount(stock.getCount()-1);
Integer version = stock.getVersion();
stock.setVersion(stock.getVersion()+1);
if( this.stockMapper.update(stock,new UpdateWrapper<Stock>().eq("id",stock.getId()).eq("version",version))==0){
try {
Thread.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
this.deduct();
}
}
}
内存溢出问题:由于重试使用的是递归调用,如果一直失败的话会导致无限重试占用内存资源导致内存溢出。所以我们可以通过增加重试时间解决。
取消手动事务注解:如果使用手动事务注解,由于事务会锁住表或者行,而方法一直发生重试会导致锁无法被释放,其他的请求方法无法获取锁导致其他的请求方法事务连接超时。
乐观锁问题:
1.高并发情况下,性能极低 原因:高并发情况可能会发生大量的重试
2.ABA问题,在将A数据更新为B的间隙中可能有其他操作将A数据转变为C.D.E其他数据,再变为A数据。
3.读写分离情况下导致乐观锁不可靠。 读写分离情况下数据库采用主从架构,主数据库写,从数据库读。在高并发情况下由于网络传输IO的延迟导致主从不一致。
MYSQL锁总结
性能:一个sql>悲观锁>JVM锁>乐观锁
如果追求极致性能、业务场景简单并且不需要记录数据前后变化的情况下,优先选择一个sql
如果写并发量较低,争抢不是很激烈的情况下优选乐观锁
如果写并发量较高,一般会经常冲突,此时选择乐观锁的话,会导致业务代码不间断的重试,此时应优选悲观锁
不推荐使用JVM本地锁
REDIS乐观锁
watch: 可以监控一个或多个key的值,如果在事务(exec)执行之前,key的值发生变化则取消事务执行
multi:开启事务
exec: 执行事务
缺点:
- 性能较差
- 由于重试机制的存在可能会导致大量的重试从而使redis连接数打满最终导致乐观锁失效。
分布式锁
特点:跨进程 跨服务 跨服务器 场景:超卖现象(NoSQL) 缓存击穿:一个热点KEY过期导致MYSQL服务器宕机
分布式锁的实现方式:
1.基于redis实现
2.基于zookeeper和etcd实现
3.基于mysql实现
基于REDIS实现分布式锁
操作:
1.加锁: setnx
2.解锁: del
3.重试: 递归 循环
特征;
1.独占排他使用 setnx
2.防止死锁的发生
死锁的产生; 如果redis客户端程序从redis服务中获取到锁之后立马宕机,此时锁无法得到释放,最终产生死锁。
解决:给锁添加过期时间。expire
3.原子性:
获取锁和过期时间要保证原子性 : set k v ex 3 nx
判断和释放锁之间保证原子性: lua脚本
注意: lua脚本只能保证指令依次执行而不受其他指令干扰,但是不能保证指令最终必定是原子性的。比如lua脚本执行到一半,服务器宕机了。就不能保证lua脚本中的所有指令可以完成。
4.防误删:一个锁的过期时间是3s,而请求完成的时间是5s,防止由于前一个请求的锁过期后,其他请求获得锁。然而前一个请求还未完成,导致请他请求获得锁之后被前一个请求误删。防止恶意加解锁,在没有加锁的情况下解锁
解决:使用UUID标识每次的加锁情况,解锁前先判断该锁的UUID是否是本次加锁的UUID。由于判断UUID和解锁两个的操作无法保证原子性。我们可以使用LUA脚本保证两个操作的原子性。
LUA脚本代码:先判断是否有该锁然后再解锁。
String script = "if redis.call('get', KEYS[1]) ==ARGV[1] "+
"then " +
" return redis.call('del',KEYS[1]) " +
"else " +
" return 0 " +
"end";
5.自动续期
定时任务+lua脚本
判断自己的锁是否存在(hexists),如果存在则重置过期时间
if redis.call('hexists',KEYS[1],ARGV[1])==1
then
redis.call('expire',KEYS[1],ARGV[2])
else
return 0
end
key:lock
arg:uuid 30
6.可重入性:同一个线程可能需要使用该锁两次,如果第一次使用锁后未将锁释放,此时再尝试取获取锁就会导致死锁问题。
ReetrantLock底层原理
加锁流程: ReentrantLock.lock()-->NonfairSync.lock()-->AQS.acquire(1)-->NonfairSync.tryAcquire(1)->sync.nonfairTryAcquire 1.CAS获取锁,如果没有线程占用锁(state==0),加锁成功并记录当前线程是有锁线程(两次)
2.如果state的值不为0,说明锁已经被占用,则判断当前线程是否是有锁线程,如果是则重入(state+1)
3.否则加锁失败,入队等待
解锁流程: ReentrantLock.unlock()-->AQS.release(1)-->Sync.tryRelease(1)
- 判断当前线程是否是有锁线程,不是则抛出异常
- 对state的值减1之后,判断state的值是否为0,为0则解锁成功,返回true
- 如果减1的值不为0,则返回false;
参照ReentrantLock中的非公平可重入锁实现分布式可重入锁:hash+lua
加锁:
1.判断锁是否存在(exists),如果存在则直接获取锁并加1 hincrby key field increment
2.如果锁存在则判断是否自己的锁(hexists),如果是自己的锁则重入:hincrby key field increment
3.否则重试: 递归 循环
if redis.call('exists',KEYS[1])==0 or redis.call('hexists',KEYS[1],ARGV[1])==1
then
redis.call('hincrby',KEYS[1],ARGV[1],1)
redis.call('expire',KEYS[1],ARGV[2])
return 1
else return 0
end
解锁:
1.判断锁是否存在(hexists),不存在则返回nil
2.如果自己的锁存在,则减1(hincrby-1),判断减1后的值是否为0,为0则释放锁(del)并返回1
3.不为0,返回0
if redis.call('hexists',KEYS[1],ARGV[1])==0
then
return nil
elseif redis.call('hincrby',KEYS[1],ARGV[1],-1)==0
then
return redis.call('del',KEYS[1])
else
return 0
end
如何保证锁的标识的唯一性?
在实现可重入锁的情况下,由于我们通过工厂模式生成的UUID是唯一的,所以仅通过生成uuid在单一的服务器上无法保证锁的标识的唯一性。此时我们需要引入线程ID保证在单一服务器上保证不同线程的锁的标识是不同的。同时由于在不同的服务器上线程ID可能会相同,所以我们选择使用uuid拼接线程ID的方式保证锁的标识的唯一性。
总结:
加锁的三种解决方案:
- setnx: 独占排他、死锁问题、不可重入、无法保证原子性
- set k v ex 30 nx:独占排他、解决服务器宕机导致的死锁 保证原子性 但不可重入
- hash+lua脚本: 可重入锁
1.判断锁是否存在(exists),如果存在则直接获取锁并加1 hincrby key field increment
2.如果锁存在则判断是否自己的锁(hexists),如果是自己的锁则重入:hincrby key field increment
3.否则重试: 递归 循环 - Timer定时器+lua脚本: 实现锁的自动续期
判断锁是否自己的锁(hexists==1),如果是自己的锁则执行expire重置过期时间
解锁 1.del:误删问题 2.先判断该锁是否自己的锁再删除同时保证原子性:lua脚本 3.hash+lua脚本:可重入 1.判断锁是否存在(hexists),不存在则返回nil 2.如果自己的锁存在,则减1(hincrby-1),判断减1后的值是否为0,为0则释放锁(del)并返回1 3.不为0,返回0
RedLock算法:
简介:在分布式版本的算法里我们假设我们有N个Redis master节点,这些节点都是完全独立的,我们不用任何复制或者其他隐含的分布式协调算法。我们已经描述了如何在单节点环境下安全地获取和释放锁。因此我们理所当然地应当用这个方法在每个单节点里来获取和释放锁。在我们的例子里面我们把N设成5,这个数字是一个相对比较合理的数值,因此我们需要在不同的计算机或者虚拟机上运行5个master节点来保证他们大多数情况下都不会同时宕机。一个客户端需要做如下操作来获取锁:
1.应用程序获取系统当前时间
2.应用程序使用相同的kv值依次从多个redis实例中获取锁,如果某一个节点超过一定时间依然没有获取到锁则直接放弃,尽快尝试从下一个健康的redis节点获取锁,以避免被一个宕机了的节点阻塞
3.计算获取锁的消耗时间=客户端程序的系统当前时间-step2中的时间,获取锁的消耗时间小于总的锁定时间(30s)并且半数以上节点获取锁成功,认为获取锁成功
4.计算剩余锁定时间=总的锁定时间-step3中的消耗时间
5.如果获取锁失败了,对所有的redis节点释放锁
Redisson:
Redisson:redis的java客户端 使用步骤:导入依赖,编写config类连接redis客户端。
@Bean
public RedissonClient redissonClient(){
Config config=new Config();
config.useSingleServer().setAddress("redis://ip:port");
return Redisson.create(config);
Redisson分布式可重入锁的底层原理:
可重入锁RLock对象
RLock lock=this.redissonClient.getLock("lockname");
lock.lock()/unlock();
与手写redis分布式锁类似,都是使用hash+lua脚本,同时结合使用CompleteableFuture异步多线程技术实现可重入锁。通过Timer定时器+lua脚本判断锁是否存在实现锁的自动续期
Redisson分布式公平锁:
使用一个等待队列实现公平锁,当线程尝试获得锁而无法获得的时候,就会进入等待队列排队,公平锁释放以后会按照等待队列的顺序先后获取锁。先入队的先获得锁。
RLock lock=this.redissonClient.getFairLock("lockname");
Redisson闭锁:
一个时间等待一组事件完成、发送、结束的场景下可以使用闭锁完成
案例:班长等待所有同学都出门之后才可以锁门。
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch=new CountDownLatch(6);
for (int i = 0; i < 6; i++) {
new Thread(()->{
System.out.println(Thread.currentThread().getName()+"准备出门了...");
try {
TimeUnit.SECONDS.sleep(new Random().nextInt(5));
System.out.println(Thread.currentThread().getName()+"出门了。。。。。。");
countDownLatch.countDown();
} catch (InterruptedException e) {
e.printStackTrace();
}
},i+"号同学").start();
}
countDownLatch.await();
System.out.println(Thread.currentThread().getName()+"班长锁门");
}
Redisson读写锁
通过设置modle的值控制并发,如果modle的值是read则可以并发,如果是write则无法并发。
RReadWriteLock reLock= this.redissonClient.getReadWriteLock("lockname");
rwLock.readLock().lock()
rwLock.writeLock().lock()