锁定是一种允许并行处理数据库中相同数据的机制。当多个事务尝试同时访问相同的数据时,锁将发挥作用,这可确保这些事务中只有一个会更改数据。JPA 支持两种类型的锁定机制:乐观模型和悲观模型。
让我们以航空公司数据库为例。该表存储有关航班的信息,并存储有关已预订机票的信息。每个航班都有自己的容量,存储在列中。我们的应用程序应控制售出的机票数量,并且不应允许购买已满航班的机票。为此,在订票时,我们需要从数据库中获取航班的容量和售出的机票数量,如果航班上有空座位,请出售机票,否则通知用户座位已用完。如果在单独的线程中处理每个用户请求,则可能会出现数据不一致。假设航班上有一个空座位,两个用户同时订票。在这种情况下,两个线程同时从数据库中读取售出的票证数量,检查是否还有剩余的座位,然后将票卖给客户端。为了避免此类碰撞,应用了锁。flights
tickets
flights.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
tickets; -
- // ...
- // getters and setters
- // ...
-
- public void addTicket(Ticket ticket) {
- ticket.setFlight(this);
- getTickets().add(ticket);
- }
-
- }
public interface FlightRepository extends CrudRepository { }
- @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 { }
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
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 类型,则更新时间戳)。如果在保存实体时原始版本与数据库中的版本不匹配,则会引发异常。@Version
int
Integer
short
Short
long
Long
java.sql.Timestamp
@Version
将属性添加到实体version
Flight
- @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
tickets; -
- @Version
- private Long version;
-
- // ...
- // getters and setters
- //
-
- public void addTicket(Ticket ticket) {
- ticket.setFlight(this);
- getTickets().add(ticket);
- }
-
- }
将列添加到表中version
flights
编号 | 名字 | 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列。id
version
where
请记住,使用属性更改和集合时,版本号不会更改。让我们恢复原始的 DbService 代码并检查一下:@OneToMany
@ManyToMany
mappedBy
- @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 之间的文本都可以添加到方法名称中,如果它不包含关键字,例如,文本是描述性的,并且该方法作为常规执行:@Lock
OPTIMISTIC_FORCE_INCREMENT
findWithLockingById
FlightRepository
find
By
Distinct
find…By…
- public interface FlightRepository extends CrudRepository
{ -
- @Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
- Optional
findWithLockingById(Long id); -
- }
在 中使用方法findWithLockingById
DbService
- @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);
- }
-
- }
当应用程序启动时,将引发两个线程中的一个。表的状态是ObjectOptimisticLockingFailureException
tickets
编号 | flight_id | first_name | last_name |
---|---|---|---|
1 | 1 | 保罗 | 李 |
2 | 1 | 罗伯特 | 史密斯 |
我们看到这次只有一个工单被保存到数据库中。
如果无法向表中添加新列,但需要使用乐观锁定,则可以应用 Hibernate 注释沙。“乐观锁定”批注中的类型值可以采用以下值:OptimisticLocking
DynamicUpdate
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
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=?
现在和列在子句中使用。如果将锁定类型更改为,将引发此类异常id
cpacity
where
ALL
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#findWithLockingById
PESSIMISTIC_WRITE
- public interface FlightRepository extends CrudRepository
{ -
- @Lock(LockModeType.PESSIMISTIC_WRITE)
- Optional
findWithLockingById(Long id); -
- }
并重新运行预订票证示例。其中一个线程将抛出并且表中只有两张票。ExceededCapacityException
tickets
编号 | flight_id | first_name | last_name |
---|---|---|---|
1 | 1 | 保罗 | 李 |
2 | 1 | 凯特 | 棕色 |
现在,第一个加载外部测试版的线程对表中的行具有独占访问权限,因此第二个线程暂停其工作,直到释放锁。在第一个线程提交事务并释放锁后,第二个线程将获得对该行的单极子访问,但此时,外部测试容量已经耗尽,因为第一个线程所做的更改将进入数据库。结果,将引发受控异常。flights
ExceededCapacityException
JPA 中有三种类型的悲观锁定:
PESSIMISTIC_READ
- 获取共享锁,并且在事务提交之前无法更改锁定的实体。PESSIMISTIC_WRITE
- 获取独占锁,锁定的实体可以更改。PESSIMISTIC_FORCE_INCREMENT
- 获取独占锁并更新版本列,锁定的实体可以更改如果许多线程锁定数据库中的同一行,则可能需要很长时间才能获得锁定。您可以设置超时以接收锁定:
- public interface FlightRepository extends CrudRepository
{ -
- @Lock(LockModeType.PESSIMISTIC_WRITE)
- @QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value ="10000")})
- Optional
findWithLockingById(Long id); -
- }
如果超时到期,将被抛出。请务必注意,并非所有持久性提供程序都支持该提示。例如,Oracle的持久性提供程序支持此提示,而不适用于PostgreSQL,MS SQL Server,MySQL和H2。CannotAcquireLockException
javax.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
- ...
数据库检测到此代码导致死锁。但是,在某些情况下,数据库将无法执行此操作,并且线程将暂停其执行,直到超时结束。
乐观锁定和悲观锁定是两种不同的方法。乐观锁适用于可以轻松处理已引发的异常并通知用户或重试的情况。同时,数据库级别的行不会被阻塞,这不会减慢应用程序的运行速度。如果有可能获得一个块,悲观锁为执行对数据库的查询提供了很好的保证。但是,使用悲观锁定时,您需要仔细编写和检查代码,因为存在死锁的可能性,这可能会成为难以查找和修复的浮动错误。