Optimistic and Pessimistic Locking in JPA

Optimistic and Pessimistic Locking in JPA

·

17 min read

💡 Khóa (locking) là một cơ chế cho phép thực hiện công việc song song với cùng một dữ liệu trong cơ sở dữ liệu. Khi có nhiều giao dịch (transaction) cố gắng truy cập cùng một dữ liệu cùng lúc, các khóa sẽ được sử dụng để đảm bảo rằng chỉ có một trong số các giao dịch này có thể thay đổi dữ liệu*. JPA hỗ trợ hai loại cơ chế khóa: **optimistic model pessimistic model**.*

Hãy xem xét cơ sở dữ liệu của một hãng hàng không làm ví dụ. Bảng flights lưu trữ thông tin về các chuyến bay, và bảng tickets lưu thông tin về các vé đã đặt. Mỗi chuyến bay có một sức chứa riêng, được lưu trong cột flights.capacity. Ứng dụng của chúng ta cần kiểm soát số lượng vé đã bán và không cho phép mua vé cho chuyến bay đã đầy chỗ. Để thực hiện điều này, khi đặt vé, chúng ta cần lấy sức chứa của chuyến bay và số vé đã bán từ cơ sở dữ liệu. Nếu vẫn còn chỗ trống, bán vé; nếu không, thông báo cho người dùng rằng đã hết chỗ.

Nếu mỗi yêu cầu của người dùng được xử lý trong một luồng riêng biệt, có thể xảy ra tình trạng không nhất quán dữ liệu. Giả sử có một chỗ trống cuối cùng trên chuyến bay và hai người dùng đặt vé cùng lúc. Trong trường hợp này, hai luồng đồng thời đọc số lượng vé đã bán từ cơ sở dữ liệu, kiểm tra rằng vẫn còn chỗ, và bán vé cho khách hàng. Để tránh những xung đột như vậy, các khóa (locks) được áp dụng.

Simultaneous changes without locking

Trong phạm vi bài viêt này, chúng ta sẽ sử dụng Spring Boot và Spring JPA để demo. Đầu tiên ta cần tạo các entity, repository và các lớp service:

@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 thực hiện các thay đổi giao dịch:

@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
    }

}

An application class:

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();
            }
        };
    }
}

Bảng flights:

idnumberdeparture_timecapacity
1FLT1232022-04-01 09:00:00+032
2FLT2342022-04-10 10:30:00+0350

Ý nghĩa:

  • Bảng flights lưu thông tin về các chuyến bay, bao gồm:

    • id: ID nhận dạng duy nhất cho mỗi chuyến bay.

    • number: Số hiệu chuyến bay.

    • departure_time: Thời gian khởi hành.

    • capacity: Sức chứa của chuyến bay (tổng số ghế).


Bảng tickets:

idflight_idfirst_namelast_name
11PaulLee

Ý nghĩa:

  • Bảng tickets lưu thông tin về vé đã đặt, bao gồm:

    • id: ID nhận dạng duy nhất cho mỗi vé.

    • flight_id: ID của chuyến bay mà vé này liên quan đến (liên kết với bảng flights).

    • first_name: Tên của hành khách.

    • last_name: Họ của hành khách.

Ví dụ:

  • Chuyến bay FLT123 (ID = 1) có sức chứa 2, và một vé đã được đặt cho hành khách Paul Lee.

Trường hợp không sử dụng khóa

@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(1000);
    }

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

}

Mình sẽ định nghĩa thêm một ExceededCapacityException cho trường hợp máy bay đã đầy chỗ

public class ExceededCapacityException extends Exception { }

Việc gọi Thread.sleep(1000); đảm bảo rằng các giao dịch được bắt đầu bởi cả hai luồng sẽ trùng lặp về mặt thời gian. Kết quả thực hiện ví dụ này trong cơ sở dữ liệu:

idflight_idfirst_namelast_name
11PaulLee
21KateBrown
31RobertSmith

Như bạn có thể thấy, ba vé đã được đặt, mặc dù sức chứa của chuyến bay FLT123 chỉ là hai hành khách.

