Friday 17th May 2024
Ho Chi Minh, Vietnam

1. Why do we need transactions?

When working on an enterprise system that persists data in a database, we mainly deal with transactions. For example, in a bank transfer transaction, if an amount of money is to be transferred from an A account to a B account, these operations must be processed:

  • Check your Account A balance
  • Debit the amount of Account A
  • Credit the amount to Account B

You can easily understand that the below criteria must be satisfied regardless this transaction is executed successfully or not, otherwise it will be a disaster:

  • The whole transaction is only successful if all operations are successful
  • If an error happens when checking the balance, withdrawing the amount from the account, or credit in the other account, the whole process should not be performed. Otherwise, inconsistencies could occur, such as leaving Account A negative or withdrawing money from A but not crediting B.

In order to deal with transactions, we have to comply with their properties – ACID, so it is critical to understand transaction mechanisms and config transactions in the right way.

2. What is @Transactional in Spring?

In the Spring framework, @Transactional was introduced in version 1.2, it is an annotation that provides an easy way to manage application transactions. It enables developers to use a consistent programming model in any environment and easily control a transaction’s scope and comply with ACID properties.

3. Configure transactions

Spring 3.1 introduces the @EnableTransactionManagement annotation but with Spring Boot projects that have a spring-data-* or spring-tx dependencies on the classpath, then transaction management will be enabled by default.

There are a few ways to enable transactions for our business using Spring, but the most popular way is about using @Transactional on top of a class or a method.

@Service
@Transactional
public class BankTransferService {
   ...
}
@Service
public class BankTransferService {

  @Transactional
  public void transfer(...) {
    ...
  }
}

There are a few parameters in this annotation but the most important parameters are isolation & propagation, it is not easy to understand these configurations without experimental so I will try to demonstrate each configuration using coding examples.

4. Propagation

Propagation provides settings for how the transaction should behave if it is called by another method or another class (parent transaction). Propagation value can be:

  • REQUIRED (default): joins the parent transaction. If one does not exist, create it
  • MANDATORY: uses the parent transaction and throws an exception if one does not exist
  • NESTED: creates a transaction parallel to the parent transaction. If one does not exist, create it
  • NEVER: does not allow transaction execution. If a transaction exists, an exception will be thrown
  • NOT_SUPPORTED: does not allow transaction execution. Suspends the parent transaction, if one exists
  • REQUIRES_NEW: creates a new transaction and suspends the parent transaction
  • SUPPORTS: joins the parent transaction. If one does not exist, it does not create it

4.1. Propagation.REQUIRES

I will use a scenario of creating a Contact with a Note in a CRM system which is stored in the contact and note table in MySQL to demonstrate all examples in this article. ContactService is the parent class, it will use NoteService to add a note for a newly created contact in the save(RequestCreateContactDTO contactDTO) method.

@Service
@Transactional
public class NoteService{
   @Autowire
   private NoteDao noteDao;

   public void save(NoteDto noteDto, Long contactId) {
      (4) throw new RuntimeException("Error");
   }
}

@Service
@Transactional
public class ContactService{
   @Autowire
   private NoteService noteService;
   @Autowire
   private ContactDao contactDao;

   public void save(RequestCreateContactDTO contactDTO) {
      Contact contact = new Contact;
      //init contact by contactDTO
      ...
      (2) contact = contactDao.save(contact);
      (3) noteService.save(contactDTO.getNoteDto(), contact.getId());
   }
}
  1. Spring will create a transaction which default values for propagation and isolation, REQUIRED and DEFAULT. This transaction will be used in ContactService.save and NoteService.save
  2. Save contact to MySQL
  3. Add a note for newly created contact using NoteService
  4. However, NoteService.save throws an exception
  5. Then, neither the contact nor the note is saved, because a rollback for both was done.

4.2. Propagation.MANDATORY

In this example, we configure propagation = Propagation.MANDATORY in NoteService and remove @Transactional in ContactService

