Redis SpringBoot 渐进开发分布式锁

101 阅读10分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第9天,点击查看活动详情

渐进开发分布式锁

在分布式的这个大环境下,本地锁 synchronized, Lock 等已经不能满足我们的需要.我们急需一种对实例进行上锁的机制.

本文将从分布式环境下产生的问题出发,解释为什么需要分布式锁,然后解释什么是分布式锁与分布式锁的特点.最后通过实践,循序渐进完成分布式锁,实现锁的续期,实现可重入锁,解决redis命令不满足原子性的问题.

一. 分布式环境下问题分析

**产生现象:**本地锁在多节点下失效(集群/分布式)

分析原因:本地锁它只能锁住本地JVM进程中的多个线程,对于多个JVM进程的不同线程间是锁不住

**解决办法:**分布式锁(在分布式环境下提供锁服务,并且达到本地锁的效果)

1.1. 什么是分布式锁

什么时候需要分布式锁: 需要利用锁的技术控制某一时刻修改数据的进程数。

  1. 商品抢购,有限的商品在同一时刻产生大量的争抢
  2. 某个业务在某一个时刻只允许一个人在执行

分布式锁锁作用跟单机锁完全一样, 只是它通常需要借助第三方服务(redis,zookeeper,mysql等性能好的存储中间件)来实现。

1.2. 分布式锁特点

**互斥性:**不仅要在同一jvm进程下的不同线程间互斥,更要在不同jvm进程下的不同线程间互斥

**锁超时:**支持锁的自动释放,防止死锁 (redis中设置过期时间实现)

**高可用:**加锁和解锁必须是同一个线程,加锁和解锁操作一定要高效,提供锁的服务要具备容错性

**可重入:**如果一个线程拿到了锁之后继续去获取锁还能获取到,我们称锁是可重入的(方法的递归调用时) **阻塞/非阻塞:**如果获取不到直接返回视为非阻塞的,如果获取不到会等待锁的释放直到获取锁或者等待超时,视为阻塞的

**公平/非公平:**按照请求的顺序获取锁视为公平的

二. 基于Redis实现分布式锁

1.1. 基本原理‼️

SETNX key value SETNX 是 SET if Not eXists (如果不存在,则 SET)的简写。

  1. 将 key 的值设为 value ,当且仅当 key 不存在.
  2. 若给定的 key 已经存在,则 SETNX 不做任何动作.
  3. 返回值: 设置成功,返回 1 设置失败,返回 0

使用SETNX完成同步锁的流程及事项如下:

通过判断是否设置成功,来检查是否持有了锁.

  • 设置成功: 拿到锁
  • 设置失败: 锁已经被其他实例持有

获取锁: 使用SETNX命令获取锁,若返回0(key已存在,锁已存在)则获取失败,反之获取成功

锁超时: 为了防止获取锁后程序出现异常,导致其他线程/进程调用SETNX命令总是返回0而进入死锁状态,需要为该key设置一个“合理”的过期时间

释放锁: 使用DEL命令将锁数据删除


基础环境搭建不再详细讲解,核心技术 Springboot,redis

1.2. 小试牛刀 (最简单的分布式锁)

Lockv1.class

public class Lockv1 {
    private static final StringRedisTemplate redisTemplate = SpringUtil.getBean(StringRedisTemplate.class);

    public static boolean tryLock(String lockName, Integer release) {
        // setIfAbsent : 设置成功返回true,设置失败返回false
        boolean lock = redisTemplate.opsForValue().setIfAbsent(lockName, "1");
        // 检查是否设置成功
        if (Boolean.TRUE.equals(lock)){
            // 设置过期时间.避免异常导致死锁
            redisTemplate.expire(lockName,release, TimeUnit.SECONDS);
            return true;
        }
        return false;
    }

    public static boolean unlock(String lockName) {
        // 删除锁,即释放
        return redisTemplate.delete(lockName);
    }
}

TestLockController.class

@RestController
@RequestMapping("/test/lock")
public class TestLockController {

