14 | 乐观锁机制和重试机制在实战中应该怎么用

news2024/11/30 6:34:07

什么是乐观锁?

乐观锁在实际开发过程中很常用,它没有加锁、没有阻塞,在多线程环境以及高并发的情况下 CPU 的利用率是最高的,吞吐量也是最大的。

而 Java Persistence API 协议也对乐观锁的操作做了规定:通过指定 @Version 字段对数据增加版本号控制,进而在更新的时候判断版本号是否有变化。如果没有变化就直接更新;如果有变化,就会更新失败并抛出“OptimisticLockException”异常。我们用 SQL 表示一下乐观锁的做法,代码如下:

复制代码

select uid,name,version from user where id=1;
update user set name='jack', version=version+1 where id=1 and version=1

假设本次查询的 version=1,在更新操作时,加上这次查出来的 Version,这样和我们上一个版本相同,就会更新成功,并且不会出现互相覆盖的问题,保证了数据的原子性。

这就是乐观锁在数据库里面的应用。那么在 Spring Data JPA 里面怎么做呢?我们通过用法来了解一下。

乐观锁的实现方法

JPA 协议规定,想要实现乐观锁可以通过 @Version 注解标注在某个字段上面,并且可以持久化到 DB 即可。其支持的类型有如下四种:

  • intorInteger
  • shortorShort
  • longorLong
  • java.sql.Timestamp

这样就可以完成乐观锁的操作。我比较推荐使用 Integer 类型的字段,因为这样语义比较清晰、简单。

注意:Spring Data JPA 里面有两个 @Version 注解,请使用 @javax.persistence.Version,而不是 @org.springframework.data.annotation.Version。

我们通过如下几个步骤详细讲一下 @Version 的用法。

第一步:实体里面添加带 @Version 注解的持久化字段。

我在上一课时讲到了 BaseEntity,现在直接在这个基类里面添加 @Version 即可,当然也可以把这个字段放在 sub-class-entity 里面。我比较推荐你放在基类里面,因为这段逻辑是公共的字段。改动完之后我们看看会发生什么变化,如下所示:

复制代码

@Data
@MappedSuperclass
public class BaseEntity {
   @Id
   @GeneratedValue(strategy= GenerationType.AUTO)
   private Long id;
   @Version
   private Integer version;
   //......当然也可以用上一课时讲解的 auditing 字段,这里我们先省略
}

第二步:用 UserInfo 实体继承 BaseEntity,就可以实现 @Version 的效果,代码如下:

复制代码

@Entity
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString(callSuper = true)
public class UserInfo extends BaseEntity {
   @Id
   @GeneratedValue(strategy= GenerationType.AUTO)
   private Long id;
   private Integer ages;
   private String telephone;
}

第三步:创建 UserInfoRepository,方便进行 DB 操作。

复制代码

public interface UserInfoRepository extends JpaRepository<UserInfo, Long> {}

第四步:创建 UserInfoService 和 UserInfoServiceImpl,用来模拟 Service 的复杂业务逻辑。

复制代码

public interface UserInfoService {
   /**
    * 根据 UserId 产生的一些业务计算逻辑
    */
   UserInfo calculate(Long userId);
}
@Component
public class UserInfoServiceImpl implements UserInfoService {
   @Autowired
   private UserInfoRepository userInfoRepository;
   /**
    * 根据 UserId 产生的一些业务计算逻辑
    * @param userId
    * @return
    */
   @Override   @org.springframework.transaction.annotation.Transactional
   public UserInfo calculate(Long userId) {
      UserInfo userInfo = userInfoRepository.getOne(userId);
      try {
         //模拟复杂的业务计算逻辑耗时操作;
         Thread.sleep(500);
      } catch (InterruptedException e) {
         e.printStackTrace();
      }
      userInfo.setAges(userInfo.getAges()+1);
      return userInfoRepository.saveAndFlush(userInfo);
   }
}

其中,我们通过 @Transactional 开启事务,并且在查询方法后面模拟复杂业务逻辑,用来呈现多线程的并发问题。

第五步:按照惯例写个测试用例测试一下。

复制代码

