skip to Main Content

I have a simple database table (PostgreSql) with the following data

Database table data

In the spring boot JPA application, the corresponding Entity class for this table is defined as follows


import java.time.LocalDateTime;

import org.hibernate.annotations.UpdateTimestamp;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@AllArgsConstructor
@NoArgsConstructor
@Data
@Entity
@Table(name = "counter", schema = "demo")
public class CounterRow {

    @Id
    private int row_id;
    private int counter;
    private String last_user;
    
    @UpdateTimestamp
    private LocalDateTime last_updated;
}

And the repository is defined as follows

import org.ravi.entity.CounterRow;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

public interface CounterRepository extends JpaRepository<CounterRow, Integer> {
    
    @Override
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    Optional<CounterRow> findById(Integer id);
}

Now, I have initialized two threads for increasing the counter concurrently and my aim is achieve the synchronization between the two threads at the DB level but not at the application level (i.e., not using synchronized keyword or any other distributed lock mechanism)

If each thread increments the counter by 5 times (in a loop), I would expect the final value of the counter be incremented by 10 compared to its current value. However, I see that it is incremented only by 5 times assuming that this could be due to improper DB synchronization.

This is the class that runs the updater threads

package org.ravi;

import org.ravi.entity.CounterRow;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;

@Component
@Slf4j
public class DbSyncTester {

    @Autowired
    private DbUpdater dbUpdater;

    @PostConstruct
    public void start() {
        final Thread thread1 = getThread("Updater_Thread_1");
        final Thread thread2 = getThread("Updater_Thread_2");
        thread1.start();
        thread2.start();
    }

    private Thread getThread(String threadName) {
        return new Thread(getDbTask(), threadName);
    }

    private Runnable getDbTask() {
        return () -> {
            int loopCounter = 0;
            while (loopCounter++ < 5) {
                dbUpdater.updateDb();
            }
        };
    }
}

DbUpdater class

package org.ravi;

import org.ravi.entity.CounterRow;
import org.ravi.repository.CounterRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import lombok.extern.slf4j.Slf4j;

/**
 * @author teju
 */
@Component
@Slf4j
public class DbUpdater {

    @Autowired
    private CounterRepository repository;

    @Transactional(readOnly = false, isolation = Isolation.SERIALIZABLE, propagation = Propagation.REQUIRED)
    public void updateDb() {
        log.info("Fetching the counter from the DB");
        CounterRow counterRow = repository.findById(1).get();
        log.info("Counter value: {}", counterRow.getCounter());
        counterRow.setCounter(counterRow.getCounter() + 1);
        counterRow.setLast_user(Thread.currentThread().getName());
        repository.save(counterRow);
        log.info("Updated the counter in the DB to {}", counterRow.getCounter());
    }
}

I have added the below line of code on the method hoping to achieve the synchronization at DB level, but no luck.

@Transactional(readOnly = false, isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)

I have also tried with Isolation.SERIALIZABLE, still doesn’t work.

Please help me in understanding the issue with the DB level locks using the above code.

2

Answers


  1. There are 2 problems:

    1. Your @Transactional doesn’t actually work. You can put a breakpoint, stop during the debug and see that in the stack trace there are no transactional proxies.
    2. Your expectations of how databases lock rows is incorrect.

    First, the way @Transactional works is that a wrapper class (a proxy) is created. And instead of working with your actual class/object, Spring injects this proxy. So when a transactional method is invoked, the proxy is invoked, and it starts the transaction. After that it invokes your actual code.

    But because you invoke the @Transactional method from inside the class, the invocation doesn’t go through the proxy. And so no transactions are started. You need to inject your bean to some other class, and only then invoke the transactional method.

    Now regarding locks.. Simple SELECT statements never experience any locks. Due to Multiversioning (MVCC), the database keeps multiple versions of your records. The SELECTing transaction will simply read an older version if the row was updated by a parallel transaction. So even if a parallel transaction updated the record, you read the old version, increment it in Java, and overwrite the previously written value.

    The locking happens when UDPATE/DELETE happens or during SELECT ... FOR SHARE/UPDATE. So if you want to properly write the code that leverages locking, you need to do this in 1 SQL statement:

    update table_name set counter=counter+1 where ...

    Login or Signup to reply.
  2. The transaction isolation level determines what data your transaction will see. It does not prevent other transactions from changing, adding, or deleting that data. If you set your transaction to the highest isolation level, SERIALIZABLE, it only means that your transaction will not see any changes that occur after it began. Within your transaction, you will not be able to tell that the records you are working with have been changed or even deleted. At the SERIALIZABLE isolation level, you will also not be able to tell that new records have been added to your table. So in your situation, as in most other cases, the best isolation level is READ COMMITTED, which is the default.

    To prevent other threads from changing a record, you need to lock that record in the database using the SELECT FOR UPDATE command.

    If you want to use Spring Data for locking, you need to specify the @Lock annotation on the method that is declared in the Spring Data JPA repository:

    public interface CounterRepository extends JpaRepository<Counter, Long> {
    
        @Lock(LockModeType.PESSIMISTIC_WRITE)
        Optional<Counter> findById(Long id);
    }
    

    In this case, Spring Data will issue a SELECT FOR NO KEY UPDATE query, which will prevent non-key fields in the requested records from being modified until your transaction completes. This is fine because you only need to change the counter value.

    If you want to use only Hibernate for locking, you need to use one of the following methods: EntityManager.find(), Session.lock(), Query.setLockMode() from Hibernate Query Language or TypedQuery.setLockMode() from Criteria API.

    Counter counter = entityManager.find(Counter.class, 1L, LockModeType.PESSIMISTIC_WRITE);
    

    And of course, you should call your transactional method from another Spring bean, for example from a controller, so that Spring’s automatic transaction management mechanism @Transactional works, which is based on proxying your objects.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search