一、前言
线上接口在并发请求时会有概率重复执行,导致数据操作被重复处理,对涉及到数据操作的接口都应做系统级同步。实现思路是对有同步需求的接口或方法进行加锁处理,采用ReentrantLock防止重入,同步并发线程。
二、设计
使用自定义注解和AOP切面编程实现快捷的锁功能,在进入方法时根据注解上自定义的key生成或获取锁,然后打开锁,在方法执行完成后关闭锁,最后根据锁的等待数量判断是否从锁缓存中移除。在具体业务需求中锁常常是和业务参数相互匹配的,比如在用户注册时需要根据电话号码开启锁,所以注解上的key需要与业务参数动态关联。可以使用AOP提供的参数动态解析key中指定的参数名称,然后按照一定规则组合生成特定的字符串,由该字符串来获取或生成锁对象。这样就可以保证同一个业务方法可以被并发执行,只对可能冲突的参数同步执行。
三、实现
自定义注解
注解包含value和type字段,value表示同步内容的键,以此来生成和获取锁,type表示键的类型,0为常量键,1为动态键。此处注解为方法级别,在程序运行时也应存在。注解只是一个标记作用,方便AOP扫描从而找到AOP的切入点。动态value的生成规则较为简单,起始位表示源方法中的参数位置(以0开始),如果该参数为对象,可以支持执行对象的方法,获取该方法的返回值再toString作为结果,支持多层级调用。如果只指定起始位,则直接使用源方法的参数进行toString生成结果。value可以指定多个内容,获取后使用下划线(“_”)进行连接生成字符串,如type为0时则默认使用value的第0为作为键,如value为空则默认使用方法名。
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SyncLock {
/**
* 同步内容的键,会将多个值组合为一个字符串,支持动态获取方法参数
* 语法:{index}[.{method}]
* index为参数位置
* method为对象中定义的方法名
* eg: ["0.getType", "1.getDate.getTime", "2"]
*/
String[] value();
/**
* 同步键的类型(0字符串 1动态参数)
*/
int type() default 0;
}
AOP编程
AOP对指定的注解扫描,使用around包围方法,对方法的请求参数进行解析,使用注解value中定义的规则获取生成锁的键,然后使用该键进行加锁和释放锁处理。使用@Aspect标记类是AOP切面类,@Around标记方法会执行AOP环绕操作。在@Around方法回调中会传入ProceedingJoinPoint对象,由该对象可以解析出源返回的相关信息,此处我们主要关注注解内容和方法入参。使用method.getAnnotation获取指定的注解,从而得到value和type字段。联合使用Object、Class、Method可以解析得到value中规则指定的内容。
@Aspect
// 设置AOP执行顺序高优先级,在与其它切面共用时保证最先执行。
// 如在与@Transactional共用时,在事务提交后再释放锁。@Transactional默认order为Ordered.LOWEST_PRECEDENCE
@Order(1)
@Component
public class SyncLockAspect {
private static final Logger logger = LoggerFactory.getLogger(SyncLockAspect.class);
@Around(value="@annotation(com.trantour.common.async.SyncLock)")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
//获取方法
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
Method targetMethod = methodSignature.getMethod();
//获取方法上的注解
SyncLock syncLock = targetMethod.getAnnotation(SyncLock.class);
//获取注解参数
String[] values = syncLock.value();
int type = syncLock.type();
String key;
if(values.length <= 0){
//默认使用方法名
key = targetMethod.getName();
}else{
if(type == 1){
//动态获取方法参数
key = getLockKey(values, joinPoint.getArgs(), targetMethod.getParameterTypes());
}else{
//固定常量键
key = values[0];
}
}
//同步
Object result;
if(key != null){
boolean isLock = false;
try{
SyncLockUtil.tryLock(key, 1000);
isLock = true;
result = joinPoint.proceed(joinPoint.getArgs());
} finally {
if(isLock)
SyncLockUtil.unLock(key);
}
}else{
logger.error("生成key失败,直接执行方法");
result = joinPoint.proceed(joinPoint.getArgs());
}
return result;
}
private String getLockKey(String[] values, Object[] args, Class>[] parameterTypes){
if(values == null || args == null || parameterTypes == null
|| values.length <= 0 || args.length <= 0 || parameterTypes.length <= 0){
return null;
}
StringBuilder keyStr = new StringBuilder();
for (String argKey : values){
//获取方法参数
String argValue = getArgByKey(argKey, args, parameterTypes);
if(argValue == null){
return null;
}
//组合拼接成锁的键
keyStr.append(argValue).append("_");
}
if(keyStr.length() <= 0){
return null;
}
keyStr.deleteCharAt(keyStr.length()-1);
return keyStr.toString();
}
private String getArgByKey(String argKey, Object[] args, Class>[] parameterTypes){
if(argKey == null || args == null || parameterTypes == null || args.length <= 0 || parameterTypes.length <= 0){
return null;
}
try{
Object object;
//是否包含对象嵌套获取
if(argKey.contains(".")){
String[] strings = argKey.split("\\.");
int index = Integer.parseInt(strings[0]);
//获取拦截器的基础参数对象
object = args[index];
Class cls = parameterTypes[index];
//循环获取参数对象
for (int i = 1; i < strings.length; i++) {
if(object == null){
logger.info("上级对象值为空:{}", strings[i]);
break;
}
boolean isFind = false;
//获取已定义的方法
Method[] f = cls.getDeclaredMethods();
for(Method method : f){
//获取指定的方法
if(method.getName().equals(strings[i])){
cls = method.getReturnType();
//执行方法获取返回值
object = method.invoke(object);
isFind = true;
break;
}
}
if(!isFind){
logger.info("找不到方法:{}", strings[i]);
break;
}
}
}else{
//直接获取参数值
object = args[Integer.parseInt(argKey)];
}
if(object != null){
return object.toString();
}
}catch (Exception e){
logger.error("获取方法参数出错", e);
}
return null;
}
}
ReentrantLock锁
使用最常见的ReentrantLock锁即可满足需求,配合线程安全的ConcurrentHashMap实现锁缓存保存同步键和锁的关系。通过key获取锁时先判断缓存是否存在,不存在则创建,使用缓存的锁进行tryLock加锁。在业务方法完成后使用key获取锁然后进行unLock释放锁,释放后使用getQueueLength判断锁是否被其它线程等待,如无线程等待则从缓存中移除锁,避免缓存累积。
public class SyncLockUtil {
private static final Logger logger = LoggerFactory.getLogger(SyncLockUtil.class);
private static Map lockMap = new ConcurrentHashMap<>();
/**
* 加锁
* @param key 同步键,由锁住的内容生成
* @param timeout 超时时间(毫秒)
* @throws InterruptedException
*/
public static void tryLock(String key, int timeout) throws Exception {
//不存在则新建
ReentrantLock lock = lockMap.computeIfAbsent(key, k -> new ReentrantLock());
if(!lock.tryLock(timeout, TimeUnit.MILLISECONDS)){
logger.info("加锁失败: {}", key);
throw new Exception("获取锁资源失败");
}else{
logger.info("加锁: {}", key);
}
}
/**
* 释放锁
* @param key 同步键,由锁住的内容生成
*/
public static void unLock(String key){
ReentrantLock lock = lockMap.get(key);
if(lock == null){
logger.error("找不到对应的锁,锁不存在或已被释放: {}", key);
return;
}
//是否有线程在等待本锁
int length = lock.getQueueLength();
if(length <= 0){
logger.info("释放锁: {}", key);
lockMap.remove(key);
}
lock.unlock();
logger.info("解锁: {}", key);
}
}
四、使用
Maven
maven需要引入AOP的支持。
org.springframework.boot
spring-boot-starter-aop
代码拷贝
将SyncLockUtil、SyncLockAspect、SyncLock 拷贝到项目合适的位置。
使用实例1
在使用电话号码注册时避免多次点击或客户端重发导致的重复注册,只需在注册的方法上使用@SyncLock标记并指定通过电话号码加锁即可。
@SyncLock(value = ["0.getMail"], type = 1)
@Transactional(rollbackFor = [Exception::class])
fun register(user: User): RespBody {
//todo
}
使用实例2
在三方账号绑定时避免多次点击或客户端重发导致的重复绑定,只需在绑定的方法上使用@SyncLock标记并指定通过平台类型和平台标识加锁即可。
@SyncLock(value = ["0.getOpenId", "0.getPlatformType"], type = 1)
@Transactional(rollbackFor = [Exception::class])
fun thirdBindingT(thirdPlatform: ThirdPlatform, userId: Int): Res {
//todo
}
使用实例3
在生成群口令时为避免生成重复口令,只需在口令生成的方法上使用@SyncLock标记并指定通过方法标识加锁即可。
@SyncLock("getAccessCode")
fun getAccessCode(groupId: Int?): RespBody.Res {
//todo
}
如不指定value则会默认使用方法名作为标识,故上述代码可以只用@SyncLock标记即可。
@SyncLock
fun getAccessCode(groupId: Int?): RespBody.Res {
//todo
}
五、参考
Springmvc 使用Aspect 使用权限注解拦截
ReentrantLock 锁详解
Java多线程问题--方法getHoldCount()、getQueueLength()和getWaitQueueLength()的用法和区别
ConcurrentHashMap性能测试