关于后端:分布式锁原理及实现

51次阅读

共计 5741 个字符,预计需要花费 15 分钟才能阅读完成。

前言
本文次要对 redis 的分布式锁的原理及实现进行深刻解说。
当前,再针对 redis 分布式锁相干的问题都有据可查。
一、背景
说说咱们为什么须要分布式锁?
当多个线程同时操作同个资源的时候,咱们通常会应用例如 synchronized 来保障同一时刻只能有一个线程获取到对象锁进而解决资源。而在分布式条件下,各个服务独立部署,此时锁服务的对象就变为以后应用服务,即其余服务依然能够执行这块代码,导致服务呈现问题,那么如果咱们想让多个服务独立部署时,也能管制资源的独自执行,那么就须要引入分布式锁来解决这种场景。
什么是分布式锁?
分布式锁。就是管制分布式系统中不同过程独特拜访同一共享资源的一种锁的实现。
分布式锁,能够了解为“总部“的概念。
总部来管制锁的占有和告诉其余服务期待。各个独立的部署都从总部这里获取锁的音讯,从而防止了各地为政的可能。分布式锁就是这种思维。
咱们能够应用一个第三方组件(例如 redis、zookeeper、数据库)进行全局锁的监控,管制锁的持有和开释。

二、分布式锁原理及应用
setnx 是[set if not exists] 的简写。
将 key 的值设为 value,当且仅当 key 不存在,若给定的 key 曾经存在,则 setnx 不做任何动作。
接下来,咱们应用这个命令来看下分布式锁应用的简略案例,理解其内在原理。
2.1 分布式锁演进:阶段 1

代码 demo 如下:

    Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "111");
    if(lock){
        // 加锁胜利
        Map<String, List<Catelog2VO>> stringListMap = getStringListMap();
        redisTemplate.delete("lock");// 删除锁
        return stringListMap;
    }else{// 加锁失败: 重试 (自旋锁)
        return getCatalogJsonFromRedis();}

复制代码
问题:
这是最原始的锁应用问题,但这里显著有一个问题,如果锁占用后在执行业务的过程中,程序宕机,此时这个锁就会永远在 redis 中存在。
解决办法:

设置锁的过期工夫,即便没有删除,会主动删除。

2.2 分布式锁演进:阶段 2

代码 demo 如下:

    Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "111");
    if(lock){redisTemplate.expire("lock",30,TimeUnit.SECONDS);
        // 加锁胜利
        Map<String, List<Catelog2VO>> stringListMap = getStringListMap();
        redisTemplate.delete("lock");// 删除锁
        return stringListMap;
    }else{// 加锁失败: 重试 (自旋锁)
        return getCatalogJsonFromRedis();}

复制代码
问题:

setnx 设置好,又去设置过期工夫,两头呈现宕机,也会呈现死锁。

解决:

设置过期工夫和占位必须是原子性的。redis 反对应用 setnx ex 命令。

2.3 分布式锁演进:阶段 3

    Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "111",30,TimeUnit.SECONDS);
    if(lock){
        // 加锁胜利
        Map<String, List<Catelog2VO>> stringListMap = getStringListMap();
        redisTemplate.delete("lock");// 删除锁
        return stringListMap;
    }else{// 加锁失败: 重试 (自旋锁)
        return getCatalogJsonFromRedis();}

复制代码
能够应用一条命令来实现这个上锁操作。
问题:

锁是间接删除的吗?(如果业务很忙,锁本人过期了,咱们间接删除,可能会把他人正在持有的锁删除)

解决:

上锁的时候,值指定为 uuid,每个人匹配是本人的锁才删除。

2.4 分布式锁演进:阶段 4

    String uuid = UUID.randomUUID().toString();
    Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid,30,TimeUnit.SECONDS);
    if(lock){
        // 加锁胜利
        Map<String, List<Catelog2VO>> stringListMap = getStringListMap();
        String lockValue = redisTemplate.opsForValue().get("lock");
        if(uuid.equals(lockValue)){redisTemplate.delete("lock");// 删除锁
        }
        return stringListMap;
    }else{// 加锁失败: 重试 (自旋锁)
        return getCatalogJsonFromRedis();}

复制代码
这个还是存在点问题。还是有可能把其余线程的值给删除了。
为什么呢?
如果咱们从 redis 中获取到 锁的值,并且通过了 equal 校验,进入删除锁的逻辑,然而此时,锁到期了,主动删除了,且另外的线程进入占用了锁,那么就存在问题,此时删除的锁就是他人的锁。起因也跟之前一样:在锁比照和值删除的时候,不是一个原子操作。
解决办法就是应用 lua 脚本进行删除。
2.5 分布式锁演进:阶段 5
应用 lua 脚本删除锁。

    String uuid = UUID.randomUUID().toString();
    Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid,30,TimeUnit.SECONDS);
    if(lock){
        Map<String, List<Catelog2VO>> stringListMap;
        try{
            // 加锁胜利
            stringListMap = getStringListMap();}finally {
            // 定义 lua 脚本
            String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
            // 原子删除
            Integer lock1 = redisTemplate.execute(new DefaultRedisScript<Integer>(script, Integer.class), Arrays.asList("lock", uuid));
        }
        return stringListMap;
    }else{// 加锁失败: 重试 (自旋锁)
        return getCatalogJsonFromRedis();}