@ExtendWith(SpringExtension.class)
@DataJpaTest
@ComponentScan(basePackageClasses=UserInfoServiceImpl.class)
public class UserInfoServiceTest {
   @Autowired
   private UserInfoService userInfoService;
   @Autowired
   private UserInfoRepository userInfoRepository;
   @Test
   public void testVersion() {
      //加一条数据
      UserInfo userInfo = userInfoRepository.save(UserInfo.builder().ages(20).telephone("1233456").build());
      //验证一下数据库里面的值
      Assertions.assertEquals(0,userInfo.getVersion());
      Assertions.assertEquals(20,userInfo.getAges());
      userInfoService.calculate(1L);
      //验证一下更新成功的值
      UserInfo u2 =  userInfoRepository.getOne(1L);
      Assertions.assertEquals(1,u2.getVersion());
      Assertions.assertEquals(21,u2.getAges());
   }
   @Test
   @Rollback(false)
   @Transactional(propagation = Propagation.NEVER)
   public void testVersionException() {
      //加一条数据
  userInfoRepository.saveAndFlush(UserInfo.builder().ages(20).telephone("1233456").build());
      //模拟多线程执行两次
      new Thread(() -> userInfoService.calculate(1L)).start();
      try {
         Thread.sleep(10L);//
      } catch (InterruptedException e) {
         e.printStackTrace();
      }
      //如果两个线程同时执行会发生乐观锁异常;
      Exception exception = Assertions.assertThrows(ObjectOptimisticLockingFailureException.class, () -> {
         userInfoService.calculate(1L);
         //模拟多线程执行两次
      });
      System.out.println(exception);
   }
}

从上面的测试得到的结果中,我们执行 testVersion(),会发现在 save 的时候, Version 会自动 +1,第一次初始化为 0;update 的时候也会附带 Version 条件。我们通过下图的 SQL,也可以看到 Version 的变化。

Drawing 0.png

而当面我们调用 testVersionException() 测试方法的时候,利用多线程模拟两个并发情况,会发现两个线程同时取到了历史数据,并在稍后都对历史数据进行了更新。

由此你会发现,第二次测试的结果是乐观锁异常,更新不成功。请看一下测试的日志。

Drawing 1.png

通过日志又会发现,两个 SQL 同时更新的时候,Version 是一样的,是它导致了乐观锁异常。

注意:乐观锁异常不仅仅是同一个方法多线程才会出现的问题,我们只是为了方便测试而采用同一个方法;不同的方法、不同的项目,都有可能导致乐观锁异常。乐观锁的本质是 SQL 层面发生的,和使用的框架、技术没有关系。

那么我们分析一下,@Version 对 save 的影响是什么,怎么判断对象是新增还是 update?

@Version 对 Save 方法的影响

通过上面的实例,你不难发现,@Version 底层实现逻辑和 @EntityListeners 一点关系没有,底层是通过 Hibernate 判断实体里面是否有 @Version 的持久化字段,利用乐观锁机制来创建和使用 Version 的值。

因此,还是那句话:Java Persistence API 负责制定协议,Hibernate 负责实现逻辑,Spring Data JPA 负责封装和使用。那么我们来看下 Save 对象的时候,如何判断是新增的还是 merge 的逻辑呢?

isNew 判断的逻辑

通过断点,我们可以进入SimpleJpaRepository.class 的 Save 方法中,看到如下图显示的界面:

Drawing 2.png

然后,我们进入JpaMetamodelEntityInformation.class 的 isNew 方法中,又会看到下图显示的界面:

Drawing 3.png

其中,我们先看第一段逻辑,判断其中是否有 @Version 标注的属性,并且该属性是否为基础类型。如果不满足条件,调用 super.isNew(entity) 方法,而 super.isNew 里面只判断了 ID 字段是否有值。

第二段逻辑表达的是,如果有 @Version 字段,那么看看这个字段是否有值,如果没有就返回 true,如果有值则返回 false。

由此可以得出结论:如果我们有 @Version 注解的字段,就以 @Version 字段来判断新增 / update;如果没有,那么就以 @ID 字段是否有值来判断新增 / update。