⇒ Tình huống này minh họa vấn đề không nhất quán dữ liệu khi không sử dụng cơ chế khóa. Mặc dù chuyến bay FLT123 chỉ có 2 chỗ ngồi, hệ thống đã cho phép đặt 3 vé. Điều này có thể xảy ra nếu nhiều yêu cầu đặt vé được xử lý song song mà không có cơ chế đảm bảo tính toàn vẹn dữ liệu, như Pessimistic locking hoặc lạc quan.

Giải pháp:

Để tránh lỗi này, cơ chế khóa cần được áp dụng:

  1. Pessimistic Locking: Khóa hàng dữ liệu ngay khi bắt đầu đọc, đảm bảo không luồng nào khác có thể thay đổi dữ liệu trong lúc xử lý.

  2. Optimistic Locking: Kiểm tra tính nhất quán dữ liệu trước khi xác nhận thay đổi, thường sử dụng trường phiên bản (version) hoặc timestamp.

Optimistic locking

Optimistic locking được dùng để xử lý các xung đột dữ liệu trong môi trường xử lý song song mà không cần khóa cứng dữ liệu.

Để sử dụng optimistic locking, một thuộc tính phiên bản (version) với chú thích @Version phải được thêm vào lớp thực thể (entity class). Thuộc tính này có thể thuộc các kiểu: int, Integer, short, Short, long, Long, hoặc java.sql.Timestamp.

Thuộc tính phiên bản được quản lý bởi nhà cung cấp cơ chế lưu trữ (persistence provider), bạn không cần phải thay đổi giá trị của nó một cách thủ công. Nếu thực thể (entity) bị thay đổi, version sẽ tự động tăng thêm 1 (hoặc dấu thời gian sẽ được cập nhật nếu trường với chú thích @Version thuộc kiểu java.sql.Timestamp). Và nếu version ban đầu không khớp với version trong cơ sở dữ liệu khi lưu thực thể, một ngoại lệ sẽ được ném ra.

Trong ví dụ trên, để sử dụng optimistic locking, bạn cần thêm thuộc tính version vào bảng flights

@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);
    }

}

Bảng flights với cột version:

idnumberdeparture_timecapacityversion
1FLT1232022-04-01 09:00:00+0320
2FLT2342022-04-10 10:30:00+03500

Bây giờ khi chúng ta cập nhật đông thời capacity của chuyến bay 1 ở 2 thread khác nhau:

@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);
    }

}

Ngoại lệ OptimisticLockException sẽ được ném ra, do dữ liệu đã thay đổi trong cơ sở dữ liệu.

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=?

Trong thông báo ngoại lệ, chúng ta thấy rằng các cột idversion được sử dụng trong câu lệnh WHERE.

Hãy lưu ý rằng số phiên bản (version number) không thay đổi khi thay đổi các collection @OneToMany @ManyToMany với thuộc tính mappedBy.

Quay trở lại code ban đầu của DbService và kiểm tra:

@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);
    }

}

Ứng dụng chạy thành công và kết quả trong bảng tickets sẽ như sau:

idflight_idfirst_namelast_name
11PaulLee
21RobertSmith
31KateBrown

Một lần nữa, số lượng vé vượt quá sức chứa của chuyến bay 🥲

Giải pháp:

JPA cho phép tăng số phiên bản (version) một cách “cưỡng bức” khi loading một entity bằng cách sử dụng chú thích @Lock với giá trị OPTIMISTIC_FORCE_INCREMENT. Hãy thêm phương thức findWithLockingById vào lớp FlightRepository.

public interface FlightRepository extends CrudRepository<Flight, Long> {

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

}

Sử dụng findWithLockingById trong 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);
    }

}

Kết quả:

Khi ứng dụng chạy, một trong hai luồng sẽ ném ngoại lệ ObjectOptimisticLockingFailureException. Trạng thái của bảng tickets là:

idflight_idfirst_namelast_name
11PaulLee
21RobertSmith

