基于Spring Boot AOP 实现分布式锁
AOP
AOP 的全称为 Aspect Oriented Programming,译为面向切面编程。实际上 AOP 就是通过预编译和运行期动态代理实现程序功能的统一维护的一种技术。在不同的技术栈中 AOP 有着不同的实现,但是其作用都相差不远,我们通过 AOP 为既有的程序定义一个切入点,然后在切入点前后插入不同的执行内容,以达到在不修改原有代码业务逻辑的前提下统一处理一些内容(比如日志处理、分布式锁)的目的。
为什么要使用 AOP
在实际的开发过程中,我们的应用程序会被分为很多层。通常来讲一个 Java 的 Web 程序会拥有以下几个层次:
- Web 层:主要是暴露一些 Restful API 供前端调用。
- 业务层:主要是处理具体的业务逻辑。
- 数据持久层:主要负责数据库的相关操作(增删改查)。
虽然看起来每一层都做着全然不同的事情,但是实际上总会有一些类似的代码,比如日志打印和安全验证等等相关的代码。如果我们选择在每一层都独立编写这部分代码,那么久而久之代码将变的很难维护。所以我们提供了另外的一种解决方案: AOP。这样可以保证这些通用的代码被聚合在一起维护,而且我们可以灵活的选择何处需要使用这些代码。
AOP 的核心概念
- 切面(Aspect) :通常是一个类,在里面可以定义切入点和通知。(
@Aspect
修饰的类) - 连接点(Joint Point) :被拦截到的点,因为 Spring 只支持方法类型的连接点,所以在 Spring 中连接点指的就是被拦截的到的方法,实际上连接点还可以是字段或者构造器。
- 切入点(Pointcut) :对连接点进行拦截的定义(在切面类上被
@Pointcut
修饰的方法, @Pointcut("execution(* com.controller.TQueryController.query(..))") )。 - 通知(Advice) :拦截到连接点之后所要执行的代码,通知分为前置、后置、异常、最终、环绕通知五类。(切面类上被
@Before
、@After
、@AfterReturning
、@Around
、@AfterThrowing
修饰的方法) - AOP 代理 :AOP 框架创建的对象,代理就是目标对象的加强。Spring 中的 AOP 代理可以使 JDK 动态代理,也可以是 CGLIB 代理,前者基于接口,后者基于子类。
Spring AOP
Spring 中的 AOP 代理还是离不开 Spring 的 IOC 容器,代理的生成,管理及其依赖关系都是由 IOC 容器负责,Spring 默认使用 JDK 动态代理,在需要代理类而不是代理接口的时候,Spring 会自动切换为使用 CGLIB 代理,不过现在的项目都是面向接口编程,所以 JDK 动态代理相对来说用的还是多一些。
Spring AOP 相关注解
-
@Aspect
: 将一个 java 类定义为切面类。 -
@Pointcut
:定义一个切入点,可以是一个规则表达式,比如下例中某个package
下的所有函数,也可以是一个注解等。 -
@Before
:在切入点开始处切入内容。 -
@After
:在切入点结尾处切入内容。 -
@AfterReturning
:在切入点 return 内容之后切入内容(可以用来对处理返回值做一些加工处理)。 -
@Around
:在切入点前后切入内容,并自己控制何时执行切入点自身的内容。 -
@AfterThrowing
:用来处理当切入内容部分抛出异常之后的处理逻辑。
其中 @Before
、 @After
、 @AfterReturning
、 @Around
、 @AfterThrowing
都属于通知。
AOP 顺序问题
在实际情况下,我们对同一个接口做多个切面,比如日志打印、分布式锁、权限校验等等。这时候我们就会面临一个优先级的问题,这么多的切面该如何告知 Spring 执行顺序呢?这就需要我们定义每个切面的优先级,我们可以使用 @Order(i)
注解来标识切面的优先级, i
的值越小,优先级越高。假设现在我们一共有两个切面,一个 WebLogAspect
,我们为其设置 @Order(100)
;而另外一个切面 DistributeLockAspect
设置为 @Order(99)
,所以 DistributeLockAspect
有更高的优先级,这个时候执行顺序是这样的:在 @Before
中优先执行 @Order(99)
的内容,再执行 @Order(100)
的内容。而在 @After
和 @AfterReturning
中则优先执行 @Order(100)
的内容,再执行 @Order(99)
的内容,可以理解为先进后出的原则。
多个AOP执行顺序是按栈先进后出的原则。
基于注解的 AOP 配置
使用注解一方面可以减少我们的配置,另一方面注解在编译期间就可以验证正确性,查错相对比较容易,而且配置起来也相当方便。相信大家也都有所了解,我们现在的 Spring 项目里面使用了非常多的注解替代了之前的 xml 配置。
官网对 execution 表达式的介绍
execution(<修饰符模式>?<返回类型模式><方法名模式>(<参数模式>)<异常模式>?)
其中除了返回类型模式、方法名模式和参数模式外,其它项都是可选的。这个解释可能有点难理解,下面我们通过一个具体的例子来了解一下。在 WebLogAspect
中我们定义了一个切点,其 execution
表达式为 * cn.itweknow.sbaop.controller..*.*(..)
,下表为该表达式比较通俗的解析:
表 1. execution()
表达式解析
标识符 | 含义 |
---|---|
execution() |
表达式的主体 |
第一个 * 符号 |
表示返回值的类型, * 代表所有返回类型 |
cn.itweknow.sbaop.controller |
AOP 所切的服务的包名,即需要进行横切的业务类 |
包名后面的 .. |
表示当前包及子包 |
第二个 * |
表示类名, * 表示所有类 |
最后的 .*(..) |
第一个 .* 表示任何方法名,括号内为参数类型, .. 代表任何类型 |
为什么要使用分布式锁
我们程序中多多少少会有一些共享的资源或者数据,在某些时候我们需要保证同一时间只能有一个线程访问或者操作它们。在传统的单机部署的情况下,我们简单的使用 Java 提供的并发相关的 API 处理即可。但是现在大多数服务都采用分布式的部署方式,我们就需要提供一个跨进程的互斥机制来控制共享资源的访问,这种互斥机制就是我们所说的分布式锁。
注意
- 互斥性。在任时刻,只有一个客户端能持有锁。
- 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。这个其实只要我们给锁加上超时时间即可。
- 具有容错性。只要大部分的 Redis 节点正常运行,客户端就可以加锁和解锁。
- 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。
注解参数解析器
由于注解属性在指定的时候只能为常量,我们无法直接使用方法的参数。而在绝大多数的情况下分布式锁的 key 值是需要包含方法的一个或者多个参数的,这就需要我们将这些参数的位置以某种特殊的字符串表示出来,然后通过参数解析器去动态的解析出来这些参数具体的值,然后拼接到 key
上。在本教程中我也编写了一个参数解析器 AnnotationResolver
。需要的读者可以 查看源码 。
可以用个约定的获取方法更讨巧方面。
实例:
pom.xml
org.springframework.boot
spring-boot-starter-web
org.springframework.boot
spring-boot-starter-logging
org.springframework.boot
spring-boot-starter-aop
io.springfox
springfox-swagger2
2.9.2
io.springfox
springfox-swagger-ui
2.9.2
Redis配置参考Springboot整合redis使用RedisTemplate.
切面类
@Component
@Aspect
@Order(100)
@Slf4j
public class DistributeLockAspect {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private AnnotationResolver annotationResolver;
@Pointcut("execution(* com.self.controller..*.*(..))")
public void distributeLockCut(){
}
@Around(value = "distributeLockCut() && @annotation(distributeLock)")
public Object doDistributeLockAround(ProceedingJoinPoint joinPoint, DistributeLock distributeLock) throws Exception {
String key = annotationResolver.resolver(joinPoint, distributeLock.key());
String keyValue = getLock(key, distributeLock.timeOut(), distributeLock.timeUnit());
if (StringUtil.isNullOrEmpty(keyValue)) {
// 获取锁失败。
return BaseResponse.addError(ErrorCodeEnum.OPERATE_FAILED, "请勿频繁操作");
}
// 获取锁成功
try {
return joinPoint.proceed();
} catch (Throwable throwable) {
return BaseResponse.addError(ErrorCodeEnum.SYSTEM_ERROR, "系统异常");
} finally {
// 释放锁。
unLock(key, keyValue);
}
}
/**
* 获取锁
* @param key 锁的key
* @param timeout 锁超时时间
* @param timeUnit 时间单位
*
* @return 锁的值
*/
private String getLock(String key, long timeout, TimeUnit timeUnit) {
try {
String value = UUID.randomUUID().toString();
Boolean lockStat = stringRedisTemplate.execute((RedisCallback)connection ->
connection.set(key.getBytes(Charset.forName("UTF-8")), value.getBytes(Charset.forName("UTF-8")),
Expiration.from(timeout, timeUnit), RedisStringCommands.SetOption.SET_IF_ABSENT));
if (!lockStat) {
// 获取锁失败。
return null;
}
return value;
} catch (Exception e) {
log.error("获取分布式锁失败,key={}", key, e);
return null;
}
}
/**
* 释放锁
*
* @param key 锁的key
* @param value 获取锁的时候存入的值
*/
private void unLock(String key, String value) {
try {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
boolean unLockStat = stringRedisTemplate.execute((RedisCallback) connection ->
connection.eval(script.getBytes(), ReturnType.BOOLEAN, 1,
key.getBytes(Charset.forName("UTF-8")), value.getBytes(Charset.forName("UTF-8"))));
if (!unLockStat) {
log.error("释放分布式锁失败,key={},已自动超时,其他线程可能已经重新获取锁", key);
}
} catch (Exception e) {
log.error("释放分布式锁失败,key={}", key, e);
}
}
}
注解
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface DistributeLock {
/**
* 锁名称
*/
String key();
/**
* 超时时间
*/
long timeOut() default 3;
/**
* 时间单位
*/
TimeUnit timeUnit() default TimeUnit.HOURS;
}
测试类
@RequestMapping("/post-test")
@DistributeLock(key = "post_test_#{baseRequest.channel}", timeOut = 10)
public BaseResponse postTest(@RequestBody @Valid BaseRequest baseRequest, BindingResult bindingResult) {
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
return BaseResponse.addResult();
}
基于Spring AOP 实现分布式锁——Jedis
分布式锁一般有数据库乐观锁(服务端是集群,数据库是单例或者读写分离库)、基于Redis的分布式锁以及基于ZooKeeper的分布式锁三种实现方式。
pom.xml文件加入下面的代码:
redis.clients
jedis
2.9.0
加锁代码
正确代码
public class RedisTool {
private static final String LOCK_SUCCESS = "OK";
private static final String SET_IF_NOT_EXIST = "NX";
private static final String SET_WITH_EXPIRE_TIME = "PX";
/**
* 尝试获取分布式锁
* @param jedis Redis客户端
* @param lockKey 锁
* @param requestId 请求标识
* @param expireTime 超期时间
* @return 是否获取成功
*/
public static boolean tryLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
if (LOCK_SUCCESS.equals(result)) {
return true;
}
return false;
}
}
可以看到,我们加锁就一行代码:jedis.set(String key, String value, String nxxx, String expx, int time),这个set()方法一共有五个形参:
第一个为key,我们使用key来当锁,因为key是唯一的。
第二个为value,我们传的是requestId,很多童鞋可能不明白,有key作为锁不就够了吗,为什么还要用到value?原因就是我们在上面讲到可靠性时,分布式锁要满足第四个条件解铃还须系铃人,通过给value赋值为requestId,我们就知道这把锁是哪个请求加的了,在解锁的时候就可以有依据。requestId可以使用UUID.randomUUID().toString()方法生成。
第三个为nxxx,这个参数我们填的是NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作;
第四个为expx,这个参数我们传的是PX,意思是我们要给这个key加一个过期的设置,具体时间由第五个参数决定。
第五个为time,与第四个参数相呼应,代表key的过期时间。
总的来说,执行上面的set()方法就只会导致两种结果:
当前没有锁(key不存在),那么就进行加锁操作,并对锁设置个有效期,同时value表示加锁的客户端。
已有锁存在,不做任何操作。
错误示例1
比较常见的错误示例就是使用jedis.setnx()和jedis.expire()组合实现加锁,代码如下:
public static void wrongGetLock1(Jedis jedis, String lockKey, String requestId, int expireTime) {
Long result = jedis.setnx(lockKey, requestId);
if (result == 1) {
// 若在这里程序突然崩溃,则无法设置过期时间,将发生死锁
jedis.expire(lockKey, expireTime);
}
}
setnx()方法作用就是SET IF NOT EXIST,expire()方法就是给锁加一个过期时间。乍一看好像和前面的set()方法结果一样,然而由于这是两条Redis命令,不具有原子性,如果程序在执行完setnx()之后突然崩溃,导致锁没有设置过期时间。那么将会发生死锁。网上之所以有人这样实现,是因为低版本的jedis并不支持多参数的set()方法。
错误示例2
这一种错误示例就比较难以发现问题,而且实现也比较复杂。实现思路:使用jedis.setnx()命令实现加锁,其中key是锁,value是锁的过期时间。执行过程:1. 通过setnx()方法尝试加锁,如果当前锁不存在,返回加锁成功。2. 如果锁已经存在则获取锁的过期时间,和当前时间比较,如果锁已经过期,则设置新的过期时间,返回加锁成功。代码如下:
public static boolean wrongGetLock2(Jedis jedis, String lockKey, int expireTime) {
long expires = System.currentTimeMillis() + expireTime;
String expiresStr = String.valueOf(expires);
// 如果当前锁不存在,返回加锁成功
if (jedis.setnx(lockKey, expiresStr) == 1) {
return true;
}
// 如果锁存在,获取锁的过期时间
String currentValueStr = jedis.get(lockKey);
if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
// 锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间
String oldValueStr = jedis.getSet(lockKey, expiresStr);
if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
// 考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才有权利加锁
return true;
}
}
// 其他情况,一律返回加锁失败
return false;
}
这段代码的错误之处在于:
- 由于是客户端自己生成过期时间,所以需要强制要求分布式下每个客户端的时间必须同步。
- 当锁过期的时候,如果多个客户端同时执行jedis.getSet()方法,那么虽然最终只有一个客户端可以加锁,但是这个客户端的锁的过期时间可能被其他客户端覆盖。
- 锁不具备拥有者标识,即任何客户端都可以解锁。
解锁代码
正确代码
public class RedisTool {
private static final Long RELEASE_SUCCESS = 1L;
/**
* 释放分布式锁
* @param jedis Redis客户端
* @param lockKey 锁
* @param requestId 请求标识
* @return 是否释放成功
*/
public static boolean unLock(Jedis jedis, String lockKey, String requestId) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
if (RELEASE_SUCCESS.equals(result)) {
return true;
}
return false;
}
}
可以看到,我们解锁只需要两行代码就搞定了!第一行代码,我们写了一个简单的Lua脚本代码,第二行代码,我们将Lua代码传到jedis.eval()方法里,并使参数KEYS[1]赋值为lockKey,ARGV[1]赋值为requestId。eval()方法是将Lua代码交给Redis服务端执行。
那么这段Lua代码的功能是什么呢?其实很简单,首先获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁(解锁)。那么为什么要使用Lua语言来实现呢?因为要确保上述操作是原子性的。那么为什么执行eval()方法可以确保原子性,源于Redis的特性,简单来说,就是在eval命令执行Lua代码的时候,Lua代码将被当成一个命令去执行,并且直到eval命令执行完成,Redis才会执行其他命令。
错误示例1
最常见的解锁代码就是直接使用jedis.del()方法删除锁,这种不先判断锁的拥有者而直接解锁的方式,会导致任何客户端都可以随时进行解锁,即使这把锁不是它的。
public static void wrongReleaseLock1(Jedis jedis, String lockKey) {
jedis.del(lockKey);
}
错误示例2
这种解锁代码乍一看也是没问题,甚至我之前也差点这样实现,与正确姿势差不多,唯一区别的是分成两条命令去执行,代码如下:
public static void wrongReleaseLock2(Jedis jedis, String lockKey, String requestId) {
// 判断加锁与解锁是不是同一个客户端
if (requestId.equals(jedis.get(lockKey))) {
// 若在此时,这把锁突然不是这个客户端的,则会误解锁.锁过期情况
jedis.del(lockKey);
}
}
如代码注释,这个代码的问题在于如果调用jedis.del()方法的时候,这把锁已经不属于当前客户端的时候会解除他人加的锁。那么是否真的有这种场景?答案是肯定的,比如客户端A加锁,一段时间之后客户端A解锁,在执行jedis.del()之前,锁突然过期了,此时客户端B尝试加锁成功,然后客户端A再执行del()方法,则将客户端B的锁给解除了。
总结
本文介绍的Redis分布式锁都是用JAVA实现,对于加锁和解锁的方法也分别给出了错误示例供大家参考。其实想要通过Redis实现分布式锁难度并不高,只要能满足上面给出的四个可靠性条件即可。
参考
使用 Spring Boot AOP 实现 Web 日志处理和分布式锁
Spring Boot 项目中使用 Swagger 文档