前言:
项目需求,搞了搞
- 实现了锁的重入
- 参考了别人的博文实现了AOP注解形式的锁、统一配置
参考博文地址:
https://www.cnblogs.com/lijiasnong/p/9952494.html
这边看了下比较主流几个分布式锁的应用,最终选择的redis
原因是:
1、懒(服务器已有redis做缓存,不想再去安装zuukeeper)
2、评估认为redis的分布式锁已能满足当下应用
正文 - 摘录核心代码:
- RedisReentrantLock
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* redis分布式锁 - 使用 ThreadLocal 做线程隔离,实现锁的重入机制
* @date 2020/12/11 9:13
* @author wei.heng
*/
@Component
@Log4j2
public class RedisReentrantLock {
/** 获取锁超时的时间 */
private static final int ACQUIRE_LOCK_TIME_OUT_IN_MILLISECONDS = 3 * 1000;
/** 自旋重试间隔 */
private static final int WAIT_INTERVAL_IN_MILLISECONDS = 100;
/** 锁的失效时间 */
private static final Integer EXPIRE_SECONDS = 5;
/** 锁的前缀,方便统一查看 */
private static final String LOCK_PREFIX = "LOCK:";
private ThreadLocal<Map<String, Integer>> lockers = new ThreadLocal<>();
private RedisService redisService;
@Autowired
public RedisReentrantLock(RedisService redisService) {
this.redisService = redisService;
}
/**
*
* 添加分布式锁
* @param key 键
* @return boolean 请求锁是否成功
* @date 2020/12/11 10:52
* @author wei.heng
*/
public boolean lock(String key){
key = LOCK_PREFIX + key;
Map<String, Integer> refs = currentLockers();
Integer refCnt = refs.get(key);
if(refCnt != null){
// 当前线程已经加锁,这里属于锁的重入,计数器加1
refs.put(key, refCnt + 1);
return true;
}
boolean ok = this._lock(key);
if(!ok){
return false;
}
refs.put(key, 1);
return true;
}
/**
*
* 释放锁
* @param key 键
* @return boolean 释放是否成功
* @date 2020/12/11 10:53
* @author wei.heng
*/
public boolean unlock(String key){
key = LOCK_PREFIX + key;
Map<String, Integer> refs = currentLockers();
Integer refCnt = refs.get(key);
if(refCnt == null){
return false;
}
refCnt -= 1;
if(refCnt > 0){
refs.put(key, refCnt);
} else {
refs.remove(key);
this._unlock(key);
}
return true;
}
private boolean _lock(String key){
// 超时时间点
long timeoutAt = System.currentTimeMillis() + ACQUIRE_LOCK_TIME_OUT_IN_MILLISECONDS;
while(true){
boolean isSuccess = redisService.setIfAbsent(key, "", EXPIRE_SECONDS);
if(isSuccess){
// 如果加锁成功,就返回
return isSuccess;
}
// 如果加锁失败,证明当前锁被其他线程占用,进入自旋
if(System.currentTimeMillis() < timeoutAt){
try {
TimeUnit.MILLISECONDS.sleep(WAIT_INTERVAL_IN_MILLISECONDS);
} catch (InterruptedException e) {
log.error("redis锁自旋等待被打断...", e);
e.printStackTrace();
}
} else {
// 自旋等待时间超时,返回获取锁失败
isSuccess = false;
return isSuccess;
}
}
}
private void _unlock(String key){
redisService.deleteObject(key);
}
private Map<String, Integer> currentLockers(){
Map<String, Integer> refs = lockers.get();
if(refs != null){
return refs;
}
lockers.set(new HashMap<>());
return lockers.get();
}
}
- RedisService 核心代码
/**
*
* 如果key不存在,则设值
* @param key 键
* @param value 值
* @param exptime 过期时间 - 单位:秒
* @return boolean 设值是否成功(如果已有该key存在,则返回false)
* @date 2020/12/11 10:42
* @author wei.heng
*/
public boolean setIfAbsent(final String key, final Serializable value, final long exptime) {
Boolean result = (Boolean) redisTemplate.execute((RedisCallback<Boolean>) connection -> {
RedisSerializer valueSerializer = redisTemplate.getValueSerializer();
RedisSerializer keySerializer = redisTemplate.getKeySerializer();
Object obj = connection.execute("set", keySerializer.serialize(key),
valueSerializer.serialize(value),
"NX".getBytes(StandardCharsets.UTF_8),
"EX".getBytes(StandardCharsets.UTF_8),
String.valueOf(exptime).getBytes(StandardCharsets.UTF_8));
return obj != null;
});
return result;
}
到这里,redis的分布式锁已经可以使用了。
看到网上别人的帖子,为了方便,
下面继续做了下抄袭、修改
再次重申,抄袭的地址:
https://www.cnblogs.com/lijiasnong/p/9952494.html
- RedisLockable 新建注解
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* <pre>
* redis分布式锁
* 使用示例(可参见 AppUserController 里被注释掉的 test 函数示例):
* 1、按对象属性加锁@RedisLockable("#ObjectName.attributeName")
* 2、按函数入参加锁@RedisLockable("#functionParamName")
* 3、按对象参数 + 函数参数加锁:@RedisLockable("#ObjectName.attributeName.concat('#').concat(#functionParamName)
* 4、非String的多参数加锁 @RedisLockable(value = {"businessId", "businessType"})
* </pre>
* @date 2020/12/11 11:48
* @author wei.heng
*/
@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
public @interface RedisLockable {
/** redis锁的关键字(keys),可使用SpEL表达式代表参数值 */
String[] value() default "";
}
- ReflectParamNames
这个类需要引入jar包,我这里搜了个最新的
<dependency>
<groupId>javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.12.1.GA</version>
</dependency>
import javassist.*;
import javassist.bytecode.CodeAttribute;
import javassist.bytecode.LocalVariableAttribute;
import javassist.bytecode.MethodInfo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* 反射获取参数名
* @date 2020/12/11 14:03
* @author wei.heng
*/
public class ReflectParamNames {
private static Logger log = LoggerFactory.getLogger(ReflectParamNames.class);
private static ClassPool pool = ClassPool.getDefault();
static{
ClassClassPath classPath = new ClassClassPath(ReflectParamNames.class);
pool.insertClassPath(classPath);
}
public static String[] getNames(String className,String methodName) {
CtClass cc = null;
try {
cc = pool.get(className);
CtMethod cm = cc.getDeclaredMethod(methodName);
// 使用javaassist的反射方法获取方法的参数名
MethodInfo methodInfo = cm.getMethodInfo();
CodeAttribute codeAttribute = methodInfo.getCodeAttribute();
LocalVariableAttribute attr = (LocalVariableAttribute) codeAttribute.getAttribute(LocalVariableAttribute.tag);
if (attr == null) return new String[0];
int begin = 0;
String[] paramNames = new String[cm.getParameterTypes().length];
int count = 0;
int pos = Modifier.isStatic(cm.getModifiers()) ? 0 : 1;
for (int i = 0; i < attr.tableLength(); i++){
// 为什么 加这个判断,发现在windows 跟linux执行时,参数顺序不一致,通过观察,实际的参数是从this后面开始的
if (attr.variableName(i).equals("this")){
begin = i;
break;
}
}
for (int i = begin+1; i <= begin+paramNames.length; i++){
paramNames[count] = attr.variableName(i);
count++;
}
return paramNames;
} catch (Exception e) {
e.printStackTrace();
}finally{
try {
if(cc != null) cc.detach();
} catch (Exception e2) {
log.error(e2.getMessage());
}
}
return new String[0];
}
}
- RedisLockAspect
package com.applet.common.security.aspect;
import com.applet.common.core.exception.GetRedisLockFailureException;
import com.applet.common.redis.util.RedisReentrantLock;
import com.applet.common.security.annotation.RedisLockable;
import com.applet.common.security.utils.ReflectParamNames;
import com.google.common.base.Joiner;
import com.alibaba.fastjson.JSON;
import lombok.extern.log4j.Log4j2;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.Signature;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.stereotype.Component;
import java.io.Serializable;
import java.lang.reflect.Method;
import java.math.BigDecimal;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 注解形式的redis分布式锁
* @date 2020/12/11 14:10
* @author wei.heng
*/
@Aspect
@Log4j2
@Component
public class RedisLockAspect {
private RedisReentrantLock redisLock;
/** 释放锁的超时毫秒数 - 超过该时间则不进行锁的释放(锁已失效,此时再进行释放,会导致其他持有该锁的线程异常) */
private static int EXPIRE_MILLISECONDS = RedisReentrantLock.EXPIRE_SECONDS * 1000;
@Autowired
public RedisLockAspect(RedisReentrantLock redisLock) {
this.redisLock = redisLock;
}
@Around("@annotation(com.applet.common.security.annotation.RedisLockable)")
public Object around(ProceedingJoinPoint point) throws Throwable {
Signature signature = point.getSignature();
MethodSignature methodSignature = (MethodSignature) signature;
Method method = methodSignature.getMethod();
String targetName = point.getTarget().getClass().getName();
String methodName = point.getSignature().getName();
Object[] arguments = point.getArgs();
RedisLockable redisLockable = method.getAnnotation(RedisLockable.class);
String[] values = redisLockable.value();
String redisKey;
if(values.length > 1) {
//获取参数名
String[] parameterNames = methodSignature.getParameterNames();
redisKey = getLockKeyMap(targetName, methodName,redisLockable.value(), parameterNames, arguments);
} else {
redisKey = getLockKey(targetName, methodName, redisLockable.value(), arguments);
}
long timeOutAt = System.currentTimeMillis() + EXPIRE_MILLISECONDS;
boolean isLock = redisLock.lock(redisKey);
if(isLock) {
try {
return point.proceed();
} finally {
if(System.currentTimeMillis() < timeOutAt){
// 当前时间超过超时时间点,则不进行锁的释放(锁已失效,此时再进行释放,会导致其他持有该锁的线程异常)
redisLock.unlock(redisKey);
}
}
} else {
log.info("未获取到锁:" + redisKey);
throw new GetRedisLockFailureException();
}
}
private String getLockKey(String targetName, String methodName, String[] keys, Object[] arguments) {
StringBuilder sb = new StringBuilder();
sb.append(targetName).append(".").append(methodName);
if(keys != null) {
String keyStr = Joiner.on(".").skipNulls().join(keys);
String[] parameters = ReflectParamNames.getNames(targetName, methodName);
ExpressionParser parser = new SpelExpressionParser();
Expression expression = parser.parseExpression(keyStr);
EvaluationContext context = new StandardEvaluationContext();
int length = parameters.length;
if (length > 0) {
for (int i = 0; i < length; i++) {
Object argument = arguments[i];
context.setVariable(parameters[i], argument);
if(argument instanceof Long){
Long keysValue = expression.getValue(context, Long.class);
sb.append("#").append(keysValue);
} else if (argument instanceof Integer){
Integer keysValue = expression.getValue(context, Integer.class);
sb.append("#").append(keysValue);
} else {
String keysValue = expression.getValue(context, String.class);
sb.append("#").append(keysValue);
}
}
}
}
return sb.toString();
}
/**
* 改方法获取 参数值必须只有一级,转换map获取 如:keys = {"name1","name2"}
* @param targetName
* @param methodName
* @param keys
* @param parameterNames
* @param arguments
* @return
* @author liugang
*/
private String getLockKeyMap(String targetName, String methodName, String[] keys,String[] parameterNames, Object[] arguments) {
StringBuilder sb = new StringBuilder();
sb.append(targetName).append(".").append(methodName).append(":");
if(arguments != null && arguments.length > 0) {
Map<String, Object> map = new HashMap<String, Object>();
for(int i = 0; i < arguments.length; i++) {
Object ob = arguments[i];
if(ob == null) {
continue;
}
if(ob instanceof String || ob instanceof Integer || ob instanceof Long
|| ob instanceof Number || ob instanceof Float || ob instanceof Double
|| ob instanceof BigDecimal || ob instanceof Date || ob instanceof Boolean ) {
map.put(parameterNames[i], ob);
} else if(ob instanceof Serializable) {
String json = JSON.toJSONString(ob);
map.putAll(JSON.parseObject(json, Map.class));
} else if(ob instanceof LinkedHashMap || ob instanceof HashMap || ob instanceof Map) {
map.putAll((Map)ob);
} else {
map.put(parameterNames[i], ob);
}
}
for(String key : keys) {
if(map.get(key) != null) {
sb.append("#").append(map.get(key));
}
}
}
return sb.toString();
}
}
到这里全部结束,可以直接使用@RedisLockable 进行分布式锁的同步控制了(▽)
测试:
发出两个postman请求,参数相同,第一个OK,第二个返回409
修改其中某个参数、导致不是同一把锁,两个请求均通过(通过的场景就不截图了)
PS:
2020年12月14日某同事反馈这个锁有BUG,多次请求发现ThreadLocal里的计数器在累加,
所以提问:按照线程隔离的字面意思,每次请求都是一个新的线程,计数器应该都从0开始才对
这里有个知识点是,ThreadLocal的线程隔离,是根据线程名来的
经过测试,发现请求N次后,会再次循环之前的线程名,这里程序就识别为和之前的请求是同一个线程了(线程名称一样),于是出现了计数器的累加
这个是ThreadLocal实现原理的问题了,不属于BUG范畴,我们只要正确使用就好了
该同事非正常使用锁导致的问题,既没有使用这里写的注解方式做锁的同步操作,在显式地使用锁后又未进行释放
我们知道Java所有的手动锁,都是使用后需要手动释放资源的,在正确使用的情况下,不存在这个问题
2021年01月21日有朋友反馈 redis锁安全问题
鉴于上文中讨论的问题,个人理解,问题三描述的是真实存在的BUG(这个也是我在使用之前就已知的)
简单描述下:进程1超时导致锁自动释放,进程2拿到锁,这时进程1突然又响应了、把进程2的锁释放掉了
这里我想了一种补偿机制:在获取到锁资源的时候,生成时间戳,在释放锁之前通过时间戳判断是否超过了redis的超时时间X秒,若超过了则认为锁已自动释放、不再进行锁的释放(redis unlock)操作
虽然这个极限情况下,BUG仍然存在的,因为生成时间戳和获取redis锁资源,不是原子操作,会有间隔时间(N纳秒或M微秒),但已能极大地规避该问题的发生。对于极小概率就等于不存在这句话我还是不赞同,但根据自身的业务需求评估,认为已能满足当下需求(这里业务出错率零容忍的可以止步了)
现在redis还有个什么redlock,暂时没有去研究,有兴趣的朋友可以去了解下
文中描述的问题2和问题4,我理解是不存在的:
我们在加锁的时候,使用了redisTemplate.execute,Spring Data Redis 提供的 SessionCallback 的接口支持多个操作的执行都在同一个连接中,所以文中描述的SETNX和EXPIRE操作,实际上是原子的
而锁的重入是在本地LocalThread中实现的,由于线程隔离,所以也不存在交叉释放的问题
生产中的应用
@RedisLockable 配合 @Cacheable 优雅地解决缓存穿透的问题
用 CyclicBarrier barrier 写了段50个线程的并发测试,只会查询一次数据库,后面的49次直接返回缓存信息 -> 自测OK