Ý nghĩa:

Nhờ sử dụng @Lock với OPTIMISTIC_FORCE_INCREMENT, chỉ có một vé được lưu thành công vào cơ sở dữ liệu. Cơ chế optimistic đảm bảo rằng chỉ một luồng có thể thực hiện thay đổi trong trường hợp xung đột, giúp duy trì tính toàn vẹn dữ liệu.

Nếu không thể thêm cột mới vào bảng nhưng cần sử dụng optimistic locking, bạn có thể áp dụng các chú thích Hibernate @OptimisticLocking@DynamicUpdate. Giá trị type trong chú thích @OptimisticLocking có thể nhận các giá trị sau:

  • ALL: Thực hiện khóa dựa trên tất cả các trường.

  • DIRTY: Thực hiện khóa chỉ dựa trên các trường đã thay đổi.

  • VERSION: Thực hiện khóa bằng cách sử dụng một cột phiên bản riêng biệt.

  • NONE: Không thực hiện khóa.

Chúng ta sẽ thử loại Optimistic Locking DIRTY trong ví dụ thay đổi capacity của chuyến bay.

Class Flight lúc này sẽ trông như sau:

@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 và Setters

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

DbService:

@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);
    }
}

Kết quả:

Ngoại lệ sau sẽ được ném ra:

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=?

Ở đây, các cột idcapacity được sử dụng trong câu lệnh WHERE. Nếu thay đổi kiểu khóa thành ALL, ngoại lệ sẽ là:

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=?

Lần này, tất cả các cột đều được sử dụng trong câu lệnh WHERE.

Pessimistic locking

Với pessimistic locking, các hàng trong bảng được khóa ở cấp độ cơ sở dữ liệu. Hãy thay đổi loại khóa trong phương thức FlightRepository#findWithLockingById thành PESSIMISTIC_WRITE:

public interface FlightRepository extends CrudRepository<Flight, Long> {

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

}

Và chạy lại ví dụ đặt vé. Một trong các luồng sẽ ném ra ngoại lệ ExceededCapacityException, và chỉ có hai vé được lưu trong bảng tickets:

idflight_idfirst_namelast_name
11PaulLee
21KateBrown

Hiện tại, luồng đầu tiên đọc thông tin chuyến bay sẽ có quyền truy cập độc quyền vào hàng trong bảng flights, do đó luồng thứ hai sẽ tạm dừng công việc cho đến khi khóa được release. Sau khi luồng đầu tiên commit giao dịch và giải phóng khóa, luồng thứ hai sẽ có quyền truy cập độc quyền vào hàng. Tuy nhiên, tại thời điểm này, sức chứa chuyến bay đã hết, vì các thay đổi được thực hiện bởi luồng đầu tiên đã được ghi vào cơ sở dữ liệu. Kết quả là ngoại lệ ExceededCapacityException được kiểm soát sẽ được ném ra.

Ba loại pessimistic locking trong JPA

  1. PESSIMISTIC_READ:

    • Nhận một khóa chia sẻ (shared lock), và thực thể bị khóa không thể thay đổi cho đến khi giao dịch được commit.

    • Sử dụng trong các trường hợp chỉ cần đọc dữ liệu và đảm bảo rằng dữ liệu không bị thay đổi bởi các giao dịch khác trong khi giao dịch hiện tại vẫn chưa kết thúc.

  2. PESSIMISTIC_WRITE:

    • Nhận một khóa độc quyền (exclusive lock), và thực thể bị khóa có thể được thay đổi.

    • Đảm bảo quyền kiểm soát hoàn toàn đối với dữ liệu, cho phép thay đổi nhưng ngăn chặn các giao dịch khác truy cập hoặc sửa đổi dữ liệu trong suốt quá trình khóa.

  3. PESSIMISTIC_FORCE_INCREMENT:

    • Nhận một khóa độc quyền và cập nhật cột phiên bản (version column), thực thể bị khóa có thể được thay đổi.

    • Ngoài việc kiểm soát như PESSIMISTIC_WRITE, kiểu khóa này còn cập nhật cột version, rất hữu ích để theo dõi và quản lý xung đột dữ liệu trong các môi trường đồng thời.