@Service
@Transactional(propagation = Propagation.MANDATORY)
public class NoteService{
   @Autowire
   private NoteDao noteDao;

   public void save(NoteDto noteDto, Long contactId) {
      Note note= new Note();
      //init note by noteDto
      ...
      note.setContactId(contactId);
      noteDao.save(note);
   }
}

@Service
public class ContactService{
   @Autowire
   private NoteService noteService;
   @Autowire
   private ContactDao contactDao;

   public void save(RequestCreateContactDTO contactDTO) {
      Contact contact = new Contact();
      //init contact by contactDTO
      ...
      (2) contact = contactDao.save(contact);
      (3) noteService.save(contactDTO.getNoteDto(), contact.getId());
   }
}
  1. Spring will not create a transaction in ContactService.save
  2. Save contact to MySQL
  3. Add a note for newly created contact using NoteService
  4. An exception is thrown: org.springframework.transaction.IllegalTransactionStateException: No existing transaction found for transaction marked with propagation ‘mandatory’
  5. The contact created at (2) was still saved successfully because there is no transaction in ContactService.

4.3. Propagation.NESTED

In this example, we configure propagation = Propagation.NESTED in NoteService. Please note that Hibernate does not support this type of transaction. Since we use SpringData and Hibernate, so it’s impossible to present a real code about this propagation. It would be necessary to show codes with JDBCTemplate, which is not the focus of this article. So I will just use a fake example to explain it.

@Service
@Transactional(propagation = Propagation.NESTED)
public class NoteService{
   @Autowire
   private NoteDao noteDao;

   public void save(NoteDto noteDto, Long contactId) {
      Note note= new Note();
      //init note by noteDto
      ...
      note.setContactId(contactId);
      (4) noteDao.save(note);
      (6) throw new RuntimeException("Error");
   }
}

@Service
@Transactional
public class ContactService{
   @Autowire
   private NoteService noteService;
   @Autowire
   private ContactDao contactDao;

   public void save(RequestCreateContactDTO contactDTO) {
      Contact contact = new Contact();
      //init contact by contactDTO
      ...
      (2) contact = contactDao.save(contact);
      (3) noteService.save(contactDTO.getNoteDto(), contact.getId());
   }
}
  1. Spring will create a transaction in ContactService.save
  2. Save contact to MySQL
  3. Add a note for newly created contact using NoteService
  4. Save note to MySQL
  5. Spring started a subordinate transaction (NESTED)
  6. NoteService.save throws an exception
  7. The contact will be still saved but the note because they are in different transactions

4.4. Propagation.NEVER

@Service
@Transactional(propagation = Propagation.NEVER)
public class NoteService{
   @Autowire
   private NoteDao noteDao;

   public void save(NoteDto noteDto, Long contactId) {
       ....
   }
}

@Service
@Transactional
public class ContactService{
   @Autowire
   private NoteService noteService;
   @Autowire
   private ContactDao contactDao;

   public void save(RequestCreateContactDTO contactDTO) {
      Contact contact = new Contact();
      //init contact by contactDTO
      ...
      (2) contact = contactDao.save(contact);
      (3) noteService.save(contactDTO.getNoteDto(), contact.getId());
   }
}
  1. Spring will create a transaction in ContactService.save
  2. Save contact to MySQL
  3. Add a note for newly created contact using NoteService
  4. An exception is thrown: org.springframework.transaction.IllegalTransactionStateException: Existing transaction found for transaction marked with propagation ‘never’
  5. The contact created at (2) will be rollbacked

4.5. Propagation.NOT_SUPPORTED

@Service
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public class NoteService{
   @Autowire
   private NoteDao noteDao;

   public void save(NoteDto noteDto, Long contactId) {
       Note note= new Note();
      //init note by noteDto
      ...
      note.setContactId(contactId);
      (5) noteDao.save(note);
   }
}

@Service
@Transactional
public class ContactService{
   @Autowire
   private NoteService noteService;
   @Autowire
   private ContactDao contactDao;

