Search code examples
javamysqlhibernatejpadatabase-deadlocks

Deadlock not detected during MySQL Hibernate JPA transaction


WARNING!!! TL;DR

MySQL 5.6.39  
mysql:mysql-connector-java:5.1.27
org.hibernate.common:hibernate-commons-annotations:4.0.5.Final  
org.hibernate.javax.persistence:hibernate-jpa-2.1-api:1.0.0.Final  
org.hibernate:hibernate-core:4.3.6.Final
org.hibernate:hibernate-entitymanager:4.3.6.Final  
org.hibernate:hibernate-validator:5.0.3.Final

HTTP Method: POST, API path: /reader

Entity "reader" Engine: innoDB

id
name
total_pages_read

Class Mapping:

@Entity
@Table(name = "reader")
public class Reader{
    @Column(name = "id")
    private Long id;

    @Column(name = "name")
    private String name;

    @Column(name = "total_pages_read")
    private Long total_pages_read;
    
    @OneToMany(fetch = FetchType.LAZY, mappedBy = "reader", orphanRemoval = true)
    private Set<Book_read> book_reads;

    ...
}

I use method createEntity() and recalculateTotalPageRead() in a Reader write service class:

@Service
public class ReaderWritePlatformServiceJpaRepositoryImpl{
    private final ReaderRepositoryWrapper readerRepositoryWrapper;
   
    ...

    @Transactional
    public Long createEntity(final Long id, final String name, final Long total_pages_read){
        try {
            final Reader reader = new Reader(id, name, total_pages_read);
            this.readerRepositoryWrapper.saveAndFlush(reader);

            return 1l;
        } catch (final Exception e) {
            return 0l;
        }
    }
    
    ...
}

HTTP Method: POST, API path: /bookread

Entity "book_read" Engine: innoDB

id  
reader_id  
book_title  
number_of_pages 

Class Mapping:

@Entity
@Table(name = "book_read")
public class Book_read{
    @Column(name = "id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "reader_id")
    private Reader reader;

    @Column(name = "book_title")
    private String book_title;

    @Column(name = "number_of_pages")
    private Long number_of_pages;
    
    ...
}

I use method createEntity() and recalculateTotalPageRead() in a Book_read write service class:

@Service
public class Book_readWritePlatformServiceJpaRepositoryImpl{
    private final ReaderRepositoryWrapper readerRepositoryWrapper;
    private final Book_readRepositoryWrapper bookReadRepositoryWrapper;
    
    ...

    @Transactional
    public Long createEntity(final Long id, final Long reader_id, final String book_title, final Long number_of_pages){
        try {
            final Reader reader = this.readerRepositoryWrapper.findOneWithNotFoundDetection(reader_id);

            final Book_read book_read = new Book_read(id, reader, book_title, number_of_pages);
            this.bookReadRepositoryWrapper.saveAndFlush(book_read);

            this.recalculateTotalPageRead(reader);

            return 1l;
        } catch (final Exception e) {
            return 0l;
        }
    }

    private void recalculateTotalPageRead(final Reader reader){
        Long total_pages_read =  Long.valueOf(0);
        Set<Book_read> book_reads = reader.getBook_reads();
        for (Book_read book_read : book_reads){
            total_pages_read += book_read.getNumber_of_pages();
        }

        reader.setTotal_pages_read(total_pages_read);
        this.readerRepositoryWrapper.saveAndFlush(reader);
    }

    ...
}

When I try to create both entities:

Sample "reader" :

id | name       | total_pages_read
-----------------------------------
1  | Foo Reader | 0(by default)

Sample "book_read": 2 separated POST method calls

id | reader_id | book_title | number_of_pages 
---------------------------------------------
1  | 1         | Foo Book   | 2
2  | 1         | Bar Book   | 3

Expecting change on entity "reader" after creating "book_read"-s as above sample:

Sample Reader:

id | name       | total_pages_read
-----------------------------------
1  | Foo Reader | 5

But from what I've been experiencing there happens to be 3 cases while creating those 2 "book_read" records concurrently:

Case 1 (OK):