    @GetMapping("/v1/lock")
    public String lock_v1(){
        // 锁的名称,可根据业务任意定制
        String lockName = "kock_1";
        // 获取到锁返回 true
        boolean lock = Lockv1.tryLock(lockName, 5);
        if (lock){
            try {
                System.out.println(Thread.currentThread().getName()+" - 获取锁成功");
                doSomething(5);
            }catch (Exception e){

            }finally {
                Lockv1.unlock(lockName);
                System.out.println(Thread.currentThread().getName()+" - 释放锁成功");
            }
        }else {
            System.out.println(Thread.currentThread().getName()+" - 获取锁失败");
        }
        return lockName;
    }

    private void doSomething(Integer time) throws InterruptedException {
        System.out.println("开始执行业务方法-->");
        TimeUnit.SECONDS.sleep(time);
        System.out.println("结束执行业务方法--<");
    }
}

// 一个请求正常执行
http-nio-8080-exec-2 - 获取锁成功
开始执行业务方法-->
结束执行业务方法--<
http-nio-8080-exec-2 - 释放锁成功

// 当一个线程持有锁时另一个线程获取锁失败不执行业务方法
http-nio-8080-exec-9 - 获取锁成功
开始执行业务方法-->
http-nio-8080-exec-10 - 获取锁失败
结束执行业务方法--<
http-nio-8080-exec-9 - 释放锁成功

v1 版本 问题分析Lockv1.class

  1. setnxexpire 是非原子性操作,如果获取到锁之后,设置过期时间之前出现异常,那么锁不会过期,就会导致死锁的发生.

  2. 错误解锁,目前的锁只要知道 lockName 的线程都可以解,需要保证自己只能解自己的锁.

  3. 手动设置的过期时间 expire ,可能会出现锁已经过期但是业务还在执行的情况,需要为锁续期.

1.3. 初入殿堂

解决: 问题1 - setnxexpire 是非原子性操作.

可以借助lua脚本实现原子性操作

lockv2.lua

local key = KEYS[1]
local value = ARGV[1]
local expire = ARGV[2]

-- setnx 设置成功返回 1 失败返回 0
if redis.call('setnx',key,value) == 1 then
    redis.call('expire',key,expire);
    return true;
else
    return false;
end
public class Lockv2 {
    private static final StringRedisTemplate redisTemplate = SpringUtil.getBean(StringRedisTemplate.class);

    // #############初始化lock脚本##############
    private static final DefaultRedisScript<Boolean> LOCK_SCRIPT;

    static {
        // 加载脚本
        LOCK_SCRIPT = new DefaultRedisScript<>();
        LOCK_SCRIPT.setScriptSource(new ResourceScriptSource(new ClassPathResource("lockv2.lua")));
        LOCK_SCRIPT.setResultType(Boolean.class);
    }
    // ###########初始化lock脚本结束############

    public static boolean tryLock(String lockName, Integer release) {
        Boolean result = redisTemplate.execute(
                LOCK_SCRIPT,
                Collections.singletonList(lockName), // KEYS[0]
                "1", release.toString() // ARGV[0,1] args是String类型

        );
        return result;
    }

    public static boolean unlock(String lockName) {
        // 删除锁,即释放
        return redisTemplate.delete(lockName);
    }
}

解决: 问题2 - 保证自己只能解自己的锁.

通过把 value 设置成一个唯一性标识实现 lockv3.lua

local key = KEYS[1]
local value = ARGV[1]
local expire = ARGV[2]

-- setnx 设置成功返回 1 失败返回 0
if redis.call('get',key,value) == 1 then
    redis.call('expire',key,expire);
    return true;
else
    return false;
end

unlockv3.lua

local key = KEYS[1]
local value = ARGV[1]

-- 判断是不是自己的锁
if redis.call('get',key) == value then
    redis.call('del',key)
    return true;
else
    return false;
end

Lockv3.class

public class Lockv3 {
    private static final StringRedisTemplate redisTemplate = SpringUtil.getBean(StringRedisTemplate.class);

    // #############初始化lock脚本##############
    private static final DefaultRedisScript<Boolean> LOCK_SCRIPT;
    private static final DefaultRedisScript<Boolean> UNLOCK_SCRIPT;

    static {
        LOCK_SCRIPT = new DefaultRedisScript<>();
        LOCK_SCRIPT.setScriptSource(new ResourceScriptSource(new ClassPathResource("lockv3.lua")));
        LOCK_SCRIPT.setResultType(Boolean.class);

        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setScriptSource(new ResourceScriptSource(new ClassPathResource("unlockv3.lua")));
        UNLOCK_SCRIPT.setResultType(Boolean.class);
    }
    // ###########初始化lock脚本结束############
    
    public static boolean tryLock(String lockName,String lockValue, Integer release) {
        Boolean result = redisTemplate.execute(
                LOCK_SCRIPT,
                Collections.singletonList(lockName), // KEYS[0]
                lockValue, release.toString() // ARGV[0,1] args是String类型

        );
        return result;
    }

    public static boolean unlock(String lockName, String lockValue) {
        Boolean result = redisTemplate.execute(
                UNLOCK_SCRIPT,
                Collections.singletonList(lockName),
                lockValue

        );
        return result;
    }
}

TestLockController.class

@RestController
@RequestMapping("/test/lock")
public class TestLockController {

    @GetMapping("/v3/lock")
    public String lock_v3(){
        // 锁的名称,可根据业务任意定制
        String lockName = "kock_3";
        String lockValue = UUID.randomUUID().toString();
        // 获取到锁返回 true
        boolean lock = Lockv3.tryLock(lockName, lockValue,5);
        if (lock){
            try {
                System.out.println(Thread.currentThread().getName()+" - 获取锁成功");
                doSomething(5);
            }catch (Exception e){

            }finally {
                // 根据 锁 和 上锁标识 解锁
                Lockv3.unlock(lockName,lockValue);
                System.out.println("尝试解别人的锁: "+Lockv3.unlock(lockName,"xxx"));
                System.out.println(Thread.currentThread().getName()+" - 释放锁成功");
            }
        }else {
            System.out.println(Thread.currentThread().getName()+" - 获取锁失败");
        }
        return lockName;
    }

    private void doSomething(Integer time) throws InterruptedException {
        System.out.println("开始执行业务方法-->");
        TimeUnit.SECONDS.sleep(time);
        System.out.println("结束执行业务方法--<");
    }
}

http-nio-8080-exec-1 - 获取锁成功
开始执行业务方法-->
结束执行业务方法--<
尝试解别人的锁: false
http-nio-8080-exec-1 - 释放锁成功

解决: 问题3 - 锁续期.

拿到锁之后执行业务,业务的执行时间超过了锁的过期时间,这样这个业务释放了锁,但是业务没有结束,导致其他业务涌入.

续期逻辑

  1. 获取锁成功就入守护线程
  2. 守护线程定期为加入的锁续期 (已经可以省略获取锁的过期时间参数了)
  3. 释放锁时将锁从守护线程移除

lock_renewalv4.lua

local key = KEYS[1]
local value = ARGV[1]
local expire = ARGV[2]

-- get 并和 value 比较
if redis.call('get',key) == value then
    redis.call('expire',key,expire);
    return true;
else
    return false;
end

Lockv4.class

@Component
public class Lockv4 {
    // redis
    private static final StringRedisTemplate redisTemplate = SpringUtil.getBean(StringRedisTemplate.class);

    // script
    private static final DefaultRedisScript<Boolean> LOCK_SCRIPT;
    private static final DefaultRedisScript<Boolean> UNLOCK_SCRIPT;
    private static final DefaultRedisScript<Boolean> LOCK_RENEWAL_SCRIPT;

    // 锁续期
    private ScheduledExecutorService executorService;//守护线程池
    private static ConcurrentHashMap<String,String> locks = new ConcurrentHashMap<>(); // 锁 map

