基于 RedisTemplate + 线程池 实现 Redis分布式锁,让你搞懂这件 ”锁事“

1,102 阅读4分钟

28fba39138d3e7bf02ae6d053fa2f513.jpeg

前言

        我们平时所使用到的 Redis 大多是用来用作 缓存的,但是 Redis 也有很多其它热门的用途,如 分布式锁、排行榜、计数器、队列等。本篇文章将结合 代码示例、图文 介绍为什么 redis能够用作分布式锁,以及容易出现的bug,和分布式锁的性能提升等,并引导大家逐步优化一个分布式锁。

什么是分布式锁?为什么需要分布式锁?

        随着软件并发需求的提高,现在的项目也已经从 单体架构 逐渐演化为 分布式架构。

6b52f31c0439442f9feebfceeddb9834.png​编辑

中间也有一些过渡,如 垂直架构

 3cb47e2a47184dbdbe3b3960ca732cbc.png

当然,接下来也在继续发展 SOA架构微服务架构等 。

所以,往往部署的后台服务不会只是单机部署了,而是通过 集群 的方式运行在两个甚至多个部署的服务器上(即便是同一个服务器的两个端口上,也会出现同样的问题)等架构来进行部署。

        在用户所发送的请求中,每个请求将会通过 负载均衡 发送到不同的服务器中。如果我们还想对集群中的某个代码片段进行加锁,那么就需要我们的 分布式锁 出场了。

        如果使用传统的加锁方式,我们会对该代码片段加上  synchronized 关键字。如下代码所示

        synchronized (this){
            // todo 业务逻辑
        }

        为什么  synchronized 不能解决分布式加锁的问题?

synchronized 关键字,只能控制该 JVM 进程中的锁资源控制,这一方法有着很大的局限性。主要也是完成 单体架构 或者 进程内 需要加锁的需求。

        分布式锁的实现方式也有多种如:Redis分布式锁zookeeper分布式锁等,本篇主要介绍 Redis 分布式锁。

redis为什么能实现分布式锁?

        我们知道 Redis 是一个以键值对存储的 nosql,所以使用 Redis 实现的分布式锁将 以数据的形式作为锁资源 存入redis。作为 “锁” 就要求在某一时刻,只会有一个线程在执行该片段。即串行执行加锁片段。

        而Redis 的 主线程(读写线程)模型 就是 单线程 的。也就是说在用户的请求到来时的同一时刻只会有一个线程在执行 redis数据 相关的操作。

如图:

0940d05dcd4d4a549231e611f04b6ce9.png​编辑

        在redis中存入锁数据之后,第二个操作redis的线程(即便是从另外一个服务器来请求的线程)能够立刻得到锁的状态(已存在该锁) 。从而实现对集群的指定代码片段进行加锁。

如何实现redis分布式锁?

前置知识:

// redis 的命令,平时使用的最多的就是 set | get
// 为实现分布式锁的特性,我们需要保证原子性,一般redis会使用 setnx 来实现
// setnx 在redis中,如果本来有该缓存数据,则不会更新数据,否则反之
// 在使用 java 的 api中如:RedisTemplate,该命令会根据更新状态返回一个 布尔值,如果插入成功则返回 true

setnx key value

        redis分布式锁的实现主要模型步骤:

  1.  在第一个线程访问时在 Redis 中添加一项缓存数据作为锁资源
  2. 每个线程在执行该片段开始时,就会执行 setnx 命令进行缓存锁资源更新
  3. 如果更新失败,也会时返回值为false,则说明有线程正在执行该片段。这时可以选择阻塞线程或给用户反馈一些提示。(如:系统繁忙之类的提示)
  4. 在线程结束时,需要主动删除该锁资源,让接下来的还未执行的线程进行争夺。

