分布式锁原理及实现

前言
本文主要对 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> 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> 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> 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> 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> 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(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 使用
导入依赖

    
    
        org.redisson
        redisson
        3.15.2
    

复制代码
配置:
以下为官网提供的参考配置:
// 默认连接地址 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 来完成这一场景。一般针对大数据项目

你可能感兴趣的:(后端java分布式)