需要注意的是:虽然我们看到的是 merge 方法,但是不一定会执行 update 操作,里面还有很多逻辑,有兴趣的话你可以再 debug 进去看看。

我直接说一下结论,merge 方法会判断对象是否为游离状态,以及有无 ID 值。它会先触发一条 select 语句,并根据 ID 查一下这条记录是否存在,如果不存在,虽然 ID 和 Version 字段都有值,但也只是执行 insert 语句;如果本条 ID 记录存在,才会执行 update 的 sql。至于这个具体的 insert 和 update 的 sql、传递的参数是什么,你可以通过控制台研究一下。

总之,如果我们使用纯粹的 saveOrUpdate方法,那么完全不需要自己写这一段逻辑,只要保证 ID 和 Version 存在该有的值就可以了,JPA 会帮我们实现剩下的逻辑。

实际工作中,特别是分布式更新的时候,很容易碰到乐观锁,这时候还要结合重试机制才能完美解决我们的问题,接下来看看具体该怎么做。

乐观锁机制和重试机制在实战中应该怎么用?

我们先了解一下 Spring 支持的重试机制是什么样的。

重试机制详解

Spring 全家桶里面提供了@Retryable 的注解,会帮我们进行重试。下面看一个 @Retryable 的例子。

第一步:利用 gradle 引入 spring-retry 的依赖 jar,如下所示:

复制代码

implementation 'org.springframework.retry:spring-retry'

第二步:在 UserInfoserviceImpl 的方法中添加 @Retryable 注解,就可以实现重试的机制了,代码如下:

Drawing 4.png

第三步:新增一个RetryConfiguration并添加@EnableRetry 注解,是为了开启重试机制,使 @Retryable 生效。

复制代码

@EnableRetry
@Configuration
public class RetryConfiguration {
}

第四步:新建一个测试用例测试一下。

复制代码

@ExtendWith(SpringExtension.class)
@DataJpaTest
@ComponentScan(basePackageClasses=UserInfoServiceImpl.class)
@Import(RetryConfiguration.class)
public class UserInfoServiceRetryTest {
   @Autowired
   private UserInfoService userInfoService;
   @Autowired
   private UserInfoRepository userInfoRepository;
   @Test
   @Rollback(false)
   @Transactional(propagation = Propagation.NEVER)
   public void testRetryable() {
      //加一条数据
    userInfoRepository.saveAndFlush(UserInfo.builder().ages(20).telephone("1233456").build());
      //模拟多线程执行两次
      new Thread(() -> userInfoService.calculate(1L)).start();
      try {
         Thread.sleep(10L);//
      } catch (InterruptedException e) {
         e.printStackTrace();
      }
      //模拟多线程执行两次,由于加了@EnableRetry,所以这次也会成功
      UserInfo userInfo = userInfoService.calculate(1L);
      //经过了两次计算,年龄变成了 22
      Assertions.assertEquals(22,userInfo.getAges());
      Assertions.assertEquals(2,userInfo.getVersion());
   }
}

这里要说的是,我们在测试用例里面执行 @Import(RetryConfiguration.class),这样就开启了重试机制,然后继续在里面模拟了两次线程调用,发现第二次发生了乐观锁异常之后依然成功了。为什么呢?我们通过日志可以看到,它是失败了一次之后又进行了重试,所以第二次成功了。

通过案例你会发现 Retry 的逻辑其实很简单,只需要利用 @Retryable 注解即可,那么我们看一下这个注解的详细用法。

@Retryable 详细用法

其源码里面提供了很多方法,看下面这个图片。

Drawing 5.png

下面对常用的 @Retryable 注解中的参数做一下说明:

  • maxAttempts:最大重试次数,默认为 3,如果要设置的重试次数为 3,可以不写;
  • value:抛出指定异常才会重试;
  • include:和 value 一样,默认为空,当 exclude 也为空时,默认异常;
  • exclude:指定不处理的异常;
  • backoff:重试等待策略,默认使用 @Backoff@Backoff 的 value,默认为 1s,请看下面这个图。

Drawing 6.png

