我正在尝试更多地了解乐观锁,并详细地通过测试验证我的实现是否正确。它的实现是通过 Java 11、SpringBoot 和 Maven 与简单的 Sql DB 交互。
我想测试的场景是,从两个不同的应用程序,我尝试同时更新同一实体记录的单个属性(使用 jpa 的 @Version 处理)。我所做的是创建一个简单的项目并进行测试,在两个不同的线程(同时启动并倒计时)中,我模拟了对更新该属性的不同方法的两次调用;我以不同的方式处理此服务,因为在一个服务中我只想通知用户对象已经更新,而在另一个服务中我想在遇到特定异常时重试更新“n”次。
***为了使用@Retryable,我在pom中插入了这个依赖项:
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aspects</artifactId>
</dependency>
主要是这个注释:@EnableRetry***
这是实体:
@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name="product")
public class ProductEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name="product_id")
private Integer productId;
@Column(name="name")
private String name;
@Column(name="version")
@Version
private Long version;
}
这是我的控制器:
@RestController
@RequestMapping("/api/product")
public class ProductAPIController {
@Autowired
private IProductService productService;
@PutMapping("/new-name-one")
public ResponseEntity<ProductDTO> updateProductOrFail(
@RequestBody @Valid ProductDTO productDTO)
throws Exception {
if(productDTO.getName() == null) {
throw new ValidationException("The name has to be present");
}
try {
ProductDTO updatedProduct = productService.updateProductOrFail(ProductDTO);
return new ResponseEntity<ProductDTO>(updatedProduct, HttpStatus.OK);
} catch (ObjectOptimisticLockingFailureException | OptimisticLockException e) {
throw new OptimisticLockException("The Product has been updated by another transaction", e);
}
}
@PutMapping("/new-name-two")
public ResponseEntity<ProductDTO> updateProductOtherwiseRetry(
@RequestBody @Valid ProductDTO productDTO)
throws Exception {
if(productDTO.getName() == null) {
throw new ValidationException("The name has to be present");
}
return new ResponseEntity<ProductDTO>(productService.updateProductOtherwiseRetry(productDTO), HttpStatus.OK);
}
我会跳过服务接口,直接进入服务内部的两个方法:
@Service
public class ProductService implements IProductService {
@Autowired
ProductRepository productRepository;
@Autowired
ModelMapper modelMapper;
@Override
@Transactional
public ProductDTO updateProductOrFail(ProductDTO productDTO) throws ServiceException {
ProductEntity product = productRepository.findById(productDTO.getProductId())
.orElseThrow(() -> new ServiceException("Product not found"));
product.setName(productDTO.getName());
productRepository.save(product);
return modelMapper.map(product, ProductDTO.class);
}
@Override
@Transactional
@Retryable(value = {OptimisticLockException.class,
OptimisticLockingFailureException.class,
ObjectOptimisticLockingFailureException.class},
maxAttempts = 2,
backoff = @Backoff(delay = 1000))
public ProductDTO updateProductOtherwiseRetry(ProductDTO productDTO) throws ServiceException {
ProductEntity product = productRepository.findById(productDTO.getProductId())
.orElseThrow(() -> new ServiceException("Product not found"));
product.setName(productDTO.getName());
productRepository.save(product);
//log.warn("The Product has been updated by another transaction try again...");
return modelMapper.map(product, ProductDTO.class);
}
在我的 Controller 类 Test 中,这是该方法的实现:
@Test
void havingTwoConcurrencyThread_whenUpdatingTheNameOfAProduct_ThenOptimisticLockExceptionHasToBeThrown() throws Exception {
ProductEntity product = new ProductEntity();
product.setUserStoryId(null);
product.setName("New Product");
productRepository.save(product);
CountDownLatch latch = new CountDownLatch(1);
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
ProductDTO productOneToUpdate = new ProductDTO();
productOneToUpdate.setProductId(1);
productOneToUpdate.setName("Product One");
try {
latch.await();
log.info("Thread 1 has started...");
MvcResult result = mockMvc.perform(MockMvcRequestBuilders.put(LINK + "/new-name-one")
.contentType(MediaType.APPLICATION_JSON)
.content(gson.toJson(productOneToUpdate)))
.andExpect(status().is2xxSuccessful()).andReturn();
log.info("Thread 1 has performed the call");
ProductDTO productChanged = gson.fromJson(result.getResponse().getContentAsString(), new TypeToken<ProductDTO>() {
}.getType());
assertEquals("Product One", productChanged.getName());
}catch (Exception e){
log.error("Exception in Thread 1", e);
}
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
ProductDTO productTwoToUpdate = new ProductDTO();
productTwoToUpdate.setProductId(1);
productTwoToUpdate.setName("Product Two");
try {
latch.await();
log.info("Thread 2 has started...");
mockMvc.perform(MockMvcRequestBuilders.put(LINK + "/new-name-two")
.contentType(MediaType.APPLICATION_JSON)
.content(gson.toJson(productTwoToUpdate)))
.andExpect(status().isConflict());
log.info("Thread 2 has performed the call");
} catch (Exception e) {
log.error("Exception in Thread 2", e);
assertTrue(e.getCause() instanceof ObjectOptimisticLockingFailureException ||
e.getCause() instanceof OptimisticLockException);
}
}
});
t1.start();
t2.start();
latch.countDown();
t1.join();
t2.join();
}
也许我没有给你足够的示例代码,但我可以告诉你我的项目已经成功构建,并且在我的 TestClass 中我有其他测试(作为 Junit 运行)成功通过。当我运行此测试时,这是控制台的结果:
2024-06-13 18:19:33.669 INFO 29940 --- [ Thread-5] c.p.c.controller.ProductControllerIT : Thread 2 has started...
2024-06-13 18:19:33.669 INFO 29940 --- [ Thread-4] c.p.c.controller.ProductControllerIT : Thread 1 has started...
2024-06-13 18:19:33.887 INFO 29940 --- [ Thread-4] o.h.e.j.b.internal.AbstractBatchImpl : HHH000010: On release of batch it still contained JDBC statements
2024-06-13 18:19:33.897 ERROR 29940 --- [ Thread-4] c.p.c.c.ControllerExceptionHandler : Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1; statement executed: update product set name=?, version=? where product_id=? and version=?; nested exception is org.hibernate.StaleStateException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1; statement executed: update product set name=?, version=? where product_id=? and version=?
org.springframework.orm.ObjectOptimisticLockingFailureException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1; statement executed: update product set name=?, version=? where product_id=? and version=?; nested exception is org.hibernate.StaleStateException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1; statement executed: update product set name=?, version=? where product_id=? and version=?
at org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:318) ~[spring-orm-5.3.30.jar:5.3.30]
at org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:233) ~[spring-orm-5.3.30.jar:5.3.30]
at org.springframework.orm.jpa.JpaTransactionManager.doCommit(JpaTransactionManager.java:566) ~[spring-orm-5.3.30.jar:5.3.30]
at org.springframework.transaction.support.AbstractPlatformTransactionManager.processCommit(AbstractPlatformTransactionManager.java:743) ~[spring-tx-5.3.30.jar:5.3.30]
at org.springframework.transaction.support.AbstractPlatformTransactionManager.commit(AbstractPlatformTransactionManager.java:711) ~[spring-tx-5.3.30.jar:5.3.30]
at org.springframework.transaction.interceptor.TransactionAspectSupport.commitTransactionAfterReturning(TransactionAspectSupport.java:654) ~[spring-tx-5.3.30.jar:5.3.30]
at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:407) ~[spring-tx-5.3.30.jar:5.3.30]
at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119) ~[spring-tx-5.3.30.jar:5.3.30]
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) ~[spring-aop-5.3.30.jar:5.3.30]
at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:762) ~[spring-aop-5.3.30.jar:5.3.30]
at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:707) ~[spring-aop-5.3.30.jar:5.3.30]
at com.example.project.service.ProductService$$EnhancerBySpringCGLIB$$******.updateProductOtherwiseRetry(<generated>) ~[classes/:na]
at com.example.project.controller.ProductAPIController.updateProductOrFail(ProductAPIController.java:80) ~[classes/:na]
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:na]
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
at java.base/java.lang.reflect.Method.invoke(Method.java:566) ~[na:na] [...]
Caused by: org.hibernate.StaleStateException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1; statement executed: update product set name=?, version=? where product_id=? and version=?
at org.hibernate.jdbc.Expectations$BasicExpectation.checkBatched(Expectations.java:67) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.jdbc.Expectations$BasicExpectation.verifyOutcome(Expectations.java:54) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.engine.jdbc.batch.internal.NonBatchingBatch.addToBatch(NonBatchingBatch.java:47) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.persister.entity.AbstractEntityPersister.update(AbstractEntityPersister.java:3571) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.persister.entity.AbstractEntityPersister.updateOrInsert(AbstractEntityPersister.java:3438) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.persister.entity.AbstractEntityPersister.update(AbstractEntityPersister.java:3870) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.action.internal.EntityUpdateAction.execute(EntityUpdateAction.java:202) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.engine.spi.ActionQueue.executeActions(ActionQueue.java:604) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.engine.spi.ActionQueue.lambda$executeActions$1(ActionQueue.java:478) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at java.base/java.util.LinkedHashMap.forEach(LinkedHashMap.java:684) ~[na:na]
at org.hibernate.engine.spi.ActionQueue.executeActions(ActionQueue.java:475) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.event.internal.AbstractFlushingEventListener.performExecutions(AbstractFlushingEventListener.java:344) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.event.internal.DefaultFlushEventListener.onFlush(DefaultFlushEventListener.java:40) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.event.service.internal.EventListenerGroupImpl.fireEventOnEachListener(EventListenerGroupImpl.java:107) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.internal.SessionImpl.doFlush(SessionImpl.java:1407) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.internal.SessionImpl.managedFlush(SessionImpl.java:489) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.internal.SessionImpl.flushBeforeTransactionCompletion(SessionImpl.java:3303) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.internal.SessionImpl.beforeTransactionCompletion(SessionImpl.java:2438) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.engine.jdbc.internal.JdbcCoordinatorImpl.beforeTransactionCompletion(JdbcCoordinatorImpl.java:449) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.resource.transaction.backend.jdbc.internal.JdbcResourceLocalTransactionCoordinatorImpl.beforeCompletionCallback(JdbcResourceLocalTransactionCoordinatorImpl.java:183) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.resource.transaction.backend.jdbc.internal.JdbcResourceLocalTransactionCoordinatorImpl.access$300(JdbcResourceLocalTransactionCoordinatorImpl.java:40) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.resource.transaction.backend.jdbc.internal.JdbcResourceLocalTransactionCoordinatorImpl$TransactionDriverControlImpl.commit(JdbcResourceLocalTransactionCoordinatorImpl.java:281) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.hibernate.engine.transaction.internal.TransactionImpl.commit(TransactionImpl.java:101) ~[hibernate-core-5.6.15.Final.jar:5.6.15.Final]
at org.springframework.orm.jpa.JpaTransactionManager.doCommit(JpaTransactionManager.java:562) ~[spring-orm-5.3.30.jar:5.3.30]
... 82 common frames omitted
Exception in thread "Thread-4" Exception in thread "Thread-5" java.lang.AssertionError: Range for response status value 500 expected:<SUCCESSFUL> but was:<SERVER_ERROR>
at org.springframework.test.util.AssertionErrors.fail(AssertionErrors.java:59)
at org.springframework.test.util.AssertionErrors.assertEquals(AssertionErrors.java:122)
at org.springframework.test.web.servlet.result.StatusResultMatchers.lambda$is2xxSuccessful$3(StatusResultMatchers.java:78)
at org.springframework.test.web.servlet.MockMvc$1.andExpect(MockMvc.java:214)
at com.example.project.controller.ProductyControllerIT$5.run(ProductControllerIT.java:476)
at java.base/java.lang.Thread.run(Thread.java:834)
java.lang.AssertionError: Status expected:<409> but was:<200>
at org.springframework.test.util.AssertionErrors.fail(AssertionErrors.java:59)
at org.springframework.test.util.AssertionErrors.assertEquals(AssertionErrors.java:122)
at org.springframework.test.web.servlet.result.StatusResultMatchers.lambda$matcher$9(StatusResultMatchers.java:627)
at org.springframework.test.web.servlet.MockMvc$1.andExpect(MockMvc.java:214)
at com.example.project.controller.ProductyControllerIT$6.run(ProductControllerIT.java:504)
at java.base/java.lang.Thread.run(Thread.java:834)
2024-06-13 18:19:33.987 INFO 29940 --- [ionShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
2024-06-13 18:19:33.988 INFO 29940 --- [ionShutdownHook] .SchemaDropperImpl$DelayedDropActionImpl : HHH000477: Starting delayed evictData of schema as part of SessionFactory shut-down'
2024-06-13 18:19:33.997 INFO 29940 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown initiated...
2024-06-13 18:19:34.004 INFO 29940 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown completed.
第一个线程中的第一个模拟调用是对具有 @Retryable 注释的服务的调用,因此我希望这是必须重试并给我 200 的服务,而第二个是应该失败的服务。 我从控制台中读到的是,我在第一个断言中的断言失败了,并给了我一个 500 代码,而第二个断言成功了,而不是给了我一个冲突代码。
我不知道我的测试是否在形式上正确并涵盖测试用例,其中通过两个并发事务,我可以让我想要的占上风。我也不完全确定这是否是某人必须正确实施乐观锁的方式。我需要学习很多东西,但我只是想亲自动手并尝试实现它。
另外,我如何正确验证这是否有效?
根据您提供的堆栈跟踪,我会说
@Retryable
注释没有效果 - 我希望在堆栈跟踪元素中找到 RetryOperationsInterceptor
类,它不存在于那里,请检查 Spring Retry with Transactional 主题和评论,不仅仅是接受的答案。
关于测试计划...
手动生成线程总是一个糟糕的主意,请使用
Executors.newSingleThreadExecutor()
代替。另一个好主意是以某种方式控制至少第一个竞争对手的执行并检查第二个竞争对手的行为,例如:
@Autowired
TransactionTemplate txTemplate;
@Autowired
CustomerRepository customerRepository;
private long customerId;
@BeforeEach
void tearUp() {
Customer customer = new Customer();
customerRepository.save(customer);
customerId = customer.getId();
}
void test() throws Exception {
ExecutorService executorService = Executors.newSingleThreadExecutor();
try {
Future<MvcResult> future = txTemplate.execute(status -> {
Customer customer = customerRepository.findById(customerId);
customer.setFirstName("test");
// customerRepository is a JpaRepository
// we need to call flush in order to lock entity
customerRepository.saveAndFlush(customer);
Future<MvcResult> interim = executorService.submit(() -> {
// rest controller call there;
});
// check mvc controller got stuck due to locked entity
Assertions.assertThatThrownBy(() -> interim.get(10, TimeUnit.SECONDS))
.isInstanceOf(TimeoutException.class);
return interim;
});
// check the result of future
MvcResult result = future.get();
Assertions.assertThat(result)
...
} finally {
executorService.shutdownNow();
}
}