Spring Data JPA 之乐观锁

14 乐观锁机制和重试机制在实战中的应用

14.1 什么是乐观锁

乐观锁在实际开发过程中很常⽤,它没有加锁、没有阻塞,在多线程环境以及⾼并发的情况下 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 ⾥⾯怎么做呢?我们通过⽤法来了解⼀下。

14.2 乐观锁的实现方法

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

  • int or Integer
  • short or Short
  • long or Long
  • java.sql.Timestamp
14.2.1 @Version 的用法

这样就可以完成乐观锁的操作。我⽐较推荐使⽤ 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)
    protected Long id;
    @Version
    protected Integer version;
    protected boolean deleted;
    
}

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

@Entity
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString(callSuper = true)
public class User extends BaseEntity {

    private String name;
    private String email;
    @Enumerated(EnumType.STRING)
    private SexEnum sex;
    private Integer age;

}

第三步:创建 UserInfoRepository,⽅便进⾏ DB 操作。

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

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

public interface UserService {

    /**
     * 根据 UserId 产⽣的⼀些业务计算逻辑
     */
    User calculate(Long userId);

}

@Service
public class UserServiceImpl implements UserService {
    
    @Autowired
    private UserRepository userRepository;

    @Override
    @Transactional
    public User calculate(Long userId) {
        User user = repository.getById(userId);
        // 模拟复杂的业务计算逻辑耗时操作;
        try {
            TimeUnit.SECONDS.sleep(2L);
        } catch (InterruptedException ignored) {
        }
        user.setAge(user.getAge() + 1);
        return userRepository.saveAndFlush(user);
    }
}

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

第五步:按照惯例写个测试⽤例测试⼀下。

@ExtendWith(SpringExtension.class)
@DataJpaTest
@ComponentScan(basePackageClasses = UserServiceImpl.class)
class UserServiceTest {
    @Autowired
    private UserService userService;
    @Autowired
    private UserRepository userRepository;

    @Test
    void testVersion() {
        // 加⼀条数据
        User user1 = userRepository.save(User.builder().age(20).name("zzn").build());
        // 验证⼀下数据库⾥⾯的值
        Assertions.assertEquals(0, user1.getVersion());
        Assertions.assertEquals(20, user1.getAge());
        userService.calculate(user1.getId());
        // 验证⼀下更新成功的值
        User user2 = userRepository.getById(user1.getId());
        Assertions.assertEquals(1, user2.getVersion());
        Assertions.assertEquals(21, user2.getAge());
    }

    @SneakyThrows
    @Test
    @Rollback(false)
    @Transactional(propagation = Propagation.NEVER)
    void testVersionException() {
        // 加⼀条数据
        userRepository.save(User.builder().age(20).name("zzn").build());
        // 模拟多线程执⾏两次
        new Thread(() -> userService.calculate(1L)).start();

        TimeUnit.SECONDS.sleep(1L);
        // 如果两个线程同时执⾏会发⽣乐观锁异常;
        Exception exception = Assertions.assertThrows(ObjectOptimisticLockingFailureException.class,
                                                      () -> userService.calculate(1L));
        log.info("error info:", exception);
    }
}

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

在这里插入图片描述

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

由此你会发现,第⼆次测试的结果是乐观锁异常,更新不成功。

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

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

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

14.2.2 @Version 对 Save 方法的影响

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

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

14.3 isNew 判断的逻辑

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

在这里插入图片描述

然后,我们进⼊JpaMetamodelEntityInformation.class 的 isNew ⽅法中,⼜会看到下图显示的界⾯:

在这里插入图片描述

其中,我们先看第⼀段逻辑,判断其中是否有 @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 会帮我们实现剩下的逻辑。

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

14.4 乐观锁机制和重试机制的实战

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

14.4.1 重试机制详解

Spring 全家桶⾥⾯提供了 @Retryable 的注解,会帮我们进⾏重试。下⾯看⼀个 @Retryable 的例⼦。

第⼀步:利⽤ maven 引⼊ spring-retry 的依赖 jar,如下所示:

<dependency>
    <groupId>org.springframework.retry</groupId>
    <artifactId>spring-retry</artifactId>
</dependency>

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

@Override
@Transactional
@Retryable
public User calculate(Long userId) {
    User user = repository.getById(userId);
    // 模拟复杂的业务计算逻辑耗时操作;
    try {
        TimeUnit.SECONDS.sleep(2L);
    } catch (InterruptedException ignored) {
    }
    user.setAge(user.getAge() + 1);
    return userRepository.saveAndFlush(user);
}

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

