虽然 JPA 中的乐观锁定处理是相对众所周知的,但它通常测试得很差或根本没有测试。 在这篇博文中,我将首先向您展示乐观锁定处理的含义,以及如何在 Spring 引导应用程序和 JPA 中实现它。 之后,您可以看到一种编写集成测试的方法,我希望它们的简单性和效率会让您感到惊讶!

但在此之前,让我们仔细看看乐观锁定到底是什么。

乐观锁定解释

如果关系数据库表中有一行,该行可以通过并发事务或并发长会话进行更新,那么很可能您应该采用乐观锁定。

实际上,在您的实时系统中没有任何锁定机制的情况下,即使在阅读😱的那一刻,您的数据库中也很可能发生了无声的数据丢失

我将向您展示几个与并发相关的问题,这些问题可以通过乐观锁定来解决。

案例1:并发数据库事务问题

JAVA乐观锁怎么实现_JAVA乐观锁怎么实现

在大多数RDBMS中,默认事务隔离级别至少是读取提交。 它解决了脏读现象,从而保证了事务只读取其他事务提交的数据。

由于事务的隔离级别和并发性质,当同时处理两个事务时,可能会发生冲突:

  • 他们都在读取相同的记录状态;
  • 他们都在做出不同的改变;
  • 其中一个将更早提交,将其更改持久保存到数据库;
  • 第二个将在稍后提交,以静默方式覆盖前一个持久化的数据。

案例2:并发长对话问题

JAVA乐观锁怎么实现_乐观锁_02

即使在事务范围之外,也可能发生冲突。 在长对话的情况下,存在多个事务,但共享资源上的冲突可能会产生类似的无提示数据丢失后果,如前面的示例所示。

示例场景:

  • 两个用户通过GUI表单编辑同一记录,具有[保存]按钮;
  • 它们都有与用户会话的最大持续时间一样多的时间可供使用;
  • 最初,它们具有相同的记录状态。它们都在数据库事务之外工作(在相反的情况下,系统的性能会很差);
  • 其中一个用户之前点击了 [保存] 按钮。它将保留用户对数据库的更改;
  • 稍后,第二个用户点击 [保存] 按钮。它将保留用户对数据库所做的更改,以静默方式覆盖前一个用户保留的数据。

优雅乐观的锁定解决方案

为了保护实体免受所解释的并发问题的影响,添加了一个新的属性版本。 此属性的类型有不同的实现,但最健壮的只是一个数字计数器(在 Java 中可能是 Long)。

案例1:并发数据库事务解决方案

JAVA乐观锁怎么实现_JAVA乐观锁怎么实现_03

案例2:并发长对话解决方案

JAVA乐观锁怎么实现_数据库_04

  • 当两个线程 (case1) 或两个用户 (case2) 检索同一记录时,它们最初具有相同的版本属性值;
  • 更改后,当数据库事务尝试提交记录时,它将用 1 增加其版本,同时使用相同的查询控制数据库中的版本属性仍具有预期值(下面是查询的示例)。 这样做,第一个并发事务将成功,而第二个将引发乐观锁定异常。

🔔即使没有任何额外的异常处理,情况也已经有所改善:在竞争条件下不会再发生静默数据丢失!

使用乐观锁定更新记录的 SQL 查询示例

update 
    item 
set 
    version=1, 
    amount=10 
where 
    id='abcd1234' 
and 
    version=0
  • 在属性版本上添加通过逻辑AND 条件进行检查是唯一的性能开销,使用乐观锁定可以获得;
  • 在多线程的情况下,仅此操作不会造成任何瓶颈;
  • 排他性悲观锁定相比,这是更快的解决方案,其中 1 笔交易获得锁,所有其他交易都被阻止,等待锁的释放。

🔔这就是为什么在可以使用任一方法解决问题的情况下,乐观锁定是比悲观锁定更可取的解决方案!

现在,您必须决定要如何处理此乐观锁定异常。

可能的乐观锁定异常处理

根据上下文的不同,有不同的方法可以处理乐观锁定异常:

  • 在案例 2 中:它可能只是作为友好(或不友好?😉)弹出消息转发回不幸的用户,通知另一个用户已经更改了记录,并且用户必须再次重新插入数据;
  • 在案例 1 中:它可能是一种自动重试机制,它重新加载记录的最新状态,执行一些合并(如果可能),然后重试以提交数据;
  • 或者它可以是某种组合机制:它可以自动尝试合并并再次提交。如果发生冲突,它可以将问题的处理委托给用户。

不是一刀切的解决方案

