领取优惠券的优化
分布式锁
集群下的锁失效问题
请求进入了多个JVM,就会出现多个锁对象(用户id对象),自然就有多个锁监视器。此时就会出现每个JVM内部都有一个线程获取锁成功的情况,没有达到互斥的效果,并发安全问题就可能再次发生了
分布式锁
分布式锁必须要满足的特征:
-
多JVM实例都可以访问
-
互斥
能满足上述特征的组件有很多,因此实现分布式锁的方式也非常多,例如:
-
基于MySQL
-
基于Redis
-
基于Zookeeper
-
基于ETCD
但目前使用最广泛的还应该是基于Redis的分布式锁
简单分布式锁
利用Redis实现的简单分布式锁流程如下
业务代码修改
从原来的乐观锁改为分布锁
分布式锁的问题
锁误删问题
解决思路:
我们会将持有锁的线程存入lock中。因此,我们应该在删除锁之前判断当前锁的中保存的是否是当前线程标示,如果不是则证明不是自己的锁,则不删除;如果锁标示是当前线程,则可以删除
超时释放问题
就在线程2获取锁成功后,线程1从阻塞中醒来,继续释放锁。由于在阻塞之前已经完成了锁标示判断,现在就无需判断而是直接删除锁,结果就把线程2的锁删除了
总结一下,误删的原因归根结底是因为什么?
-
超时释放
-
判断锁标示、删除锁两个动作不是原子操作
其它问题
除了上述问题以外,分布式锁还会碰到一些其它问题:
-
锁的重入问题:同一个线程多次获取锁的场景,目前不支持,可能会导致死锁
-
锁失败的重试问题:获取锁失败后要不要重试?目前是直接失败,不支持重试
-
Redis主从的一致性问题:由于主从同步存在延迟,当线程在主节点获取锁后,从节点可能未同步锁信息。如果此时主宕机,会出现锁失效情况。此时会有其它线程也获取锁成功。从而出现并发安全问题。
解决方案
当然,上述问题并非无法解决,只不过会比较麻烦。例如:
-
原子性问题:可以利用Redis的LUA脚本来编写锁操作,确保原子性
-
超时问题:利用WatchDog(看门狗)机制,获取锁成功时开启一个定时任务,在锁到期前自动续期,避免超时释放。而当服务宕机后,WatchDog跟着停止运行,不会导致死锁。
-
锁重入问题:可以模拟Synchronized原理,放弃setnx,而是利用Redis的Hash结构来记录锁的持有者以及重入次数,获取锁时重入次数+1,释放锁是重入次数-1,次数为0则锁删除
-
主从一致性问题:可以利用Redis官网推荐的RedLock机制来解决
我们通常会使用一些开源框架来实现分布式锁,而不是自己来编码实现。目前对这些解决方案实现的比较完善的一个第三方组件:Redisson
Redisson
在微服务中应用的步骤:
-
引入tj-common、Redisson依赖
-
注入RedissonClient,使用分布式锁
业务代码修改
优化-通用分布式锁组件
Redisson的分布式锁使用并不复杂,基本步骤包括:
-
1)创建锁对象
-
2)尝试获取锁
-
3)处理业务
-
4)释放锁
但是,除了第3步以外,其它都是非业务代码,对业务的侵入较多
可以基于AOP的思想,将业务部分作为切入点,将业务前后的锁操作作为环绕增强。
但是,我们该如何标记这些切入点呢?
不是每一个service方法都需要加锁,因此我们不能直接基于类来确定切入点;另外,需要加锁的方法可能也较多,我们不能基于方法名作为切入点,这样太麻烦。因此,最好的办法是把加锁的方法给标记出来,利用标记来确定切入点。如何标记呢?
最常见的办法就是基于注解来标记了。同时,加锁时还有一些参数,比如:锁的key名称、锁的waitTime、releaseTime等等,都可以基于注解来传参。
定义注解
注解本身起到标记作用,同时还要带上锁参数:
-
锁名称
-
锁等待时间
-
锁超时时间
-
时间单位
定义切面
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();}}
Spring中的AOP切面有很多,会按照Order排序,按照Order值从小到大依次执行。Spring事务AOP的order值是Integer.MAX_VALUE,优先级最低。
我们的分布式锁一定要先于事务执行,因此,我们的切面一定要实现Ordered接口,指定order值小于Integer.MAX_VALUE即可。
@Overridepublic int getOrder() {return 0;}
使用锁
优化-对锁的实现进行优化
现在还存在几个问题:
-
Redisson中锁的种类有很多,目前的代码中把锁的类型写死了
-
Redisson中获取锁的逻辑有多种,比如获取锁失败的重试策略,目前都没有设置
-
锁的名称目前是写死的,并不能根据方法参数动态变化
所以呢,我们接下来还要对锁的实现进行优化,注意解决上述问题。
工厂模式切换锁类型
问题:Redisson中锁的种类有很多,目前的代码中把锁的类型写死了
如何让用户选择锁类型呢?
锁的类型虽然有多种,但类型是有限的几种,完全可以通过枚举定义出来。然后把这个枚举作为MyLock
注解的参数,交给用户去选择自己要用的类型。
锁类型枚举
public enum MyLockType {RE_ENTRANT_LOCK, // 可重入锁FAIR_LOCK, // 公平锁READ_LOCK, // 读锁WRITE_LOCK, // 写锁;
}
然后在自定义注解中添加锁类型这个参数
锁对象工厂
@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);}
}
改造切面代码
我们将锁对象工厂注入MyLockAspect,然后就可以利用工厂来获取锁对象了
在业务中,就能通过注解来指定自己要用的锁类型了
(其实很好理解,注解的参数相当于买家,切面实现相当于商铺中介,商铺发订单给工厂)
锁失败策略
问题:Redisson中获取锁的逻辑有多种,比如获取锁失败的重试策略,目前都没有设置
重试策略 + 失败策略组合,总共以下几种情况:
一种设计模式:策略模式。同时,我们还需要定义一个失败策略的枚举。在MyLock注解中定义这个枚举类型的参数,供用户选择。
在策略比较简单的情况下,我们完全可以用枚举代替策略工厂,简化策略模式,所以实现的思路和前面的锁类型一样
我们定义一个失败策略枚举;
在MyLock注解中添加枚举参数;
修改切面代码,基于用户选择的策略来处理;
我们就可以在使用锁的时候自由选择锁类型、锁策略了;
(写的时候这个代码顺序正好执行的时候是反着的感觉)
基于SPEL的动态锁名
问题:锁的名称目前是写死的,并不能根据方法参数动态变化
Spring中提供了一种表达式语法,称为SPEL表达式,可以执行java代码,获取任意参数。
首先,在使用锁注解时,锁名称可以利用SPEL表达式;
而如果是通过UserContext.getUser()获取,则可以利用下面的语法:
这里T(类名).方法名()
就是调用静态方法。