Cài đặt timeout để nhận khóa

Nếu nhiều luồng cùng khóa một hàng trong cơ sở dữ liệu, việc nhận khóa có thể mất nhiều thời gian. Bạn có thể đặt thời gian chờ để nhận khóa như sau:

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);

}

Nếu thời gian chờ hết hạn, ngoại lệ CannotAcquireLockException sẽ được ném ra.

Lưu ý quan trọng:

Không phải tất cả các nhà cung cấp persistence đều hỗ trợ gợi ý javax.persistence.lock.timeout.

  • Hỗ trợ: Nhà cung cấp persistence của Oracle.

  • Không hỗ trợ: PostgreSQL, MS SQL Server, MySQL, và H2.

Cẩn thận với Deadlock

Bây giờ chúng ta xét đến tình huống xảy ra deadlock.

@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);
    }
}

Chúng ta sẽ gặp stack trace sau từ một trong các luồng:

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
...
  • Trong ví dụ này, hai giao dịch thực hiện khóa các hàng trong bảng theo thứ tự khác nhau:

    • changeFlight1 khóa chuyến bay 1L trước, sau đó cố gắng khóa chuyến bay 2L.

    • changeFlight2 khóa chuyến bay 2L trước, sau đó cố gắng khóa chuyến bay 1L.

  • Điều này tạo ra một vòng lặp phụ thuộc, dẫn đến deadlock vì mỗi luồng đang chờ tài nguyên mà luồng kia đang giữ.

Cơ sở dữ liệu phát hiện rằng đoạn mã này dẫn đến một tình huống deadlock. Tuy nhiên, có thể xảy ra các tình huống mà cơ sở dữ liệu không thể phát hiện ra deadlock, và các luồng sẽ tạm dừng thực thi của chúng cho đến khi hết thời gian chờ (timeout).

Cách phòng tránh:

  • Thứ tự khóa thống nhất: Đảm bảo rằng tất cả các giao dịch thực hiện khóa tài nguyên theo cùng một thứ tự. Ví dụ: luôn khóa chuyến bay 1L trước rồi đến 2L.

  • Thời gian khóa ngắn: Giảm thiểu thời gian giữ khóa bằng cách tránh các thao tác tốn thời gian trong khi giữ khóa (ví dụ: Thread.sleep).

  • Sử dụng công cụ phát hiện và xử lý deadlock: Ví dụ như cấu hình cơ sở dữ liệu hoặc ứng dụng để phát hiện và xử lý deadlock một cách hiệu quả.

Conclusion

  1. Optimistic locking

    • Ưu điểm: Thích hợp cho những tình huống mà ngoại lệ có thể được xử lý dễ dàng (ví dụ: thông báo lỗi cho người dùng hoặc thử lại giao dịch). Không gây ra khóa ở cấp độ cơ sở dữ liệu, giúp tránh làm giảm hiệu suất ứng dụng.

    • Nhược điểm: Đôi khi có thể xảy ra các lỗi đồng thời (concurrent modification) khi nhiều giao dịch cố gắng thay đổi cùng một dữ liệu.

  2. Pessimistic locking

    • Ưu điểm: Đảm bảo rằng chỉ có một giao dịch có thể thay đổi dữ liệu tại một thời điểm, tránh xung đột đồng thời. Phù hợp với các tình huống yêu cầu sự nhất quán dữ liệu tuyệt đối.

    • Nhược điểm: Có thể xảy ra deadlock nếu các giao dịch cố gắng khóa tài nguyên theo thứ tự khác nhau, dẫn đến việc treo giao dịch. Các deadlock có thể là những lỗi khó phát hiện và sửa chữa, đòi hỏi phải kiểm tra mã cẩn thận và tối ưu hóa quy trình khóa.

