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