1

I would like to count user likes for review articles. There are several 'Review' objects, each one has a 'likes' counter. Users can increase/decrease the counter by a web request. After the increase/decrease command, the new 'likes' counter value is displayed to the user. If two users send a web request at the same time, then one of the requests must wait until the other one has completed, and both users get different 'likes' counter values.

To test this race condition, I called Thread.sleep(3000) so that I can run the same web request manually from two different browsers.

My first approach was to use the @Transactional(isolation = Isolation.SERIALIZABLE) annotation. The code retrieves the Review entity, updates the 'likes' counter value, and saves it.

I expected that if my ReviewServiceImpl method was called from my two manual web requests, the second call would arrive while the first call is still in the sleep() function, the first transaction is still open and the second call can’t even start the transaction until after the first call is completed and the first transaction is committed. So the second request should pick up the updated value from the first request.

However, both browser windows display the same 'likes' counter value so there is an error somewhere.

My second approach was to use a database update query as suggested in Spring, JPA, and Hibernate - how to increment a counter without concurrency issues . This works better as no 'likes' updates get lost. But the pages display the value before the update so the controller somehow retrieves outdated data.

The full example code can be found here: https://github.com/schirmacher/transactionproblem . I have copied the essential code below for convenience.

Please advise how to fix this problem.

Entity Review.java:

@Entity
public class Review implements Serializable {

    private static final long serialVersionUID = 1L;

    @Id
    @GeneratedValue
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private Long likes;

    public String getName() {
        return name;
    }

    public Long getLikes() {
        return likes;
    }

    public void setLikes(Long likes) {
        this.likes = likes;
    }
}

Repository ReviewRepository.java:

interface ReviewRepository extends JpaRepository<Review, Long> {

    Review save(Review review);

    List<Review> findAll();

    Review findByName(String name);

    @Transactional
    @Modifying
    @Query(value = "UPDATE Review c SET c.likes = c.likes + 1 WHERE c = :review")
    void incrementLikes(Review review);
}

Service ReviewServiceImpl.java:

@Component("categoryService")
class ReviewServiceImpl implements ReviewService {

    ...

    // this code does not work
    @Transactional(isolation = Isolation.SERIALIZABLE)
    @Override
    public void incrementLikes_variant1(String name) {
        Review review = reviewRepository.findByName(name);
        Long likes = review.getLikes();
        likes = likes + 1;
        review.setLikes(likes);

        try {
            Thread.sleep(3000L);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    // this code does not work either
    @Override
    public void incrementLikes_variant2(String name) {
        Review review = reviewRepository.findByName(name);
        reviewRepository.incrementLikes(review);

        try {
            Thread.sleep(3000L);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

Controller LikeController.java:

@Controller
public class LikeController {

    @Autowired
    private ReviewService categoryService;

    @RequestMapping("/")
    @ResponseBody
    public String increaseLike() {
        categoryService.incrementLikes_variant2("A random movie");
        Review review = categoryService.findByName("A random movie");

        return String.format("Movie '%s' has %d likes", review.getName(), review.getLikes());
    }
}
NewLomter
  • 95
  • 1
  • 7
nn4l
  • 945
  • 1
  • 12
  • 28

1 Answers1

2

Answer to how to get incrementLikes_variant1 working

Issue

  • I suspect the issue is in memory h2 database is not doing the Isolation.SERIALIZABLE table level lock. But this is just my suspicion

Solution

  • Anyway row level lock is better than table level lock.
    @Transactional
    @Override
    public void incrementLikes_variant1(String name) {
        Review review = reviewRepository.findByNameForUpdate(name);
        Long likes = review.getLikes();
        likes = likes + 1;
        review.setLikes(likes);

        try {
            Thread.sleep(3000L);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
interface ReviewRepository extends JpaRepository<Review, Long> {

    Review findByName(String name);

    @Query(value = "Select r from Review r WHERE r.name = :name")
    @Lock(LockModeType.PESSIMISTIC_READ)
    Review findByNameForUpdate(String name);
    ...
}

Note

  • And optimistic concurrency approach using the @Version support is better than this row level lock.
  • You can implement auto retry as it is suitable for your use case when OptimisticConcurrencyException happens

Answer to how to get incrementLikes_variant2 working

    public interface ReviewRepositoryCustom {

      void refresh(Review review);
    }
    public class ReviewRepositoryImpl implements ReviewRepositoryCustom {

     @Autowired
     EntityManager em;

     @Override
     public void refresh(Review review) {
        em.refresh(review);
     }
   }
   interface ReviewRepository extends JpaRepository<Review, Long>, 
                                      ReviewRepositoryCustom 
    @Override
    @Transactional
    public void incrementLikes_variant2(String name) {
        Review review = reviewRepository.findByName(name);
        reviewRepository.incrementLikes(review);
        reviewRepository.refresh(review);

        try {
            Thread.sleep(3000L);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

Note:

  • Variant2 is better than Variant1 as it does not involve locking by the app
  • I just tried variant1 with Isolation.SERIALIZABLE and PostgreSQL: got a org.springframework.dao.CannotAcquireLockException: could not execute statement and org.postgresql.util.PSQLException: ERROR: could not serialize access due to concurrent update. Looks as if PostgreSQL rightfully rejects this request, but HSQL does not (bug?). My understanding is that @Transactional(isolation = Isolation.SERIALIZABLE) somehow makes the second call wait until the first call commits - maybe this understanding is wrong? – nn4l Jul 05 '20 at 08:10
  • Is my solution working as I am not using `Isolation.SERIALIZABLE`? I will comeback to you on your `PostgreSQL` comment as I come across something else with MySql recently when I was answering another question – Kavithakaran Kanapathippillai Jul 05 '20 at 08:12
  • My suspicion is H2 ignores the `Isolation.SERIALIZABLE` I came across this recently and then we found out that it was non transactional engine in db and he has to switch to transaction engine in mysql. https://stackoverflow.com/questions/62613814/spring-transactional-does-not-rollback-checked-exceptions/62615314#62615314. Similarly yours is a db issue not a hibernate issue. i.e `PostgreSQL` locks the table on first and it doesn't allow the second. But `h2` doesn't seems to lock – Kavithakaran Kanapathippillai Jul 05 '20 at 08:20
  • I am now using your em.refresh() call but placed it into the Service instead of the Repository. It works just the same and I don't need to introduce and modify several Repository files. – nn4l Jul 05 '20 at 08:48
  • Even thought your option is easier now, if it is production code, it is better to keep it in repos for the following reasons 1) Encapsulation of `em` in repos 2) In the future, you might need more custom methods for some other purposes. – Kavithakaran Kanapathippillai Jul 05 '20 at 08:51