锁定是一种允许并行处理数据库中相同数据的机制。当多个事务尝试同时访问相同的数据时,锁将发挥作用,这可确保这些事务中只有一个会更改数据。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<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 类型,则更新时间戳)。如果在保存实体时原始版本与数据库中的版本不匹配,则会引发异常。@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<Ticket> 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<Flight, Long> {
@Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
Optional<Flight> 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<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=?
现在和列在子句中使用。如果将锁定类型更改为,将引发此类异常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<Flight, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Flight> 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<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。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
...
数据库检测到此代码导致死锁。但是,在某些情况下,数据库将无法执行此操作,并且线程将暂停其执行,直到超时结束。
结论
乐观锁定和悲观锁定是两种不同的方法。乐观锁适用于可以轻松处理已引发的异常并通知用户或重试的情况。同时,数据库级别的行不会被阻塞,这不会减慢应用程序的运行速度。如果有可能获得一个块,悲观锁为执行对数据库的查询提供了很好的保证。但是,使用悲观锁定时,您需要仔细编写和检查代码,因为存在死锁的可能性,这可能会成为难以查找和修复的浮动错误。