其中:

  • value=delay:隔多少毫秒后重试,默认为 1000L,单位是毫秒;
  • multiplier(指定延迟倍数)默认为 0,表示固定暂停 1 秒后进行重试,如果把 multiplier 设置为 1.5,则第一次重试为 2 秒,第二次为 3 秒,第三次为 4.5 秒。

下面是一个关于 @Retryable 扩展的使用例子,具体看一下代码:

复制代码

@Service
public interface MyService {
    @Retryable( value = SQLException.class, 
      maxAttempts = 2, backoff = @Backoff(delay = 100))
    void retryServiceWithCustomization(String sql) throws SQLException;
}

可以看到,这里明确指定 SQLException.class 异常的时候需要重试两次,每次中间间隔 100 毫秒。

复制代码

@Service 
public interface MyService { 
  @Retryable( value = SQLException.class, maxAttemptsExpression = "${retry.maxAttempts}",
            backoff = @Backoff(delayExpression = "${retry.maxDelay}")) 
  void retryServiceWithExternalizedConfiguration(String sql) throws SQLException; 
}

此外,你也可以利用 SpEL 表达式读取配置文件里面的值。

关于 Retryable 的语法就介绍到这里,常用的基本就这些,如果你遇到更复杂的场景,可以到 GitHub 中看一下官方的 Retryable 文档:https://github.com/spring-projects/spring-retry。下面再给你分享一个我在使用乐观锁+重试机制中的最佳实践。

乐观锁+重试机制的最佳实践

我比较建议你使用如下配置:

复制代码

@Retryable(value = ObjectOptimisticLockingFailureException.class,backoff = @Backoff(multiplier = 1.5,random = true))

这里明确指定 ObjectOptimisticLockingFailureException.class 等乐观锁异常要进行重试,如果引起其他异常的话,重试会失败,没有意义;而 backoff 采用随机 +1.5 倍的系数,这样基本很少会出现连续 3 次乐观锁异常的情况,并且也很难发生重试风暴而引起系统重试崩溃的问题。

到这里讲的一直都是乐观锁相关内容,那么 JPA 也支持悲观锁吗?

除了乐观锁,悲观锁的类型怎么实现?

Java Persistence API 2.0 协议里面有一个 LockModeType 枚举值,里面包含了所有它支持的乐观锁和悲观锁的值,我们看一下。

复制代码

public enum LockModeType
{
    //等同于OPTIMISTIC,默认,用来兼容2.0之前的协议
    READ,
    //等同于OPTIMISTIC_FORCE_INCREMENT,用来兼容2.0之前的协议
    WRITE,
    //乐观锁,默认,2.0协议新增
    OPTIMISTIC,
    //乐观写锁,强制version加1,2.0协议新增
    OPTIMISTIC_FORCE_INCREMENT,
    //悲观读锁 2.0协议新增
    PESSIMISTIC_READ,
    //悲观写锁,version不变,2.0协议新增
    PESSIMISTIC_WRITE,
    //悲观写锁,version会新增,2.0协议新增
    PESSIMISTIC_FORCE_INCREMENT,
    //2.0协议新增无锁状态
    NONE
}

悲观锁在 Spring Data JPA 里面是如何支持的呢?很简单,只需要在自己的 Repository 里面覆盖父类的 Repository 方法,然后添加 @Lock 注解并指定 LockModeType 即可,请看如下代码:

复制代码

public interface UserInfoRepository extends JpaRepository<UserInfo, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    Optional<UserInfo> findById(Long userId);
}

你可以看到,UserInfoRepository 里面覆盖了父类的 findById 方法,并指定锁的类型为悲观锁。如果我们将 service 改调用为悲观锁的方法,会发生什么变化呢?如下图所示:

Drawing 7.png

然后再执行上面测试中 testRetryable 的方法,跑完测试用例的结果依然是通过的,我们看下日志。

Drawing 8.png

你会看到,刚才的串行操作完全变成了并行操作。所以少了一次 Retry 的过程,结果还是一样的。但是,你在生产环境中要慎用悲观锁,因为它是阻塞的,一旦发生服务异常,可能会造成死锁的现象。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1095805.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

服务器感染了.360、.halo勒索病毒,如何确保数据文件完整恢复?