存在各种并发问题,这些问题无法通过乐观锁定来解决。例如:

  • 同一记录上的高并发性,通过大量重试可以方便地处理;
  • 必须保证某些操作只发生一次;
  • 并发问题,其中需要在并发记录的状态之间进行某种形式的联接同步;

在这种情况下,有替代的处理策略。其中一些是:

  • 由于相应调整的软件架构而避免竞争条件;
  • 悲观锁定;
  • 具有错误处理责任的软件组件(例如微服务),可能涉及自动化或/和人工交互。

实施乐观的锁定处理

项目设置

如果您希望可以从GitHub 克隆完整的示例。

或者你可以从头开始一个新的 Spring Boot 项目,使用Spring Initializr选择以下模块:

  • 马文项目
  • Spring Boot 2.3.0(也适用于Spring Boot 2.2.0+,这是第一个默认使用JUnit 5的Sprint引导版本)
  • 添加依赖项:Spring Data JPA,H2 Database,Lombok

实现您的实体

基本实体

@Setter
@Getter
@MappedSuperclass
public class BaseEntity {

    @Version
    private Long version;
}
  • 添加@Version属性是激活实体的乐观锁定所需的全部内容;
  • 如果您的大多数实体需要乐观锁定机制,那么创建一个 BaseEntity 类可能是个好主意;
  • 属性@MappedSuperclass为 JPA 声明这不是一个实体,但它可以由其他实体扩展。

项目

@Data
@EqualsAndHashCode(callSuper = false)
@Entity
public class Item extends BaseEntity {

    @Id
    private String id = UUID.randomUUID().toString();

    private int amount = 0;
}
  • 为了这个例子,让我们有一个名为Item 的简单实体。它可能是苹果、邮戳等的抽象。只要其中有金额属性😉是有意义的;
  • id属性由 Java 生成为 UUID;
  • 为了根据 Java 的 equals 规范保证 equals 的一致性,最好排除父级的equals 和哈希码的生成。

实施存储库和服务

项目存储库

public interface ItemRepository extends CrudRepository<Item, String> {
}

这是一个典型的CrudRepository,它将具有开箱即用的实现,由Spring Data为典型的CRUD操作提供。

项目服务

@RequiredArgsConstructor
@Service
public class ItemService {

    private final ItemRepository itemRepository;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void incrementAmount(String id, int amount) {
        Item item = itemRepository.findById(id).orElseThrow(EntityNotFoundException::new);
        item.setAmount(item.getAmount() + amount);
    }

}
  • ItemService是一种低级服务,由InventoryService控制;
  • 龙目岛注释RequiredArgsConstructor为所有最终属性(在本例中为 ItemRepository)创建一个构造函数;
  • 在构造函数中将itemRepository作为参数将通过Spring Framework提供自动连接;
  • 它将其低级别事务标记为REQUIRES_NEW,因为它是嵌套事务,需要其父服务进行正确的乐观锁定处理。
  • findById(id)返回一个 Optional of Item。与其编写长代码来控制if isPresent()then get()else抛出异常,不如使用orElseThrow() 方法来做同样的事情。

库存服务

@Slf4j
@RequiredArgsConstructor
@Service
public class InventoryService {

    private final ItemService itemService;

    @Transactional(readOnly = true)
    public void incrementProductAmount(String itemId, int amount) {
        try {
            itemService.incrementAmount(itemId, amount);
        } catch (ObjectOptimisticLockingFailureException e) {
            log.warn("Somebody has already updated the amount for item:{} in concurrent transaction. Will try again...", itemId);
            itemService.incrementAmount(itemId, amount);
        }
    }

}
  • 库存服务是一项高级服务,控制项目服务;
  • itemService将由Spring Framework通过构造函数自动连接(如ItemService中所述);
  • 如果出现乐观锁定异常,它将尝试再次调用 incrementAmount 方法。因为它传递的是项 ID,而不是整个实体,所以 incrementAmount 方法将从数据库中加载最新版本的实体;
  • InventoryService 将事务标记为readOnly=true以获得更好的性能,因为其高级事务不需要对托管实体进行任何更改。 只读事务禁用 JPA/Hibernate 中的自动脏检查机制。最新版本的Spring Data还可以提供额外的性能优化。

🔔在您的实现中,只读事务的使用可能不是这种情况。所以它与乐观的锁定处理无关。但很高兴知道,如果您愿意,您可以将外部事务标记为只读!

乐观锁定处理测试

库存服务测试

@SpringBootTest
class InventoryServiceTest {

    @Autowired
    private InventoryService inventoryService;

