分布式系统开发中常常用到分布式锁,比如防止多个用户同时预订同一个商品,传统的synchronized
就无法实现了,而基于数据库的乐观锁实现又可能会对数据库产生较大的压力。而分布式锁相对较轻量,对性能影响也较小。目前主流的分布式锁都基于Redis实现。使用分布式锁的流程一般如下:
如果需要使用分布式锁的地方有多个,那么就需要写多个类似的代码。而重复代码是开发中最常见到的 bad smell 。我们可以使用 AOP 把这段逻辑抽象出来,这样就避免了重复代码,也极大地减去了工作量。
目标
- 对业务代码无侵入(或侵入性较小)
- 使用方便
- 对性能影响小
- 易维护
方案
- 使用注解(假设注解为
@Lock
)声明要使用分布式锁的业务method
、要锁定的对象(一般是业务主键)、失效时间等信息。在这里插入代码片
- 使用Spring AOP
arround
(环绕通知)增强被@Lock
注解的方法,把前面提到的”使用分布式锁的流程“逻辑抽象到切面中。 - 使用Redis实现分布式锁。一般是基于string类型的
set
命令实现。
难点
- 如何根据请求的不同,锁定不同的对象?
可以使用 Spring EL 表达式指定锁定对象,加锁时根据业务方法参数值、参数名称解析表达式,得出要加锁的对象(Redis string的key)。 - 分布式锁该如何选择?
- 可以选择自己实现(加锁使用
set
命令即可,解锁需要使用lua
脚本保证命令的原子性,先判断锁是否仍有效、是否由当前线程加锁,是的话才能通过del
来解锁)。 - 也可以选择使用
redisson
等第三方库。使用方式可以参考官方示例:Spring版/Spring Boot版
- 可以选择自己实现(加锁使用
优点
使用时只需在业务方法上加一个注解就可以了,使用灵活、开发效率高、侵入小、适用性强。业务方法只需专注于业务代码,可读性强,易维护。
适用场景
- 防止并发修改
- 防止重复提交
- 幂等校验
- ……
实现
1. 引入包
Spring Boot方式
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.12.5</version>
</dependency>
Spring 方式
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aop</artifactId>
<version>5.1.3.RELEASE</version>
</dependency>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>1.9.2</version>
</dependency>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.12.5</version>
</dependency>
2. 定义注解
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Documented
@Inherited
public @interface Lock {
/** 锁分组名称,用于避免key重复 */
String group() default "redis_lock_order";
/** key 表达式 */
String value();
/** 获取锁失败时的提示信息 */
String lockFailedMsg() default "当前订单正在修改中,请稍候重试";
/**
* 等待时长(ms),默认为0,获取不到锁立即返回。
* @return 等待时长 ms
*/
long waitTime() default 0;
/**
* 最大持有锁时间,如果超过该时间仍未主动释放,将自动释放锁。
* @return 最大持有锁时间 ms
*/
long leaseTime() default 5000;
}
3. 定义注解数据模型
@Value
public class LockVO {
/** key前缀 */
private String group;
/** key */
private String key;
/** 加锁失败时的提示信息 */
private String lockFailedMsg;
/** 等待时长 */
private Long waitTime;
/** 持有锁时长 */
private Long leaseTime;
}
4. 定义切面逻辑
@Aspect
@Component
@Slf4j
public class LockAspect {
/** 拦截所有注解了@Lock的方法 */
@Pointcut(value = "@annotation(lock)")
public void pointcut(Lock lock) {}
@Around(value = "pointcut(lock)", argNames = "jp,lock")
public Object aroundRemote(ProceedingJoinPoint jp, Lock lock) throws Throwable {
// 解析注解数据
LockVO lockVo = parseLockAnnotationData(jp, lock);
// 生成锁key
String lockKey = lockVo.getGroup() + ":" + lockVo.getKey();
// 竞争分布式锁
String lockId = lock(lockVo, lockKey);
try {
return jp.proceed();
} finally {
// 释放锁
unlock(lockKey, lockId);
}
}
private String lock(LockVO lockVo, String lockKey) {
// 具体加锁逻辑可以自己选择,推荐使用redisson,也可以选择自己实现。
// 自己实现需要考虑满足可重入性、锁超时等问题。
// 目前没有完美的分布式锁实现,需要根据自己的项目的应用场景做出权衡和选择。
// 以下介绍自己实现逻辑的大致思路
// 1. 如果当前线程已经拿到锁,直接返回
// 可以通过ThreadLocal实现可重入式分布式锁,具体实现省略。
// 2. 否则,获取redis分布式锁,拿到新的lockId
Long waitTime = lockVo.getWaitTime();
Long leaseTime = lockVo.getLeaseTime();
if (waitTime > 0) {
// 基于set命令实现,指定key失效时间,如果未获取到锁,则等待若干ms后重试,
// 在waitTime过后仍未获取到锁则获取锁失败。
lockId = RedisStringUtil.tryLock(lockKey, waitTime, leaseTime, TimeUnit.MILLISECONDS);
if (lockId == null) {
log.error("WARN:延迟加锁失败,数据可能出现问题,锁key:{},锁数据:{}",
lockKey, lockVo);
}
} else {
// 基于set命令实现,指定key失效时间,如果未获取到锁。则加锁失败。
lockId = RedisStringUtil.tryLock(RedisNamespaceEnum.LOCK.getValue(), lockKey, leaseTime, TimeUnit.MILLISECONDS);
}
// 未拿到锁
if (lockId == null) {
throw new RuntimeException(lockVo.getLockFailedMsg());
}
// 放入ThreadLocal,用于实现可重入性
return lockId;
}
private void unlock(String lockKey, String lockId) {
// 释放分布式锁
if(ReentrantUtil.canRemove(lockKey)){
ReentrantUtil.remove(lockKey);
// 如果lockId代表的锁依然存在,则可以解锁成功。
Boolean success = RedisStringUtil.unlock(lockKey, lockId);
if (!success) {
// 锁超时情况下会出现该问题,当出现该问题时,需要根据情况特殊处理。
log.error("释放锁失败,lockKey:{},lockId:{}", lockKey, lockId);
}
} else {
ReentrantUtil.release(lockKey);
}
}
/**
* 解析@Lock数据
*/
private LockVO parseLockAnnotationData(ProceedingJoinPoint jp, Lock lock) {
Method method = (MethodSignature)jp.getSignature();
String keyExpression = lock.value();
// 解析el表达式,获取锁key
String key = parseElExpression(jp.getArgs(), method, keyExpression, String.class);
return new LockVO(lock.group(), key, lock.lockFailedMsg(), lock.waitTime(), lock.leaseTime());
}
/**
* 解析EL表达式
* @param args 方法参数
* @param method 方法
* @param elExpression EL表达式
* @param resultType 结果类型
* @param <T> 结果类型
* @return 结果
*/
private static <T> T parseElExpression(Object[] args, Method method, String elExpression, Class<T> resultType) {
Parameter[] parameters = method.getParameters();
StandardEvaluationContext elContext = new StandardEvaluationContext();
if (parameters != null && parameters.length > 0) {
// 设置解析变量
for (int i = 0; i < parameters.length; i++) {
String paraName = parameters[i].getName();
Object paraValue = args[i];
elContext.setVariable(paraName, paraValue);
}
}
ExpressionParser parser = new SpelExpressionParser();
return parser.parseExpression(elExpression)
.getValue(elContext, resultType);
}
}
5. 打开编译开关
在项目的pom文件中的编译插件中添加参数:-parameters
(JDK8+才支持),用于在编译时把方法参数名称保留到 class 文件中。这样我们就可以通过Spring EL表达式动态指定要加锁的key。
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.3</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<compilerArgs>
<arg>-parameters</arg>
</compilerArgs>
</configuration>
</plugin>
JDK8以下版本可以使用别的方式获取方法参数名称,也可以将表达式写为类似于
[0].getOrderId()
(获取第一个入参的orderId属性的值)
的格式动态指定key(因为方法入参可以看做是一个Object[]
)。
6. 使用
- 防止并发修改,可以把
Lock
注解在例如修改订单的接口方法上,waitTime设置为0,key为订单id,这样就可以防止多个人并发修改订单。
@Lock(group = "order_lock", value = "#dto.getOrderId()",
lockFailedMsg = "当前订单正在修改中,请稍候重试", leaseTime = 5000)
public void modifyOrder(ModifyOrderDTO dto) {
// ......
}
- 防止重复提交。例如防止重复下单,可以将waitTime设置为0,key为会员id,在订单未保存成功前,用户多次提交订单都会直接返回提示信息。
@Lock(group = "forbid_repeat_submission_4_order", value = "#orderVO.getMemberId()",
lockFailedMsg = "订单已提交,请稍候", leaseTime = 10000)
@Transactional(rollbackFor = Exception.class)
public OrderVO saveOrder(OrderVO orderVO) {
// ......
}
总结
基于注解的分布式锁可以帮助我们减少大量模板代码,使用方便,出现问题也很容易修复。对于具体的加锁逻辑可以选择自己实现,也可以选择使用redisson
等第三方库。
博客原文链接