Java中管理数据库并发的6种锁模式

并发数据库更新是指多个用户或进程试图同时或快速连续地修改同一数据库记录或数据的情况。在多用户或多线程环境中,当多个实体(例如用户或应用程序)同时访问和修改同一数据时,可能会发生并发更新。并发数据库更新可能导致各种问题和挑战,包括:

  1. 数据不一致:如果管理不当,并发更新可能会导致数据不一致,数据库会包含冲突或不正确的信息。
  2. 丢失更新:一个更新可能会覆盖另一个更新所做的更改,从而导致数据丢失。
  3. 脏读:一个事务可能会读取另一个事务正在更新的数据,从而导致信息不准确或不完整。
  4. 不可重复读:一个事务可能多次读取相同的数据,但由于其他事务的持续更新,每次都会得到不同的结果。

为了防止 Spring Boot 应用程序中的并发数据库更新,您可以使用各种策略和技术:
  1. 数据库锁定:利用数据库级锁定(例如行级或表级锁定)来确保一次只有一个事务可以更新特定记录。Spring Boot 使用注释支持声明式事务管理@Transactional,可与数据库锁定机制结合使用。
  2. 乐观锁定:使用乐观锁定和版本控制。这涉及向数据库表添加版本列并在更新期间检查此版本。如果自检索数据以来版本已发生更改,则更新将失败,表示并发修改。
  3. 悲观锁定:通过使用SELECT ... FOR UPDATESQL 语句或类似的数据库特定机制在更新之前明确锁定记录或表来实现悲观锁定。
  4. 隔离级别:配置数据库事务的隔离级别。较高的隔离级别可SERIALIZABLE确保事务以防止并发更新的方式执行,但会影响性能。
  5. 应用程序级锁定:使用 Java 构造(如块或其他线程同步机制)实现应用程序级锁定synchronized,以控制对代码关键部分的访问。
  6. 数据库事务:明智地使用数据库事务,确保事务是短暂的并且仅在必要的时间内持有锁,以最大限度地减少发生冲突的机会。
  7. 重试策略:在乐观锁定失败的情况下实现重试机制,允许应用程序在短暂延迟后重试操作。

策略的选择取决于应用程序的具体要求和约束。在许多情况下,可能需要结合使用这些技术才能有效地防止并发数据库更新并确保数据一致性。

1、数据库锁
下面的代码块演示了如何使用 Spring 的 @Transactional 注释来确保一次只有一个线程可以更新特定的数据库记录。

 @Service 
public  class  ProductService {
    
     @Autowired 
    private ProductRepository productRepository;

     @Transactional 
    public void updateProductPrice(int productId, double newPrice){
         Product  product  = productRepository.findById(productId); product.setPrice(newPrice);
         // @Transactional 注解确保此更新操作是原子的。
       
// 每次只能有一个线程可以更新产品。 
    } }

@Transactional:此注释应用于updateProductPrice方法。它表示此方法应在事务中运行。事务用于将一个或多个数据库操作分组为单个原子单元。当您将方法标记为时@Transactional,Spring 将为您处理事务管理。

updateProductPrice方法内部:

  • 它首先使用 productRepository.findById(productId) 方法,根据 ProductId 抓取 Product 实体。该获取操作是事务的一部分。
  • 然后,使用 product.setPrice(newPrice) 为产品设置新价格。
  • 由于该方法使用 @Transactional 进行了注解,因此获取产品和更新其价格的整个序列被视为一个单一的原子事务。

这里需要注意的关键点是,@Transactional 注解确保一次只能有一个线程执行 updateProductPrice 方法,防止对同一产品记录进行并发更新。如果另一个线程试图在事务正在进行时调用该方法,它将不得不等待事务完成,从而确保数据一致性并防止并发问题。

2、乐观锁
以下代码块演示了如何在 Spring Boot 应用程序中使用 JPA 中的 @Version 注解实现乐观锁定。

@Entity
public class Product {

    @Id
    private Long id;

    private String name;
    private double price;

    @Version
    private int version; // Version field for optimistic locking

   
// Getters and setters
}

私有 int 版本:该字段用 @Version 进行注解,这是一种用于乐观锁定的特殊注解。该字段的目的是跟踪实体的版本。每次更新实体时,JPA 都会自动增加版本。

乐观锁的工作原理如下:

  • 当您从数据库检索产品实体时,JPA 会记录当时实体的版本号。
  • 当您更新产品实体并将其保存回数据库时,JPA 会自动检查数据库中实体的版本是否与您之前检索到的版本一致。如果匹配,则允许继续更新。如果不匹配,则表明另一个事务已并发更新了同一实体,通常会抛出异常(如 OptimisticLockException)来处理这种情况。

这种机制可确保只有当实体的版本与最初检索到的版本相匹配时,才会应用更新,从而防止并发更新导致数据不一致。

在服务或存储库方法中,通常会通过捕获乐观锁定异常并采取适当措施(如通知用户或重试操作)来处理该异常。

3、悲观锁
以下代码块演示了如何在 Spring Boot 应用程序中使用带有 FOR UPDATE 子句的本地 SQL 查询实现悲观锁定,从而显式锁定所选数据库记录以进行更新。这意味着,如果另一个事务试图并发更新同一记录,它将被阻塞,直到锁被释放,从而确保数据一致性。

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

@Repository
public class ProductRepository {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    public Product findByIdForUpdate(int productId) {
        // Use a native SQL query with "FOR UPDATE" to lock the record for update.
        return jdbcTemplate.queryForObject(
           
"SELECT * FROM product WHERE id = ? FOR UPDATE",
            new Object[]{productId},
            (rs, rowNum) -> new Product(
                rs.getInt(
"id"),
                rs.getString(
"name"),
                rs.getDouble(
"price")
            )
        );
    }
}

findByIdForUpdate(int productId):该方法旨在通过 productId 检索产品实体,同时使用本地 SQL 查询锁定该实体。

方法内部:

  • 它使用 FOR UPDATE 子句构造了一个 SQL 查询,该子句是数据库特有的功能,可锁定所选记录直到事务提交,从而防止其他事务同时更新这些记录。
  • jdbcTemplate.queryForObject 方法用于执行 SQL 查询并检索 Product 实体。该方法还将结果集(数据库中的记录)映射到 Product 对象。

请记住,FOR UPDATE 的具体 SQL 语法可能因数据库系统(如 MySQL、PostgreSQL、Oracle)而异,因此应根据具体数据库进行调整。此外,在使用悲观锁处理潜在争用情况时,您需要在服务层中适当处理异常和事务管理。

4、隔离级别
实施隔离级别需要在 Spring Boot 应用程序中配置数据库并指定隔离级别。下面的代码块演示了如何在 Spring Boot 应用程序中设置 Serializable 隔离级别:

import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.stereotype.Service;

@Service
public class ProductService {

    @Autowired
    private ProductRepository productRepository;

    @Transactional(isolation = Isolation.SERIALIZABLE)
    public void updateProductPrice(int productId, double newPrice) {
        Product product = productRepository.findById(productId);
        product.setPrice(newPrice);
        // The @Transactional annotation with SERIALIZABLE isolation level ensures the highest level of isolation.
    }
}

@Transactional(isolation = Isolation.SERIALIZABLE):
  • 将 @Transactional 注解应用于 updateProductPrice 方法,
  • 并将隔离属性设置为 Isolation.SERIALIZABLE。该属性指定了事务的隔离级别。

隔离级别

  • Isolation.SERIALIZABLE:这是最高隔离级别。它确保事务一个接一个地串行执行。它提供完全隔离,防止并发事务影响彼此的数据。不过,由于严格的序列化,它可能会影响性能。
  • 其他可使用的隔离级别包括 Isolation.READ_COMMITTED、Isolation.REPEATABLE_READ 和 Isolation.READ_UNCOMMITTED。每个级别都提供了不同程度的数据一致性和并发控制。

Product product = productRepository.findById(productId);:在 updateProductPrice 方法中,将从数据库中获取 Product 实体。在 @Transactional 注解中配置的特定隔离级别将影响该读取操作在并发事务中的表现。

SERIALIZABLE 可确保最高级别的隔离,但可能会降低并发性,并可能延长事务处理时间。其他隔离级别可在并发性和数据一致性之间取得平衡,允许多个事务同时发生,但相互之间的隔离程度各不相同。

隔离级别的选择应符合应用程序的要求,并考虑数据完整性、性能和并发访问的可能性等因素。

5、应用程序级锁
使用同步(synchronized)等结构实现应用级锁定,可确保一次只能有一个线程执行特定的代码块。

import org.springframework.stereotype.Service;

@Service
public class ProductService {
    
    private final Object lock = new Object();

    public void updateProductPrice(int productId, double newPrice) {
        synchronized (lock) {
            // This synchronized block ensures that only one thread can execute this code at a time.
            Product product = productRepository.findById(productId);
            product.setPrice(newPrice);
        }
    }
}

synchronized (lock) { ...}:该代码块使用锁对象同步。这意味着在任何时候,只有一个线程可以执行该代码块中的代码。

  • 在同步代码块中,使用 productRepository.findById(productId); 从数据库中获取 Product 实体。此操作现在受同步保护,确保并发线程无法同时执行这部分代码。
  • 检索产品后,使用 product.setPrice(newPrice); 设置新价格。

使用 synchronized 进行应用程序级锁定是确保代码关键部分免受并发访问的一种简单方法。不过,必须谨慎使用应用程序级锁定,因为过度同步会导致性能瓶颈和潜在的死锁。

6、重试策略
在处理并发问题或网络相关问题时,实施重试策略非常有用。

import org.springframework.stereotype.Service;

@Service
public class ProductService {
    
    private static final int MAX_RETRIES = 3; // Maximum number of retry attempts
    
    public void updateProductPriceWithRetry(int productId, double newPrice) {
        int retryCount = 0;
        boolean success = false;

        while (retryCount < MAX_RETRIES && !success) {
            try {
                updateProductPrice(productId, newPrice);
                success = true;
            } catch (ConcurrencyException e) {
               
// 处理并发异常,如记录或等待重试。
                retryCount++;
            }
        }

        if (!success) {
           
// 处理重试次数用尽的情况,例如抛出错误或记录日志。
        }
    }

    private void updateProductPrice(int productId, double newPrice) throws ConcurrencyException {
       
// 通过抛出异常来模拟并发问题。
       
// 在实践中,您将执行实际更新并在此处处理任何并发问题。
        throw new ConcurrencyException(
"Concurrency issue occurred during update.");
    }
}

这段代码演示了一种重试次数有限的简单重试策略。您可以自定义重试逻辑、错误处理和最大重试次数,以满足您的特定要求和用例。此外,还可以考虑使用 Spring Retry 等更高级的重试库,以获得更强大和可配置的重试策略。

结论
在多用户和多线程环境中,防止数据库并发更新的策略在维护数据一致性和完整性方面发挥着关键作用。无论是通过数据库锁定机制、乐观或悲观锁定、隔离级别,还是应用级同步,每种方法都有其独特的优势和利弊。关键在于根据应用程序的特定需求,深思熟虑地选择和实施这些策略,以确保数据库系统稳健可靠,经得起并发更新的挑战。