分布式锁专题--个人学习笔记

77 阅读9分钟

分布式锁

分布式锁,即分布式系统中的锁,控制分布式系统有序的对共享资源进行操作
JVM锁是解决多线程下对于共享资源的操作,而分布式锁则是多进程下对于共享资源的操作

锁分类

可重入锁

一个线程得到一个对象锁后再次请求该对象锁,是永远可以拿到锁的。
每一个锁关联一个线程持有者和计数器。
持有锁的线程如果再次请求这个锁,可以再次拿到这个锁,计数器会递增。当线程退出一个synchronized 方法/块时,计数器会递减,如果计数器为 0 则释放该锁。

悲观锁

范围:mysql中的行级锁是基于索引的,如果sql没有走索引,那将使用表级锁把整张表锁住。
mysql的排他锁,select .... for update来实现悲观锁。

begin;
select nums from tb_goods_stock where goods_id = {$goods_id} for update;
update tb_goods_stock set nums = nums - {$num} where goods_id = {$goods_id} and nums >= {$num};
commit;

读锁(共享锁) 写锁(排他锁)

读写锁不共存
读锁:允许其他会话读取数据,但不允许修改数据
写锁:禁止其他会话读取和修改数据。

MDL锁

元数据锁(Meta Data Lock) 是server层的锁,表级锁,每执行一条DML、DDL语句时都会申请MDL锁,DML操作需要MDL读锁,DDL操作需要MDL写锁

公平锁

公平锁:按顺序排队获取锁,持有锁的线程执行完了,唤醒队列的下一个线程去获取锁
非公平锁:谁抢到就是谁的,不排队,抢不到就进入队列等待下一次时机,但不是排队,看运气。
公平和非公平的区别就是:线程执行同步代码块时,是否会去尝试获取锁。

隔离级别

#图片来源 动力节点 image.png
脏读:读到未提交的数据
幻读:读到的数据是假的,前后读到的数据不一致,即数据集不一样

synchronized与ReentrantLock都是可重入锁

区别:
synchronized是自动加锁、解锁 ---------------------- ReentrantLock 手动加锁、解锁
synchronized是非公平锁,谁抢到谁执行-------------ReentrantLock允许实现公平锁,遵循先来后到

解决售卖商品超卖问题

MySql锁与JVM锁

#尚硅谷 image.png

使用JVM锁解决超卖问题 ----数据为服务内共享资源

@Data
public class Srock {

    private  Integer stack = 5000;
}

@Service
public class RedissionService {

private Srock srock = new Srock();
private  ReentrantLock Lock =  new ReentrantLock();
  public  void sell(){
      Lock.lock();
      try {
          srock.setStack( srock.getStack()-1);
          System.out.println("库存:"+srock.getStack());
      }finally {
          Lock.unlock();
      }
  }
  }

适用于共享资源存在于服务内部(也可用于MySql)
synchronized与ReentrantLock都行。

使用JVM锁解决超卖问题 --数据在MySql

image.png image.png

三种情况造成JVM锁失效

  • 多例模式(Spring默认是单例模式)
    • JVM锁是对象锁,多例情况下,有多个对象,锁不住了
  • 事务
    mysql默认隔离级别是 可重复读
image.png
  • 集群部署
    多个服务器访问MySql
    集群部署 和 多例模式情况差不多,不同的服务器不同的锁,并发下失效。

直接使用一条SQL语句解决(自带表级锁)

update db_stock set count = count - 1 where product_code = '1001' and count >=1;
该sql语句没有走索引,所以是表级锁。

直接使用一条SQL语句解决(设置索引且条件字段是具体值,走行级锁)

product_code设置索引,且查询条件必须是具体值
update db_stock set count = count - 1 where product_code = '1001' and count >=1;

使用以上SQL缺点

同一个字段有多条,更新哪个? 其实两个都更新了
无法记录库存变化前后的状态
image.png

