Spring @Transactional Mistakes Everyone Makes

By Aleksandr Kozhenkov

Date: 2021-09-28

Transactional is probably one of Spring’s most commonly used annotations. Despite this popularity, abuse can sometimes occur, resulting in results that are not what programmers expect.

In this article, I collected some of the problems I encountered during the project. Hopefully, the problem list will help you understand transactions better and resolve some of the issues you encounter.

Called in the same class

The @Transactional annotation rarely has adequate test case coverage, which means problems are hard to spot at first glance. Therefore, you might encounter code like this:

public void registerAccount(Account acc) {
    createAccount(acc);

    notificationSrvc.sendVerificationEmail(acc);
}

@Transactional
public void createAccount(Account acc) {
    accRepo.save(acc);
    teamRepo.createPersonalTeam(acc);
}
Copy the code

In this case, when registerAccount() is called, the save user and create team are not executed in a generic transaction. Transactional is based on aspect-oriented Programming. Therefore, transactions occur when one object calls another. In the example above, the methods are called in the same class so the proxy cannot be used. The same is true for other annotations such as @cacheable.

This problem can be solved in the following three ways:

  1. Inject itself
  2. Create another abstraction layer
  3. inregisterAccount()Method.TransactionTemplateThe parcelcreateAccount()The method call

The first method may not look very clear, but using this method can avoid repetitive logic if @Transactional contains parameters.

@Service
@RequiredArgsConstructor
public class AccountService {
    private final AccountRepository accRepo;
    private final TeamRepository teamRepo;
    private final NotificationService notificationSrvc;
    @Lazy private final AccountService self;

    public void registerAccount(Account acc) {
        self.createAccount(acc);

        notificationSrvc.sendVerificationEmail(acc);
    }

    @Transactional
    public void createAccount(Account acc) { accRepo.save(acc); teamRepo.createPersonalTeam(acc); }}Copy the code

If you are using Lombok, add @lazy to lombok.config.

Handle exceptions

By default, a rollback will only occur with a RuntimeException or Error. Also, the code may contain compile-time exceptions, in which case the transaction will need to be rolled back.

@Transactional(rollbackFor = StripeException.class)
public void createBillingAccount(Account acc) throws StripeException {
    accSrvc.createAccount(acc);

    stripeHelper.createFreeTrial(acc);
}
Copy the code

Isolation levels and propagation mechanisms for transactions

Often, developers add annotations without really thinking about what behavior they want to achieve. The default isolation level READ_COMMITED is almost always used.

Understanding the isolation level is essential to avoid hard-to-debug errors later on.

For example, if you need to generate reports, you might perform multiple lookups for different data within the same transaction and at the default isolation level. At this point parallel commits of things may occur. Using REPEATABLE_READ helped us avoid this situation and save a lot of troubleshooting time.

Different isolation levels help us limit transactions in our business logic. For example, if you need to execute some code in another transaction and not in an external transaction, you can use REQUIRES_NEW isolation level, which suspends the external transaction and creates a new one, then resumes the external transaction.

Transactions do not lock data

@Transactional
public List<Message> getAndUpdateStatuses(Status oldStatus, Status newStatus, int batchSize) {
    List<Message> messages = messageRepo.findAllByStatus(oldStatus, PageRequest.of(0, batchSize));
    
    messages.forEach(msg -> msg.setStatus(newStatus));

    return messageRepo.saveAll(messages);
}
Copy the code

Sometimes structures like this, where some data is queried from the database and then updated, are all done in the same transaction, executing the code in a single request transaction is atomic.

The problem is that other application instances cannot be prevented from calling the findAllByStatus method at the same time as the first power. So the same method returns the same data in both forces, causing the data to be processed twice.

There are two ways to avoid this problem.

Select for Update (pessimistic lock)

UPDATE message
SET status = :newStatus
WHERE id in (
   SELECT m.id FROM message m WHERE m.status = :oldStatus LIMIT :limit
   FOR UPDATE SKIP LOCKED)
RETURNING *
Copy the code

In the example above, when the SELECT is executed, the data row is locked until the update is complete. The query returns all modified rows.

Physical version (Optimistic Locking)

This method avoids locking data. The solution is to add a Version column to our entity. We can then query the data and update the data that the database version matches with the application version. If using JPA, you can use the @version annotation.

Two different data sources

For example, we create a new version of the datastore, but we still need to keep the old data for a while.

@Transactional
public void saveAccount(Account acc) {
    dataSource1Repo.save(acc);
    dataSource2Repo.save(acc);
}
Copy the code

Of course, in this case, only one save will be processed in the transaction, which is the default in TransactionalManager.

Spring provides two other options.

ChainedTransactionManager (outdated)

1st TX Platform: begin
  2nd TX Platform: begin
    3rd Tx Platform: begin
    3rd Tx Platform: commit
  2nd TX Platform: commit <-- fail
  2nd TX Platform: rollback  
1st TX Platform: rollback
Copy the code

ChainedTransactionManager is a way to define multiple data sources, which in the case of abnormal, will be rolled back in the opposite order. So when there are three data sources, if an error occurs on the second COMMIT, only the first and second data sources will attempt to roll back. The third data source has committed changes.

JtaTransactionManager

This manager allows the use of fully supported distributed transactions, based on two-phase commit. However, it delegates management to the back-end JTA provider. Can be a Java EE service or standalone solution.

conclusion

Transactions are a tricky topic, and problems often arise. Many times, tests are not fully covered, so most problems can only be found in code Review. If an accident occurs in a production environment, finding the problem is always a challenge.