前面说到为了解决分布式环境下锁失效问题,我们最常用的一个第三方开源框架就是Redisson。
Redisson是一个基于Redis的工具包,功能十分强大,将JDK中的常见的队列、锁、对象都基于Redis实现了对应的分布式版本。
1.使用Redisson的步骤
项目中引入依赖
<!--redisson-->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
</dependency>
配置
@Configuration
public class RedisConfig {
@Bean
public RedissonClient redissonClient() {
// 配置类
Config config = new Config();
// 添加redis地址,这里添加了单点的地址,也可以使用config.useClusterServers()添加集群地址
config.useSingleServer()
.setAddress("redis://192.168.150.101:6379")
.setPassowrd("123456");
// 创建客户端
return Redisson.create(config);
}
}
用法
@Autowired
private RedissonClient redissonClient;
@Test
void testRedisson() throws InterruptedException {
// 1.获取锁对象,指定锁名称
RLock lock = redissonClient.getLock("anyLock");
try {
// 2.尝试获取锁,参数:waitTime、leaseTime、时间单位
boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
if (!isLock) {
// 获取锁失败处理 ..
} else {
// 获取锁成功处理
}
} finally {
// 4.释放锁
lock.unlock();
}
}
我们在使用Redisson获得锁时使用tryLock尝试获得锁的参数有三个:
- waitTime:获得锁的等待时间,当获取锁失败时可以多次重试,直到waitTime时间耗尽。waitTime默认-1,即失败后立刻返回,不重试。
-
leaseTime:锁超时释放时间。默认是30,同时会利用看门狗机制来不断更新超时时间。如果手动设置leaseTime值,会导致WatchDog失效。
-
TimeUnit:时间单位。
2.结合场景来使用一下Redisson吧
首先定义配置类
@Slf4j
@ConditionalOnClass({RedissonClient.class, Redisson.class})
@Configuration
@EnableConfigurationProperties(RedisProperties.class)
public class RedissonConfig {
private static final String REDIS_PROTOCOL_PREFIX = "redis://";
private static final String REDISS_PROTOCOL_PREFIX = "rediss://";
@Bean
@ConditionalOnMissingBean
public LockAspect lockAspect(RedissonClient redissonClient){
return new LockAspect(redissonClient);
}
@Bean
@ConditionalOnMissingBean
public RedissonClient redissonClient(RedisProperties properties){
log.debug("尝试初始化RedissonClient");
// 1.读取Redis配置
RedisProperties.Cluster cluster = properties.getCluster();
RedisProperties.Sentinel sentinel = properties.getSentinel();
String password = properties.getPassword();
int timeout = 3000;
Duration d = properties.getTimeout();
if(d != null){
timeout = Long.valueOf(d.toMillis()).intValue();
}
// 2.设置Redisson配置
Config config = new Config();
if(cluster != null && !CollectionUtil.isEmpty(cluster.getNodes())){
// 集群模式
config.useClusterServers()
.addNodeAddress(convert(cluster.getNodes()))
.setConnectTimeout(timeout)
.setPassword(password);
}else if(sentinel != null && !StrUtil.isEmpty(sentinel.getMaster())){
// 哨兵模式
config.useSentinelServers()
.setMasterName(sentinel.getMaster())
.addSentinelAddress(convert(sentinel.getNodes()))
.setConnectTimeout(timeout)
.setDatabase(0)
.setPassword(password);
}else{
// 单机模式
config.useSingleServer()
.setAddress(String.format("redis://%s:%d", properties.getHost(), properties.getPort()))
.setConnectTimeout(timeout)
.setDatabase(0)
.setPassword(password);
}
// 3.创建Redisson客户端
return Redisson.create(config);
}
private String[] convert(List<String> nodesObject) {
List<String> nodes = new ArrayList<>(nodesObject.size());
for (String node : nodesObject) {
if (!node.startsWith(REDIS_PROTOCOL_PREFIX) && !node.startsWith(REDISS_PROTOCOL_PREFIX)) {
nodes.add(REDIS_PROTOCOL_PREFIX + node);
} else {
nodes.add(node);
}
}
return nodes.toArray(new String[0]);
}
}
说明:
@ConditionalOnClass({RedissonClient.
class
, Redisson.
class
})加上这个注解的作用是,只要引入配置类所在的模块,并且引入redisson的依赖,这套配置就会生效
。-
RedissonClient的配置无需自定义Redis地址,而是直接基于SpringBoot中的Redis配置即可。而且不管是Redis单机、Redis集群、Redis哨兵模式都可以支持
//获取锁对象
String key = "lock:coupon";
RLock lock = redissonClient.getLock(key);
//尝试获取锁
long waitTime = 1;
long leaseTime = 3;
boolean result;
try {
result = lock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
//判断是否拿到锁
if (result) {
//拿到锁了
//开启事务
transactionTemplate.executeWithoutResult(action -> {
try {
this.checkAndSave(coupon, null);
} catch (Exception e) {
//要回滚
action.setRollbackOnly();
throw e;
} finally {
//成功或是超时或是业务抛出异常都要放锁
lock.unlock();
}
});
} else {
//没拿到锁
throw new BizIllegalException("你的操作太频繁了");
}
2.1.AOP改造(初步优雅)
观察思考后发现非业务代码格式固定,每次获取锁总是在重复编码。接下来可以使用AOP对这部分代码进行抽取优化。
只有红框部分是业务代码,在前后都是固定的操作,这样的话我们可以根据AOP的思想对业务前后的锁进行环绕增强。
接下来使用注解对方法进行标记,锁的key名称、锁的waitTime、releaseTime等等,都可以基于注解来传参。
先定义一个自定义注解:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface MyLock {
String name();
long waitTime() default 1;
long leaseTime() default -1;
TimeUnit unit() default TimeUnit.SECONDS;
}
再来定义一个环绕增强切面。
@Component
@Aspect
@RequiredArgsConstructor
public class MyLockAspect {
private final RedissonClient redissonClient;
@Around("@annotation(myLock)")
public Object tryLock(ProceedingJoinPoint pjp, MyLock myLock) throws Throwable {
// 1.创建锁对象
RLock lock = redissonClient.getLock(myLock.name());
// 2.尝试获取锁
boolean isLock = lock.tryLock(myLock.waitTime(), myLock.leaseTime(), myLock.unit());
// 3.判断是否成功
if(!isLock) {
// 3.1.失败,快速结束
throw new BizIllegalException("请求太频繁");
}
try {
// 3.2.成功,执行业务
return pjp.proceed();
} finally {
// 4.释放锁
lock.unlock();
}
}
}
定义完了注解和切面就可以在需要加锁的方法上添加我们的@MyLock(name="") 注解,可以指定参数,不指定使用默认值。这样在业务中不用再做加锁、释放锁的动作没有了任何入侵,太优雅了!下面来整一个更优雅的。
现在还存在几个问题:
-
Redisson中锁的种类有很多,目前的代码中把锁的类型写死了
-
Redisson中获取锁的逻辑有多种,比如获取锁失败的重试策略,目前都没有设置
-
锁的名称目前是写死的,并不能根据方法参数动态变化
那么接下来,我们结合工厂模式、失败策略、枚举代替If-else、SPEL表达式来优雅的解决上面的问题。
2.2.工厂模式切换锁类型(进阶优雅)
Redisson中有很多中锁的类型,在上面的代码中可以发现我们把获得锁的动作写死了。实际上redisson中锁的类型有很多种,所以我们在切面中不能直接把所类型写死。
锁的类型有:可重入锁、公平锁、读锁、写锁这四种。在切面中我们可以根绝业务选择的锁类型创建对应的锁对象。当然可以用if-else实现这个场景,但是这样太不优雅了。
- 我们的需求是根据用户选择的所类型,创建不同的锁对象,有一种设计模刚好解决这一问题:简单工厂模式。
2.2.1.锁类型枚举
先定义一个锁类型枚举:
public enum MyLockType {
RE_ENTRANT_LOCK, // 可重入锁
FAIR_LOCK, // 公平锁
READ_LOCK, // 读锁
WRITE_LOCK, // 写锁
;
}
简单说一下我个人对这四种锁的理解:
- 可重入锁:可重入锁它允许同一个线程多次获取同一个锁对象。优点是可以避免死锁和提高系统性能。如果锁中有别的锁这是很危险的情况,很容易出现死锁的情况。
- 公平锁:多线程来同时抢锁资源的时候不论先后顺序,那个线程获得锁都是随机的,可能会出现一个线程等待好久都抢不到锁资源。在公平锁中锁的获取顺序是按照加锁的顺序获得的,就是先到先得公。平锁的实现需要额外的开销,因为需要维护一个线程队列来记录等待锁的线程,但是它可以避免线程饥饿和优化系统性能。
- 读锁:当多个线程需要同时读取共享资源时。读锁是共享锁,多个线程可以同时持有读锁。 但是不能持有写锁。当一个线程持有读锁时,其他线程可以同时持有读锁,但是不能持有写锁。只有当所有的读锁都被释放后,才能获取写锁。
- 写锁:写锁是独占锁,只有一个线程可以持有写锁,其他线程不能持有读锁或写锁。当一个线程持有写锁时,其他线程不能持有读锁或写锁,直到写锁被释放为止。
- 锁的饥饿问题是指在并发编程中,某个线程因为无法获取所需的锁而一直无法执行的情况。这种情况下,该线程就会一直处于等待状态,无法执行其它的任务,从而影响了整个程序的性能和响应时间。 锁的饥饿问题通常是由于线程优先级的不合理分配或者锁的竞争激烈导致的。线程优先级过低的情况下,可能会被其它线程抢占资源,从而无法获取锁;而锁的竞争激烈则可能会导致某些线程一直无法获取到锁,从而一直处于等待状态。
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MyLock {
String name();
long waitTime() default 1;
long leaseTime() default -1; //redis看门狗机制默认是30秒
/**
* 锁的类型
*/
MyLockType lockType() default MyLockType.RE_ENTRANT_LOCK;
}
在自定义注解中加入锁类型的参数并设置默认值
2.2.2.锁对象工厂
@Component
public class MyLockFactory {
private final Map<MyLockType, Function<String, RLock>> map;
public MyLockFactory(RedissonClient redissonClient) {
//枚举
map = new EnumMap<>(MyLockType.class);
map.put(MyLockType.FAIR_LOCK, redissonClient::getFairLock);
map.put(MyLockType.RE_ENTRANT_LOCK, redissonClient::getLock);
map.put(MyLockType.READ_LOCK, key -> redissonClient.getReadWriteLock(key).readLock());
map.put(MyLockType.WRITE_LOCK, key -> redissonClient.getReadWriteLock(key).writeLock());
}
//提供获取锁对象方法
public RLock getLock(MyLockType lockType, String key) {
return map.get(lockType).apply(key);
}
}
这里面就有的说喽;
首先MyLockFactory工厂中维护了一个map,key是锁类型的枚举值,值这个地方初步写的时候我用的是RLock,但是这样的话在reids中锁的key我们还是无法指定。所以改用Function。
MyLockFactory内部的Map采用了EnumMap
。只有当Key是枚举类型时可以使用EnumMap
,其底层不是hash表,因为枚举字段是定值所以是数组实现。 这样就能根据枚举项序号作为角标快速定位到数组中的数据。
然后改造 我们的aop切面:
在业务中,就能通过注解来指定自己要用的锁类型
2.2.3.锁失败策略
分析一下锁失败的处理策略有哪些。
大的方面来说,获取锁失败要从两方面来考虑:
-
获取锁失败是否要重试?有三种策略:
-
不重试,对应API:
lock.tryLock(0, 10, SECONDS)
,也就是waitTime小于等于0 -
有限次数重试:对应API:
lock.tryLock(5, 10, SECONDS)
,也就是waitTime大于0,重试一定waitTime时间后结束 -
无限重试:对应API
lock.lock(10, SECONDS)
, lock就是无限重试
-
-
重试失败后怎么处理?有两种策略:
-
直接结束
-
抛出异常
-
还是使用策略模式,先定义一个失败策略枚举
public enum MyLockStrategy {
SKIP_FAST(){
@Override
public boolean tryLock(RLock lock, MyLock prop) throws InterruptedException {
return lock.tryLock(0, prop.leaseTime(), prop.unit());
}
},
FAIL_FAST(){
@Override
public boolean tryLock(RLock lock, MyLock prop) throws InterruptedException {
boolean isLock = lock.tryLock(0, prop.leaseTime(), prop.unit());
if (!isLock) {
throw new BizIllegalException("请求太频繁");
}
return true;
}
},
KEEP_TRYING(){
@Override
public boolean tryLock(RLock lock, MyLock prop) throws InterruptedException {
lock.lock( prop.leaseTime(), prop.unit());
return true;
}
},
SKIP_AFTER_RETRY_TIMEOUT(){
@Override
public boolean tryLock(RLock lock, MyLock prop) throws InterruptedException {
return lock.tryLock(prop.waitTime(), prop.leaseTime(), prop.unit());
}
},
FAIL_AFTER_RETRY_TIMEOUT(){
@Override
public boolean tryLock(RLock lock, MyLock prop) throws InterruptedException {
boolean isLock = lock.tryLock(prop.waitTime(), prop.leaseTime(), prop.unit());
if (!isLock) {
throw new BizIllegalException("请求太频繁");
}
return true;
}
},
;
public abstract boolean tryLock(RLock lock, MyLock prop) throws InterruptedException;
}
注解中加入失败枚举参数。
改造切面。
这样一来就可以在注解传参指定锁类型了。
2.3.基于SPEL动态获取锁名
之前我们锁对象原本是当前登录用户,是动态获取的,但是现在加锁是基于注解参数添加的,在编码时就需要指定,使用SPEL表达式可以满足我们目前的业务需求,达到动态获取锁名。
Spring中提供了一种表达式语法,称为SPEL表达式,可以执行java代码,获取任意参数。所以我们可以让用户指定锁名称参数时不要写死,而是基于SPEL表达式,然后解析表达式获取锁名称。
2.3.1.使用SPEL指定锁名
使用参数作为动态锁名可以使用#{}占位符动态获取。很多Spring源码中也是以这种形式使用了SPEL表达式。
如果是通过上下文UserContext.getUser()获取的可以写方法的全路径.方法 提供一个通用模板解析锁名。
/**
* SPEL的正则规则
*/
private static final Pattern pattern = Pattern.compile("\\#\\{([^\\}]*)\\}");
/**
* 方法参数解析器
*/
private static final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();
/**
* 解析锁名称
* @param name 原始锁名称
* @param pjp 切入点
* @return 解析后的锁名称
*/
private String getLockName(String name, ProceedingJoinPoint pjp) {
// 1.判断是否存在spel表达式
if (StringUtils.isBlank(name) || !name.contains("#")) {
// 不存在,直接返回
return name;
}
// 2.构建context,也就是SPEL表达式获取参数的上下文环境,这里上下文就是切入点的参数列表
EvaluationContext context = new MethodBasedEvaluationContext(
TypedValue.NULL, resolveMethod(pjp), pjp.getArgs(), parameterNameDiscoverer);
// 3.构建SPEL解析器
ExpressionParser parser = new SpelExpressionParser();
// 4.循环处理,因为表达式中可以包含多个表达式
Matcher matcher = pattern.matcher(name);
while (matcher.find()) {
// 4.1.获取表达式
String tmp = matcher.group();
String group = matcher.group(1);
// 4.2.这里要判断表达式是否以 T字符开头,这种属于解析静态方法,不走上下文
Expression expression = parser.parseExpression(group.charAt(0) == 'T' ? group : "#" + group);
// 4.3.解析出表达式对应的值
Object value = expression.getValue(context);
// 4.4.用值替换锁名称中的SPEL表达式
name = name.replace(tmp, ObjectUtils.nullSafeToString(value));
}
return name;
}
private Method resolveMethod(ProceedingJoinPoint pjp) {
// 1.获取方法签名
MethodSignature signature = (MethodSignature)pjp.getSignature();
// 2.获取字节码
Class<?> clazz = pjp.getTarget().getClass();
// 3.方法名称
String name = signature.getName();
// 4.方法参数列表
Class<?>[] parameterTypes = signature.getMethod().getParameterTypes();
return tryGetDeclaredMethod(clazz, name, parameterTypes);
}
private Method tryGetDeclaredMethod(Class<?> clazz, String name, Class<?> ... parameterTypes){
try {
// 5.反射获取方法
return clazz.getDeclaredMethod(name, parameterTypes);
} catch (NoSuchMethodException e) {
Class<?> superClass = clazz.getSuperclass();
if (superClass != null) {
// 尝试从父类寻找
return tryGetDeclaredMethod(superClass, name, parameterTypes);
}
}
return null;
}