   public void save(RequestCreateContactDTO contactDTO) {
      Contact contact = new Contact();
      //init contact by contactDTO
      ...
      (2) contact = contactDao.save(contact);
      (3) noteService.save(contactDTO.getNoteDto(), contact.getId());
   }
}
  1. Spring will create a transaction in ContactService.save
  2. Save contact to MySQL
  3. Add a note for newly created contact using NoteService
  4. Spring stops the transaction in ContactService.save
  5. Save note to MySQL
  6. A weird behavior happens: Spring pauses the executions for a period of time and then throws an exception: org.springframework.dao.PessimisticLockingFailureException: could not execute statement; SQL [n/a]; nested exception is org.hibernate.PessimisticLockException: could not execute statement
  7. Neither the contact nor the note is saved because a rollback for both was done.

This behavior is because at (4) Spring stops the transaction in ContactService.save so the contact created at (2) is locked and still has not been saved to the database. So at (5) we try to access the locked contact to save the note which is not possible.

4.6. Propagation.REQUIRES_NEW

In order to explain this propagation, I will use a different example saving two entities into the database without any relationship to avoid the same exception with 4.5. Propagation.NOT_SUPPORTED. In this example, we have an AccountContactService to create an account using AccountService and then create a Contact using ContactService.

@Service
@Transactional
public class AccountService{
   @Autowire
   private AccountDao accountDao;

   public void save(AccountDto accountDto) {
      Account account = new Account ();
      //init account by accountDto
      ...
      (2)accountDao.save(account);
   }
}

@Service
@Transactional(propagation = Propagation.REQUIRES_NEW)
public class ContactService{
   @Autowire
   private ContactDao contactDao;

   public void save(RequestCreateContactDTO contactDTO) {
      Contact contact = new Contact();
      //init contact by contactDTO
      ...
      (6) contact = contactDao.save(contact);
      (7) throw new FakeException("Error");
   }
}

@Service
@Transactional
public class AccountContactService{
   @Autowire
   private AccountService accountService;
   @Autowire
   private ContactService contactService;

   public void save(RequestCreateAccountDTO requestCreateAccountDto ) { 
      try {
          (2) accountService.save(requestCreateAccountDto.getAccountDto());
          (3) contactService.save(requestCreateAccountDto.getContactDto());
      } catch (FakeException e) {
         (8) throw e;
      }
   }
}
  1. Spring will create a transaction in AccountContactService.save and AccountService.save
  2. Save account to MySQL using AccountContactService.save
  3. Save contact to MySQL using ContactService.save
  4. Spring pauses the above transaction
  5. Spring started a new transaction for ContactService.save
  6. Save contact to MySQL
  7. Throw a FakeException, then rollback to contact
  8. Stop ContactService.save transaction and resume AccountContactService transaction then goes to catch block
  9. The account will be still saved but the contact because they are in different transactions

4.7. Propagation.SUPPORT

@Service
@Transactional(propagation = Propagation.SUPPORT)
public class ContactService{
   @Autowire
   private ContactDao contactDao;

   public void save(RequestCreateContactDTO contactDTO) {
      Contact contact = new Contact();
      //init contact by contactDTO
      ...
      contact = contactDao.save(contact);
   }
}

@Service
public class AccountContactService{
   @Autowire
   private AccountService accountService;
   @Autowire
   private ContactService contactService;

   @Transactional
   public void saveWithTransaction(RequestCreateAccountDTO requestCreateAccountDto ) { 
      accountService.save(requestCreateAccountDto.getAccountDto());
      ...
   }

   public void saveWithoutTransaction(RequestCreateAccountDTO requestCreateAccountDto ) { 
      accountService.save(requestCreateAccountDto.getAccountDto());
      ...
   }
}

In this example, ContactService is configured with SUPPORT propagation, it is used by two methods in AccountContactService, saveWithTransaction with @Transactional and saveWithoutTransaction without @Transactional.

