Redis分布式锁的实现
参考
使用 Spring Boot AOP 实现 Web 日志处理和分布式锁
Redis分布式锁正确的实现方法
Spring Boot 项目中使用 Swagger 文档
问题
Q:AOP如何实现在不修改原有代码业务逻辑的前提下统一处理一些内容?
Q:AOP的实现原理?
Q:通过AOP实现分布式锁的优点?
Q:分布式锁加锁为什么要通过SETNX和EXPIRE达到原子执行的目的?解锁为什么要使用Lua脚本来执行?
Q:Spring 默认使用 JDK 动态代理,在需要代理类而不是代理接口的时候,Spring 会自动切换为使用 CGLIB 代理。能自动切换么?
Q:自定义注解的作用?如何自定义注解?什么时候选择自定义注解?为什么要自定义注解?
基于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
<!--web起步依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions><!-- 去掉springboot默认配置 -->
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<!--添加 AOP 相关依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!--添加 Swagger 依赖-->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>2.9.2</version>
</dependency>
<!--添加 Swagger UI 依赖-->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>2.9.2</version>
</dependency>
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<Boolean>)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<Boolean>) 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 tryGetDistributedLock(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,很多童鞋可能不明