MySql悲观锁中使用行级锁 select ... from update

设置索引且条件字段是具体值,走行级锁
对于一条SQL解决的问题: 可以选择合适的字段执行更新 、可以记录库存变化

问题
性能:低于一条SQL(2000) 悲观锁(500)
死锁问题
操作要统一: 统一使用select ... from update 语句,不能使用select * from ;之类的不加锁语句

乐观锁 (时间戳 version版本号 CAS机制) 效率极低 了解

提交数据更新时,才会对数据是否冲突进行检测,不会锁表。
使用时,不能加事务,递归调用前先sleep一会

CAS机制 (MySql没有)

Compare And Swap
执行更新操作时,第一次查询出来的变量X 再次与 数据库的旧值B 做比较 ,如果相等,则执行更新操作,且时间戳/版本号也同时更新。

实现

MySql数据库新增一列(时间戳)/(Version递增)来实现CAS机制
问题:
异常一致递归,栈溢出
连接超时 image.png
问题分析:
栈溢出 ---->使用sleep()
DML操作本身是会加MDL锁的,又加了事务注解,会造成阻塞,从而造成超时问题---->关闭事务
SQL执行失败就会释放锁

MySql锁总结

image.png

数据位于redis

JVM本地锁 类似以上

redis乐观锁 watch multi exec (了解 性能300低)

watch + key 监听数据,如果exec执行之前数据变化,则事务取消执行
multi 开启事务
exec 提交事务
image.pngimage.png
代码操作

@Autowired
private RedisTemplate redisTemplate;