复制代码
三、分布式锁 Redission
3.1 Redission 简介
Redisson 是一个在 Redis 的根底上实现的 Java 驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的 Java 罕用对象,还提供了许多分布式服务。其中包含(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 的关注拆散(Separation of Concern),从而让使用者可能将精力更集中地放在解决业务逻辑上。
redission 是 redis 官网举荐的客户端,提供了 RedLock 的锁,RedLock 继承自 juc 的 Lock 接口,提供了中断、超时、尝试获取锁等操作,反对可重入,互斥等个性。
3.2 应用
导入依赖

    <!-- 整合 redission 作为分布式锁等性能框架 -->
    <dependency>
        <groupId>org.redisson</groupId>
        <artifactId>redisson</artifactId>
        <version>3.15.2</version>
    </dependency>

复制代码
配置:
以下为官网提供的参考配置:
// 默认连贯地址 127.0.0.1:6379
RedissonClient redisson = Redisson.create();

// 配置
Config config = new Config();
config.useSingleServer().setAddress(“redis://127.0.0.1:6379”);
RedissonClient redisson = Redisson.create(config);
复制代码
Config config = new Config();
config.useClusterServers()

.setScanInterval(2000) // 集群状态扫描间隔时间,单位是毫秒
// 能够用 "rediss://" 来启用 SSL 连贯
.addNodeAddress("redis://127.0.0.1:7000", "redis://127.0.0.1:7001")
.addNodeAddress("redis://127.0.0.1:7002");

RedissonClient redisson = Redisson.create(config);
复制代码
3.3 测试分布式锁
@Controller
public class TestRedissonClient {

@Autowired
RedissonClient redisson;

@ResponseBody
@GetMapping("/hello")
public String hello(){
    // 1、获取一把锁,只有锁的名字一样,既是同一把锁
    RLock lock = redisson.getLock ("my-lock");

    // 2、加锁
    lock.lock ();// 阻塞式期待

    try {System.out.println ("加锁胜利,执行业务..."+Thread.currentThread ().getId ());
        // 模仿超长期待
        Thread.sleep (20000);
    } catch (Exception e) {e.printStackTrace ();
    }finally {
        // 3、解锁
        System.out.println ("开释锁..."+Thread.currentThread ().getId ());
        lock.unlock ();}
    return "hello";
}

}
复制代码
redission 解决了两个问题:

1、锁的主动续期,如果业务超长,运行期间主动给锁上新的 30s,不必放心锁的工夫过长。锁主动过期被删掉。
2、加锁的业务,只有运行实现,就不会给以后锁续期,即便不手动解锁,锁默认在 30s 当前删除(以后线程销毁前会调用 lock 办法)

如果给 lock 传了超时工夫,就发送给 redis 执行脚本,进行占锁,默认超时工夫就是咱们设置的工夫
如果未指定超时工夫,就是用默认的开门狗工夫。(30*1000)
只有占锁胜利,就会启动一个定时工作【从新给锁设置过期工夫,新的过期工夫就是看门狗的默认工夫】,每隔 10 秒续签,续签满工夫。
internaLockLeaseTime【看门狗工夫】/3 10s。
最佳实战,【举荐写法】
加个工夫,省掉续签的工夫。
// 10s 主动解锁,指定工夫肯定要大于业务工夫(不然会报错,没把握就不要用)
lock.lock (10, TimeUnit.SECONDS);
复制代码
3.4 读写锁
一次只有一个线程能够占有写模式的读写锁,然而能够有多个线程同时占有读模式的读写锁。
读写锁适宜于对数据结构的读次数比写次数多的状况,因为,读模式锁定时能够共享,以写模式锁住意味着独占,所以读写锁又叫共享 - 独占锁。
保障肯定能读到最新数据,批改期间,写锁是一个排他锁。读锁是一个共享锁,
写锁没开释读就必须期待
写 + 读:期待写锁
写 + 读:期待写锁开释
写 + 写:阻塞形式
读 + 写:有读锁。写也须要期待
只有有写的存在,都必须期待
3.5 缓存一致性问题
缓存里的数据如何与数据库保持一致。
3.5.1 双写模式
数据库改完后,再改缓存。

问题:会有脏数据
计划一:加锁
计划二:若是容许提早(明天更新的数据今天展现,或者几分钟几小时提早),设置过期工夫(倡议)
3.5.2 生效模式
数据库改完,再将缓存删掉,期待下次被动查问,再进行更新。

问题:会有读写,脏数据
计划一:加锁
计划二:如果常常写,少读,不如间接数据库操作,去掉缓存层。
3.5.3 计划(过期工夫 + 读写锁)
无论是双写模式还是生效模式,都会导致缓存的不统一问题。即多个实例同时更新会出事。怎么办?
(1)如果是用户纬度数据(订单数据、用户数据),这种并发几率十分小,不必思考这个问题,缓存数据加上过期工夫,每隔一段时间触发读的被动更新即可
(2)如果是菜单,商品介绍等根底数据,也能够去应用 canal 订阅 binlog 的形式。
(3)缓存数据 + 过期工夫也足够解决大部分业务对于缓存的要求。
(4)通过加锁保障并发读 + 写,写 + 写的时候按程序排好队。读读无所谓。所以适宜应用读写锁。(业务不关心脏数据,容许长期脏数据可疏忽)
总结:
(1)咱们能放入缓存的数据本就不应该是实时性、一致性要求超高的。所以缓存数据的时候加上过期工夫,保障每天拿到以后最新数据即可。
(2)咱们不应该适度设计,减少零碎的复杂性
(3)遇到实时性、一致性要求高的数据,就应该查数据库,即便慢点。
针对缓存数据一致性问题,咱们能够应用 Cannal 来实现这一场景。个别针对大数据我的项目

正文完
 0