Transaction khi thực hiện nhiều lệnh ghi dữ liệu đảm bảo tính chất nguyên tố (Atomicity): hoặc là tất cả thành công, hoặc là quay lui về trạng thái ban đầu.  Nếu chỉ xử lý một transaction thì khá đơn giản, nhưng khi xử lý hai transaction lồng nhau theo ý đồ riêng thì cần phải nắm vững cấu hình annotation @Transactional. Bài này dài nên tôi chia thành 2-3 phần để giải thích cho kỹ. Đây là phần đầu tiên. Tải mã nguồn ví dụ ở đây

1. Nhập môn transaction

Nội dung bài này đúng với cả ứng dụng Spring Boot và Quarkus. Bạn cần làm quen với vài khái niệm:

  • @Transactional đánh dấu một method khi thực thi sẽ nằm trong transaction context. Nếu @Transactional đánh dấu một class thì mọi method thuộc class khi thực thi sẽ nằm trong transaction context.
  • transaction context hiểu là hoàn cảnh của giao dịch. Nó bắt đầu khi method bắt đầu được thực thi cho đến khi method kết thúc.
  • Nested transactions: là 2 hoặc nhiều transaction lồng nhau.
  • Một transaction không nhất thiết chỉ có các lệnh ghi vào 1 cơ sở dữ liệu, mà có thể gồm các lệnh ghi vào CSDL, ghi vào ổ cứng, gọi vào REST API...
  • Một transaction roll back trong 2 trường hợp:
    1. Khi annotate class hoặc method bởi @Transactional để bắt exception được ném ra.
    2. lập trình viên chủ ý dùng lệnh TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();

2. Xem log transaction qua cửa sổ console

Khi debug phần transaction, bạn sẽ luôn tò mò xem transaction context được tạo ra, tạm dừng, tiếp tục, hay quay lui như thế nào. Hãy bổ xung những dòng sau đây vào file application.properties

logging.level.ROOT=INFO 
logging.level.org.springframework.orm.jpa=DEBUG 
logging.level.org.springframework.transaction=DEBUG 
logging.level.org.hibernate.engine.transaction.internal.TransactionImpl=DEBUG

3. Giới thiệu về ví dụ demo transaction

Để minh hoạ cho cấu hình nested transaction tôi dùng một ví dụ kinh điển là giao dịch chuyển tiền giữa các tài khoản ngân hàng. Tôi xây dựng một REST API, khi chạy sẽ hứng POST request ở http://localhost:8080/transfer. Mỗi lệnh chuyển khoản gồm 3 trường:

  • from: mã tài khoản bị trừ tiền
  • to: mã tài khoản được cộng tiền
  • amount: số tiền chuyển

Bạn hãy biên dịch ứng dụng và dùng Postman để tạo ra lệnh chuyển tiền như hình dưới đây. Lần 1 sẽ thành công. Nhưng lần 2 sẽ thất bại vì tài khoản 1 không còn đủ tiền.

Một lệnh chuyển tiền thành công

 Lần chuyển tiền thứ 2 sẽ không thành công.

Một lệnh chuyển tiền thất bại do tài khoản bị trừ không đủ số tiền cần chuyển.

3.1 Cấu trúc ứng dụng

3.2 Mô tả các file trong dự án

.
├── controller
│   ├── request
│   │   └── TransferRequest.java -> Class lưu 3 tham số lệnh chuyển tiền gửi từ POST request
│   ├── response
│   │   └── TransferResult.java -> Class lưu kết quả của lệnh chuyển tiền trả về client
│   └── BankController.java -> REST Controller xử lý POST Request
├── exception
│   ├── BankErrorCode.java -> Enum lưu các mã lỗi lệnh chuyển tiền
│   └── BankException.java -> Custom RuntimeException dùng để ném ra trong logic thực thi chuyển tiền
├── model
│   ├── Account.java -> Thực thể mô tả tài khoản ngân hàng
│   ├── AccountState.java -> Enum trạng thái tài khoản
│   ├── AllLog.java -> Mô tả bảng lưu mọi giao dịch bất kể thành công hay thất bại
│   └── TransactLog.java -> Mô tả bảng lưu những giao dịch thành công
├── repository
│   ├── AccountRepo.java -> Interface thao tác bảng Account
│   ├── AllLogRepo.java -> Interface thao tác bảng AllLog
│   └── TransactLogRepo.java -> Interface thao tác bảng TransactLog
├── service
│   ├── BankService.java -> Logic thực thi chuyển tiền sẽ gọi đến các interface repository
│   └── LoggingService.java -> Dịch vụ log lại mọi giao dịch
├── AppRunner.java -> Thực lệnh insert một số tài khoản ban đầu ngay sau khi Spring Boot khởi động
└── BankApplication.java -> class SpringBoot chứa hàm main

4. Định nghĩa Entity ứng với các bảng trong CSDL

4.1 Account.java

