最近工作涉及到一个需求是关于禁止重复操作的后端校验,当时通过一种与业务耦合的redis加锁方式暂时满足了功能需求,后来在大佬的指点下将该功能抽离出来单独做一个组件,公司目前的项目是典型的分布式系统,自己之前接触的很少,中途也遇到了一些困难,在此做下总结。(基于Spring AOP+自定义注解实现redis分布式锁)
什么是分布式锁
要介绍分布式锁,首先要提到与分布式锁相对应的是线程锁、进程锁。
① 线程锁:主要用来给方法、代码块加锁。当某个方法或代码使用锁,在同一时刻仅有一个线程执行该方法或该代码段。线程锁只在同一JVM中有效果,因为线程锁的实现在根本上是依靠线程之间共享内存实现的,比如synchronized是共享对象头,显示锁Lock是共享某个变量(state)。
② 进程锁:为了控制同一操作系统中多个进程访问某个共享资源,因为进程具有独立性,各个进程无法访问其他进程的资源,因此无法通过synchronized等线程锁实现进程锁。
分布式锁:当多个进程不在同一个系统中,用分布式锁控制多个进程对资源的访问。
为什么需要分布式锁(即要解决什么问题)
随着业务越来越复杂,应用服务都会朝着分布式、集群方向部署,而分布式CAP原则告诉我们,Consistency(一致性)、 Availability(可用性)、Partition tolerance(分区容错性),三者不可得兼。实践中一般满足CP或AP。
很多场景中,需要使用分布式事务、分布式锁等技术来保证数据最终一致性。有的时候,我们需要保证某一方法同一时刻只能被一个线程执行。
在单机环境中,多个线程对共享变量进行访问时,我们可以简单的通过Java自身的同步操作来协调同一时刻对变量的串行访问。然而在分布式环境中,进程的独立性,进程之间无法访问相互之间的资源,无法像之前那样的方式实现进程锁,故需要一个独立的中心节点,以协调多个系统对共享变量的访问,所有进程在访问该变量时,都从同一个地方进行取值并控制,从而实现在类似于单机环境中同步控制的效果。
需要满足什么条件
① 可以保证在分布式部署的应用集群中,同一个方法在同一时间只能被一台机器上的一个线程执行
② 这把锁要是一把可重入锁(避免死锁)
③ 这把锁最好是一把阻塞锁
④ 有高可用的获取锁和释放锁功能
⑤ 获取锁和释放锁的性能要好
实现方式
主要有三种实现方式:①基于数据库(乐观锁);②基于缓存(redis,memcached等)③基于zookeeper
本次实践主要选用第二种,基于redis进行实现。
实现思路
粗粒度的大致思路:
① 在对共享变量(key)操作前,判断该变量值在redis中是否有对应的key
② 若无,则设置到redis中,并设置该key的超时时间,返回true;若有,则说明该变量已经被别的线程/进程持有,不做任何动作,返回false
大致的思路相对简单,实际实现会遇到很多困难,如实现方式,加锁位置,操作原子性,死锁问题,性能问题等。
实现方案
以下方式没有谁好谁坏之分,需要看实际的业务情况取舍,性能,或是跟业务代码的耦合度,代码结构设计等等
① 以服务的方式提供分布式锁
这种方式主要是将加锁解锁操作封装成一个服务提供给业务使用,将锁服务耦合到业务中,能够较细粒度的控制好变量的访问,实现较为灵活,性能相对较好,但是技术需求对于业务代码的入侵相对较多。
② 以切面的方式提供分布式锁
主要通过自定义注解,集成spring提供的aop切面,在方法执行前后进行拦截,对要加锁的变量进行协调,这种方式对业务代码的入侵较低,可以方便的对业务的加锁操作进行扩展。不过随之带来的有性能问题,因为在方法拦截后需要对当前方法进行反射,从而获取当前注解变量和方法参数,反射是一种耗性能的操作方式。
自定义注解+Spring AOP+Redis实现分布式锁
本次采用第二种方式,通过自定义注解+Spring AOP+Redis实现
① 自定义注解 RedisLock
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23 1package cn.jyycode.annotition;
2
3import java.lang.annotation.*;
4
5/** redis锁
6 * @author zhangjiayuan@qipeipu.com
7 * @date 2019/1/29 19:23
8 * @since 1.0.0
9 */
10
11@Documented
12@Target({ElementType.METHOD,ElementType.PARAMETER})
13@Retention(RetentionPolicy.RUNTIME)
14public @interface RedisLock {
15
16 /**
17 * 要锁定的属性参数值(key值)索引
18 * @return
19 */
20 int argsIndex();
21
22}
23
这里的argsIndex是指形参中参数的索引值,即第0个参数,第1个参数,第2个参数……
因为我们需要获取加锁的变量值(非固定锁),这个值在方法参数中提供(若要加锁的对象在业务逻辑中提供而不是方法参数,就应当采用以服务的方式提供分布式锁而不是现在这种方法),所以本方式也是具有它的局限性的。
② RedisLockAspect
1
2
3
4
5
6
7
8 1@Slf4j
2@Aspect
3@Component
4public class RedisLockAspect {
5
6 @Autowired
7 private StringRedisTemplate stringRedisTemplate;
8
集成spring aop的方式,声明RedisLock注解的拦截切面。并将redis服务通过spring bean的方式注入进来。
③ 设置超时时间
1
2
3
4
5 1 /**
2 * 设置key超时时间,默认为5s
3 */
4 private static final int LOCK_EXPIRE_SECONDS = 5;
5
具体超时时间根据业务需求而定了,这里默认5s
④ 设置拦截切点
1
2
3
4
5
6
7 1/**
2 * 要锁定的操作切点
3 */
4 @Pointcut("@annotation(cn.jyycode.annotition.RedisLock)")
5 public void lockAspect() {
6 }
7
对注解了RedisLock的方法进行拦截
⑤ 方法执行前
1
2
3
4
5
6
7
8
9
10 1 /**
2 * 用于拦截操作,在方法执行前执行
3 *
4 * @param joinPoint 切点
5 */
6 @Before(value = "lockAspect()")
7 public void before(JoinPoint joinPoint) throws Throwable {
8 this.handle(joinPoint);
9 }
10
对拦截的方法进行代理增强,进行相关逻辑的操作
⑥ 加锁判断
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23 1 /**
2 * redis分布式锁
3 *
4 * @param joinPoint 连接点
5 * @return
6 */
7 private void handle(JoinPoint joinPoint) throws Throwable {
8 RedisLock annotation = this.getAnnotation(joinPoint);
9 Object[] args = joinPoint.getArgs();
10 boolean result = false;
11 try {
12 key = String.valueOf(args[Optional.ofNullable(annotation.argsIndex()).orElse(0)]);
13 result = this.lock(key, LOCK_EXPIRE_SECONDS);
14 if (!result) {
15 log.info("【锁定失败】:{}", "要操作的属性值已被锁定");
16 throw new RedisLockException("【锁定失败】:要操作的属性值已被锁定");
17 } else {
18 log.info("【成功加锁】");
19 }
20 } catch (Exception e) {
21 }
22 }
23
-
获取方法上的注解,将要加锁对象的参数索引值取出,通过切入点的参数数组获取要加锁对象的值;
-
执行lock加锁操作,若加锁成功则返回true,失败则返回false
⑦ 加锁操作(重点)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 1 /**
2 * 加锁
3 *
4 * @param key
5 * @param expire 过期时间,单位秒
6 * @return true:加锁成功,false:加锁失败
7 */
8 private boolean lock(String key, int expire) {
9 String oldValue = stringRedisTemplate.opsForValue().getAndSet(key, String.valueOf(expire));
10 stringRedisTemplate.expire(key, expire, TimeUnit.SECONDS);
11 if (oldValue != null) {
12 return false;
13 }
14 return true;
15 }
16
1.对redis进行getAndSet操作,该操作是原子性的,获取旧值并设置新值
2.对获取的旧值进行判断,若为空,说明是第一次加锁(即前面没线程/进程进来设置value值),则表明加锁成功,返回true;若旧值不为空,则说明上一个线程/进程已经在使用该变量,并且还在使用或是未超时,则本次应当返回加锁失败(虽然设了新值进去,但是)返回false
⑧ 获取方法上的注解
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24 1 /**
2 * 获取方法上的注解
3 *
4 * @param joinPoint 连接点
5 * @return 返回方法注解
6 * @throws Exception
7 */
8 private RedisLock getAnnotation(JoinPoint joinPoint) {
9 Method method = null;
10 try {
11 method = Optional.ofNullable(((MethodSignature) joinPoint.
12 getSignature()).
13 getMethod()).orElse(null);
14 } catch (Exception ignore) {
15 log.info("【反射当前方法失败】:{}", ignore);
16 ignore.printStackTrace();
17 throw new RedisLockException("【反射当前方法失败】");
18 }
19 if (method == null) {
20 throw new RedisLockException("【获取方法注解异常】");
21 }
22 return method.getAnnotation(RedisLock.class);
23 }
24
⑨ 自定义异常
1
2
3
4
5
6
7
8
9
10
11
12
13 1/** redis锁异常
2 * @author zhangjiayuan@qipeipu.com
3 * @date 2019/1/30 10:00
4 * @since 1.0.0
5 */
6@NoArgsConstructor
7public class RedisLockException extends RuntimeException {
8
9 public RedisLockException(String message){
10 super(message);
11 }
12}
13
用于统一处理此类异常
基于redisson实现分布式锁
redisson是redis官方推荐的java语言实现分布式锁的项目,主要连接方式有:
Cluster(集群)
Sentinel servers(哨兵)
Master/Slave servers(主从)
Single server(单机)
本次测试采用简单的单机连接,实践redisson的简单使用
① 导入maven依赖
1
2
3
4
5
6 1 <dependency>
2 <groupId>org.redisson</groupId>
3 <artifactId>redisson</artifactId>
4 <version>2.7.0</version>
5 </dependency>
6
② 程序化配置方法:
Redisson程序化的配置方法是通过构建Config对象实例来实现的,通过RedissonManager来创建redisson对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20 1/**获取redisson对象
2 * @author zhangjiayuan@qipeipu.com
3 * @date 2019/2/13 10:39
4 * @since 1.0.0
5 */
6public class RedissonManager {
7 private static Config config = new Config();
8 //声明redisson对象
9 private static Redisson redisson = null;
10 //实例化
11 static{
12 config.useSingleServer().setAddress("ip:6379");
13 redisson = (Redisson)Redisson.create(config);
14 }
15 //获取redisson对象的方法
16 public static Redisson getRedisson(){
17 return redisson;
18 }
19}
20
③ 从RedissonManager获取redisson对象,并编写redisson加锁,解锁操作
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38 1/**
2 * 高性能分布式锁redisson的使用
3 *
4 * @author zhangjiayuan@qipeipu.com
5 * @date 2019/2/13 10:45
6 * @since 1.0.0
7 */
8public class DistributeRedisLock {
9 //从配置类中获取redisson对象
10 private static Redisson redisson = RedissonManager.getRedisson();
11 private static final String LOCK_TITLE = "redisLock_";
12
13 //加锁
14 public static boolean acquire(String lockName) {
15 //声明key对象
16 String key = LOCK_TITLE + lockName;
17 //获取锁对象
18 RLock myLock = redisson.getLock(key);
19 //加锁,并且设置锁过期时间,防止死锁的产生
20 myLock.lock(5, TimeUnit.SECONDS);
21 System.out.println("=====lock=====" + Thread.currentThread().getName());
22 //加锁成功
23 return true;
24 }
25
26 //锁的释放
27 public static void release(String lockName) {
28 //必须是和加锁同一个key
29 String key = LOCK_TITLE + lockName;
30 //获取锁对象
31 RLock myLock = redisson.getLock(key);
32 //释放锁(解锁)
33 myLock.unlock();
34 System.out.println("=====unlock====="+Thread.currentThread().getName());
35 }
36}
37
38
④ 业务逻辑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22 1/**
2 * @author zhangjiayuan@qipeipu.com
3 * @date 2019/2/13 10:54
4 * @since 1.0.0
5 */
6@RestController
7@RequestMapping("/redisLock")
8@Slf4j
9public class RedissonController {
10
11 @RequestMapping("/get")
12 public void redissonLock(){
13 String key = "test";
14 //加锁
15 DistributeRedisLock.acquire(key);
16 //执行具体业务逻辑
17 System.out.println("dosomething()");
18 //释放锁
19 DistributeRedisLock.release(key);
20 }
21}
22
⑤ 测试结果,跑10个线程,均能够在指定业务同步操作中串行访问
存在的问题及不足(时间仓促,待完善)
① 上述写的很粗糙,需要完善,大致完善点如下
② 对上面写的内容进行补充和review
③ 需要更合乎逻辑的加锁处理(如锁失效条件,超时还是自己手动解锁等),操作原子性等
④ 潜在的死锁问题分析
⑤ 本次是悲观锁实现,性能差,需要寻求乐观锁实现(配合分布式事务)
⑥ 实践spring官方提供的一种解决方案redisson
⑦ 遇到的问题总结及改进方式,例utils->自定义注解+aop拦截,方式的转变,非固定锁值的获取(方法注解,参数注解等),异常处理(空指针,数组越界,边界条件等)