Tóm lại:

  • Optimistic locking phù hợp với các trường hợp ít xung đột và dễ xử lý lỗi, giúp ứng dụng hoạt động nhanh và hiệu quả.

  • Pessimistic locking cung cấp tính toàn vẹn cao hơn cho dữ liệu nhưng cần cẩn thận để tránh lỗi deadlock.

    Khi persistence providers không hỗ trợ gợi ý javax.persistence.lock.timeout thì xử lý như thế nào?

    1. Quản lý thời gian chờ (Timeout) ở tầng ứng dụng

    Thay vì dựa vào cơ sở dữ liệu để xử lý thời gian chờ, bạn có thể thiết lập thời gian chờ ở tầng ứng dụng.

    • Sử dụng các cơ chế hủy tác vụ (cancellation) hoặc xử lý ngoại lệ khi giao dịch vượt quá thời gian cho phép.

    • Trong Java, có thể sử dụng Future hoặc CompletableFuture với timeout.

Ví dụ:

    ExecutorService executor = Executors.newSingleThreadExecutor();
    Callable<Void> task = () -> {
        flightRepository.findWithLockingById(1L);
        return null;
    };

    try {
        Future<Void> future = executor.submit(task);
        future.get(10, TimeUnit.SECONDS); // Timeout sau 10 giây
    } catch (TimeoutException e) {
        System.out.println("Timeout while waiting for lock.");
    } finally {
        executor.shutdown();
    }

2. Sử dụng Retry Logic

Thực hiện lại giao dịch khi gặp lỗi CannotAcquireLockException. Giới hạn số lần thử lại để tránh vòng lặp vô hạn.

Ví dụ:

    int maxRetries = 3;
    int retryCount = 0;
    boolean success = false;

    while (!success && retryCount < maxRetries) {
        try {
            flightRepository.findWithLockingById(1L);
            success = true;
        } catch (CannotAcquireLockException e) {
            retryCount++;
            if (retryCount == maxRetries) {
                throw new RuntimeException("Failed to acquire lock after retries", e);
            }
        }
    }

3. Sử dụng Lock Externally (Khóa bên ngoài)

Áp dụng cơ chế khóa ngoài cơ sở dữ liệu bằng các công cụ như Redis hoặc Zookeeper để kiểm soát truy cập vào tài nguyên.

Ví dụ sử dụng Redis:

Sử dụng thư viện Redisson để thực hiện Distributed Lock.

    RLock lock = redissonClient.getLock("flight_lock");
    try {
        if (lock.tryLock(10, 5, TimeUnit.SECONDS)) { // Thời gian chờ nhận và giữ khóa
            flightRepository.findWithLockingById(1L);
        } else {
            System.out.println("Could not acquire lock within timeout.");
        }
    } finally {
        lock.unlock();
    }

4. Tái cấu trúc luồng xử lý giao dịch

  • Tăng tính bất đồng bộ: Tách riêng các giao dịch không phụ thuộc vào nhau để giảm khả năng va chạm.

  • Sử dụng cơ chế hàng đợi: Dùng các hệ thống hàng đợi (message queue) như Kafka hoặc RabbitMQ để xử lý các yêu cầu theo thứ tự, tránh tình trạng các luồng truy cập đồng thời.

5. Kiểm tra và tối ưu hóa thiết kế cơ sở dữ liệu

  • Cấu trúc dữ liệu: Xem xét thay đổi thiết kế bảng hoặc thêm các chỉ mục để giảm thời gian khóa.

  • Khóa cấp hàng: Sử dụng khóa ở mức hàng (row-level lock) thay vì khóa toàn bộ bảng nếu có thể.


Tóm lại: Nếu không thể sử dụng javax.persistence.lock.timeout, cách tiếp cận phù hợp nhất sẽ phụ thuộc vào yêu cầu cụ thể và môi trường triển khai của bạn. Tuy nhiên, quản lý thời gian chờ ở tầng ứng dụng và retry logic thường là hai giải pháp phổ biến và dễ triển khai nhất.

References: hackernoon.com/optimistic-and-pessimistic-l..