@Table(name="account")
@Entity(name="account")
@Data
@NoArgsConstructor
public class Account {
  @Id @GeneratedValue(strategy = GenerationType.AUTO)
  private Long id;
  private String owner;
  private Long balance;
  private AccountState state;
  public Account(String owner, Long balance) {
    this.owner = owner;
    this.balance = balance;
    this.state = AccountState.ACTIVE;
  }
}

4.2 TransactLog.java chỉ lưu lệnh chuyển tiền thành công

@Entity(name="transactlog")
@Table(name="transactlog")
@Data
public class TransactLog {
  @Id @GeneratedValue(strategy = GenerationType.AUTO)
  private Long id;
  
  @ManyToOne(fetch = FetchType.EAGER)
  private Account accountFrom;
  
  @ManyToOne(fetch = FetchType.EAGER)
  private Account accountTo;

  private long amount;

  private Date createdOn;
  
  public TransactLog (Account accountFrom, Account accountTo, Long amount, Date createdOn){
    this.accountFrom = accountFrom;
    this.accountTo = accountTo;
    this.amount = amount;
    this.createdOn = createdOn;
  }
}

4.3 AllLog.java lưu mọi lệnh chuyển tiền bất kể kết quả thành công hay thất bại

@Entity(name="alllog")
@Table(name="alllog")
@Data
public class AllLog {
  @Id @GeneratedValue(strategy = GenerationType.AUTO)
  private Long id;

  private long fromID;

  private long toID;

  private long amount;

  private BankErrorCode resultCode;

  private String detail;

  private Date createdOn;
  
  public AllLog (long fromID, long toID, Long amount, 
    BankErrorCode resultCode, String detail){
    this.fromID = fromID;
    this.toID = toID;
    this.amount = amount;
    this.resultCode = resultCode;
    this.detail = detail;
    this.createdOn = new Date();
  }
}

5. Controller, Request, Response

5.1 BankController.java xử lý POST request ở đường dẫn /transfer

@RestController
public class BankController {

  @Autowired
  private BankService bankService;

  @PostMapping("/transfer")
  public ResponseEntity<TransferResult> transfer(@ModelAttribute TransferRequest transferRequest) {

    try {
      TransferResult result = bankService.transfer(
      transferRequest.getFrom(), 
      transferRequest.getTo(), 
      transferRequest.getAmount());

      return ResponseEntity.ok().body(result);
    } catch (BankException e) {
      TransferResult transerError = new TransferResult(
        e.getErrorCode(), 
        e.getMessage() + " : " + e.getDetail(),
        new Date());
      return ResponseEntity.badRequest().body(transerError);
    }   
  }
}

5.2 TransferRequest.java lưu tham số của lệnh chuyển tiền

@Data
public class TransferRequest {  
  private long from;
  private long to;
  private long amount;
}

5.3 TransferResult.java lưu kết quả của lệnh chuyển tiền

public class TransferResult {
  private BankErrorCode resultCode; //200 success, 404 account not found, 405 balance not enought
  private String message;
  private Date transferDate;
}

5.4 BankErrorCode.java enum lưu các mã lỗi

public enum BankErrorCode {
  SUCCESS(200),
  UNKNOWN_ERROR(400),
  ACCOUNT_DISABLED(401),
  ID_NOT_FOUND(404),
  BALANCE_NOT_ENOUGH(405),
  DATABASE_ERROR(500);

  private int value;

  BankErrorCode(int value) { 
    this.value = value; 
  }

  public int getValue() { 
    return value; 
  }
}

6. Định nghĩa các interface Respository

Để ngắn gọn tôi gom hết vào một code block chứ thực ra đây là 3 file interface AccountRepo.java, AllLogRepo.java, TrasactLogRepo.java

public interface AccountRepo extends JpaRepository<Account, Long> {
  
}

public interface AllLogRepo extends CrudRepository<AllLog, Long>{
  
}

public interface TransactLogRepo extends CrudRepository<TransactLog, Long> {
  
}

7. AppRunner.java kế thừa CommandLineRunner để chạy logic sau khi SpringBoot khởi động xong

Trong ví dụ demo tôi cần tạo ra một vài tài khoản ngân hàng làm ví dụ mẫu. Tôi có thể sử dụng lệnh spring.jpa.properties.hibernate.hbm2ddl.import_files=account.sql trong file application.properties. Tuy để linh hoạt hơn, tôi tạo ra một class AppRunner kế thừa class CommandLineRunner

@Component
public class AppRunner implements CommandLineRunner {
  @Autowired
  private BankService bankService;

  @Override
  public void run(String... args) throws Exception {
    bankService.generateSampleData();
  }
}

Phương thức generateSampleData() trong BankService.java tạo một số tài khoản ngân hàng

public void generateSampleData() {
    Account johnAccount = new Account("John", 1000L);
    Account bobAccount = new Account("Bob", 2000L);
    Account aliceAccount = new Account("Alice", 1500L);

    Account tomAccount = new Account("Tom", 100L);
    tomAccount.setState(AccountState.DISABLED);

    accountRepo.save(johnAccount);
    accountRepo.save(bobAccount);
    accountRepo.save(aliceAccount);
    accountRepo.save(tomAccount);
    accountRepo.flush();
}

8. BankService nơi các transaction diễn ra