    @Autowired
    private ItemRepository itemRepository;

    @SpyBean
    private ItemService itemService;

    private final List<Integer> itemAmounts = Arrays.asList(10, 5);

    @Test
    void shouldIncrementItemAmount_withoutConcurrency() {
        // given
        final Item srcItem = itemRepository.save(new Item());
        assertEquals(0, srcItem.getVersion());

        // when
        for(final int amount: itemAmounts) {
            inventoryService.incrementProductAmount(srcItem.getId(), amount);
        }

        // then
        final Item item = itemRepository.findById(srcItem.getId()).get();

        assertAll(
                () -> assertEquals(2, item.getVersion()),
                () -> assertEquals(15, item.getAmount()),
                () -> verify(itemService, times(2)).incrementAmount(anyString(), anyInt())
        );
    }

    @Test
    void shouldIncrementItemAmount_withOptimisticLockingHandling() throws InterruptedException {
        // given
        final Item srcItem = itemRepository.save(new Item());
        assertEquals(0, srcItem.getVersion());

        // when
        final ExecutorService executor = Executors.newFixedThreadPool(itemAmounts.size());
        
        for (final int amount : itemAmounts) {
            executor.execute(() -> inventoryService.incrementProductAmount(srcItem.getId(), amount));
        }
        
        executor.shutdown();
        executor.awaitTermination(1, TimeUnit.MINUTES);

        // then
        final Item item = itemRepository.findById(srcItem.getId()).get();

        assertAll(
                () -> assertEquals(2, item.getVersion()),
                () -> assertEquals(15, item.getAmount()),
                () -> verify(itemService, times(3)).incrementAmount(anyString(), anyInt())
        );
    }
}

此集成测试有两种测试方法:

  • 一个用于按顺序测试清单服务的调用,避免任何并发;
  • 一个用于同时(在并行线程中)测试库存服务的调用,以这种方式引发乐观锁定异常。

您可能会注意到这两种方法非常相似,并且它们之间只有很少的区别。

🔔JUnit 5 assertAll 运算符的使用能够显示并行测试断言失败。 实际上,如果您不使用此运算符,则可以使用 JUnit 4 原语编写相同的测试!

正确进行乐观的锁定处理测试!

为了正确测试乐观的锁定处理,您必须满足以下需求:

  • 您需要具有多线程;
  • 您的线程必须完全同时启动:
  • executor.execute()方法具有异步执行,它将线程的执行留在后台并返回到当前线程执行;
  • executor.awaitTermination()是阻塞机制,它将等待到线程结束,但不超过1分钟;
  • 您必须确保您的线程正在管理单独的数据库事务。这是由 Spring 框架保证的,因为它将事务的状态存储在 LocalThread 中,并且每个线程都有一个单独的实例;
  • 在多线程连接点之后,您应该从数据库重新加载记录以获得其最新状态;
  • 为了确保乐观锁定确实发生了,您需要以某种方式断言它。一个优雅的方法是在Mockito SpyBean的帮助下。这些豆子包裹着实际的豆子,而不会嘲笑它们的方法。它们有一个内部计数器,用于跟踪方法的调用量。

为什么在乐观锁定处理的情况下,方法增量Amout被调用3次?

在我们的场景中,这意味着:

  • 较快的事务没有遇到任何乐观锁定异常。它已直接提交到数据库中,成功递增持久化记录的属性版本。此时,增量金额方法的调用计数器为 1;
  • 第二个事务将导致乐观锁定异常。调用的incrementAmount计数器将首先变为 2。然后,InventoryService 将处理乐观锁定异常,并重试,将计数器增加到 3。

现在你可以注意到:

  • 在没有乐观锁定的方法中,方法增量的调用计数器应为 2;
  • 在具有乐观锁定的方法中,计数器预期为 3。

总结

  • 乐观锁定是一种强大的并发机制。如果使用得当,它可以解决不同的并发问题,而不会引起性能问题;
  • 它自然适合,没有开销。您只需要在所有需要乐观锁定的 JPA 实体中添加带有注释@Version的属性版本。这样做,多亏了JPA,您将自动解决棘手的无声数据丢失问题;
  • 乐观锁定不是一刀切的解决方案。还有其他更好的方法来解决一些特定的并发问题;
  • 测试您的乐观锁定处理对于所花费的时间来说始终是一项不错的投资。此外,它可以帮助您防止将来因难以重现的竞争条件而导致的错误;
  • 最后但并非最不重要的一点是:进行乐观的锁定处理测试肯定会改善您和客户的乐观情绪!😃