public void redisTest(){
    //redis乐观锁  使用execute方法,才允许使用 watch multi exec等命令
    redisTemplate.execute(new SessionCallback() {
        @Override
        public Object execute(RedisOperations operations) throws DataAccessException {
            //1.watch
            operations.watch("stock");
            //2.查询 数据
            String stock = operations.opsForValue().get("stock").toString();
            if(stock!=null&&stock.length()>0){
                Integer count = Integer.valueOf(stock);
                if(count>0){
                    //3.multi 开启事务
                    operations.multi();
                    //4.数据操作
                    operations.opsForValue().set("stock",String.valueOf(count--));
                    //5.exec 提交事务
                    List exec = operations.exec();
                    //如果结果集为空,表示执行失败
                    if(exec==null || exec.size()==0){
                        try {
                            Thread.sleep(20);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                        redisTest();
                    }
                    //递归
                    return exec;
                }
            }
            return null;
        }
    });
}

分布式锁 (重点)

跨进程 、 跨服务、跨服务器

应用场景

超卖现象
缓存击穿 热点key失效

三种主流实现

基于redis

初级:独占排他使用 setnx lock 加锁 del解锁
问题:加锁后服务器马上宕机,导致死锁问题。---->给锁设置过期时间

进阶1:防死锁(设置过期时间) expire key time
问题
1:加锁语句 和 设置过期时间语句 ---的时间内内服务器宕机---->原子性
2:不可重入问题 -->在进阶6

54.drawio.png

进阶3:原子性 set key value ex 时间 nx ----- 查看剩余时间 ttl key
问题:lock误删 、任务执行时间 大于 锁过期时间

33.drawio.png

进阶4:防误删 使用UUID 解锁前判断
仅使用UUID的问题:
image.png

@Service
public class RedissionService {


    @Autowired
    private RedisTemplate redisTemplate;

    public void redisTest(){
        //redis乐观锁
        //1.加锁
        while (!redisTemplate.opsForValue().setIfAbsent("lock","111",5, TimeUnit.MINUTES)){
            try {
                Thread.sleep(50);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        //2.查询 数据
        try {
            String stock = redisTemplate.opsForValue().get("stock").toString();
            if(stock!=null&&stock.length()>0){
                Integer count = Integer.valueOf(stock);
                if(count>0){
                    //3.数据操作
                    redisTemplate.opsForValue().set("stock",String.valueOf(count--));
                }
            }
        } finally {
            //4 解锁
            redisTemplate.delete("lock");
        }
    }
}

进阶5:防误删加强版 保证判断和删除的原子性
使用Lua脚本的原因:一次性发送多个指令给redis ,redis是单线程的,执行指令遵循one bye one规则

Redis 的一大特性就是它是单线程的。这意味着在任何给定的时间点,Redis 只会执行一个命令。 这种“一次只执行一个命令”的规则,也被称为 “one by one” 规则。这是因为 Redis 使用单线程模型来处理命令,所以命令是顺序执行的,而不是并行执行的。当一个命令在执行时,其他的命令会被放入队列中,等待当前命令执行完毕后再执行。

redis提供了对Lua的支持----eval指令
参数:numkeys 表示key的数量,从1开始 image.png

Lua入门:
学习一门语言,从hello world开始       Lua不用print,使用return返回  

image.png
变量
a = 5               -- 全局变量
local b = 5         -- 局部变量

流程控制

[ 0 为 true ]
if(0)   
then
    return("0 为 true")
end

第一条redis Lua脚本

if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end

image.png 代码实现:

image.png

    } finally {
        //4 解锁  代码改造部分
        String script = "if redis.call('get',KEYS[1]) == ARGV[1] " +
                "then " +
                "return redis.call('del',KEYS[1]) " +
                "else " +
                "return 0 " +
                "end";
        redisTemplate.execute(new DefaultRedisScript(script,Boolean.class), Arrays.asList("lock"),uuid);
        /*
        老版本
        String lock = redisTemplate.opsForValue().get("lock").toString();
        if(uuid.equals(lock)){
            redisTemplate.delete("lock");
        }*/
    }
}

进阶6:可重入

研究reentrantlock锁(可重入的) #尚硅谷截图

image.png
redis实现可重入锁 hash结构 外层key lock 内存key uuid value 重入次数
map<lock,map<uuid,value>> + Lua脚本

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
加锁

image.png
hincrby 命令在也具有设置锁的功能,替换hset image.png
参数替换

image.png

解锁

image.png

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

代码实现:
//注意 : DistributedLockClient是单例的,容器初始化时会生成唯一的uuid,可以 加上当前线程的id,作为lock

public class RedissDistributedLock implements Lock {


    private StringRedisTemplate redisTemplate;

    private String lockName;
    private String uuid;

    private long expire = 30;
    public RedissDistributedLock(StringRedisTemplate redisTemplate, String lockName,String uuid) {
        this.redisTemplate = redisTemplate;
        this.lockName = lockName;
        this.uuid =uuid+":"+Thread.currentThread().getId();
    }

    @Override
    public void lock() {
        this.tryLock();
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {

    }

    //加锁
    @Override
    public boolean tryLock() {
        try {
            this.tryLock(-1L,TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return false;
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        if(time!=-1){
            this.expire = unit.toSeconds(time);
        }
        String script = "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";
        while (!redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(lockName),uuid,String.valueOf(expire)))
       {
           Thread.sleep(50);
       }
        return true;
    }


    @Override
    public void unlock() {
        String script = "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";
        Long flag = redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList(lockName), uuid);
        if (flag == null) {
            throw new IllegalMonitorStateException("lock is not yours");
        }
    }


        @Override
    public Condition newCondition() {
        return null;
    }


}
@Component
public class DistributedLockClient {

    private String uuid;

    public DistributedLockClient(String uuid) {
        this.uuid = uuid;
    }

    @Autowired
    private StringRedisTemplate redisTemplate;
    public RedissDistributedLock getRedisClient(String lockName){
        return new RedissDistributedLock(redisTemplate,lockName,uuid);
    }
}
@Service
public class RedissionService {

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Autowired
    private DistributedLockClient distributedLockClient;
    public void redisTest(){
        String uuid = UUID.randomUUID().toString();
        RedissDistributedLock redisClient = distributedLockClient.getRedisClient("lock");
        redisClient.lock();
        //2.查询 数据
        try {
            String stock = redisTemplate.opsForValue().get("stock").toString();
            if(stock!=null&&stock.length()>0){
                Integer count = Integer.valueOf(stock);
                if(count>0){
                    //3.数据操作
                    redisTemplate.opsForValue().set("stock",String.valueOf(count--));
                }
            }
        } finally {
            //4 解锁
            redisClient.unlock();
        }
    }
}

end 进阶7:自动续期 定时任务
添加定时刷新方法 image.png

进阶8:redis集群下,锁机制失效
问题:
客户端A从主节点获取到锁
在主节点将锁同步到从节点之前,主节点宕机
从节点晋升为主节点,但它不知道A已经拿到锁了
客户端B又从它获取到了同一个资源(a拿到锁的资源)拿到了锁

redLock算法 (麻烦)

#尚硅谷 image.png

Redisson

redisson配置

redisson:
  address: redis://192.168.80.150:6379
  #password: 123456
  database: 2
@ConfigurationProperties(prefix = "redisson")
@ConditionalOnProperty("redisson.password")
@Data
public class RedissonProperties {

    private int timeout = 3000;

    private String address;

    private String password;

    private int database = 2;

    private int connectionPoolSize = 64;

    private int connectionMinimumIdleSize=10;

    private int slaveConnectionPoolSize = 250;

    private int masterConnectionPoolSize = 250;

    private String[] sentinelAddresses;

    private String masterName;
}
@Configuration
@ConditionalOnClass(Config.class)
@EnableConfigurationProperties(RedissonProperties.class)
@RequiredArgsConstructor
public class RedissonAutoConfiguration {

     final RedissonProperties redissonProperties;

    /**
     * 哨兵模式自动装配
     *
     * @return
     */
    @Bean
    @ConditionalOnProperty(name = "redisson.master-name")
    RedissonClient redissonSentinel() {
        Config config = new Config();
        SentinelServersConfig serverConfig = config
                .useSentinelServers()
                .addSentinelAddress(redissonProperties.getSentinelAddresses())
                .setMasterName(redissonProperties.getMasterName())
                .setTimeout(redissonProperties.getTimeout())
                .setMasterConnectionPoolSize(redissonProperties.getMasterConnectionPoolSize())
                .setSlaveConnectionPoolSize(redissonProperties.getSlaveConnectionPoolSize());

        if (StringUtils.isNotBlank(redissonProperties.getPassword())) {
            serverConfig.setPassword(redissonProperties.getPassword());
        }
        return Redisson.create(config);
    }

    /**
     * 单机模式自动装配
     *
     * @return
     */
    @Bean
    @ConditionalOnProperty(name = "redisson.address")
    RedissonClient redissonSingle() {
        Config config = new Config();
        config.setCodec(new org.redisson.codec.JsonJacksonCodec());
        SingleServerConfig serverConfig = config
                .useSingleServer()
                .setAddress(redissonProperties.getAddress())
                //等待节点回复命令的时间。该时间从命令发送成功时开始计时。
                .setTimeout(redissonProperties.getTimeout())
                //设置对于master节点的连接池中连接数最大为50
                .setConnectionPoolSize(redissonProperties.getConnectionPoolSize())
                //  连接池的最小空闲连接数。当连接池中的连接数低于此值时,会尝试创建新连接。
                .setConnectionMinimumIdleSize(redissonProperties.getConnectionMinimumIdleSize());

        if (StringUtils.isNotBlank(redissonProperties.getPassword())) {
            serverConfig.setPassword(redissonProperties.getPassword());
        }

        return Redisson.create(config);
    }

}

使用:RedissonClient接口

@Autowires
private RedissonClient redissonClient;
基于zookeeper
基于mysql