  • 1st "book_read" finished creating
  • Get any existing "book_read" of "reader" id 1 into a list "book_reads". "book_reads" size = 1.
  • Add number_of_pages of each "book_read" in the list to total_pages_read of "reader" id 1. Current total_pages_read = 2.
  • Start creating 2nd "book_read"
  • 2nd "book_read" finished creating
  • Get any existing "book_read" of "reader" id 1 into a list "book_reads". "book_reads" size = 2.
  • Add number_of_pages of each "book_read" in the list to total_pages_read of "reader" id 1.
  • Final result: total_pages_read = 5.

Case 2 (OK):

  • (Transaction 1) Start creating 1st "book_read"
  • (Transaction 2) Start creating 2nd "book_read"
  • (Transaction 1) 1st "book_read" finished creating
  • (Transaction 2) 2nd "book_read" finished creating
  • (Transaction 1) Get any existing "book_read" of "reader" id 1 into a list "book_reads". "book_reads" size = 1.
  • (Transaction 2) Get any existing "book_read" of "reader" id 1 into a list "book_reads". "book_reads" size = 1.
  • (Transaction 1) Add number_of_pages of each "book_read" in the list to total_pages_read of "reader" id 1. Current total_pages_read = 2.
  • (Transaction 2) Add number_of_pages of each "book_read" in the list to total_pages_read of "reader" id 1. Deadlock Exception Thrown.
  • Retry (Transaction 2) Start creating 2nd "book_read"
  • (Transaction 2) 2nd "book_read" finished creating
  • (Transaction 2) Get any existing "book_read" of "reader" id 1 into a list "book_reads". "book_reads" size = 2.
  • Add number_of_pages of each "book_read" in the list to total_pages_read of "reader" id 1.
  • Final result: total_pages_read = 5.

Case 3 (NOT OK):

  • 1st "book_read" finished creating
  • Get any existing "book_read" of "reader" id 1 into a list "book_reads". "book_reads" size = 1.
  • Add number_of_pages of each "book_read" in the list to total_pages_read of "reader" id 1. Current total_pages_read = 2.
  • Start creating 2nd "book_read"
  • 2nd "book_read" finished creating
  • Get any existing "book_read" of "reader" id 1 into a list "book_reads". "book_reads" size = 1.
  • Add number_of_pages of each "book_read" in the list to total_pages_read of "reader" id 1. Current total_pages_read = 3. Not Detecting Deadlock.
  • Final result: total_pages_read = 3.

How do I fix case 3?

Cheers, Happy programming :D

SOLVED!

Optimistic Locking Implementation on data model

@Entity
@Table(name = "reader")
public class Reader{
    @Version
    @Column(name = "version")
    private int version;
    
    @Column(name = "id")
    private Long id;

    @Column(name = "name")
    private String name;

    @Column(name = "total_pages_read")
    private Long total_pages_read;
    
    @OneToMany(fetch = FetchType.LAZY, mappedBy = "reader", orphanRemoval = true)
    private Set<Book_read> book_reads;

    ...
}

Solution

  • What you have experienced is called a lost update and it's really not a JPA-level problem, you can easily reproduce this in MySQL shell. I'm assuming you did not do any changes in the database itself, so your default transaction isolation level is REPEATABLE READ.

    In MySQL, REPEATABLE READ does not detect possible lost updates (even though that's the common understanding of this isolation level). You can check this answer on SO and the comments thread to learn more.

    Basically with the use of MVCC, MySQL tries to avoid contention and deadlocks. In your case you will have to make a trade-off and choose to sacrifice some speed for the sake of consistency.

    Your options are to use a SELECT ... FOR UPDATE statement or to set a more strict isolation level, which is SERIALIZABLE (you can do this for individual transaction). Both of these options will block the reads until the concurrent transaction commit/rollback. Thus you will see the consistent view of your data, just a bit later (or a lot later, depending on the application's requirements).

    You can also read up on this here, here and here.

    Concurrency is hard. :)

    UPDATE: After thinking about comments below, there is actually another option that you have: implement Optimistic Locking for your data model. JPA has a support for this, please take look here and here. What you achieve is basically the same, but with a bit different approach (you will have to restart the transactions that failed to to mismatched versions) and less contention due to less locking.