分布式锁:
概念&&特性:
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
分布式锁的核心思想就是让大家都使用同一把锁,只要大家使用的是同一把锁,那么我们就能锁住线程,不让线程进行,让程序串行执行,这就是分布式锁的核心思路
那么分布式锁他应该满足一些什么样的条件呢?
可见性:多个线程都能看到相同的结果,注意:这个地方说的可见性并不是并发编程中指的内存可见性,只是说多个进程之间都能感知到变化的意思
互斥:互斥是分布式锁的最基本的条件,使得程序串行执行
高可用:程序不易崩溃,时时刻刻都保证较高的可用性
高性能:由于加锁本身就让性能降低,所有对于分布式锁本身需要他就较高的加锁性能和释放锁性能
安全性:安全也是程序中必不可少的一环
三种锁方式:
基于Redis的分布式锁:
为了确保在添加锁之后不会由于系统宕机从而未能设值lock过期时间,采用set原子操作
案例:
修改单机模式下一人一单问题改进版2:
新建Ilock.java接口:
package com.hmdp.utils;
/**
* 基于Redis的分布式锁
*/
public interface ILock {
/**
* 尝试获取锁
*
* @param timeoutSec 超时时间(秒)
* @return true:获取成功 false:获取失败
*/
boolean trylock(long timeoutSec);
/**
* 释放锁
*/
void unlock();
}
Ilock实现类SimpleRedisLock.java:
package com.hmdp.utils;
import java.util.concurrent.TimeUnit;
public class SimpleRedisLock implements ILock {
private String key;
private RedisUtil redisUtil;
public SimpleRedisLock( String key,RedisUtil redisUtil) {
this.redisUtil = redisUtil;
this.key = key;
}
private static final String KEY_PREFIX = "lock:";
@Override
public boolean trylock(long timeoutSec) {
// 获取线程id(作为value值)
long id = Thread.currentThread().getId();
// 尝试获取锁
Boolean success = redisUtil.setnx(KEY_PREFIX + key, id, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
// 释放锁
redisUtil.del(KEY_PREFIX + key);
}
}
修改VoucherOrderServiceImpl:
同样参照day03的集群部署测试方法,成功实现单用户一单。
锁误删问题:
案例:
修改SimpleRedisLock.java:
package com.hmdp.utils;import cn.hutool.core.lang.UUID;
import java.util.concurrent.TimeUnit;
public class SimpleRedisLock implements ILock {
private String name;
private RedisUtil redisUtil;
public SimpleRedisLock(String name, RedisUtil redisUtil) {
this.redisUtil = redisUtil;
this.name = name;
}
private static final String KEY_PREFIX = "lock:";
String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@Override
public boolean trylock(long timeoutSec) {
// 获取线程id(作为value值)
String id = ID_PREFIX + Thread.currentThread().getId();
System.out.println("线程id:" + id);
// 尝试获取锁
Boolean success = redisUtil.setnx(name, id, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override public void unlock()
{
// 获取线程标识
String id = ID_PREFIX + Thread.currentThread().getId();
// 与锁中的线程标识比较,如果相同则释放锁
if (id.equals(redisUtil.get(name))) {
// 释放锁
redisUtil.del(name); }
}
}
分布式锁的原子性问题:
上述:判断锁标识和释放锁是两个动作,之间可能会发生阻塞,所以要保证这两个动作的原子性。
基于上述,若有两个线程,在线程1判断线程值与redis一致成功后,在将要删除之前阻塞了。
结果删除了线程2的锁,又发生了误删
Lua脚本解决多条命令原子性问题:
释放锁的业务流程是这样的
1、获取锁中的线程标示
2、判断是否与指定的标示(当前线程标示)一致
3、如果一致则释放锁(删除)
4、如果不一致则什么都不做
-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
案例:
unlock.lua:
// lua脚本
private static DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT=new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class); }
// 改进,基于lua脚本实现原子性
public void unlock() {
stringRedisTemplate.execute(UNLOCK_SCRIPT,Collections.singletonList(name),ID_PREFIX + Thread.currentThread().getId());
}
笔者总结:我们一路走来,利用添加过期时间,防止死锁问题的发生,但是有了过期时间之后,可能出现误删别人锁的问题,这个问题我们开始是利用删之前 通过拿锁,比锁,删锁这个逻辑来解决的,也就是删之前判断一下当前这把锁是否是属于自己的,但是现在还有原子性问题,也就是我们没法保证拿锁比锁删锁是一个原子性的动作,最后通过lua表达式来解决这个问题
但是目前还剩下一个问题锁不住,什么是锁不住呢,你想一想,如果当过期时间到了之后,我们可以给他续期一下,比如续个30s,就好像是网吧上网, 网费到了之后,然后说,来,网管,再给我来10块的,是不是后边的问题都不会发生了,那么续期问题怎么解决呢,可以依赖于我们接下来要学习redission啦
基于Redis分布式锁的优化:
自制分布式锁存在的问题:
基于setnx实现的分布式锁存在下面的问题:
**重入问题**:重入问题是指 获得锁的线程可以再次进入到相同的锁的代码块中,可重入锁的意义在于防止死锁,比如HashTable这样的代码中,他的方法都是使用synchronized修饰的,假如他在一个方法内,调用另一个方法,那么此时如果是不可重入的,不就死锁了吗?所以可重入锁他的主要意义是防止死锁,我们的synchronized和Lock锁都是可重入的。
**不可重试**:是指目前的分布式只能尝试一次,我们认为合理的情况是:当线程在获得锁失败后,他应该能再次尝试获得锁。
**超时释放:**我们在加锁时增加了过期时间,这样的我们可以防止死锁,但是如果卡顿的时间超长,虽然我们采用了lua表达式防止删锁的时候,误删别人的锁,但是毕竟没有锁住,有安全隐患
**主从一致性:** 如果Redis提供了主从集群,当我们向集群写数据时,主机需要异步的将数据同步给从机,而万一在同步过去之前,主机宕机了,就会出现死锁问题。
Redisson实现分布式锁:
引入依赖:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
配置Redisson客户端:
RedisConfig.java:
/**
* 配置redisson
*/
@Bean
public RedissonClient redissonClient() {
// 配置redisson
Config config=new Config();
// 使用单机版singleServer
config.useSingleServer().setAddress("redis://localhost:6379");
// 创建redissonClient对象
return Redisson.create(config);
}
修改voucherOrderServiceImpl.java的一人一单问题:
// 改动四:基于redis的分布式锁使用redisson
//1. 普通的可重入锁
RLock lock = redissonClient.getLock("lock:order:" + userId);
// boolean islock = lock.tryLock(1,10, TimeUnit.SECONDS); 尝试获取锁,最多等待(重试时间)1秒,最多持有锁(超时时间TTL)10秒
// 尝试拿锁1s后停止重试,返回false
// 没有Watch Dog ,30s后自动释放
boolean islock = lock.tryLock();
System.out.println(islock);
if (!islock) {
return Result.fail("请勿重复下单");
}
int i = 1;
try {
return voucherOrderService.createVoucherOrder(voucherId);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
测试:
使用Jmeter测试,同一用户token,50个线程同时请求;
stock减少1,http请求成功1
常见Redisson可重入锁:
private void redissonDoc() throws InterruptedException {
//1. 普通的可重入锁
RLock lock = redissonClient.getLock("generalLock");
// 拿锁失败时会不停的重试
// 具有Watch Dog 自动延期机制 默认续30s 每隔30/3=10 秒续到30s
lock.lock();
// 尝试拿锁10s后停止重试,返回false
// 具有Watch Dog 自动延期机制 默认续30s
boolean res1 = lock.tryLock(10, TimeUnit.SECONDS);
// 拿锁失败时会不停的重试
// 没有Watch Dog ,10s后自动释放
lock.lock(10, TimeUnit.SECONDS);
// 尝试拿锁100s后停止重试,返回false
// 没有Watch Dog ,10s后自动释放
boolean res2 = lock.tryLock(100, 10, TimeUnit.SECONDS);
//2. 公平锁 保证 Redisson 客户端线程将以其请求的顺序获得锁
RLock fairLock = redissonClient.getFairLock("fairLock");
//3. 读写锁 没错与JDK中ReentrantLock的读写锁效果一样
RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("readWriteLock");
readWriteLock.readLock().lock();
readWriteLock.writeLock().lock();
}
Redisson可重入锁原理:
可重入:表示已经获取了锁,线程还是当前线程,表示发生了锁的重入。
获取锁的lua脚本:
释放锁的lua脚本:
Redisson分布式锁可重入、可重试、超时续约底层原理:
Redisson分布式锁解决主从一致性底层原理:
**主从一致性:** 如果Redis提供了主从集群,当我们向集群写数据时,主机需要异步的将数据同步给从机,而万一在同步过去之前,主机宕机了,就会出现死锁问题
Redis集群可能发生主从一致性锁失效问题
为了提高redis的可用性,我们会搭建集群或者主从,现在以主从为例
此时我们去写命令,写在主机上, 主机会将数据同步给从机,但是假设在主机还没有来得及把数据写入到从机去的时候,此时主机宕机,哨兵会发现主机宕机,并且选举一个slave变成master,而此时新的master中实际上并没有锁信息,此时锁信息就已经丢掉了。
Redisson分布式锁主从一致性问题解决:
当我们去设置了多个锁时,redission会将多个锁添加到一个集合中,然后用while循环去不停去尝试拿锁,但是会有一个总共的加锁时间,这个时间是用需要加锁的个数 * 1500ms ,假设有3个锁,那么时间就是4500ms,假设在这4500ms内,所有的锁都加锁成功, 那么此时才算是加锁成功,如果在4500ms有线程加锁失败,则会再次去进行重试.
联锁MultiLock:
只有全部节点加锁成功才可,区别于红锁,红锁是在联锁的基础上,只有过半成功加锁,才算成功。
案例:
(1)在config中配置三个redis节点,创建三个Redisson对象
(2)注入redission对象,各声明可重入锁Rlock
自动生成标识(UUID+ThreadID)Hset的field(小key):
hset REDLOCK_KEY uuid+threadId 1
(3)创建联锁
(4)业务
boolean isLock;
try {
// isLock = lock.tryLock();
// 500ms拿不到锁, 就认为获取锁失败。10000ms即10s是锁失效时间。
isLock = lock.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();
}
分布式锁总结:
基于JVM阻塞队列的秒杀优化:
业务问题:
在这六步操作中,又有很多操作是要去操作数据库的,而且还是一个线程串行执行, 这样就会导致我们的程序执行的很慢,所以我们需要异步程序执行
优化:
流程:
优化方案:我们将耗时比较短的逻辑判断放入到redis中,比如是否库存足够,比如是否一人一单,这样的操作,只要这种逻辑可以完成,就意味着我们是一定可以下单完成的,我们只需要进行快速的逻辑判断,根本就不用等下单逻辑走完,我们直接给用户返回成功, 再在后台开一个线程,后台线程慢慢的去执行queue里边的消息,这样程序不就超级快了吗?而且也不用担心线程池消耗殆尽的问题,因为这里我们的程序中并没有手动使用任何线程池,当然这里边有两个难点
第一个难点是我们怎么在redis中去快速校验一人一单,还有库存判断
第二个难点是由于我们校验和tomct下单是两个线程,那么我们如何知道到底哪个单他最后是否成功,或者是下单完成,为了完成这件事我们在redis操作完之后,我们会将一些信息返回给前端,同时也会把这些信息丢到异步queue中去,后续操作中,可以通过这个id来查询我们tomcat中的下单逻辑是否完成了。
案例:
我们现在来看看整体思路:当用户下单之后,判断库存是否充足只需要导redis中去根据key找对应的value是否大于0即可,如果不充足,则直接结束,如果充足,继续在redis中判断用户是否可以下单,如果set集合中没有这条数据,说明他可以下单,如果set集合中没有这条记录,则将userId和优惠卷存入到redis中,并且返回0,整个过程需要保证是原子性的,我们可以使用lua来操作
当以上判断逻辑走完之后,我们可以判断当前redis中返回的结果是否是0 ,如果是0,则表示可以下单,则将之前说的信息存入到到queue中去,然后返回,然后再来个线程异步的下单,前端可以通过返回的订单id来判断是否下单成功。
1、修改新增优惠券代码,实现同时添加优惠券库存到Redis
// 秒杀优化
// 保存优惠券到redis,采用string存储
stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString());
2、编写lua脚本,实现判断秒杀库存和一人一单问题判断
---
--- Generated by EmmyLua(https://github.com/EmmyLua)
--- Created by 80954.
--- DateTime: 2023/7/1 17:53
---
-- 1.参数列表
-- 1.1优惠券idlocal voucherId = ARGV[1]
-- 1.2.用户id
local userId = ARGV[2]
-- 2.数据key
-- 2.1.库存key
local stockKey = "seckill:stock:" .. voucherId
-- 2.2.订单key
local orderKey = "seckill:order:" .. voucherId
-- 3.脚本业务
-- 3.1.判断库存是否充足 get stockKey
if (tonumber(redis.call("get", stockKey)) <= 0)
then
-- 3.2.库存不足,返回1
return 1
end
-- 3.2.判断用户是否下单 SISMEMBER orderKey userId
if (redis.call("sismember", orderKey, userId) == 1)
then
-- 3.3.存在,说明是重复下单,返回2
return 2
end
-- 3.4.扣库存 incrby stockKey -1
redis.call("incrby", stockKey, -1)
-- 3.5.下单(保存用户)sadd orderKey userId
redis.call("sadd", orderKey, userId)
return 0
3、载入lua脚本和改写seckillvocher.java:
private static DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
// UNLOCK_SCRIPT.setScriptText("if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end");
SECKILL_SCRIPT.setResultType(Long.class);
}
/**
* 秒杀优化后代码
* @param voucherId
* @return
*/
@Override
public Result seckillVoucher(Long voucherId) {
// 获取用户id
Long userId = UserHolder.getUser().getId();
// 1.执行lua脚本
Long result = stringRedisTemplate.execute(SECKILL_SCRIPT,
Collections.emptyList(), voucherId.toString(), userId.toString());
// 2.判断结果是否为0
int r = result.intValue();
if (r != 0) {
// 2.1 不为0,代表没有购买资格
return Result.fail(r == 1 ? "库存不足" : "重复下单");
}
// 2.2 为0,有购买资格,把下单信息保存到阻塞队列
long orderId = redisIdWorker.nextId("order");
// TODO 保存到阻塞队列
return Result.ok(0);
}
redis已经成功获取
4、如果抢购成功,将优惠券id和用户id封装后存入阻塞队列
* 如果抢购成功,将优惠券id和用户id封装后存入阻塞队列
* 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
修改下单动作,现在我们去下单时,是通过lua表达式去原子执行判断逻辑,如果判断我出来不为0 ,则要么是库存不足,要么是重复下单,返回错误信息,如果是0,则把下单的逻辑保存到队列中去,然后异步执行
//新建阻塞队列
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);
// 2.2 r为0,有购买资格,把下单信息保存到阻塞队列
VoucherOrder voucherOrder = new VoucherOrder();
//2.3.订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 2.4用户id
voucherOrder.setUserId(UserHolder.getUser().getId());
// 2.5优惠券id
voucherOrder.setVoucherId(voucherId);
// 2.6放入阻塞队列
orderTasks.add(voucherOrder);
5、开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
新建阻塞队列和单线程池:
新建init方法和匿名多线程内部类
新建下订单函数:
修改seckillvoucher和createVoucherOrder代码
测试:
redis和数据库都成功修改