• JPA中的乐观和悲观锁定


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

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

    无需锁定即可同时更改

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

    1. @Entity
    2. @Table(name = "flights")
    3. public class Flight {
    4. @Id
    5. @GeneratedValue(strategy = GenerationType.IDENTITY)
    6. private Long id;
    7. private String number;
    8. private LocalDateTime departureTime;
    9. private Integer capacity;
    10. @OneToMany(mappedBy = "flight")
    11. private Set tickets;
    12. // ...
    13. // getters and setters
    14. // ...
    15. public void addTicket(Ticket ticket) {
    16. ticket.setFlight(this);
    17. getTickets().add(ticket);
    18. }
    19. }

    public interface FlightRepository extends CrudRepository { }
    

    1. @Entity
    2. @Table(name = "tickets")
    3. public class Ticket {
    4. @Id
    5. @GeneratedValue(strategy = GenerationType.IDENTITY)
    6. private Long id;
    7. @ManyToOne(fetch = FetchType.LAZY)
    8. @JoinColumn(name = "flight_id")
    9. private Flight flight;
    10. private String firstName;
    11. private String lastName;
    12. // ...
    13. // getters and setters
    14. // ...
    15. }

    public interface TicketRepository extends CrudRepository { }

    DbService执行事务更改:

    1. @Service
    2. public class DbService {
    3. private final FlightRepository flightRepository;
    4. private final TicketRepository ticketRepository;
    5. public DbService(FlightRepository flightRepository, TicketRepository ticketRepository) {
    6. this.flightRepository = flightRepository;
    7. this.ticketRepository = ticketRepository;
    8. }
    9. @Transactional
    10. public void changeFlight1() throws Exception {
    11. // the code of the first thread
    12. }
    13. @Transactional
    14. public void changeFlight2() throws Exception {
    15. // the code of the second thread
    16. }
    17. }

    一个应用程序类:

    1. import org.apache.commons.lang3.function.FailableRunnable;
    2. @SpringBootApplication
    3. public class JpaLockApplication implements CommandLineRunner {
    4. @Resource
    5. private DbService dbService;
    6. public static void main(String[] args) {
    7. SpringApplication.run(JpaLockApplication.class, args);
    8. }
    9. @Override
    10. public void run(String... args) {
    11. ExecutorService executor = Executors.newFixedThreadPool(2);
    12. executor.execute(safeRunnable(dbService::changeFlight1));
    13. executor.execute(safeRunnable(dbService::changeFlight2));
    14. executor.shutdown();
    15. }
    16. private Runnable safeRunnable(FailableRunnable runnable) {
    17. return () -> {
    18. try {
    19. runnable.run();
    20. } catch (Exception e) {
    21. e.printStackTrace();
    22. }
    23. };
    24. }
    25. }

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

    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

    保罗

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

    1. @Service
    2. public class DbService {
    3. // ...
    4. // autowiring
    5. // ...
    6. private void saveNewTicket(String firstName, String lastName, Flight flight) throws Exception {
    7. if (flight.getCapacity() <= flight.getTickets().size()) {
    8. throw new ExceededCapacityException();
    9. }
    10. var ticket = new Ticket();
    11. ticket.setFirstName(firstName);
    12. ticket.setLastName(lastName);
    13. flight.addTicket(ticket);
    14. ticketRepository.save(ticket);
    15. }
    16. @Transactional
    17. public void changeFlight1() throws Exception {
    18. var flight = flightRepository.findById(1L).get();
    19. saveNewTicket("Robert", "Smith", flight);
    20. Thread.sleep(1_000);
    21. }
    22. @Transactional
    23. public void changeFlight2() throws Exception {
    24. var flight = flightRepository.findById(1L).get();
    25. saveNewTicket("Kate", "Brown", flight);
    26. Thread.sleep(1_000);
    27. }
    28. }

    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

    1. @Entity
    2. @Table(name = "flights")
    3. public class Flight {
    4. @Id
    5. @GeneratedValue(strategy = GenerationType.IDENTITY)
    6. private Long id;
    7. private String number;
    8. private LocalDateTime departureTime;
    9. private Integer capacity;
    10. @OneToMany(mappedBy = "flight")
    11. private Set tickets;
    12. @Version
    13. private Long version;
    14. // ...
    15. // getters and setters
    16. //
    17. public void addTicket(Ticket ticket) {
    18. ticket.setFlight(this);
    19. getTickets().add(ticket);
    20. }
    21. }

    将列添加到表中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

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

    1. @Service
    2. public class DbService {
    3. // ...
    4. // autowiring
    5. // ...
    6. @Transactional
    7. public void changeFlight1() throws Exception {
    8. var flight = flightRepository.findById(1L).get();
    9. flight.setCapacity(10);
    10. Thread.sleep(1_000);
    11. }
    12. @Transactional
    13. public void changeFlight2() throws Exception {
    14. var flight = flightRepository.findById(1L).get();
    15. flight.setCapacity(20);
    16. Thread.sleep(1_000);
    17. }
    18. }

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

    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

    1. @Service
    2. public class DbService {
    3. // ...
    4. // autowiring
    5. // ...
    6. private void saveNewTicket(String firstName, String lastName, Flight flight) throws Exception {
    7. if (flight.getCapacity() <= flight.getTickets().size()) {
    8. throw new ExceededCapacityException();
    9. }
    10. var ticket = new Ticket();
    11. ticket.setFirstName(firstName);
    12. ticket.setLastName(lastName);
    13. flight.addTicket(ticket);
    14. ticketRepository.save(ticket);
    15. }
    16. @Transactional
    17. public void changeFlight1() throws Exception {
    18. var flight = flightRepository.findById(1L).get();
    19. saveNewTicket("Robert", "Smith", flight);
    20. Thread.sleep(1_000);
    21. }
    22. @Transactional
    23. public void changeFlight2() throws Exception {
    24. var flight = flightRepository.findById(1L).get();
    25. saveNewTicket("Kate", "Brown", flight);
    26. Thread.sleep(1_000);
    27. }
    28. }

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

    编号

    flight_id

    first_name

    last_name

    1

    1

    保罗

    2

    1

    罗伯特

    史密斯

    3

    1

    凯特

    棕色

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

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

    1. public interface FlightRepository extends CrudRepository {
    2. @Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
    3. Optional findWithLockingById(Long id);
    4. }

    在 中使用方法findWithLockingByIdDbService

    1. @Service
    2. public class DbService {
    3. // ...
    4. // autowiring
    5. // ...
    6. private void saveNewTicket(String firstName, String lastName, Flight flight) throws Exception {
    7. // ...
    8. }
    9. @Transactional
    10. public void changeFlight1() throws Exception {
    11. var flight = flightRepository.findWithLockingById(1L).get();
    12. saveNewTicket("Robert", "Smith", flight);
    13. Thread.sleep(1_000);
    14. }
    15. @Transactional
    16. public void changeFlight2() throws Exception {
    17. var flight = flightRepository.findWithLockingById(1L).get();
    18. saveNewTicket("Kate", "Brown", flight);
    19. Thread.sleep(1_000);
    20. }
    21. }

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

    编号

    flight_id

    first_name

    last_name

    1

    1

    保罗

    2

    1

    罗伯特

    史密斯

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

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

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

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

    1. @Entity
    2. @Table(name = "flights")
    3. @OptimisticLocking(type = OptimisticLockType.DIRTY)
    4. @DynamicUpdate
    5. public class Flight {
    6. @Id
    7. @GeneratedValue(strategy = GenerationType.IDENTITY)
    8. private Long id;
    9. private String number;
    10. private LocalDateTime departureTime;
    11. private Integer capacity;
    12. @OneToMany(mappedBy = "flight")
    13. private Set tickets;
    14. // ...
    15. // getters and setters
    16. // ...
    17. public void addTicket(Ticket ticket) {
    18. ticket.setFlight(this);
    19. getTickets().add(ticket);
    20. }
    21. }

    1. @Service
    2. public class DbService {
    3. // ...
    4. // autowiring
    5. // ...
    6. @Transactional
    7. public void changeFlight1() throws Exception {
    8. var flight = flightRepository.findById(1L).get();
    9. flight.setCapacity(10);
    10. Thread.sleep(1_000);
    11. }
    12. @Transactional
    13. public void changeFlight2() throws Exception {
    14. var flight = flightRepository.findById(1L).get();
    15. flight.setCapacity(20);
    16. Thread.sleep(1_000);
    17. }
    18. }

    将引发异常

    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

    1. public interface FlightRepository extends CrudRepository {
    2. @Lock(LockModeType.PESSIMISTIC_WRITE)
    3. Optional findWithLockingById(Long id);
    4. }

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

    编号

    flight_id

    first_name

    last_name

    1

    1

    保罗

    2

    1

    凯特

    棕色

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

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

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

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

    1. public interface FlightRepository extends CrudRepository {
    2. @Lock(LockModeType.PESSIMISTIC_WRITE)
    3. @QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value ="10000")})
    4. Optional findWithLockingById(Long id);
    5. }

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

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

    1. @Service
    2. public class DbService {
    3. // ...
    4. // autowiring
    5. // ...
    6. private void fetchAndChangeFlight(long flightId) throws Exception {
    7. var flight = flightRepository.findWithLockingById(flightId).get();
    8. flight.setCapacity(flight.getCapacity() + 1);
    9. Thread.sleep(1_000);
    10. }
    11. @Transactional
    12. public void changeFlight1() throws Exception {
    13. fetchAndChangeFlight(1L);
    14. fetchAndChangeFlight(2L);
    15. Thread.sleep(1_000);
    16. }
    17. @Transactional
    18. public void changeFlight2() throws Exception {
    19. fetchAndChangeFlight(2L);
    20. fetchAndChangeFlight(1L);
    21. Thread.sleep(1_000);
    22. }
    23. }

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

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

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

    结论

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

  • 相关阅读:
    Go语言学习(六)-- 结构体和匿名字段
    【Android Studio】Android Studio修改代码编辑区(工作区)背景色
    Vue+Vux引入国际化
    软设之冒泡排序
    【NOI模拟赛】思门弄数(数论,链表)
    总结单例模式的写法
    如何把Word文件设置成不能编辑
    Redis开发规范与性能优化(一)
    我的NVIDIA开发者之旅——优化显卡性能
    「技术人生」第9篇:如何设定业务目标
  • 原文地址:https://blog.csdn.net/allway2/article/details/127682522