@Configuration
@EnableRetry
public class RetryConfiguration {
}

第四步:新建⼀个测试⽤例测试⼀下。

@Test
@Rollback(false)
@SneakyThrows
@Transactional(propagation = Propagation.NEVER)
void testRetryable() {
    // 加⼀条数据
    userRepository.save(User.builder().age(20).name("zzn").build());
    // 模拟多线程执⾏两次
    new Thread(() -> userService.calculate(1L)).start();

    TimeUnit.SECONDS.sleep(1L);
    // 模拟多线程执⾏两次,由于加了 @EnableRetry,所以这次也会成功
    User user = userService.calculate(1L);
    // 经过了两次计算,年龄变成了 22
    Assertions.assertEquals(22, user.getAge());
    Assertions.assertEquals(2, user.getVersion());
}

这⾥要说的是,我们在测试⽤例⾥⾯执⾏ @Import(RetryConfiguration.class),这样就开启了重试机制,然后继续在⾥⾯模拟了两次线程调⽤,发现第⼆次发⽣了乐观锁异常之后依然成功了。为什么呢?我们通过⽇志可以看到,它是失败了⼀次之后⼜进⾏了重试,所以第⼆次成功了。

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

14.4.2 @Retryable 的详细用法

这个注解提供了很多的属性,接下来,我们对常用的属性参数做一下说明:

  • maxAttempts:最⼤重试次数,默认为 3,如果要设置的重试次数为 3,可以不写;
  • value:抛出指定异常才会重试;
  • include:和 value ⼀样,默认为空,当 exclude 也为空时,默认异常;
  • exclude:指定不处理的异常;
  • backoff:重试等待策略,默认使⽤ @Backoff@Backoff 的 value,默认为 1s。
    • 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。

14.4.3 乐观锁重试机制的实践

我⽐较建议你使⽤如下配置:

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

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

到这⾥讲的⼀直都是乐观锁相关内容,那么 JPA 也⽀持悲观锁吗?

14.5 悲观锁的实现

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 ⾥⾯覆盖⽗类的 Repositoory ⽅法,然后添加 @Lock 注解并指定 LockModeType 即可,请看如下代码:

public interface UserRepository extends JpaRepository<User, Long> {
    @Override
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    Optional<User> findById(Long aLong);
}

你可以看到,UserInfoRepository ⾥⾯覆盖了⽗类的 findById ⽅法,并指定锁的类型为悲观锁。如果我们将 service 改调⽤为悲观锁的⽅法,会发⽣什么变化呢?

这里如果使用getById 加锁会失败,因为 Hibernate ⾥⾯ getById 是利⽤的 lazy 的加载机制,lazy 的不知道什么时间会上锁,这样⻛险太⾼,锁的发⽣时机不好控制的⻆度考虑的。

@Override
@Transactional
public User calculate(Long userId) {
    User user = repository.findById(userId).get();
    // 模拟复杂的业务计算逻辑耗时操作;
    try {
        TimeUnit.SECONDS.sleep(2L);
    } catch (InterruptedException ignored) {
    }
    user.setAge(user.getAge() + 1);
    return repository.saveAndFlush(user);
}

再执⾏上⾯测试中 testRetryable 的⽅法,跑完测试⽤例的结果依然是通过的,我们看下⽇志。

Hibernate: select user0_.id as id1_1_0_, user0_.create_user_id as create_u2_1_0_, user0_.created_date as created_3_1_0_, user0_.deleted as deleted4_1_0_, user0_.last_modified_date as last_mod5_1_0_, user0_.last_modified_user_id as last_mod6_1_0_, user0_.version as version7_1_0_, user0_.age as age8_1_0_, user0_.email as email9_1_0_, user0_.name as name10_1_0_, user0_.sex as sex11_1_0_ from user user0_ where user0_.id=? for update

你会看到,在查询语句后面都加上了 for update,刚才的串⾏操作完全变成了并⾏操作。所以少了⼀次 Retry 的过程,结果还是⼀样的。但是,你在⽣产环境中要慎⽤悲观锁,因为它是阻塞的,⼀旦发⽣服务异常,可能会造成死锁的现象。

14.6 本章小结

本课时的内容到这⾥就介绍完了。在这⼀课时中,我为你详细讲解了乐观锁的概念及使⽤⽅法、@Version 对 Save ⽅法的影响,分享了乐观锁与重试机制的最佳实践,此外也提到了悲观锁的使⽤⽅法(不推荐使⽤)。

  • 2
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值