代码演示: ( setIfPrefent() 方法是 RedisTemplate 中的api,相当于 setnx命令。

        try{
            // 获取分布式锁
            Boolean lock = redisTemplate.opsForValue().setIfPresent("lock", "resource");
            // 如果锁资源未正常更新,则返回提示
            if(!lock){
                return "系统繁忙";
            }
            // 如果正常更新,则进行业务逻辑代码
            // todo 业务逻辑
            
        }finally {
            // 执行完成后,删除锁
            redisTemplate.delete("lock");
        }

这样下来,一个简单的分布锁就完成了。

但是,聪明的同学已经发现问题了,这一段分布式锁还有很多bug。我们一个个来解决问题

在执行业务逻辑代码时该服务挂掉了怎么办?

        finally只能处理异常出现的错误,如果执行业务逻辑时挂掉,说明锁已经加上,但是却没有删除。

        这个时候说明锁永远的留在了 Redis 中。那么所有的用户就都需要进行等待,这种情况在我们的生产环境中肯定是不允许出现的。

       解决方案:利用 Redis 的过期策略,为该锁资源添加过期时间。

代码参考:

        try{
            // 获取分布式锁
            Boolean lock = redisTemplate.opsForValue().setIfPresent("lock", "resource", 10, TimeUnit.SECONDS);
            // 如果锁资源未正常更新,则返回提示
            if(!lock){
                return "系统繁忙";
            }
            // 如果正常更新,则进行业务逻辑代码
            // todo 业务逻辑
            
        }finally {
            // 执行完成后,删除锁
            redisTemplate.delete("lock");
        }

        这样即便服务挂掉了,在到了过期时间之后,该锁资源也会自动释放。但是又出现了一个新的问题:

如果运行时间超过了过期时间怎么办?

        运行时间超过了过期时间,在第一个线程没有全部执行完时,第二个线程就开始执行了。如下图模拟的场景所示:假设线程一共需要执行15s,但是redis锁过期时间只有 10s。

66a606626d0040e7915aa0a6500431eb.png

 这样就违背了分布式锁的作用。而因为线程1的锁已经被过期了,线程2马上就能得到锁。

出现的新问题有:

1、原本应该串行的两个线程,有了并发的情况。这可能违背我们所设想的情况,而出现不可预料的错误。

2、由于线程1 还没结束,线程2重新加了锁。而不久之后 线程1 结束了,又执行了删除锁的操作,导致线程2 刚加的锁 就被释放了。

解决方案:

问题1:创建出分线程对过期时间进行 “续命”, 即延长过期时间

问题2:对每个线程存入值时创建一个线程标识,在执行删除操作时,核对自己的标识,如果是自己当时创建的锁,才执行删除操作。

代码参考:

        String clientID = UUID.randomUUID().toString();// 问题2:创建线程标识,并存入redis;
        try{
            // 获取分布式锁
            Boolean lock = redisTemplate.opsForValue().setIfPresent("lock", clientID, 10, TimeUnit.SECONDS);
            // 如果锁资源未正常更新,则返回提示
            if(!lock){
                return "系统繁忙";
            }
            // 问题1 创建线程续命
            new Thread(new Runnable() {
                @Override
                public void run() {
                    // 对 redis的锁过期时间进行续命
                }
            }).start();
            // 如果正常更新,则进行业务逻辑代码
            // todo 业务逻辑

        }finally {
            // 执行完成后,判断为自己创建的锁,则删除锁
            if(clientID.equals(redisTemplate.opsForValue().get("lock"))){
                redisTemplate.delete("lock");
            }

        }

        创建出分线程的时机应该在判断是否已存在后 立刻 创建,避免因前面代码执行时间过长而导致来不及续命。

        现在看来好像分布式锁已经是一个比较完善了,但仍然有待优化。也需要根据自己的业务逻辑代码进行修改和设计。

如何设计架构将分布式锁性能提升?

        其实不管是分布式锁还是我们平时使用的锁,本质上都是将原本应该并发的线程转化为串行化。只不过分布锁的范围更大一些。

        如果想要将性能进行提升,怎样安全的提高并发数? 是我们应该思考的。

        示例中的redis key 始终是固定值,真实业务中,为了程序拓展的灵活性和性能。在 Redis 的分布式锁中,redis 的 key 往往是一个关键的角色。

业务场景描述:

        笔者在这里举个例子:假设有10个产品需要进行抢购,我们需要对库存进行防超卖的操作,如果key为固定值。那么10个产品的库存预减的逻辑,都需要串行化,直接这样操作的话,很明显,这是一个比较低效率实现的分布式锁。因为所有的抢购线程都串行化了。

7ca1f235ca434d03ba6b100c99b439c0.png

        如此,无论哪个线程先抢到资源后,(即最先向 Redis 中存入了锁数据),其它的线程都要进行等待,或进行反馈。所有的抢购线程都变成了串行执行。

第一次优化

         那么我们应该怎么进行优化呢?

        其实我们只需要保证单个产品没有超卖就能满足需求。因此我们的应对策略就可以是 在存储锁的 key 中加入 商品的id ;如( product:2 ),如下图所示:

ed4ba6cfe9c84b038e3e44be4974749e.png

        这样每个产品只需要争夺自己的产品的锁资源即可,避开与所有线程的竞争。同时提高了线程并发数。 这样我们的分布式锁的单位就变为了每个商品。性能能够直接提升10倍。

第二次优化

        精益求精:在完成初次优化的分布式锁后,我们还能不能再次提升一下性能?

        答案是可以的。在 Redis 的分布式锁中,我们想要提升性能是要基于 Redis 的key来作文章的。因为 key 是我们实现 Redis 分布式锁的根本。我们已经将商品对于每个商品做出了不同的 key,那么还有没什么办法能够将key再次划分呢?

        其实可以采用 分段 的方法来进行库存段的区分,从而再次细分 key。假设 商品 1 有100件库存,我们可以分为10段,即将 key 设置为 product:2:1product:2:2 ~~~ 、product:2:10。这样每段控制的库存量为10。那是不是又将性能在原本的基础上提升了10 倍呢?

0ab3856e8d674787a24de2a3d39707e0.png

 这个方法本质上其实与第一种优化方式类似,主要的优化思想就是通过 分解key 来提高线程的并发数。而细分key的操作也需要再次完善部分业务逻辑。

在别的业务场景中实现分布锁也一定有其优化的方案。

最终分布式锁代码模板

在实际开发中还是不建议直接通过 Thread类来进行创建线程,这里模板使用 JUC 提供的 

ScheduledThreadPoolExecutor 类来实现线程管理
	// 该线程池能够轻松帮助我们实现有关时间控制的任务
    @Resource
	ScheduledThreadPoolExecutor scheduledThreadPoolExecutor;
    
    
// ----------- 业务方法分布式锁片段 --------------


    ScheduledFuture<?> addLockLifeThread = null;
    try{
        // 创建线程id, 用作判断
    	String clientId = UUID.randomUUID().toString();
        // 设置分布式锁
	    Boolean lock = redisTemplate.opsForValue().setIfPresent(LOCK_KEY, clientId, LOCK_TTL, TimeUnit.SECONDS);
	    if (lock == null || !lock) {
	        // todo 如果没有拿到锁优化为阻塞,不要直接返回
		    return false;
	    }
	    // 使用线程池创建定时任务线程
	    addLockLifeThread = scheduledThreadPoolExecutor.scheduleAtFixedRate(() -> {
		    // lock锁续命
		    lengthenLockLife(clientId);
	    }, ADD_LOCK_TTL, ADD_LOCK_TTL, TimeUnit.SECONDS); 
        // 后面的参数表示,ADD_LOCK_TTL秒后,开始第1次执行,每隔ADD_LOCK_TTL秒在执行一次

    // ===== todo 完成需要进行加锁的业务逻辑 ==========

    } catch (Exception e){
        log.info("执行出错:{}", e.getMessage());
    }finally{
        // 关闭续命线程,释放锁资源
        if(addLockLifeThread != null){
	        addLockLifeThread.cancel(true);
        }
	    redisTemplate.delete(LOCK_KEY);
    }



// -----------------------------------------------

/**
 * 分布式锁进行续命
 *
 * @param clientId 创建的线程id
 */
public void lengthenLockLife(String clientId) {
	String redisLock = redisTemplate.opsForValue().get(LOCK_KEY);
	if (clientId.equals(redisLock)) {
		// 如果是此线程加的锁,进行续命操作
		redisTemplate.expire(LOCK_KEY, LOCK_TTL, TimeUnit.SECONDS);
		log.info("线程id {},进行续命", clientId);
	}
}

        创建线程池时,需要合理配置线程池参数。如:最多允许并发线程为 5 时,可将线程池核心线程数配置为 5等。尽量避免线程添加到阻塞队列中,甚至是使用非核心线程。当然具体情况需要根据业务情况而定。毕竟线程池相关的资源在使用过程中不容易被垃圾回收

        模板是死的,在具体实现时,一定会根据业务有不同的修改。也需要分析业务来对分布式锁性能做提升。

最后可以看看我的开源项目: i集大校园(类似于一个定位为校园里的微博)

i集大校园软件服务端,基于SpringCloud Alibaba 微服务组件及部分分布式技术实现服务之间关联及协作进行前后端分离项目实现。计划实现微信小程序和app两端同步。

使用技术栈为:Spring Boot、Spring Cloud Alibaba、rabbitMQ、JWT、minIO、mysql、redis、ES、docker、Jenkins、mybatis-plus

前端使用 微信小程序编写。

欢迎一起参加开源贡献和star项目哈!