今天来聊聊分布式锁的解决方案,就从什么是分布式锁和分布式锁的解决方案以及具体实现来进行分析,内容纯属个人见解,如有纰漏及错误还请指正!
什么是分布式锁
- 传统的单机应用中,需求在一台jvm上如果有线程并发的安全问题使用jvm自带的锁机制就能很好的解决;但是在微服务的分布式部署下理论上会有N台JVM集群,显然单机锁已经解决不了,那么如何保证他们对共有资源的访问安全呢?这就需要引入分布式锁;分布式锁同样能保证在某一时刻内共有资源只被一个应用的某个线程所占用,其他资源无法抢占,除非该线程执行完自己的业务操作主动释放锁,那么分布式锁有哪些主流解决方案呢?
分布式锁的三种解决方案
通过数据库实现
实现思路
- 通过创建一个具有uk、开始时间、过期时间的表,然后业务侧拿到资源后将资源id作为uk插入该表,如果插入成功即成功抢占锁;为了防止抢占到锁的资源宕机导致释放锁失败还需要新建定时任务固定时间内删除已经失效的锁记录
表结构参考
create table LOCK_INFO
(
ID VARCHAR2(100),
busi_Id VARCHAR2(255),
CREATE_TIME DATE,
EXPIRE_TIME DATE
);
create unique index UK_LOCK on LOCK_INFO (busi_Id);
comment on table LOCK_INFO is '分布式锁信息表';
comment on column LOCK_INFO.ID is '自增主键';
comment on column LOCK_INFO.busi_Id is '共享资源的id';
comment on column LOCK_INFO.CREATE_TIME is '锁的创建时间';
comment on column LOCK_INFO.EXPIRE_TIME is '锁的过期时间';
加锁
insert into LOCK_INFO values('10020201','BUSI_0001',sysdate,sysdate+numtodsinterval(5,'minute'))
说明:加锁只强调了insert代码的sql,实际应用中是需要在代码中trycatch捕获异常的,不然万一加锁失败整个业务都进行不下去了
释放锁
- 业务正常执行完毕的释放
delete from LOCK_INFO where busi_id = 'BUSI_0001';
- 定时任务的释放
delete from LOCK_INFO where expire_time < sysdate
缺点
- 按照上述思路实现还有一个问题即拿到锁的程序执行时间大于删除锁的定时任务的时间该怎么办?这样会导致定时任务把锁删掉了别的程序会抢占到该锁但是原程序仍在执行,这就会造成数据不一致的问题了
- 解决方案
当拿锁的程序执行时间过长开启异步程序去数据库续期即可
实际运用
由于工作中我们的分布式锁用的就是数据库实现的,所以这里聊下我们的业务场景以及具体实现
业务场景
系统内的定时任务需要使用分布式锁
具体实现
- 1、自定义注解@Lock,需要使用锁的方法直接加上该注解即可,注解类如下
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Lock {
/**
* 锁名称
* @return
*/
String name() default CommonConstant.EMPLY;
/**
* 锁类型
* @return
*/
String type() default "DB";
}
- 2、利用AOP拦截方法上有@Lock注解的请求,执行加锁、执行业务、解锁的操作,关键代码如下:
@Aspect
@Component
public class LockAop {
@Resource
private LockService lockService;
private final static Logger log = LoggerFactory.getLogger(LockAop.class);
@Pointcut("@annotation(com.darling.annotation.Lock)")
public void lockPointCut(){}
@Around("lockPointCut()")
public Object around(ProceedingJoinPoint point) throws Throwable {
Signature signature = point.getSignature();
MethodSignature methodSignature = (MethodSignature)signature;
Method method = methodSignature.getMethod();
Method targetMethod = point.getTarget().getClass().getMethod(method.getName(), method.getParameterTypes());
String className = method.getDeclaringClass().getName()+"."+method.getName();
Lock lock = targetMethod.getAnnotation(Lock.class);
Object result = null;
if (Objects.nonNull(lock)) {
String name = lock.name();
if (StringUtils.isBlank(name)) {
name = className;
}
try {
lockService.lock(name);
try {
result = point.proceed();
} finally {
lockService.unLock(name);
}
} catch (Exception e) {
log.info("抢占失败,name = " + name + e);
}
}
return result;
}
}
说明:LockService里封装的就是加减锁对应的insert、delete操作
- 3、开发定时任务定期删除已过期的锁,这里是五分钟删除一次,释放锁的逻辑在lockService里实现
@Scheduled(cron = "0 0/5 * * * ?")
@ScheProfile(value = PRD)
private void autoUnlock() {
lockService.autoUnLock();
}
通过redis实现
实现原理
通过redis的 setnx这个原子操作命令来实现,为了降低数据一致性风险建议设置key的时候添加过期时间,如set busiId 999 nx ex 300;但是需要注意的是业务侧一定要保证每次加锁的命令一定是标准化的,因为如果像上面的命令加锁成功后其他业务设置了set busiId 888 ex 300不仅能设置成功而且还会覆盖掉原值,这样锁就失效了,如下所示:
优缺点
- 优点:redis基于内存所以效率上肯定比操作数据库快,并且redis可以自己管理过期时间不用写任务去删除
- 缺点:单点故障、续期问题
单点故障的解决方案-红锁
- 一台redis的单点故障问题很容易会想到用主从复制的架构来解决,如果当应用在master上拿到锁执行业务后master挂掉了,那么其他应用很容易会在slave上拿到锁,这样就又破坏了数据的一致性;所以红锁的解决方案应运而生;
- 红锁即部署由用户可知个数和顺序的redis集群,集群内各个实例互不影响,这里提到的个数建议为奇数个,应用侧按照顺序依次对集群内的redis实例进行setnx ex操作,如果成功的实例数大于集群内总实例数的一半加一时即认为加锁成功;这种方案明显提升了redis作为分布式锁的可靠性,但是显然增加了维护和部署的难度;
- 缺点:当应用拿到锁的某个redis实例宕机需要重启的时候其内存是没有对应key值的,这时候就又会被其他程序抢锁成功而先抢到锁的应用还没有执行完业务;这就又产生了一致性的问题,解决方案就是延缓宕机实例的重启时间,可以设置1小时或者更久后重启,理论上1小时先抢到锁的应用业务肯定也执行完毕了
通过ZK+数据库乐观锁实现
思考 假设应用侧是一个部署了10台JVM应用的集群,其中一个JVM应用(称之为A应用)利用红锁拿到了锁去执行业务了,假设该应用A在执行过程中做了一次很长时间的io或者干脆STW了,时间长到大于redis里设置的过期时间了,此时redis里的key过期失效了,而另一个应用B抢到了该锁去执行任务了,此时应用A恢复正常响应继续执行业务,那么就又造成了数据一致性的问题了!
- 上面问题可以通过ZK的临时顺序节点+mysql的乐观锁来实现,具体的实现步骤我就不用文字一一描述了,这里贴上我画的流程图: