JPA中的乐观和悲观锁定

锁定是一种允许并行处理数据库中相同数据的机制。当多个事务尝试同时访问相同的数据时,锁将发挥作用,这可确保这些事务中只有一个会更改数据。JPA 支持两种类型的锁定机制:乐观模型和悲观模型。

让我们以航空公司数据库为例。该表存储有关航班的信息,并存储有关已预订机票的信息。每个航班都有自己的容量,存储在列中。我们的应用程序应控制售出的机票数量,并且不应允许购买已满航班的机票。为此,在订票时,我们需要从数据库中获取航班的容量和售出的机票数量,如果航班上有空座位,请出售机票,否则通知用户座位已用完。如果在单独的线程中处理每个用户请求,则可能会出现数据不一致。假设航班上有一个空座位,两个用户同时订票。在这种情况下,两个线程同时从数据库中读取售出的票证数量,检查是否还有剩余的座位,然后将票卖给客户端。为了避免此类碰撞,应用了锁。flightsticketsflights.capacity

无需锁定即可同时更改

我们将使用 Spring Data JPA 和 Spring Boot。让我们创建实体、存储库和其他类:

@Entity
@Table(name = "flights")
public class Flight {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String number;

    private LocalDateTime departureTime;

    private Integer capacity;

    @OneToMany(mappedBy = "flight")
    private Set<Ticket> tickets;

    // ...
    // getters and setters
    // ...

    public void addTicket(Ticket ticket) {
        ticket.setFlight(this);
        getTickets().add(ticket);
    }

}

public interface FlightRepository extends CrudRepository<Flight, Long> { }

@Entity
@Table(name = "tickets")
public class Ticket {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "flight_id")
    private Flight flight;

    private String firstName;

    private String lastName;

    // ...
    // getters and setters
    // ...
}

public interface TicketRepository extends CrudRepository<Ticket, Long> { }

DbService执行事务更改:

@Service
public class DbService {

    private final FlightRepository flightRepository;

    private final TicketRepository ticketRepository;

    public DbService(FlightRepository flightRepository, TicketRepository ticketRepository) {
        this.flightRepository = flightRepository;
        this.ticketRepository = ticketRepository;
    }

    @Transactional
    public void changeFlight1() throws Exception {
        // the code of the first thread
    }

    @Transactional
    public void changeFlight2() throws Exception {
        // the code of the second thread
    }

}

一个应用程序类:

import org.apache.commons.lang3.function.FailableRunnable;

@SpringBootApplication
public class JpaLockApplication implements CommandLineRunner {

    @Resource
    private DbService dbService;

    public static void main(String[] args) {
        SpringApplication.run(JpaLockApplication.class, args);
    }

    @Override
    public void run(String... args) {
        ExecutorService executor = Executors.newFixedThreadPool(2);
        executor.execute(safeRunnable(dbService::changeFlight1));
        executor.execute(safeRunnable(dbService::changeFlight2));
        executor.shutdown();
    }

    private Runnable safeRunnable(FailableRunnable<Exception> runnable) {
        return () -> {
            try {
                runnable.run();
            } catch (Exception e) {
                e.printStackTrace();
            }
        };
    }
}

我们将在应用程序的每次后续运行中使用此数据库状态

flights桌子:

编号

departure_time

能力

1

FLT123

2022-04-01 09:00:00+03

2

2

FLT234

2022-04-10 10:30:00+03

50

tickets桌子:

编号

flight_id

first_name

last_name

1

1

保罗

让我们编写一个代码来模拟同时购买门票而不锁定。

@Service
public class DbService {

    // ...
    // autowiring
    // ...

    private void saveNewTicket(String firstName, String lastName, Flight flight) throws Exception {
        if (flight.getCapacity() <= flight.getTickets().size()) {
            throw new ExceededCapacityException();
        }
        var ticket = new Ticket();
        ticket.setFirstName(firstName);
        ticket.setLastName(lastName);
        flight.addTicket(ticket);
        ticketRepository.save(ticket);
    }

    @Transactional
    public void changeFlight1() throws Exception {
        var flight = flightRepository.findById(1L).get();
        saveNewTicket("Robert", "Smith", flight);
        Thread.sleep(1_000);
    }

    @Transactional
    public void changeFlight2() throws Exception {
        var flight = flightRepository.findById(1L).get();
        saveNewTicket("Kate", "Brown", flight);
        Thread.sleep(1_000);
    }

}

public class ExceededCapacityException extends Exception { }

调用确保由两个线程启动的事务将在时间上重叠。在数据库中执行此示例的结果:Thread.sleep(1_000);

编号

flight_id

first_name

last_name

1

1

保罗

2

1

凯特

棕色

3

1

罗伯特

史密斯

如您所见,尽管FLT123航班的容量为两名乘客,但仍预订了三张机票。

乐观锁定

现在,看看乐观阻塞是如何工作的。让我们从一个更直接的例子开始 - 航班变化的同时容量。为了使用乐观锁定,必须将带有批注的持久属性添加到实体类中。此属性可以是类型,,,,,,或。版本属性由持久性提供程序管理,无需手动更改其值。如果实体发生更改,版本号将增加 1(或者,如果带有注释的字段具有 java.sql.Timestamp 类型,则更新时间戳)。如果在保存实体时原始版本与数据库中的版本不匹配,则会引发异常。@VersionintIntegershortShortlongLongjava.sql.Timestamp@Version

将属性添加到实体versionFlight

@Entity
@Table(name = "flights")
public class Flight {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String number;

    private LocalDateTime departureTime;

    private Integer capacity;

    @OneToMany(mappedBy = "flight")
    private Set<Ticket> tickets;

    @Version
    private Long version;

    // ...
    // getters and setters
    //

    public void addTicket(Ticket ticket) {
        ticket.setFlight(this);
        getTickets().add(ticket);
    }

}

将列添加到表中versionflights

编号

名字

departure_time

能力

版本

1

FLT123

2022-04-01 09:00:00+03

2

0

2

FLT234

2022-04-10 10:30:00+03

50

0

现在我们在两个线程中更改飞行容量:

@Service
public class DbService {

    // ...
    // autowiring
    // ...

    @Transactional
    public void changeFlight1() throws Exception {
        var flight = flightRepository.findById(1L).get();
        flight.setCapacity(10);
        Thread.sleep(1_000);
    }

    @Transactional
    public void changeFlight2() throws Exception {
        var flight = flightRepository.findById(1L).get();
        flight.setCapacity(20);
        Thread.sleep(1_000);
    }

}

现在,在执行我们的应用程序时,我们将得到一个异常

org.springframework.orm.ObjectOptimisticLockingFailureException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1; statement executed: update flights set capacity=?, departure_time=?, number=?, version=? where id=? and version=?

因此,在我们的示例中,一个线程保存了更改,而另一个线程无法保存更改,因为数据库中已经存在更改。因此,可以防止同一航班同时更改。在异常消息中,我们看到子句中使用了 and列。idversionwhere

请记住,使用属性更改和集合时,版本号不会更改。让我们恢复原始的 DbService 代码并检查一下:@OneToMany@ManyToManymappedBy

@Service
public class DbService {

    // ...
    // autowiring
    // ...

    private void saveNewTicket(String firstName, String lastName, Flight flight) throws Exception {
        if (flight.getCapacity() <= flight.getTickets().size()) {
            throw new ExceededCapacityException();
        }
        var ticket = new Ticket();
        ticket.setFirstName(firstName);
        ticket.setLastName(lastName);
        flight.addTicket(ticket);
        ticketRepository.save(ticket);
    }

    @Transactional
    public void changeFlight1() throws Exception {
        var flight = flightRepository.findById(1L).get();
        saveNewTicket("Robert", "Smith", flight);
        Thread.sleep(1_000);
    }

    @Transactional
    public void changeFlight2() throws Exception {
        var flight = flightRepository.findById(1L).get();
        saveNewTicket("Kate", "Brown", flight);
        Thread.sleep(1_000);
    }

}

应用程序将成功运行,表中的结果将如下所示tickets

编号

flight_id

first_name

last_name

1

1

保罗

2

1

罗伯特

史密斯

3

1

凯特

棕色

同样,机票数量超过了飞行容量。

JPA 使得在使用带有值的注释加载实体时强制增加版本号成为可能。让我们将方法添加到类中。在 Spring Data JPA 中,任何介于 and 之间的文本都可以添加到方法名称中,如果它不包含关键字,例如,文本是描述性的,并且该方法作为常规执行:@LockOPTIMISTIC_FORCE_INCREMENTfindWithLockingByIdFlightRepositoryfindByDistinctfind…By…

public interface FlightRepository extends CrudRepository<Flight, Long> {

    @Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
    Optional<Flight> findWithLockingById(Long id);

}

在 中使用方法findWithLockingByIdDbService

@Service
public class DbService {

    // ...
    // autowiring
    // ...

    private void saveNewTicket(String firstName, String lastName, Flight flight) throws Exception {
        // ...
    }

    @Transactional
    public void changeFlight1() throws Exception {
        var flight = flightRepository.findWithLockingById(1L).get();
        saveNewTicket("Robert", "Smith", flight);
        Thread.sleep(1_000);
    }

    @Transactional
    public void changeFlight2() throws Exception {
        var flight = flightRepository.findWithLockingById(1L).get();
        saveNewTicket("Kate", "Brown", flight);
        Thread.sleep(1_000);
    }

}

当应用程序启动时,将引发两个线程中的一个。表的状态是ObjectOptimisticLockingFailureExceptiontickets

编号

flight_id

first_name

last_name

1

1

保罗

2

1

罗伯特

史密斯

我们看到这次只有一个工单被保存到数据库中。

如果无法向表中添加新列,但需要使用乐观锁定,则可以应用 Hibernate 注释沙。“乐观锁定”批注中的类型值可以采用以下值:OptimisticLockingDynamicUpdate

  • ALL- 根据所有字段执行锁定
  • DIRTY- 仅根据更改的字段字段执行锁定
  • VERSION- 使用专用版本列执行锁定
  • NONE- 不要执行锁定

我们将在更改飞行容量示例中尝试乐观锁定类型。DIRTY

@Entity
@Table(name = "flights")
@OptimisticLocking(type = OptimisticLockType.DIRTY)
@DynamicUpdate
public class Flight {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String number;

    private LocalDateTime departureTime;

    private Integer capacity;

    @OneToMany(mappedBy = "flight")
    private Set<Ticket> tickets;

    // ...
    // getters and setters
    // ...

    public void addTicket(Ticket ticket) {
        ticket.setFlight(this);
        getTickets().add(ticket);
    }

}

@Service
public class DbService {

    // ...
    // autowiring
    // ...

    @Transactional
    public void changeFlight1() throws Exception {
        var flight = flightRepository.findById(1L).get();
        flight.setCapacity(10);
        Thread.sleep(1_000);
    }

    @Transactional
    public void changeFlight2() throws Exception {
        var flight = flightRepository.findById(1L).get();
        flight.setCapacity(20);
        Thread.sleep(1_000);
    }

}

将引发异常

org.springframework.orm.ObjectOptimisticLockingFailureException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1; statement executed: update flights set capacity=? where id=? and capacity=?

现在和列在子句中使用。如果将锁定类型更改为,将引发此类异常idcpacitywhereALL

org.springframework.orm.ObjectOptimisticLockingFailureException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1; statement executed: update flights set capacity=? where id=? and capacity=? and departure_time=? and number=?

现在,所有列都用于子句中。where

悲观锁定

使用悲观锁定时,表行在数据库级别锁定。让我们将方法的阻止类型更改为FlightRepository#findWithLockingByIdPESSIMISTIC_WRITE

public interface FlightRepository extends CrudRepository<Flight, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    Optional<Flight> findWithLockingById(Long id);

}

并重新运行预订票证示例。其中一个线程将抛出并且表中只有两张票。ExceededCapacityExceptiontickets

编号

flight_id

first_name

last_name

1

1

保罗

2

1

凯特

棕色

现在,第一个加载外部测试版的线程对表中的行具有独占访问权限,因此第二个线程暂停其工作,直到释放锁。在第一个线程提交事务并释放锁后,第二个线程将获得对该行的单极子访问,但此时,外部测试容量已经耗尽,因为第一个线程所做的更改将进入数据库。结果,将引发受控异常。flightsExceededCapacityException

JPA 中有三种类型的悲观锁定:

  • PESSIMISTIC_READ- 获取共享锁,并且在事务提交之前无法更改锁定的实体。
  • PESSIMISTIC_WRITE- 获取独占锁,锁定的实体可以更改。
  • PESSIMISTIC_FORCE_INCREMENT- 获取独占锁并更新版本列,锁定的实体可以更改

如果许多线程锁定数据库中的同一行,则可能需要很长时间才能获得锁定。您可以设置超时以接收锁定:

public interface FlightRepository extends CrudRepository<Flight, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value ="10000")})
    Optional<Flight> findWithLockingById(Long id);

}

如果超时到期,将被抛出。请务必注意,并非所有持久性提供程序都支持该提示。例如,Oracle的持久性提供程序支持此提示,而不适用于PostgreSQL,MS SQL Server,MySQL和H2。CannotAcquireLockExceptionjavax.persistence.lock.timeout

现在我们考虑一个僵局情况。

@Service
public class DbService {

    // ...
    // autowiring
    // ...

    private void fetchAndChangeFlight(long flightId) throws Exception {
        var flight = flightRepository.findWithLockingById(flightId).get();
        flight.setCapacity(flight.getCapacity() + 1);
        Thread.sleep(1_000);
    }

    @Transactional
    public void changeFlight1() throws Exception {
        fetchAndChangeFlight(1L);
        fetchAndChangeFlight(2L);
        Thread.sleep(1_000);
    }

    @Transactional
    public void changeFlight2() throws Exception {
        fetchAndChangeFlight(2L);
        fetchAndChangeFlight(1L);
        Thread.sleep(1_000);
    }

}

我们将从其中一个线程获得以下堆栈跟踪

org.springframework.dao.CannotAcquireLockException: could not extract ResultSet; SQL [n/a]; nested exception is org.hibernate.exception.LockAcquisitionException: could not extract ResultSet
...
Caused by: org.postgresql.util.PSQLException: ERROR: deadlock detected
...

数据库检测到此代码导致死锁。但是,在某些情况下,数据库将无法执行此操作,并且线程将暂停其执行,直到超时结束。

结论

乐观锁定和悲观锁定是两种不同的方法。乐观锁适用于可以轻松处理已引发的异常并通知用户或重试的情况。同时,数据库级别的行不会被阻塞,这不会减慢应用程序的运行速度。如果有可能获得一个块,悲观锁为执行对数据库的查询提供了很好的保证。但是,使用悲观锁定时,您需要仔细编写和检查代码,因为存在死锁的可能性,这可能会成为难以查找和修复的浮动错误。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值