1 前言
几年前工作的时候,当时做的项目因可预估的业务量激增准备上k8s集群,但此前业务量较小,项目中涉及到并发锁的地方全部采用的单机锁,实现起来
要么像这样:
public void test() {
synchronized (obj) {
// do your business
}
}
要么像这样:
Lock lock = new ReentraintLock();
public void test() {
lock.lock();
try {
// do your business
} finally {
lock.unlock();
}
}
ps:当然,我们做了封装,像这样的工具锁,必然是放到了common模块
但即便如此,始终涉及到代码的修改,但又不能写死,即:不能直接将common模块中的锁实现改为分布式锁,因为我们的项目除了上云做云服务以外,还涉及到在客户方进行私有化部署,有一部分客户业务量有限,资源有限,无法部署用来做分布式锁的中间件,因此还需要继续用到单机锁,有的客户方又对中间件有要求,基于客户方已部署好的环境,用其现有的中间件支持分布式锁,因此,这就导致无法直接“一棍子打死”将common模块直接修改。
在上述种种限制、要求、压力下,我决定借此机会,利用springboot特性,做一款通用的starter插件,支持业务灵活自定义配置,并基于配置文件决定项目启用的是单机锁、还是分布式锁,在分布式锁的情况下,又可以决定是使用Redis、zk,还是其它中间件
2 starter 简介及使用方式
该starter已上传个人gitee仓库:gitee.com/LawlietPers…
欢迎大家start、fork、提出宝贵的意见。
首先,这是一个springboot插件,可直接集成到任何springboot工程进行使用。
它封装了锁的使用,尤其是并发场景下的分布式互斥锁,可以通过注解的方式对业务无侵入的施加锁
在加锁的同时,还可以指定被锁定业务逻辑的时间长度,如果时间到而业务逻辑尚未执行完毕,可通过已提供的策略进行自动处理(直接抛出异常、忽略等等)
目前该插件实现了针对juc(原生的java.concurrent.lock)、redis(基于redisson,同时,支持单节点redis,sentinel模式redis,cluster集群模式的redis)、zookeeper的锁机制,juc只适用于单机服务,redis和zookeeper可用于施加分布式锁
2.1 简易使用
2.1.1 引入依赖
2.1.1.1 maven工程如下方式引入
<dependency>
<groupId>com.gitee.lawlietpersonal</groupId>
<artifactId>component-lock</artifactId>
<version>1.4.0</version>
</dependency>
2.1.1.2 gradle工程如下方式引入
compile("com.gitee.lawlietpersonal:component-lock:1.4.0")
其中版本号可选用对应tag的版本号,你可以选择release版本(已发布到maven中央仓库的稳定版),也可以使用SNAPSHOT版本
2.1.2 配置文件
2.1.2.1 application.yml
在resources目录下
默认的application.yml application.properties ...
或者是你自定义能够识别的xxx.yml xxx.properties ... 等配置文件中指定如下属性
# 根节点配置
lock:
enable: true #是否开启,若不开启,则springboot启动时不会扫描并加载相关的配置
type: juc # 必须。锁的类型,目前支持 juc redis zookeeper
# 锁对象的缓存容量,超过该容量,将触发lfu算法,保证容量始终处于该阈值以内,防止扩容带来的吞吐量降低
# 默认是128 但需要根据你们业务的实际情况,合理的设置容量,太小的话会频繁触发lfu算法,影响被加锁的业务执行效率,太大的话会占用过多的内存资源
lockCacheCapacity: 1024
# 为了保证缓存的锁对象容量在 lock.lockCacheCapacity * 0.75 范围内不会扩容,所指定的清理算法,目前支持lfu(默认),lru
strategyForCapacity: lru
# 这是用来控制主线程fork出子线程总体数量的参数
# 因为每调用业务接口之前,在lock之后,主线程会fork出一个子线程去执行业务逻辑,而主线程处于阻塞状态
# 这样做的目的是便于控制处于锁状态下业务逻辑的执行时长等
# 所以需要控制进程中总体的线程数
# 一般来说,每一个锁对应一个key,需要参考应用中key的总数,然后结合当前服务器和应用资源合理设置
# 若不配置,该值默认是 128
forkTaskQueueSize: 256
zookeeper: # 当type = zookeeper时必须,zookeeper锁的配置
# 必须,zookeeper集群或单机的服务地址
connectionUrl: 192.168.240.129:2182,192.168.216.130:2182,192.168.216.131:2182
# 非必须,zookeeper中锁的节点名称,当为空时,该值默认为 businessLock
# zookeeper中会在根节点下默认创建一个用于该工程的锁的节点目录,名为 /lock-${applicationName}-${rootPath}
rootPath: lawlietLock
redis: # 当type = redis时必须,redis锁的配置 注意:single、sentinel、cluster配置三者只能配置一种
# # 非必须 密码
# password: 123456
single:
# 单节点时的地址
host: 127.0.0.1
# 单节点时的端口
port: 6379
# # 哨兵部署的模式配置
# sentinel:
# # 非必须 master节点名称,默认redisMasterNode
# masterName: xxx
# # 必须 哨兵地址
# addresses:
# - 192.168.1.104:6371
# - 192.168.1.104:6372
# # 集群的部署模式
# cluster:
# # 非必须 集群状态扫描间隔时间,单位是毫秒,默认2000ms
# scanInterval: 2000
# # 必须 集群中各个主从节点的地址
# addresses:
# - 192.168.2.104:6371
# - 192.168.2.104:6372
- 当你的lock.type设置为redis时,redis服务的部署方式 -> single、sentinel、cluster 三选一,有且仅有一种
- 如果没有密码,则不要配置lock.redis.password
2.1.3 使用前,关键注解介绍
2.1.3.1 @LockAcquire注解
该注解使用仅限于方法,key是你针对这项业务逻辑自定义的key,用于区分业务
而strategyOfExpired目前有2种策略(详见常量类注释)
此外,对于stopOnExpired 属性,当设置了超时时间,可以配置超时后是否继续执行业务代码,默认继续执行
/**
* 申请加锁的注解,注解于方法上,则整个方法会根据配置锁的类型进行加锁
* @author xiangjz
* @version 1.0
* @date 2020/9/3 11:03
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Inherited
public @interface LockAcquire {
/**
* 自定义的业务lock.key
* @return
*/
String key() default "";
/**
* 锁的过期的时间
* 默认永不过期
* 单位 ms
* @return
*/
long expire() default -1L;
/**
* 当锁超时仍没有被释放释放时的回调类型
* @return
*/
int strategyOfExpired() default LockExpireStrategyTypeConstant.LOCK_EXPIRE_STRATEGY_IGNORE;
/**
* 当expire > 0,当超时时,是否立即停止尚未完成的业务逻辑
* @return
*/
boolean stopOnExpired() default false;
/**
* 当前进程中,fork的子线程之前尝试申请资源的耗时(ms)
* 若<0,则视为永久等待可用资源
* @return
*/
long expireOnForkingFull() default -1L;
}
2.1.3.2 @LockCustomParam注解
该注解作用于方法中的参数,key和keys用于制定某一参数中的字段值,order是最终生成锁key时的拼接顺序
需要注意的是,一个方法可以有N个参数,你可以根据参数(基本类型:String, Integer,int)的值,或者参数(Object, Map)中字段的值去根据你指定的顺序生成不同的锁key,达到灵活配置分布式锁的目的
引入ognl技术,支持无限层级的动态lock key的配置,例如:
@LockAcquire(key = "lockTest2-", expireOnForkingFull = 100)
public String lock2(@LockCustomParam(key = "key1.key11") Map<String, Object> params,
@LockCustomParam(key = "name", keys = {"child.child.child.name"}) LockTestEntity entity) {
System.out.println("开始获取锁2---");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("执行完毕2");
return "2";
}
值得注意的是:目前仍不支持Collection类型的参数
package com.qingzhu.component.lock.annotation.business;
import java.lang.annotation.*;
/**
* 申请加锁的注解,注解于方法参数上,需要在该方法上有 LockAcquire 注解
* @author xiangjz
* @version 1.0
* @date 2020/9/3 11:03
*/
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.PARAMETER})
@Inherited
public @interface LockCustomParam {
/**
* 根据参数字段值而动态拼接的key
* 同一个注解中,优先级高于keys
* @return
*/
String key() default "";
/**
* key() 拼接成lock.key的顺序
* 当有多个LockCustomParam注解时,order升序拼接
* @return
*/
int order() default 0;
/**
* 注解于一个参数,但需要指定参数中多个字段时
* 按照数组顺序,进行lock.key的拼接
* 同一个注解中,优先级低于key
* @return
*/
String[] keys() default "";
}
2.1.4 开始使用
2.1.4.1 通过注解使用(完整功能,可以带超时时间的配置)
目前,本插件只支持针对方法级别加锁,也就是说,如果你要针对某一段业务逻辑加锁,你必须把它抽离出来,放到一个spring bean的方法中
package com.qingzhu.biz.labor.lock.service;
import com.alibaba.fastjson.JSONObject;
import com.qingzhu.component.lock.annotation.business.LockAcquire;
import com.qingzhu.component.lock.annotation.business.LockCustomParam;
import org.springframework.stereotype.Component;
import java.util.Map;
@Service
public class LockTestService {
@LockAcquire(key = "testBusiness", expire = 10, strategyOfExpired = 0)
public String doBusiness(@LockCustomParam(key = "fdfasdf") Integer a,
@LockCustomParam long b,
@LockCustomParam(keys = {"", "key1", "key2"}) Map<String, Object> map,
@LockCustomParam(key = "name", keys = "age") BusinessEntity entity) {
System.out.println(a);
System.out.println(b);
for (int i = 0; i < 100000; i++) {
log.info(JSONObject.toJSONString(entity) + i);
}
System.out.println(JSONObject.toJSONString(map));
System.out.println(JSONObject.toJSONString(entity));
return "success";
}
@LockAcquire(key = "testBusiness", expire = 2000, strategyOfExpired = 0, stopOnExpired = true, expireOnForkingFull = 100)
public String doBusiness2(@LockCustomParam(key = "fdfasdf") Integer a,
@LockCustomParam long b,
@LockCustomParam(keys = {"", "key1", "key2"}) Map<String, Object> map,
@LockCustomParam(key = "name", keys = "age") BusinessEntity entity) throws InterruptedException {
System.out.println(a);
System.out.println(b);
Thread.sleep(3000);
System.out.println(JSONObject.toJSONString(map));
System.out.println(JSONObject.toJSONString(entity));
return "success";
}
}
2.1.4.2 通过代码块使用(简易功能,不支持定义加锁期间的业务执行超时时间)
如果你觉得麻烦,不想将已有的业务代码单独提炼到一个类中,你可以像如下例子一样,直接通过注入 LockExecutor 类的方式,通过lock和unlock的方式,包裹你的业务代码,完成分布式锁
注意:这种方式不支持加业务的超时时间
同时,你需要自定义你的lockKey,自行拼接字符串
@Service
@Slf4j
public class LockTestService {
private final LockExecutor lockExecutor;
public LockTestService(LockExecutor lockExecutor) {
this.lockExecutor = lockExecutor;
}
public String doBusiness3(BusinessEntity entity) {
LockEntity lock = null;
try {
lock = lockExecutor.lock("testBusiness-" + entity.getName());
// 你的业务代码开始
for (int i = 0; i < 1000000; i++) {
System.out.println(JSONObject.toJSONString(entity) + i);
}
// 你的业务代码结束
} finally {
if(lock != null) {
lock.unlock();
}
}
return "success";
}
}
2.1.5 注意事项
2.1.5.1 业务逻辑的抽离
由于使用了spring的aop来实现加锁,所以你不可以在其它使用到动态代理的类的方法中,直接将该方法写到本类,并直接调用,这将使得后者的aop不生效,原因是动态代理在调用本类中的其它方法时,直接使用this.xxx(),故无法进行第二次代理
2.1.5.2 lockCacheCapacity的合理设置
该属性需要在配置文件中,使用lock.lockCacheCapacity配置锁缓存的容量(默认是128)
插件内使用ConcurrentHashMap实现对锁对象的缓存,而capacity是该容器的容量,会在一开始初始化完成,并且不会扩容,当你产生的锁对象即将超过capacity * 0.75f时,会触发lfu算法,将最近最少频次使用的锁对象清除(清除至capacity的一半),使得该缓存始终保持不扩容的状态,保证整个系统的吞吐量和稳定性
2.1.5.3 forkTaskQueueSize的合理设置,以及@LockAcquire注解中expireOnForkingFull属性的合理设置
2.1.5.3.1 forkTaskQueueSize的合理设置
该属性需要在配置文件中,使用lock.forkTaskQueueSize配置锁缓存的容量(默认是128)
这是用来控制主线程fork出子线程总体数量的参数
因为每调用业务接口之前,在lock之后,主线程会fork出一个子线程去执行业务逻辑,而主线程处于阻塞状态。这样做的目的是便于控制处于锁状态下业务逻辑的执行时长等通用逻辑
但是如果每次调用业务逻辑之前都去fork一个子线程,会造成应用内线程随着并发数增加(尤其是根据参数内的key值动态生成锁的业务)而无限增加线程,可能造成系统吞吐降低甚至瘫痪
因此在每次lock之后,需要申请一下fork thread资源,如果没有申请到,则意味着fork出来的子线程数量达到阈值,此时会怎样,下面4.3.2介绍
2.1.5.3.2 @LockAcquire注解中expireOnForkingFull毫秒数属性的合理设置
这个属性是配合lock.forkTaskQueueSize用的,如果不设置,默认是-1
意思就是,每次lock之后,需要申请fork thread资源,在申请资源的同时,需要设置一个expireOnForkingFull(超时时间)
如果expireOnForkingFull是 -1,则永久阻塞等待,直到被signal唤醒
如果expireOnForkingFull >= 0,则在申请资源的时候,只在expireOnForkingFull时间内进行申请,超过时间还没有申请到资源,则会直接抛出LockExpiredException异常,可以在外层业务代码中捕获处理
2.1.5.4 本插件支持与spring其它aop注解同时使用
因为spring的aop最终原理是通过拦截器链层层调用,所以如果你自定义了很多aop注解,或者直接使用spring自带的注解(比如:@Transactional 事务注解),是完全没有问题的。但如果你的其它自定义注解是通过自己用动态代理的方式实现的,就会有问题,原因如上 4.1 所述
3 写在最后
该插件被引入项目后,一直稳定运行至今,还是相对稳定的,但插件内部设计及实现仍存在一些定制化的痕迹,同时抽象和自定义程度仍没有做到100%完美,目前本人由于工作繁忙已疏于维护,希望大家能够多多支持,本人将持续分享starter插件设计及开发要点、springboot自动装配原理以及其它知识点。