    static {
        // 加载脚本
        LOCK_SCRIPT = new DefaultRedisScript<>();
        LOCK_SCRIPT.setScriptSource(new ResourceScriptSource(new ClassPathResource("lockv3.lua")));
        LOCK_SCRIPT.setResultType(Boolean.class);

        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setScriptSource(new ResourceScriptSource(new ClassPathResource("unlockv3.lua")));
        UNLOCK_SCRIPT.setResultType(Boolean.class);

        LOCK_RENEWAL_SCRIPT = new DefaultRedisScript<>();
        LOCK_RENEWAL_SCRIPT.setScriptSource(new ResourceScriptSource(new ClassPathResource("lock_renewalv4.lua")));
        LOCK_RENEWAL_SCRIPT.setResultType(Boolean.class);
    }

    // 续期核心方法
    @PostConstruct
    public void init(){
        executorService = Executors.newScheduledThreadPool(1);

        // 时间等于或超过time首次执行task,之后每隔period毫秒重复执行task
        executorService.scheduleAtFixedRate(new Runnable() {
            @Override
            public void run() {
                Enumeration<String> keys = locks.keys();
                while (keys.hasMoreElements()) {
                    String key = keys.nextElement();
                    // 每次续期 1s
                    Boolean result = redisTemplate.execute(
                            LOCK_RENEWAL_SCRIPT,
                            Collections.singletonList(key), // KEYS[0]
                            locks.get(key), "1" // ARGV[0,1] args是String类型

                    );
                    System.out.println("续期:"+ key+" ## "+locks.get(key) + (result?" - 成功":" - 失败"));
                }
            }
            // 延时 0 ms,每隔 500ms 运行一次任务
        },0,500, TimeUnit.MILLISECONDS);
    }

    public static boolean tryLock(String lockName,String lockValue) {
        Boolean result = redisTemplate.execute(
                LOCK_SCRIPT,
                Collections.singletonList(lockName), // KEYS[0]
                lockValue, "1" // ARGV[0,1] args是String类型

        );
        // 获取到锁,加入守护线程
        if (result){
            locks.put(lockName,lockValue);
        }
        return result;
    }

    public static boolean unlock(String lockName, String lockValue) {
        locks.remove(lockName);
        Boolean result = redisTemplate.execute(
                UNLOCK_SCRIPT,
                Collections.singletonList(lockName),
                lockValue
        );
        return result;
    }
}

TestLockController.class

@RestController
@RequestMapping("/test/lock")
public class TestLockController {

    @GetMapping("/v4/lock")
    public String lock_v4(){
        // 锁的名称,可根据业务任意定制
        String lockName = "kock_4";
        String lockValue = UUID.randomUUID().toString();
        // 获取到锁返回 true
        boolean lock = Lockv4.tryLock(lockName, lockValue);
        if (lock){
            try {
                System.out.println(Thread.currentThread().getName()+" - 获取锁成功");
                doSomething(1);
            }catch (Exception e){

            }finally {
                // 根据 锁 和 上锁标识 解锁
                Lockv4.unlock(lockName,lockValue);
                System.out.println(Thread.currentThread().getName()+" - 释放锁成功");
            }
        }else {
            System.out.println(Thread.currentThread().getName()+" - 获取锁失败");
        }
        return lockName;
    }

    private void doSomething(Integer time) throws InterruptedException {
        System.out.println("开始执行业务方法-->");
        TimeUnit.SECONDS.sleep(time);
        System.out.println("结束执行业务方法--<");
    }
}


http-nio-8080-exec-1 - 获取锁成功
开始执行业务方法-->
续期:kock_4 ## 19b60f5d-d786-45e0-ad3f-1526689b37c5 - 成功
续期:kock_4 ## 19b60f5d-d786-45e0-ad3f-1526689b37c5 - 成功
结束执行业务方法--<
http-nio-8080-exec-1 - 释放锁成功

到了这里,我们基本完成了一个简单的 非阻塞 锁的开发,实现的功能有:

  1. 获取锁
  2. 释放锁
  3. 避免了错误解锁
  4. 加锁解锁过程中的原子性
  5. 锁的自动刷新

接下来我们再在之前的代码上优化一下,完成 阻塞 锁的开发(基于Lockv4.class) Lockv5.class

// TODO


1.4. 可从入锁

重入锁:也叫做递归锁,指的是在同一线程内,外层函数获得锁之后,内层递归函数仍然可以获取到该锁。换一种说法:同一个线程再次进入同步代码时,可以使用自己已获取到的锁。可重入锁可以避免因同一线程中多次获取锁而导致死锁发生。

获取锁的步骤:

存储在锁中的信息就必须包含:lock_key、hashkey、重入次数。

  1. 判断lock是否存在 EXISTS lock_5
    1. 不存在,则自己获取锁,记录重入层数为1.
    2. 存在,说明有人获取锁了,下面判断是不是自己的锁,即判断hashKey是否存在:HEXISTS lock_5 hashKey
      1. 不存在,说明锁已经有了,且不是自己获取的,锁获取失败.
      2. 存在,说明是自己获取的锁,重入次数+1: HINCRBY lock_5 hashKey 1 ,最后更新锁自动释放时间, EXPIRE lock_5 1

释放锁的步骤:

  1. 判断当前hashKey是否存在: HEXISTS lock_5 hashKey
    1. 不存在,说明锁已经失效,不用管了

    2. 存在,说明锁还在,重入次数减1: HINCRBY lock_5 hashKey -1

      1.获取新的重入次数,判断重入次数是否为0,为0说明锁全部释放,删除key: DEL lock

reentrant_lockv5.lua 获取锁的lua脚本

local key = KEYS[1]
local value = ARGV[1]
local expire = ARGV[2]

if(redis.call('exists', key) == 0) then
    redis.call('hset', key, value, '1');
    redis.call('expire', key, expire);
    return true;
end;

if(redis.call('hexists', key, value) == 1) then
    redis.call('hincrby', key, value, '1');
    redis.call('expire', key, expire);
    return true;
end;
return false;

reentrant_unlockv5.lua 释放锁的lua脚本

local key = KEYS[1]
local value = ARGV[1]

if (redis.call('HEXISTS', key, value) == 0) then
    return false; -- 如果已经不是自己,则直接返回
end;
local count = redis.call('HINCRBY', key, value, -1);

if (count == 0) then
    redis.call('DEL', key);
    return true;
end;

lock_refresh.lua 刷新锁的lua脚本

local key = KEYS[1]
local value = ARGV[1]
local expire = ARGV[2]

if redis.call('hget',key,value) ~= nil then
    redis.call('expire',key,expire);
    return true;
else
    return false;
end

Lockv6.class 可重入锁实现

@Component
public class Lockv6 {
    // redis
    private static final StringRedisTemplate redisTemplate = SpringUtil.getBean(StringRedisTemplate.class);

    // script
    private static final DefaultRedisScript<Boolean> LOCK_SCRIPT;
    private static final DefaultRedisScript<Boolean> UNLOCK_SCRIPT;
    private static final DefaultRedisScript<Boolean> LOCK_RENEWAL_SCRIPT;

    // 锁续期
    private ScheduledExecutorService executorService;//守护线程池
    private static ConcurrentHashMap<String,String> locks = new ConcurrentHashMap<>(); // 锁 map

    static {
        // 加载脚本
        LOCK_SCRIPT = new DefaultRedisScript<>();
        LOCK_SCRIPT.setScriptSource(new ResourceScriptSource(new ClassPathResource("reentrant_lockv5.lua")));
        LOCK_SCRIPT.setResultType(Boolean.class);

        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setScriptSource(new ResourceScriptSource(new ClassPathResource("reentrant_unlockv5.lua")));
        UNLOCK_SCRIPT.setResultType(Boolean.class);

        LOCK_RENEWAL_SCRIPT = new DefaultRedisScript<>();
        LOCK_RENEWAL_SCRIPT.setScriptSource(new ResourceScriptSource(new ClassPathResource("lock_refresh.lua")));
        LOCK_RENEWAL_SCRIPT.setResultType(Boolean.class);
    }