Nghiệp vụ cơ bản chỉ là:

  1. Tìm tài khoản bị trừ tiền theo mã tài khoản
  2. Tìm tài khoản được nhận tiền theo mã tài khoản
  3. Nếu không tìm được tài khoản có nghĩa mã tài khoản bị lỗi, throw BankException
  4. Nếu số tiền trong tài khoản bị trừ tiền nhỏ hơn số tiền phải chuyển cũng throw BankException
  5. Nếu tài khoản nhận tiền bị đóng băng cũng throw BankException
  6. Nếu mọi thứ hợp lệ thì trừ tiền từ tài khoản chuyển đi, cộng tiền từ tài khoản nhận
  7. Trả về kết quả.

Phương thức transfer sẽ được annotate bởi @Transactional(rollbackOn = { BankException.class }). Mục đích sẽ transaction sẽ được rollback khi có exception kiểu BankException.class được ném ra

@Transactional(rollbackOn = { BankException.class })
  public TransferResult transfer(long fromAccID, long toAccID, long amount) {
//Chi tiết logic tạm lược bỏ để cho ngắn gọn
}

8.1 Luôn kiểm tra các điều kiện, nếu không hợp lệ lập tức ném ra Exception và rollback Transaction sớm nhất có thể

Nếu các bạn xem code chi tiết của phương thức transfer, tôi luôn đưa các lệnh kiểm tra hợp lệ lên đầu tiên, trước khi bước vào những lệnh ghi. Vì hai nguyên nhân:

  1. Loại bỏ ngay những trường hợp không hợp lệ càng sớm càng tốt
  2. Rút ngắn thời gian thực hiện các lệnh ghi, gom chúng càng gần nhau càng tốt, để rút ngắn thời gian locking bảng, hoặc dòng trước khi cập nhật. Điều này cũng tăng tốc độ của database.

8.2 Code chi tiết phương thức transfer

@Transactional(rollbackOn = { BankException.class })
  public TransferResult transfer(long fromAccID, long toAccID, long amount) {
    Optional<Account> o_fromAccount = accountRepo.findById(fromAccID);
    Optional<Account> o_toAccount = accountRepo.findById(toAccID);
    Account fromAccount;
    Account toAccount;
    

    if (o_fromAccount.isPresent()) {
      fromAccount = o_fromAccount.get();
    } else {
      String detail = "From Account id " + fromAccID + " does not exist";
      loggingService.saveLog(fromAccID, toAccID, amount, BankErrorCode.ID_NOT_FOUND, detail);
      throw new BankException(BankErrorCode.ID_NOT_FOUND, "Invalid bank account",
        detail);
    }

    if (o_toAccount.isPresent()) {
      toAccount = o_toAccount.get();
    } else {
      String detail = "To Account id " + toAccID + " does not exist";
      loggingService.saveLog(fromAccID, toAccID, amount, BankErrorCode.ID_NOT_FOUND, detail);
      throw new BankException(BankErrorCode.ID_NOT_FOUND, "Invalid bank account", detail);
    }

    if (fromAccount.getBalance() < amount) {
      String detail = "Account " + fromAccount.getId() + " of " + fromAccount.getOwner() + " does not have enough balance";
      loggingService.saveLog(fromAccID, toAccID, amount, BankErrorCode.BALANCE_NOT_ENOUGH, detail);
      throw new BankException(BankErrorCode.BALANCE_NOT_ENOUGH, "Not enough balance", detail);
    }

    if (toAccount.getState() == AccountState.DISABLED) {
      String detail = "Account " + toAccount.getId() + " of " + toAccount.getOwner() + " is disabled";
      loggingService.saveLog(fromAccID, toAccID, amount, BankErrorCode.ACCOUNT_DISABLED, detail);
      throw new BankException(BankErrorCode.ACCOUNT_DISABLED, "Account is disabled", detail);      
    }
    

    fromAccount.setBalance(fromAccount.getBalance() - amount);
    toAccount.setBalance(toAccount.getBalance() + amount);
    Date transferDate = new Date();
    TransactLog transactLog = new TransactLog(fromAccount, toAccount, amount, transferDate);

    accountRepo.save(fromAccount);
    accountRepo.save(toAccount);
    transactLogRepo.save(transactLog);
    
    loggingService.saveLog(fromAccID, toAccID, amount, BankErrorCode.SUCCESS, "Success");

    return new TransferResult(BankErrorCode.SUCCESS, "Transfer success", transferDate);
  } 

9. Kết của phần 1

Ở phần đầu tiên, tôi đã giới thiệu các thành phần trong ứng dụng và sơ lược về annotation @Transactional. Bạn có thể tải mã nguồn hoàn chỉnh về chạy được luôn. Ở bài số 2, chúng ta sẽ xem xét kỹ nested transaction, transaction context, các cấu hình để tuỳ biến kết quả transaction theo ý đồ của lập trình viên. Chúc ngày nghỉ lễ 30/4 và 1/5 vui vẻ và an toàn nhé. Tôi bơi ra đảo cắm trại đây 🏊🏻‍♂️🏝🏕