3

Spring Boot中操作数据库的几种并发事务方式

 1 week ago
source link: https://www.jdon.com/71719.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Spring Boot中操作数据库的几种并发事务方式

当有多个并发事务时,会发生丢失更新异常。来自一个或多个事务的更新可能会丢失,因为其他事务会用其结果覆盖它。

让我们通过一个例子来检验一下。考虑以下执行事务的方法。

public void withdraw(Long accountId, double amount) {
 Account account = accountRepository.findById(accountId).orElseThrow(() -> {
 throw new IllegalStateException("account does not exist: " + accountId);
 });

double newBalance = (account.getBalance() - amount);
 if (newBalance < 0) {
 throw new IllegalStateException("there's not enough balance");
 }
 account.setBalance(newBalance);
 accountRepository.save(account);
 }

只要在任何给定时间点只有单个事务交易,这段代码会按预期工作。

当有多个同时事务时会发生什么?

在这种情况下,上述代码将无法正常工作。线程 1 对 newBalance 所做的修改线程 2 是看不到的。因此,它可能会破坏数据。当我们用 @Transactional 对方法进行注解时,行为不会发生变化。反正它只是定义应用程序的事务边界。

如何防止损失更新异常?
请注意,Spring 默认遵循底层数据存储的隔离级别。Postgres 的默认隔离级别是 READ_COMMITTED。这意味着它只能看到查询开始前提交的数据,而看不到未提交的数据或查询执行期间并发事务提交的更改。

实际上,我们可以通过原子更新操作来解决这个问题!
怎么做?

使用本地更新查询,在数据库中执行直接更新,而不是使用普通 ORM 风格的 "选择、修改和保存"。

@Transactional
 public void withdraw(Long accountId, double amount) {

Double currentBalance = accountRepository.getBalance(accountId);
 if (currentBalance < 0) {
 throw new IllegalStateException("there's not enough balance");
 };
 accountRepository.update(accountId, amount);
 }

因此,我们使用了自定义更新方法,而不是通常的保存方法。这种更新方法具体是怎样的呢?

下面是在存储库类中添加的更新方法:

@Transactional
 @Modifying
 @Query(nativeQuery = true,
 clearAutomatically=true,
 flushAutomatically=true,
 value = """
 update account
 set balance = (balance - :amount)
 where id = :accountId
 """
 )
 public int update(Long accountId, Double amount);

请注意,我们在这两个方法中都使用了 @Transactional 注解。但它们属于两种不同类型的 Bean:一种来自服务,另一种来自存储库类。因此,更新方法遵循自己的事务定义。

  • @Modifying 会触发注解为 UPDATE 查询的方法,而不是 SELECT 查询。
  • 由于在执行Update修改查询后,实体管理器(EntityManager)中可能会包含过时的实体,所以它不会自动清除它,因此,我们需要明确说明 clearAutomatically=true。
  • 在执行Update修改查询之前,我们还需要自动清除持久化上下文中的任何受管实体。因此使用 flushAutomatically=true。

实现并发安全的更多方法
1、对任何更新使用悲观锁
将下面的注解与现有的事务注解一起使用:
@Lock(LockModeType.PESSIMISTIC_WRITE)

2、使用数据存储特定的咨询锁
在 postgres 中使用 pg_try_advisory_xact_lock 咨询advisory锁,同时使用超时和键(通常是数据库主键)。

将其与retry 重试模板一起使用,这样它就会不断重试,直到获得主键锁的指定超时为止。

 @Transactional
 public void tryWithLock(String key, Duration timeout, Runnable operation) {
 lock(key.getKey(), timeout);
 // your DB updates run here.
 operation.run(); 
 }

private void lock(final String key, Duration timeout) {
 //尝试获取锁,直到超时结束
 retryTemplate.execute(retryContext -> {
 boolean acquired = jdbcTemplate
 .queryForObject("select pg_try_advisory_xact_lock(pg_catalog.hashtextextended(?, 0))", Boolean.class, key);

if (!acquired) {
 throw new AdvisoryLockNotAcquiredException("Advisory lock not acquired for key '" + key + "'");
 }
 return null;
 });
 }

您可以直接在 JPA 查询中使用咨询锁,这样会简单得多。

 @Transactional
  @Query(value = """
  select c
  from Account c
  where c.id = :accountId
  and pg_try_advisory_xact_lock(
  pg_catalog.hashtextextended('account', c.id)
  ) is true
  """
  )
  public Account findByIdWithPessimisticAdvisoryLocking(Long accountId);

3、在 POJO 类中使用带版本号的乐观锁

在 POJO 类中添加注释为 @Version 的属性。
然后使用常规的 Spring JPA 查询来获取更新数据。
在将更新写入数据库之前,Spring JPA 会自动检查版本。如果有任何脏写入,事务将中止,客户端可以使用新版本重新尝试事务。这最适合大容量系统。

4、使用悲观的 NO_WAIT 锁定

 @Transactional
 @Lock(LockModeType.PESSIMISTIC_WRITE)
 @Query("select c from Account c where c.id = :accountId")
 @QueryHints({
 @QueryHint(name = "javax.persistence.lock.timeout", value = (LockOptions.NO_WAIT + ""))
  })
  public Account findByIdWithPessimisticNoWaitLocking(Long accountId);

在这种情况下,线程不会因为写操作释放锁而无限期阻塞。相反,它会在上述 javax.persistence.lock.timeout 之后立即返回锁获取失败。如果需要,我们也可以处理此异常并重试事务。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK