1.业务背景
2.分析流程
加锁
超时问题
解决方案:增加一个续时
3.设计方案
4.实操
相关属性类配置
核心切面拦截的操作
5.开始测试
6.总结
1.业务背景
在系统的开发过程中,我们发现有些业务请求是耗时的操作,需要对数据库进行操作,因此需要进行加锁操作,以防止后续的并发操作对之前的业务造成影响。同时,我们还实现了基于 Spring Boot + MyBatis Plus + Vue & Element 的后台管理系统和用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能。我们相信这些功能的实现将使系统更加完善,用户的体验也会得到极大的提升。
2.分析流程
在这个分析流程中,我们使用 Redis 作为分布式锁,将锁的状态放到 Redis 中进行统一维护。这样做的好处是,可以解决集群中单机 JVM 信息不互通的问题,规定操作顺序,保护用户的数据正确。下面我们来详细地介绍一下设计流程:
首先,我们需要新建一个注解 @interface,并在注解中设定入参标志,这样可以方便我们在后面的步骤中使用。
接下来,我们需要增加一个 AOP 切点,用于扫描特定注解。
然后,我们需要建立一个 @Aspect 切面任务,用于注册 bean 和拦截特定方法。
特定方法参数 ProceedingJoinPoint,对方法 pjp.proceed() 前后进行拦截。
切点前进行加锁,任务执行后进行删除 key。
除了上述步骤之外,我们还需要进行一些核心步骤,包括加锁、解锁和续时。这些步骤非常重要,可以确保我们的分析流程更加完善和准确。
加锁
在本文中,介绍了使用 RedisTemplate 的 opsForValue.setIfAbsent 方法实现分布式锁,防止多个线程同时访问相同的资源。具体实现方式是:在 Redis 中设定一个随机数 UUID.random().toString,作为 value,并使用 opsForValue.setIfAbsent 方法设定该 key 的值,判断是否有 key。只有第一个成功设定 key 的请求,才能进行后续的数据操作,后续其它请求由于无法获得锁资源,将会失败结束。另外,从 redis 中获取锁之后,对 key 设定 expire 失效时间,到期后自动释放锁。
超时问题
然而,本文也提到了一些在实际应用中可能会遇到的问题。一个主要问题是任务执行时间过长,导致锁的过期时间到期,锁被自动释放,但任务还没有完成。这种情况下,另一个线程可能会获取到相同的锁,进而对相同的资源进行操作,导致数据出现不准确的情况。
解决方案:增加一个续时
为了解决上述问题,本文提出了一种解决方案:增加一个「续时」。具体来说,可以使用定时线程池 ScheduledExecutorService,每隔 2s 去扫描加入队列中的 Task,判断是否失效时间是否快到了。如果失效时间快到了,就将失效时间延长三分之一的过期时间。这样可以确保任务得到充分的执行时间,同时又不会出现多个线程同时访问相同资源的情况。
3.设计方案
经过上面的分析,设计出了这个方案:
前面提到了整体流程,接下来我们将详细介绍一下核心步骤:
首先,我们需要对 @RedisLock 注解进行拦截。在此过程中,我们需要确保获取必要的参数,以便后续的操作。
接下来,我们需要进行加锁操作。在这个步骤中,我们会使用 Redis 数据库来实现分布式锁。通过这种方式,我们可以确保同一时间只有一个线程可以访问特定的资源。
在加锁之后,我们需要进行续时操作。这是一项非常重要的步骤,因为它可以确保锁在业务处理期间不会过期。如果锁在处理期间过期,那么其他线程就有机会访问同一个资源,这可能导致数据不一致的问题。
最后,在结束业务处理之后,我们需要释放锁。这是一个非常关键的步骤,因为如果我们不释放锁,那么其他线程就无法访问同一个资源。因此,我们必须确保在业务处理完成后及时释放锁,以便其他线程可以访问该资源。
4.实操
之前也有整理过 AOP
使用方法,可以参考一下
相关属性类配置
核心切面拦截的操作
- 任务队列保留参数
设定被拦截的名字
RedisLockAspect.java
该类分成三部分来描述具体作用
前面步骤定义了我们想要拦截的切点,下一步就是在切点前后做一些自定义操作:
上述流程简单总结一下:
解析注解参数,获取注解值和方法上的参数值
redis 加锁并且设置超时时间
将本次 Task 信息加入「延时」队列中,进行续时,方式提前释放锁
加了一个线程中断标志
结束请求,finally 中释放锁
这个续时操作的目的是在任务队列中对超时的任务进行处理。在这种情况下,我们使用了 ScheduledExecutorService 来维护一个线程,该线程不断对任务队列中的任务进行判断,并延长超时时间。这样,即使任务队列中的任务超过了预定时间,我们也可以确保任务能够在规定的时间内完成,从而使系统更加稳定可靠:
这段代码的目的是为了实现设计图中虚线框的思想。在这段代码中,我们引入了「线程中断」Thread#interrupt,以防止请求十分耗时,导致提前释放了锁。这个操作可以在超过重试次数后,让线程中断。需要注意的是,这个操作未经过严谨测试,仅供参考。
虽然这个方法可以解决一时的问题,但我们还是建议从根源上查找问题,分析耗时路径,并进行业务优化或其它处理,避免这些耗时操作。这样可以避免在未来出现类似的问题。
因此,我们建议在代码中加入更多的Log。这样在分析问题时,可以更快速地找到答案,减少问题的解决时间。
5.开始测试
在一个入口方法中,使用该注解,然后在业务中模拟耗时请求,使用了 Thread#sleep
使用时,可以在方法上添加该注解,然后根据需要设定相应参数。该注解可以区分多种业务类型,从而限制该业务被同时操作,提高了系统的并发性能和安全性。
同时,该注解还可以帮助开发人员更好地维护系统的稳定性和可靠性。例如,当系统面临高并发请求时,该注解可以有效地控制请求的访问频率,避免系统的崩溃和数据的丢失。
测试结果如下:
我这里测试的是重试次数过多,失败的场景,如果减少睡眠时间,就能让业务正常执行。
如果同时请求,你将会发现以下错误信息:
6.总结
在处理耗时业务和核心数据时,需要避免重复请求同时操作数据,以防止数据不正确。因此,我们可以使用分布式锁来保护它们。
设计流程如下:
新建注解 @interface,在注解里设定入参标志
增加 AOP 切点,扫描特定注解
建立 @Aspect 切面任务,注册 bean 和拦截特定方法
使用特定方法参数 ProceedingJoinPoint,对方法 pjp.proceed() 前后进行拦截
切点前进行加锁,任务执行后进行删除 key
通过学习小伙伴的代码设计,我们了解了分布式锁的具体实现。仿照他的设计,我们重新写了一份简化版的业务处理,并考虑了之前没有考虑到的「续时」操作。为了避免锁提前释放,我们使用了守护线程来定时判断和延长超时时间。
因此,本次学习回顾了以下三个知识点:
AOP 的实现和常用方法
定时线程池 ScheduledExecutorService 的使用和参数含义
线程 Thread#interrupt 的含义以及用法。这个知识点挺有意思的,可以深入再学习一下。