    @PostConstruct
    public void init(){
        executorService = Executors.newScheduledThreadPool(1);

        // 时间等于或超过time首次执行task,之后每隔period毫秒重复执行task
        executorService.scheduleAtFixedRate(new Runnable() {
            @Override
            public void run() {
                Enumeration<String> keys = locks.keys();
                while (keys.hasMoreElements()) {
                    String key = keys.nextElement();
                    // 每次续期 1s
                    Boolean result = redisTemplate.execute(
                            LOCK_RENEWAL_SCRIPT,
                            Collections.singletonList(key), // KEYS[0]
                            locks.get(key), "1" // ARGV[0,1] args是String类型

                    );
                    System.out.println("续期:"+ key+" ## "+locks.get(key) + (result?" - 成功":" - 失败"));
                }
            }
            // 延时 0 ms,每隔500ms运行一次任务
        },0,500, TimeUnit.MILLISECONDS);
    }

    public static boolean tryLock(String lockName,String lockValue) {
        Boolean result = redisTemplate.execute(
                LOCK_SCRIPT,
                Collections.singletonList(lockName), // KEYS[0]
                lockValue, "1" // ARGV[0,1] args是String类型

        );
        // 获取到锁,加入守护线程
        if (result){
            if (!locks.containsKey(lockName)){
                locks.put(lockName,lockValue);
            }
        }
        return result;
    }

    public static boolean unlock(String lockName, String lockValue) {
        locks.remove(lockName);
        Boolean result = redisTemplate.execute(
                UNLOCK_SCRIPT,
                Collections.singletonList(lockName),
                lockValue
        );
        return result;
    }
}

TestLockController.class

@RestController
@RequestMapping("/test/lock")
public class TestLockController {

    @GetMapping("/v5/lock/{lockValue}")
    public String lock_v5(@PathVariable("lockValue") String lockValue){
        // 锁的名称,可根据业务任意定制
        String lockName = "kock_5";
//        String lockValue = UUID.randomUUID().toString();
        // 获取到锁返回 true
        boolean lock = Lockv6.tryLock(lockName, lockValue);
        if (lock){
            try {
                System.out.println(Thread.currentThread().getName()+" - 获取锁成功");
                doSomething(3);
            }catch (Exception e){

            }finally {
                // 根据 锁 和 上锁标识 解锁
                Lockv6.unlock(lockName,lockValue);
                System.out.println(Thread.currentThread().getName()+" - 释放锁成功");
            }
        }else {
            System.out.println(Thread.currentThread().getName()+" - 获取锁失败");
        }
        return lockName;
    }

    private void doSomething(Integer time) throws InterruptedException {
        System.out.println("开始执行业务方法-->");
        TimeUnit.SECONDS.sleep(time);
        System.out.println("结束执行业务方法--<");
    }
}

可以看到 kock_5 获取了两次也释放了两次

http-nio-8080-exec-6 - 获取锁成功
开始执行业务方法-->
续期:kock_5 ## hashKey - 成功
续期:kock_5 ## hashKey - 成功
续期:kock_5 ## hashKey - 成功
续期:kock_5 ## hashKey - 成功
http-nio-8080-exec-7 - 获取锁成功
开始执行业务方法-->
续期:kock_5 ## hashKey - 成功
续期:kock_5 ## hashKey - 成功
结束执行业务方法--<
http-nio-8080-exec-6 - 释放锁成功
结束执行业务方法--<
http-nio-8080-exec-7 - 释放锁成功

三. 总结

在本篇文章,我们从0出发,一步一步完成了一个功能较为完善的分布式锁,实现了锁的动态刷新和可重入锁.

但是,本篇文章只是对分布式锁的一次探索,还存在许多的不足,比如不支持阻塞与非阻塞态的切换,不支持公平锁等.你可以使用从本篇文章中学习到的核心思想,再结合 Redission 等主流的分布式锁学习,相信你一定会有意想不到的收获.