我们知道如果我们的项目服务不只是一个实例的时候,单体锁就不再适用,而我们自己去用redis实现分布式锁的话,会有比如锁误删、超时释放、锁的重入、失败重试、Redis主从一致性等等一系列的问题需要自己解决。
当然,上述问题并非无法解决,只不过会比较麻烦。例如:
- 原子性问题:可以利用Redis的LUA脚本来编写锁操作,确保原子性
- 超时问题:利用WatchDog(看门狗)机制,获取锁成功时开启一个定时任务,在锁到期前自动续期,避免超时释放。而当服务宕机后,WatchDog跟着停止运行,不会导致死锁。
- 锁重入问题:可以模拟Synchronized原理,放弃setnx,而是利用Redis的Hash结构来记录锁的持有者以及重入次数,获取锁时重入次数+1,释放锁是重入次数-1,次数为0则锁删除
- 主从一致性问题:可以利用Redis官网推荐的RedLock机制来解决
这些解决方案实现起来比较复杂,因此我们通常会使用一些开源框架来实现分布式锁,而不是自己来编码实现。目前对这些解决方案实现的比较完善的一个第三方组件:Redisson
因此,我们只要会使用Redisson,即可解决上述问题,无需自己动手编码了。
下面就将介绍Redission在项目实战中的最佳实践。
快速入门
首先引入依赖:
<!--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://localhost:6379")
.setPassowrd("root");
// 创建客户端
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获取锁时可以传3个参数:
- waitTime:获取锁的等待时间。当获取锁失败后可以多次重试,直到waitTime时间耗尽。waitTime默认-1,即失败后立刻返回,不重试。
- leaseTime:锁超时释放时间。默认是30,同时会利用WatchDog来不断更新超时时间。需要注意的是,如果手动设置leaseTime值,会导致WatchDog失效。
- TimeUnit:时间单位
项目集成
common模块已经完成了Redission的基础配置:
@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);
}
几个关键点:
- 这个配置上添加了条件注解
@ConditionalOnClass({RedissonClient.
class
, Redisson.
class
})
也就是说,只要引用了common模块,并且引用了Redisson依赖,这套配置就会生效。不引入Redisson依赖,配置自然不会生效,从而实现按需引入。 - RedissonClient的配置无需自定义Redis地址,而是直接基于SpringBoot中的Redis配置即可。而且不管是Redis单机、Redis集群、Redis哨兵模式都可以支持
所以,在微服务中应用的步骤:
- 引入common、Redisson依赖
- 注入RedissonClient,使用分布式锁
应用到项目中:
通用分布式锁组件
Redisson的分布式锁使用并不复杂,基本步骤包括:
- 1)创建锁对象
- 2)尝试获取锁
- 3)处理业务
- 4)释放锁
但是,除了第3步以外,其它都是非业务代码,对业务的侵入较多:
可以发现,非业务代码格式固定,每次获取锁总是在重复编码。我们可不可以对这部分代码进行抽取和简化呢?
实现思路分析
要优化这部分代码,需要通过整个流程来分析:
可以发现,只有红框部分是业务功能,业务前、后都是固定的锁操作。既然如此,我们完全可以基于AOP的思想,将业务部分作为切入点,将业务前后的锁操作作为环绕增强。
但是,我们该如何标记这些切入点呢?
不是每一个service方法都需要加锁,因此我们不能直接基于类来确定切入点;另外,需要加锁的方法可能也较多,我们不能基于方法名作为切入点,这样太麻烦。因此,最好的办法是把加锁的方法给标记出来,利用标记来确定切入点。如何标记呢?
最常见的办法就是基于注解来标记了。同时,加锁时还有一些参数,比如:锁的key名称、锁的waitTime、releaseTime等等,都可以基于注解来传参。
因此,注解的核心作用是两个:
- 标记切入点
- 传递锁参数
综上,我们计划利用注解来标记切入点,传递锁参数。同时利用AOP环绕增强来实现加锁、释放锁等操作。
定义注解
注解本身起到标记作用,同时还要带上锁参数:
- 锁名称
- 锁等待时间
- 锁超时时间
- 时间单位
代码如下:
@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 implements Ordered{
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();
}
}
@Override
public int getOrder() {
return 0;
}
}
注意,Spring中的AOP切面有很多,会按照Order排序,按照Order值从小到大依次执行。Spring事务AOP的order值是Integer.MAX_VALUE,优先级最低。
我们的分布式锁一定要先于事务执行,因此,我们的切面一定要实现Ordered接口,指定order值小于Integer.MAX_VALUE即可。
使用锁
定义好了锁注解和切面,接下来就可以改造业务了:
可以看到,业务中无需手动编写加锁、释放锁的逻辑了,没有任何业务侵入,使用起来也非常优雅。
不过呢,现在还存在几个问题:
- Redisson中锁的种类有很多,目前的代码中把锁的类型写死了
- Redisson中获取锁的逻辑有多种,比如获取锁失败的重试策略,目前都没有设置
- 锁的名称目前是写死的,并不能根据方法参数动态变化
所以呢,我们接下来还要对锁的实现进行优化,注意解决上述问题。
工厂模式切换锁类型
Redisson中锁的类型有多种,比如:
可重入锁(Reentrant Lock):
- 允许同一个线程多次获取同一个锁,计数器记录获取次数,释放时需要调用相同次数。
公平锁(Fair Lock):
- 按照请求锁的顺序来获取锁,避免饥饿情况。先请求锁的线程会先获得锁。
非公平锁(Unfair Lock):
- 允许线程抢占锁,可能会导致某些线程被长时间阻塞。性能较公平锁更好,但可能导致饥饿。
读写锁(Read-Write Lock):
- 允许多个线程同时读取资源,但在写入时会独占锁。适合读多写少的场景。
信号量(Semaphore):
- 允许多个线程获取一定数量的许可,适合限制资源的并发访问。
限流器(Rate Limiter):
- 控制操作的频率,限制单位时间内的请求数量。
多节点锁(Multi-node Lock):
- 支持在分布式环境中使用,能够在多个 Redis 节点上进行锁的管理。
因此,我们不能在切面中把锁的类型写死,而是交给用户自己选择锁类型。
那么问题来了,如何让用户选择锁类型呢?
锁的类型虽然有多种,但类型是有限的几种,完全可以通过枚举定义出来。然后把这个枚举作为MyLock
注解的参数,交给用户去选择自己要用的类型。
而在切面中,我们则需要根据用户选择的锁类型,创建对应的锁对象即可。但是这个逻辑不能通过if-else
来实现,太low了。
这里我们的需求是根据用户选择的锁类型,创建不同的锁对象。有一种设计模式刚好可以解决这个问题:简单工厂模式。
锁类型枚举
我们首先定义一个锁类型枚举:
具体代码:
public enum MyLockType {
RE_ENTRANT_LOCK, // 可重入锁
FAIR_LOCK, // 公平锁
READ_LOCK, // 读锁
WRITE_LOCK, // 写锁
;
}
然后在自定义注解中添加锁类型这个参数:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface MyLock {
String name();
long waitTime() default 1;
long leaseTime() default -1;
TimeUnit unit() default TimeUnit.SECONDS;
MyLockType lockType() default MyLockType.RE_ENTRANT_LOCK;//默认可重入
}
锁对象工厂
然后定义一个锁工厂,用于根据锁类型创建锁对象:
具体代码:
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;
import java.util.EnumMap;
import java.util.Map;
import java.util.function.Function;
@Component
public class MyLockFactory {
private final Map<MyLockType, Function<String, RLock>> lockHandlers;
public MyLockFactory(RedissonClient redissonClient) {
this.lockHandlers = new EnumMap<>(MyLockType.class);
this.lockHandlers.put(RE_ENTRANT_LOCK, redissonClient::getLock);
this.lockHandlers.put(FAIR_LOCK, redissonClient::getFairLock);
this.lockHandlers.put(READ_LOCK, name -> redissonClient.getReadWriteLock(name).readLock());
this.lockHandlers.put(WRITE_LOCK, name -> redissonClient.getReadWriteLock(name).writeLock());
}
public RLock getLock(MyLockType lockType, String name){
return lockHandlers.get(lockType).apply(name);
}
}
- MyLockFactory内部持有了一个Map,key是锁类型枚举,值是创建锁对象的Function。注意这里不是存锁对象,因为锁对象必须是多例的,不同业务用不同锁对象;同一个业务用相同锁对象。
- MyLockFactory内部的Map采用了
EnumMap
。只有当Key是枚举类型时可以使用EnumMap
,其底层不是hash表,而是简单的数组。由于枚举项数量固定,因此这个数组长度就等于枚举项个数,然后按照枚举项序号作为角标依次存入数组。这样就能根据枚举项序号作为角标快速定位到数组中的数据。
改造切面代码
我们将锁对象工厂注入MyLockAspect,然后就可以利用工厂来获取锁对象了:
此时,在业务中,就能通过注解来指定自己要用的锁类型了:
锁失败策略
多线程争抢锁,大部分线程会获取锁失败,而失败后的处理方案和策略是多种多样的。目前,我们获取锁失败后就是直接抛出异常,没有其它策略,这与实际需求不一定相符。
策略分析
接下来,我们就分析一下锁失败的处理策略有哪些。
大的方面来说,获取锁失败要从两方面来考虑:
- 获取锁失败是否要重试?有三种策略:
-
不重试,对应API:
lock.tryLock(0, 10, SECONDS)
,也就是waitTime小于等于0 -
有限次数重试:对应API:
lock.tryLock(5, 10, SECONDS)
,也就是waitTime大于0,重试一定waitTime时间后结束 -
无限重试:对应API
lock.lock(10, SECONDS)
, lock就是无限重试
-
不重试,对应API:
- 重试失败后怎么处理?有两种策略:
- 直接结束
- 抛出异常
对应的API和策略名如下:
重试策略 + 失败策略组合,总共以下几种情况:
那么该如何用代码来表示这些失败策略,并让用户*选择呢?
相信大家应该能想到一种设计模式:策略模式。同时,我们还需要定义一个失败策略的**枚举。**在MyLock注解中定义这个枚举类型的参数,供用户选择。
一般的策略模式大概是这样:
- 定义策略接口
- 定义不同策略实现类
- 提供策略工厂,便于根据策略枚举获取不同策略实现
而在策略比较简单的情况下,我们完全可以用枚举代替策略工厂,简化策略模式。
综上,我们可以定义一个基于枚举的策略模式,简化开发。
策略实现
我们定义一个失败策略枚举:
然后直接将失败策略定义到枚举中:
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;
}
然后,在MyLock注解中添加枚举参数:
最后,修改切面代码,基于用户选择的策略来处理:
这个时候,我们就可以在使用锁的时候*选择锁类型、锁策略了:
基于SPEL的动态锁名
现在还剩下最后一个问题,就是锁名称的问题。
在当前业务中,我们的锁对象本来应该是当前登录用户,是动态获取的。而加锁是基于注解参数添加的,在编码时就需要指定。怎么办?
Spring中提供了一种表达式语法,称为SPEL表达式,可以执行java代码,获取任意参数。
我们可以让用户指定锁名称参数时不要写死,而是基于SPEL表达式。在创建锁对象时,解析SPEL表达式,动态获取锁名称。
SPEL表达式
SpEL(Spring Expression Language)是一种强大的表达式语言,允许在 Spring 中使用字符串表达式来操作对象图、调用方法、访问属性等。SpEL 的主要用途包括但不限于:动态属性访问、条件表达式、方法调用等。
基本语法
-
属性访问:
expression = "user.name" // 访问 user 对象的 name 属性
-
方法调用:
expression = "user.getName()" // 调用 user 对象的 getName() 方法
-
集合操作:
expression = "users.?[age > 18]" // 过滤出年龄大于18的用户
-
条件表达式:
expression = "age > 18 ? 'Adult' : 'Minor'" // 三元运算符
-
运算符:
- 算术运算符:
+
,-
,*
,/
,%
- 比较运算符:
==
,!=
,<
,>
,<=
,>=
- 逻辑运算符:
and
,or
,not
- 算术运算符:
具体官网链接:8. Spring 表达式语言 (SpEL) (itmyhome.com)
首先,在使用锁注解时,锁名称可以利用SPEL表达式,例如我们指定锁名称中要包含参数中的用户id,则可以这样写:
而如果是通过UserContext.getUser()获取,则可以利用下面的语法:
这里T(类名).方法名()
就是调用静态方法。
解析SPEL
在切面中,我们需要基于注解中的锁名称做动态解析,而不是直接使用名称:
其中获取锁名称用的是getLockName()
这个方法:
/**
* 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 (