导言&#xff1a; 数据的安全性至关重要&#xff0c;但威胁不断进化&#xff0c;.360、.halo勒索病毒是其中的令人担忧的勒索软件。本文91数据恢复将深入介绍.360、.halo勒索病毒&#xff0c;包括其威胁本质、数据恢复方法和如何采取预防措施来保护您的数据。 如果受感染的数据…

智慧公厕高精尖技术揭秘,让卫生管理更智能、更舒适

随着科技的飞速发展&#xff0c;智慧公厕正逐渐走进人们的生活。借助物联网、互联网、云计算、大数据、人工智能、自动化控制等技术的应用&#xff0c;智慧公厕将卫生管理提升到一个全新的水平&#xff0c;为公众打造了清洁舒适的使用环境。本文以智慧公厕源头厂家广州中期科技…

94. 二叉树的中序遍历(递归+迭代)

题目链接&#xff1a;力扣&#xff08;LeetCode&#xff09;官网 - 全球极客挚爱的技术成长平台 解题思路&#xff1a; 方法一&#xff1a;递归 中序遍历的操作定义为&#xff0c;若二叉树为空&#xff0c;则空操作&#xff0c;否则&#xff1a; 中序遍历左子树访问根节点中…

✔ ★【备战实习(面经+项目+算法)】 10.15学习时间表

✔ ★【备战实习&#xff08;面经项目算法&#xff09;】 坚持完成每天必做如何找到好工作1. 科学的学习方法&#xff08;专注&#xff01;效率&#xff01;记忆&#xff01;心流&#xff01;&#xff09;2. 每天认真完成必做项&#xff0c;踏实学习技术 认真完成每天必做&…

【Vue面试题二十六】、SSR解决了什么问题?有做过SSR吗?你是怎么做的?

文章底部有个人公众号&#xff1a;热爱技术的小郑。主要分享开发知识、学习资料、毕业设计指导等。有兴趣的可以关注一下。为何分享&#xff1f; 踩过的坑没必要让别人在再踩&#xff0c;自己复盘也能加深记忆。利己利人、所谓双赢。 面试官&#xff1a;SSR解决了什么问题&…

【Vue面试题二十九】、Vue项目中你是如何解决跨域的呢?

文章底部有个人公众号&#xff1a;热爱技术的小郑。主要分享开发知识、学习资料、毕业设计指导等。有兴趣的可以关注一下。为何分享&#xff1f; 踩过的坑没必要让别人在再踩&#xff0c;自己复盘也能加深记忆。利己利人、所谓双赢。 面试官&#xff1a;Vue项目中你是如何解决跨…

京东优惠券怎么找?

京东优惠券怎么找&#xff1f; 1、手机安装「草柴」后&#xff0c;打开京东挑选要购买的商品&#xff1b; 2、挑选好京东商品后&#xff0c;点击右上角的「分享」&#xff0c;并点击「复制链接」&#xff1b; 3、将复制的京东商品链接&#xff0c;粘贴到草柴输入框&#xff0c…

查找算法:二分查找、插值查找、斐波那契查找

二分查找 查找的前提是数组有序 思路分析 代码实现 # 二分查找&#xff08;递归法实现&#xff09; # 找到一个相等的值就返回该值的下标 def binary_search(arr: list, find_val: int, left: int, right: int):mid (left right) // 2 # 寻找数组中间位置的下标if left &…

MySQL进阶(再论JDBC)——JDBC编程思想的分析 JDBC的规范架构 JDBC相关的类分析

前言 SQL&#xff08;Structured Query Language&#xff09;是一种用于管理关系型数据库的标准化语言&#xff0c;它用于定义、操作和管理数据库中的数据。SQL是一种通用的语言&#xff0c;可以用于多种关系型数据库管理系统&#xff08;RDBMS&#xff09;&#xff0c;如MySQ…

自动泊车系统设计学习笔记

1 概述 1.1 自动泊车系统研究现状 目前对于自动泊车系统的研究方法通常有两种实现方式&#xff1a; 整个泊车操作可以分为四个阶段&#xff1a;第一阶段车辆向前行驶进行车位识别&#xff0c;第二阶段车辆行驶到准备泊车时的待泊车区域&#xff0c;第三阶段车辆按照规划好的…