The result is that all contacts are saved without any problems. The saveWithoutTransaction method does not start a transaction, so the ContactService.save method is executed non-transactionally. The saveWithTransaction method creates a transaction, so now ContactService.save executes transactionally way.

5. Isolation

Isolation provides configurations of how the transactions see each other. Isolation value can be:

  • DEFAULT: uses the isolation defined directly in the database
  • READ_COMMITED: cannot read data that has not yet been committed
  • READ_UNCOMMITED: can read data that has not yet been committed
  • REPEATABLE_READ: indicates that if a transaction initially reads a set of rows from the database, the next several readings must have the same result. If rows were updated, deleted, or inserted and are expected to appear in this reading, an exception is thrown
  • SERIALIZABLE: indicates that the transaction sees only the data that belongs to it. That is, even if other data has been committed in other transactions, they will not be visible. It happens because they were not manipulated in the current transaction. It is the most restrictive isolation

5.1. Isolation.READ_COMMITED

@Transactional(isolation = Isolation.READ_COMMITTED)
public Contact saveAndReturn(RequestCreateContactDTO contactDTO) {
 Contact contact = new Contact();
 //init contact by contactDTO
 ...
 this.contactService.save(contact);
 return this.searchService.findContactByUuid(contact.getUuid());
}

In this example, the contact was saved in save. However, the saveAndReturn method has not finished yet and this transaction has not yet been committed. So, due to the READ_COMMITTED isolation, the findContactByUuid method cannot get the contact.

5.2. Isolation.READ_UNCOMMITED

@Transactional(isolation = Isolation.READ_UNCOMMITTED)
public Contact saveAndReturn(RequestCreateContactDTO contactDTO) {
 Contact contact = new Contact();
 //init contact by contactDTO
 ...
 this.contactService.save(contact);
 return this.searchService.findContactByUuid(contact.getUuid());
}

This example is the same as the 5.1 example but we change isolation to READ_UNCOMMITTED, the result is that the findContactByUuid method can get the newly created contact due to READ_UNCOMMITTED allowing to do that.

5.3. Isolation.REPEATABLE_READ

@Transactional(isolation = Isolation.REPEATABLE_READ)
public List<Contact>updateThenReturn() {
 try {
  List<Contact> contacts = contactService.getAllContacts();
  Contact contact = contacts.get(0);
  student.setFirstName("Thoai");
  contactService.save(contact );
  return contactService.getAllContacts();
 } catch (CannotAcquireLockException e) {
  throw e;
 }

}

In this code, the first reading of all contacts is done using the getAllContacts method. After that, an update is made on the first. However, when trying to read all contacts again, an error occurs. This is because the REPEATABLE_READ isolation identified that the reading would no longer be the same, as an updated row was returned, which was modified by a concurrent transaction (contactService.save).

5.4. Isolation.SERIALIZABLE

@Transactional(isolation = Isolation.SERIALIZABLE)
public void updateThenReturn() {
 contactService.getAllContacts();
 Contact contact = contacts.get(0);
 student.setFirstName("Thoai");
 contactService.save(contact );
 return contactService.getAllContacts();
}

In this code, contacts that were shown the first time will be returned exactly the same the second time. Even with the updated contact in contactService.save, it will not appear in the second query. This is because this new contact was created by a new transaction and not by the one that is being executed (execute method), with SERIALIZABLE isolation. This isolation is the most restrictive isolation and limits its scope only to the current transaction.

6. Conclusion

Transactions are really important in most IT systems nowadays, we usually deal with transactions in our daily work so it is really important to use them in the right way, especially in the high volume of transactions systems such as e-commerce that may execute a thousand transactions per second and perform critical data as money, stock.

In distributed systems such as micro-services, we may need to deal with distributed transactions which are transactions executed by multiple services, so we need another technique as Two-Phases Commit or SAGA. You can research more on this topic: https://microservices.io/patterns/data/saga.html

Leave a Reply

Your email address will not be published. Required fields are marked *

Back To Top