当前位置: 首页 > news >正文

【项目实践08】【事务加锁的问题】

文章目录

  • 一、前言
  • 二、项目背景
  • 三、解决方案
  • 四、思路延展
    • 1. Lock4j 分布式锁
      • 1.1 基础使用
        • 1.1.1 基础示例
        • 1.1.2 自定义异常策略
      • 1.4 源码简析
    • 2. AOP 切面的执行顺序
  • 五、参考内容


一、前言

本系列用来记录一些在实际项目中的小东西,并记录在过程中想到一些小东西,因为是随笔记录,所以内容不会过于详细。

二、项目背景

在 A 项目中,存在多个聊天角色,每个角色对应都对应一个会话,具体的创建逻辑是当用户点击到会话页面时会调用一个 createConversation 接口,这个接口会去查找后台所有没有创建过会话的角色,并为其创建会话。

基于以上 :

  • 由于这个接口会创建多个会话,所以需要加上事务。
  • 为了避免并发调用该接口时会话的重复创建,需要在接口中加上分布式锁。

因此该需求的大致逻辑如下:

  @SneakyThrows@Transactional(rollbackFor = Exception.class)@Overridepublic int createConversation() {RLock lock = redisson.getLock("lock");try {// 尝试获取锁,等待10秒,自动释放时间为10秒if (lock.tryLock(10, 10, java.util.concurrent.TimeUnit.SECONDS)) {// 查询未创建会话的角色List<Role> roles = findNeedCreateConversationRole();// 为其创建会话createConversationForRoles(roles);} else {log.warn("分布式锁获取失败");}} finally {lock.unlock();}return 0;}

这里存在两个问题:

  1. 分布式锁的锁时长只有 10s,10s 后如果接口逻辑未执行完则锁会释放:这里可以直接使用 Redission 的看门狗机制来自动续期,非本篇重点,不再赘述。

    看门狗的工作原理

    • 默认续期时间:30 秒(可通过 Config.lockWatchdogTimeout 配置)
    • 自动续期逻辑:
      1. 线程获取锁成功后,Redisson 会启动一个后台定时任务
      2. 每过 lockWatchdogTimeout / 3 秒(默认 10 秒)检查锁是否还被持有
      3. 如果锁仍被持有,则自动将锁的过期时间延长至 lockWatchdogTimeout(默认 30 秒)
      4. 直到线程释放锁或发生异常
  2. 分布式锁的释放先于事务,则可能存在锁已经释放但事务未提交时,新的请求进来导致会话重复插入。而事实上我们确实出现了这个问题。

三、解决方案

我们以下面 Demo为例 :

    @Transactional(rollbackFor = Exception.class)@Overridepublic int insertWithLock(String name) {RLock lock = redisson.getLock("lock");try {// 尝试获取锁,等待10秒,自动释放时间为10秒if (lock.tryLock(10, 10, java.util.concurrent.TimeUnit.SECONDS)) {DataDemo dataDemo = new DataDemo();dataDemo.setId(IdUtil.getSnowflake().nextId());dataDemo.setName(name);dataDemoMapper.insert(dataDemo);} else {log.warn("分布式锁获取失败");}} finally {lock.unlock();}return 0;}

按照我们一开始的设想,即使并发请求,也只有一个请求会获取到分布式锁,从而保证了请求的幂等性,但实际上,当我们实际尝试就会发现上面的写法并不能保证幂等性,关键在于 事务的提交和锁的释放的先后顺序关系。

即当两个请求 A,B 并发请求时,其执行顺序可能如下:

序号请求A请求 B说明
1事务 A 开启
2事务 B 开启
3获取分布式锁
4插入数据
5释放分布式锁此时分布式锁已经释放,但事务 A 尚未提交
6获取分布式锁由于事务 A 已经将分布式锁释放,所以这里事务 B 可以获取到分布式锁
7插入数据
8释放分布式锁
9事务A提交
10事务B提交

基于上述的执行顺序,则可能造成分布式锁的 “失效” 问题,解决方案也很简单,因为锁先于事务释放,所以可以先加锁,再开启事务即可,如下(使用声明式事务保证事务的开启和提交都是在锁的作用范围内):

//    @Transactional(rollbackFor = Exception.class)@Overridepublic int insertWithLock(String name) {RLock lock = redisson.getLock("lock");try {// 尝试获取锁,等待10秒,自动释放时间为10秒if (lock.tryLock(10, 10, java.util.concurrent.TimeUnit.SECONDS)) {transactionTemplate.executeWithoutResult(transactionStatus -> {DataDemo dataDemo = new DataDemo();dataDemo.setId(IdUtil.getSnowflake().nextId());dataDemo.setName(name);dataDemoMapper.insert(dataDemo);});} else {log.warn("分布式锁获取失败");}} finally {lock.unlock();}return 0;}

四、思路延展

1. Lock4j 分布式锁

除了上面给出例子中自己实现分布式锁外,还可以直接引入 Lock4J 来简化分布式锁的实现。

Lock4J 并没有 Redission 的看门狗机制,这也就意味着如果业务执行时间超过锁的过期时间,锁会自动释放,可能导致多个线程同时访问临界区。

Lock4J 是一个基于 Spring Boot 的分布式锁框架,旨在简化分布式系统中的锁管理,支持 Redis、ZooKeeper 等多种分布式锁实现,而在 Spring 中 Lock4J 为这些不同的实现提供的不同的依赖,可以在项目中引入如下依赖来使用功能:

<!-- 基于 Redisson 实现的分布式锁 -->
<dependency><groupId>com.baomidou</groupId><artifactId>lock4j-redisson-spring-boot-starter</artifactId><version>2.2.7</version>
</dependency><!-- 基于 ZooKeeper 实现的分布式锁 -->
<dependency><groupId>com.baomidou</groupId><artifactId>lock4j-zookeeper-spring-boot-starter</artifactId><version>2.2.7</version>
</dependency>

1.1 基础使用

1.1.1 基础示例

我们可以通过 @Lock4j 注解来实现一个分布式锁,如下:

    @Override@SneakyThrows@Lock4j(keys = {"#lockKey"}, expire = 30000, acquireTimeout = 1000, autoRelease = false)public void lock2Do(String lockKey, String lockName) {Thread.sleep(1000);log.info("[lock2Do][{}-{} 获取到分布式锁]", lockKey, lockName);}

@Lock4j 注解存在几个参数,如下:

  • name :用于多个方法锁同一把锁 可以理解为锁资源名称 为空则会使用 包名+类名+方法名
  • executor :指定锁执行,如 Redis、ZooKeeper。(如果容器中有多个Redis 或 Zk 实例,也可以在不同的方法指定不同的实例)
  • keys :锁键表达式,支持 SpEL(如 #lockKey 表示方法参数 lockKey)。
  • expire :锁的过期时间(毫秒),过期时间一定是要长于业务的执行时间。未设置则为默认时间30秒
  • acquireTimeout :获取锁的等待时间(毫秒),结合业务,建议该时间不宜设置过长,特别在并发高的情况下。未设置则为默认时间3秒。
  • autoRelease :业务方法执行完后(方法内抛异常也算执行完)自动释放锁,如果为false,锁将不会自动释放直至到达过期时间才释放
  • failStrategy :锁获取失败后的处理策略,我们可以自定义失败处理策略
  • keyBuilderStrategy :key 的生成策略

Lock4j 的默认配置都是从 com.baomidou.lock.spring.boot.autoconfigure.Lock4jProperties 中获取,我们可以在 yaml 中通过配置修改默认值。

lock4j:acquire-timeout: 3000expire: 30000retry-interval: 100

1.1.2 自定义异常策略

Lock4J 提供了锁获取失败的默认处理(抛出 LockException),也支持自定义异常处理器,我们只需要在自定义一个实现 LockFailureStrategy 接口的类注册到容器中即可启用,如下:

@Component
@Slf4j
public class LogLockFailureStrategy implements LockFailureStrategy {@Overridepublic void onLockFailure(String key, Method method, Object[] arguments) {log.info("[onLockFailure][{}-{} 获取分布式锁失败]", key, method.getName());}
}

使用方式则是直接在 Lock4j 注解的 failStrategy 属性指定即可,如下:

    @SneakyThrows@Lock4j(keys = {"#lockKey"}, failStrategy = LogLockFailureStrategy.class)public void lock2Do(String lockKey, String lockName) {Thread.sleep(5000);log.info("[lock2Do][{}-{} 获取到分布式锁]", lockKey, lockName);}

LockInterceptor 在注入到Spring 容器中会将容器中的 LockFailureStrategy 实例注入到其中,在获取分布式锁失败时,会根据指定的失败策略类型获取到对应的策略执行。

1.4 源码简析

Lock4J 功能的实现基于 @Lock4J 注解,在 Spring 中这种注解实现显然是通过 AOP 实现的,而 Lock4J 的增强实现则是 LockAnnotationAdvisor 。

Spring AOP 的具体实现在 Spring源码分析:全集整理 的 AOP 部分有过分析,这里不再赘述。


LockAnnotationAdvisor 的部分关键代码如下:

public class LockAnnotationAdvisor extends AbstractPointcutAdvisor implements BeanFactoryAware {private final Advice advice;private final Pointcut pointcut = new AnnotationMethodPoint(Lock4j.class);public LockAnnotationAdvisor(@NonNull LockInterceptor lockInterceptor, int order) {this.advice = lockInterceptor;// 用于 Spring AOP 排序setOrder(order);}// 切面类的实现:判断逻辑就是判断当前方法是否被 @Lock4j 注解修饰。@Overridepublic Pointcut getPointcut() {return this.pointcut;}// 切面的增强,其实现由构造函数传入@Overridepublic Advice getAdvice() {return this.advice;}...
}

上面可以知道,AOP 增强的逻辑在 LockInterceptor 中。下面我们来看LockInterceptor#invoke 的实现,如下:

AnnotationMethodPoint 的逻辑就是判断方法是否被 @Lock4j 注解修饰,如果修饰则满足条件。

@Override
public Object invoke(MethodInvocation invocation) throws Throwable {// 修复使用其他AOP组件时方法被多次代理的问题// 通过AopProxyUtils获取目标对象的最终类Class<?> cls = AopProxyUtils.ultimateTargetClass(invocation.getThis());// 如果最终类与当前代理对象的类不相同,直接放行调用if (!cls.equals(invocation.getThis().getClass())) {return invocation.proceed();}// 从方法上解析Lock4j注解信息Lock4j lock4j = AnnotatedElementUtils.findMergedAnnotation(invocation.getMethod(), Lock4j.class);LockInfo lockInfo = null;try {// 构建锁操作对象,包含锁键生成器和失败策略LockOperation lockOperation = buildLockOperation(lock4j);// 构建锁键前缀,格式为:配置前缀:注解名称(或类名+方法名)String prefix = lock4jProperties.getLockKeyPrefix() + ":";prefix += StringUtils.hasText(lock4j.name()) ? lock4j.name() :invocation.getMethod().getDeclaringClass().getName() + invocation.getMethod().getName();// 构建完整锁键:前缀#SpEL表达式解析后的键String key = prefix + "#" + lockOperation.lockKeyBuilder.buildKey(invocation, lock4j.keys());// 尝试获取锁,参数:锁键、过期时间、获取超时时间、锁执行器lockInfo = lockTemplate.lock(key, lock4j.expire(), lock4j.acquireTimeout(), lock4j.executor());// 成功获取锁则继续执行目标方法if (null != lockInfo) {return invocation.proceed();}// 锁获取失败处理lockOperation.lockFailureStrategy.onLockFailure(key, invocation.getMethod(), invocation.getArguments());return null;} finally {// 自动释放锁配置检查if (null != lockInfo && lock4j.autoRelease()) {// 释放锁并记录失败日志final boolean releaseLock = lockTemplate.releaseLock(lockInfo);if (!releaseLock) {log.error("releaseLock fail,lockKey={},lockValue={}", lockInfo.getLockKey(),lockInfo.getLockValue());}}}
}

该方法的整个时间还是比较清楚的,具体逻辑已经添加注释,这里不再赘述。

当我们需要一些更复杂的自定义分布式锁的逻辑,可以直接使用 lockTemplate 来完成自定义的分布所锁的逻辑。

2. AOP 切面的执行顺序

在使用 Lock4j 时会额外引申出一个问题:在一个方法中同时添加 @Transactional@Lock4j 注解时,他们增强的顺序是如何确定的?如果 @Transactional 的增强在 @Lock4j 外层,则不是又出现了 “先开启事务再加锁” 的情况了吗?

先说结论:在 Spring AOP 中存在多个切面对同一个方法增强时,会对各个切面进行排序,多个切面可以通过实现 Ordered 接口 或者切面类上添加 @Order 注解,指定优先级,数字越小,越先执行。

Spring AOP 会向容器中注册一个自动代理创建器,在Bean 创建时会 AbstractAdvisorAutoProxyCreator#findEligibleAdvisors 方法来查找容器中所有的 Advisor,如下:

	protected List<Advisor> findEligibleAdvisors(Class<?> beanClass, String beanName) {List<Advisor> candidateAdvisors = findCandidateAdvisors();List<Advisor> eligibleAdvisors = findAdvisorsThatCanApply(candidateAdvisors, beanClass, beanName);extendAdvisors(eligibleAdvisors);if (!eligibleAdvisors.isEmpty()) {// 对 所有的 Advisor 进行排序,按照 Ordered 接口或 @Order 注解指定的优先级进行排序,数字越小优先级越高。eligibleAdvisors = sortAdvisors(eligibleAdvisors);}return eligibleAdvisors;}

而 LockAnnotationAdvisor 默认的优先级是 Ordered.HIGHEST_PRECEDENCE (Integer.MIN_VALUE),所以其优先级最高,会优先于 @Transactional 注解执行,从而保证 “先加锁后开启事务” 的情况。


五、参考内容

  1. 豆包
http://www.dtcms.com/a/285172.html

相关文章:

  • 【C++类和对象解密】面向对象编程的核心概念(下)
  • openinstall上线SSL证书服务,开启数字安全新纪元
  • 从现场出发:能源系统中的智能设备与实际落地工具解读
  • 7 基本的空间关系判断
  • Git仓库核心概念与工作流程详解:从入门到精通
  • 「Java案例」递归实现整数的倒置
  • MyBatis-Flex 学习与整理
  • LeNet-5 详解:从理论到实践
  • HTML前端性能优化完整指南
  • LeetCode 234:回文链表
  • 文件类型说明
  • H7-TOOL脱机下载后,自动重连RTT,CAN和串口助手三合一模式方法,方便项目测试(2025-07-16)
  • Spring Boot 分层架构详解:Controller、Service、Mapper...
  • C++网络编程 5.TCP套接字(socket)通信进阶-基于多线程的TCP多客户端通信
  • 鸿蒙状态栏操作
  • 能碳管理平台:企业碳减排解决方案绿色工厂达标工具
  • Trae IDE:打造完美Java开发环境的实战指南
  • 基于深度学习的电信号分类识别与混淆矩阵分析
  • AI 总结工作报告
  • 【人工智能agent】--dify版本更新(通用)
  • 错误经验一:计算两个整数a和b的和
  • Paimon 动态分桶
  • 如何优雅处理 Flowable 工作流的 TaskAlreadyClaimedException?
  • SpringBoot02-application配置文件
  • 行业研究 | 2025金融可观测性实践与趋势洞察报告重磅发布!
  • 数据结构自学Day9: 二叉树的遍历
  • 克鲁斯焊接机器人保护气省气方案
  • JS - - - - - 数组乱序排序「进阶版」
  • c++:类型转换函数
  • mongodb-org-mongos : Depends: libssl1.1 (>= 1.1.1) but it is not installable