虽然 JPA 中的乐观锁定处理是相对众所周知的,但它通常测试得很差或根本没有测试。 在这篇博文中,我将首先向您展示乐观锁定处理的含义,以及如何在 Spring 引导应用程序和 JPA 中实现它。 之后,您可以看到一种编写集成测试的方法,我希望它们的简单性和效率会让您感到惊讶!
但在此之前,让我们仔细看看乐观锁定到底是什么。
如果关系数据库表中有一行,该行可以通过并发事务或并发长会话进行更新,那么很可能您应该采用乐观锁定。
实际上,在您的实时系统中没有任何锁定机制的情况下,即使在阅读😱的那一刻,您的数据库中也很可能发生了无声的数据丢失!
我将向您展示几个与并发相关的问题,这些问题可以通过乐观锁定来解决。

在大多数RDBMS中,默认事务隔离级别至少是读取提交。 它解决了脏读现象,从而保证了事务只读取其他事务提交的数据。
由于事务的隔离级别和并发性质,当同时处理两个事务时,可能会发生冲突:

即使在事务范围之外,也可能发生冲突。 在长对话的情况下,存在多个事务,但共享资源上的冲突可能会产生类似的无提示数据丢失后果,如前面的示例所示。
示例场景:
为了保护实体免受所解释的并发问题的影响,添加了一个新的属性版本。 此属性的类型有不同的实现,但最健壮的只是一个数字计数器(在 Java 中可能是 Long)。
案例1:并发数据库事务解决方案

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

🔔即使没有任何额外的异常处理,情况也已经有所改善:在竞争条件下不会再发生静默数据丢失!
使用乐观锁定更新记录的 SQL 查询示例
- update
- item
- set
- version=1,
- amount=10
- where
- id='abcd1234'
- and
- version=0
🔔这就是为什么在可以使用任一方法解决问题的情况下,乐观锁定是比悲观锁定更可取的解决方案!
现在,您必须决定要如何处理此乐观锁定异常。
可能的乐观锁定异常处理
根据上下文的不同,有不同的方法可以处理乐观锁定异常:
不是一刀切的解决方案
存在各种并发问题,这些问题无法通过乐观锁定来解决。例如:
在这种情况下,有替代的处理策略。其中一些是:
如果您希望可以从GitHub 克隆完整的示例。
或者你可以从头开始一个新的 Spring Boot 项目,使用Spring Initializr选择以下模块:
基本实体
- @Setter
- @Getter
- @MappedSuperclass
- public class BaseEntity {
-
- @Version
- private Long version;
- }
项目
- @Data
- @EqualsAndHashCode(callSuper = false)
- @Entity
- public class Item extends BaseEntity {
-
- @Id
- private String id = UUID.randomUUID().toString();
-
- private int amount = 0;
- }
项目存储库
- public interface ItemRepository extends CrudRepository
- {
- }
这是一个典型的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);
- }
-
- }
库存服务
- @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);
- }
- }
-
- }
🔔在您的实现中,只读事务的使用可能不是这种情况。所以它与乐观的锁定处理无关。但很高兴知道,如果您愿意,您可以将外部事务标记为只读!
- @SpringBootTest
- class InventoryServiceTest {
-
- @Autowired
- private InventoryService inventoryService;
-
- @Autowired
- private ItemRepository itemRepository;
-
- @SpyBean
- private ItemService itemService;
-
- private final List
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 原语编写相同的测试!
为了正确测试乐观的锁定处理,您必须满足以下需求:
在我们的场景中,这意味着:
现在你可以注意到:
🔔此示例可在GitHub 上找到。