联邦学习综述三

A Survey on Federated Learning Systems Vision Hype and Reality for Data Privacy and Protection 选自&#xff1a;IEEE Transactions on Knowledge and Data Engineering&#xff0c;2021 链接 本文主要从数据分布、机器学习模型、隐私机制、通信架构、联邦规模和联邦动…

aps.net core 6.0 web API SwaggerUI IIS部署【23.10.15】亲测,通过

目录 一、aps.net core 6.0 web API 项目配置 创建项目设置 Swagger UI 为起始页发布项目二、在 IIS 部署 安装IIS安装 Hosting Bundle三、内网穿透 使用的是 VS2022社区版&#xff0c;WebAPI的版本是 .netcore6.0 一、aps.net core 6.0 web API 项目配置 1.创建项目 运行项目…

网络层哪些事?

在本文讲解的网络层中&#xff0c;注意了解一下&#xff1a;IP协议&#xff01; 地址管理&#xff1a;每个网络上的设备&#xff0c;要能分配一个地址&#xff08;唯一&#xff09;路由选择&#xff1a;A给B发消息&#xff0c;具体走哪条路线&#xff1f;&#xff1f; IP地址&…

kube-controller-manager和kube-scheduler不能正常启动

kube-controller-manager-k8s-worker01和kube-scheduler-k8s-worker01没有启动起来 原因&#xff1a; 解决&#xff1a;进入/etc/kubernetes/manifests 编辑 将镜像地址修改为 然后重启kubelet&#xff1a;systemctl restart kubelet.service

JVM 垃圾回收机制(可达性分析、引用计数)

目录 1 什么是垃圾2 为什么需要回收3 哪些对象被判定为垃圾呢3.1 引用计数法3.2 可达性分析算法&#xff1a;GC Roots根 1 什么是垃圾 垃圾是指在运行程序中没有任何指针指向的对象&#xff0c;就是需要被回收的。 2 为什么需要回收 执行程序会不断地分配内存空间&#xff0c…

快速上手 vue3

快速上手 vue3 文章目录 1. 组合式 api2. 路由3. 定义全局方法和变量4. 父子组件调用 --> 自定义事件5. 生命周期函数6. 状态管理 vuex 1. 组合式 api setuprefreactivecomputed生命周期钩子 , onMounted … 2. 路由 安装路由: npm install vue-router 创建一个 router…

欠拟合、过拟合及优化:岭回归

问题:训练数据训练的很好啊,误差也不大,为什么在测试集上面有问题呢? 当算法在某个数据集当中出现这种情况,可能就出现了过拟合现象。 1、 什么是过拟合与欠拟合 欠拟合 过拟合 分析 第一种情况:因为机器学习到的天鹅特征太少了,导致区分标准太粗糙,不能准确识别出天鹅…

基于SSM的摄影约拍系统

基于SSM的摄影约拍系统的设计与实现 开发语言&#xff1a;Java数据库&#xff1a;MySQL技术&#xff1a;SpringSpringMVCMyBatisJSP工具&#xff1a;IDEA/Ecilpse、Navicat、Maven 【主要功能】 前台系统&#xff1a;首页拍摄作品展示、摄影师展示、模特展示、文章信息、交流论…

【Java学习之道】线程的生命周期与状态转换

引言 多线程编程是Java程序员必备的技能之一&#xff0c;它可以让我们的程序更加高效地运行。在学习多线程编程时&#xff0c;我们需要了解线程的生命周期和状态转换。本篇文章将为你揭示线程的奥秘&#xff0c;让你轻松掌握多线程编程的基础知识。 一、线程的生命周期 线程…

解码自然语言处理之 Transformers

自 2017 年推出以来&#xff0c;Transformer 已成为机器学习领域的一支重要力量&#xff0c;彻底改变了翻译和自动完成服务的功能。 最近&#xff0c;随着 OpenAI 的 ChatGPT、GPT-4 和 Meta 的 LLama 等大型语言模型的出现&#xff0c;Transformer 的受欢迎程度进一步飙升。这…