@Transactional & Spring AOP
Tung Bui Thanh (TungDaDev)12 mins read

# giới thiệu
Làm Spring lâu năm thì có 2 thứ nên hiểu cho kỹ: Transactions và AOP (Aspect-Oriented Programming). Hai thứ này dính chặt với nhau, vì bản thân @Transactional chạy được là nhờ AOP proxy.
Transaction đảm bảo ACID: hoặc commit hết, hoặc rollback hết. Không có chuyện "nửa nọ nửa kia" kiểu order tạo rồi mà payment chưa ghi, hay inventory đã trừ mà order lại fail. Sang microservices thì quản lý transaction còn rắc rối hơn nữa, phải tính tới distributed transaction và saga pattern.
AOP giúp tách mấy thứ "cross-cutting concerns" như logging, security, caching, metrics ra khỏi business logic. Thay vì copy-paste dòng log vào từng method, bạn viết 1 aspect là áp được cho tất cả.
# @Transactional: đào sâu
# cơ bản: service layer pattern
Trong Spring, ranh giới transaction đặt ở service layer, không phải controller, cũng không phải repository. Lý do: 1 nghiệp vụ thường gọi tới nhiều repository, và tất cả phải nằm chung 1 transaction.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true) // Default: read-only cho toàn class
@Slf4j
public class OrderService {
private final OrderRepository orderRepository;
private final InventoryService inventoryService;
private final PaymentService paymentService;
private final ApplicationEventPublisher eventPublisher;
// Read-only: Hibernate skip dirty checking → faster
public OrderDTO getById(UUID id) {
return orderRepository.findById(id)
.map(this::toDTO)
.orElseThrow(() -> new OrderNotFoundException(id));
}
// Write operation: override class-level readOnly
@Transactional // readOnly = false
public OrderDTO create(CreateOrderRequest request) {
// Tất cả trong 1 transaction:
Order order = buildOrder(request);
order = orderRepository.save(order); // 1. Save order
inventoryService.reserve(order.getItems()); // 2. Reserve inventory
paymentService.authorize(order); // 3. Authorize payment
// Nếu step 3 throw exception → phần ghi DB của step 1, 2 đều rollback
// (gọi API bên ngoài thì không rollback được, phải tự bù trừ)
eventPublisher.publishEvent(new OrderCreatedEvent(order));
return toDTO(order);
}
// Explicit rollback rules
@Transactional(rollbackFor = Exception.class) // Rollback cho checked exceptions
public void importOrders(List<CreateOrderRequest> requests) {
// Mặc định: chỉ rollback RuntimeException + Error
// rollbackFor = Exception.class: rollback cả checked exceptions
requests.forEach(this::create);
}
// No rollback cho specific exceptions
@Transactional(noRollbackFor = DuplicateOrderException.class)
public OrderDTO createIdempotent(CreateOrderRequest request) {
try {
return create(request);
} catch (DuplicateOrderException e) {
// Exception đã bị catch nên rollback rule không dính vào;
// noRollbackFor chỉ có tác dụng khi exception ném RA KHỎI method
return getByIdempotencyKey(request.getIdempotencyKey());
}
}
}Mấy chỗ trong ví dụ trên dễ hiểu sai. create() gửi publishEvent khi transaction còn mở: listener @EventListener thường chạy đồng bộ ngay lúc đó, nằm trong cùng transaction, gửi email xong mà transaction rollback là email "ma". Việc cần chạy sau commit thì dùng @TransactionalEventListener (mặc định phase AFTER_COMMIT).
noRollbackFor chỉ quyết định khi exception ra khỏi method có @Transactional. Exception bị catch bên trong thì rollback rule không liên quan. Vấn đề thật nằm ở chỗ khác: nếu DuplicateOrderException sinh ra từ vi phạm unique constraint, thì PostgreSQL đã đánh dấu transaction ở phía DB là hỏng ("current transaction is aborted"), Hibernate cũng đánh dấu rollback-only, và câu getByIdempotencyKey phía sau sẽ lỗi tiếp. Cách an toàn là kiểm tra idempotency key trước khi insert, hoặc chạy phần insert trong một transaction riêng (REQUIRES_NEW ở bean khác) rồi đọc lại ở transaction ngoài.
# propagation: transaction khi method gọi nhau
@Service
public class OrderService {
// REQUIRED (default): Join TX hiện có, hoặc tạo mới
@Transactional(propagation = Propagation.REQUIRED)
public OrderDTO create(CreateOrderRequest request) {
// Nếu caller đã có TX → join
// Nếu không → tạo TX mới
}
// REQUIRES_NEW: LUÔN tạo TX mới (suspend TX hiện tại)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logAudit(AuditEntry entry) {
// TX riêng: commit ngay cả khi caller TX rollback
// Lưu ý: TX ngoài vẫn giữ connection, TX mới lấy thêm 1 connection nữa
// Use case: audit log phải ghi bất kể business op thành công hay không
auditRepository.save(entry);
}
// NESTED: Tạo savepoint trong TX hiện tại
@Transactional(propagation = Propagation.NESTED)
public void processItem(OrderItem item) {
// Nếu fail → rollback đến savepoint (không rollback cả TX)
// Caller có thể catch và continue với item tiếp
// Chỉ đúng với JDBC; với JPA, savepoint không rollback entity trong persistence context
}
// MANDATORY: PHẢI có TX sẵn, exception nếu không
@Transactional(propagation = Propagation.MANDATORY)
public void updateInventory(UUID productId, int qty) {
// Force caller phải gọi trong TX context
// Tránh vô tình gọi standalone mà quên TX
}
// NOT_SUPPORTED: Suspend TX, chạy không TX
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public ReportDTO generateReport(ReportRequest request) {
// Read-heavy, long-running → không cần giữ TX lock
}
}Lưu ý mọi propagation ở trên chỉ có tác dụng khi method được gọi qua proxy (từ bean khác). Gọi this.logAudit(...) từ trong cùng class thì REQUIRES_NEW bị bỏ qua (xem bẫy số 1 bên dưới). REQUIRES_NEW còn một bẫy ở tải cao: mỗi request giữ 2 connection cùng lúc, pool 10 connection mà có 10 request cùng vào là cả 10 đứng chờ connection thứ hai, treo tới khi timeout. NESTED cần JDBC savepoint: DataSourceTransactionManager hỗ trợ tốt; JpaTransactionManager chỉ áp savepoint lên JDBC connection chứ không lên EntityManager (JPA không có nested transaction), và từ Spring Framework 7 tính năng này mặc định tắt với JPA.
# isolation level: kiểm soát truy cập đồng thời
// READ_COMMITTED (default PostgreSQL): Không đọc uncommitted data
@Transactional(isolation = Isolation.READ_COMMITTED)
public OrderDTO getOrder(UUID id) { ... }
// REPEATABLE_READ: Cùng query trong TX luôn trả cùng kết quả
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void transferFunds(UUID from, UUID to, BigDecimal amount) {
// Snapshot đọc không đổi, nhưng KHÔNG tự khoá dòng:
// PostgreSQL: TX ghi sau bị lỗi serialization → phải retry
// MySQL InnoDB: UPDATE đọc giá trị mới nhất → vẫn có thể lost update
// Muốn chắc: SELECT ... FOR UPDATE hoặc optimistic lock (@Version)
}
// SERIALIZABLE: Cao nhất — kết quả như thể các TX chạy tuần tự
@Transactional(isolation = Isolation.SERIALIZABLE)
public void processPayment(PaymentRequest request) {
// DB không thật sự chạy tuần tự: PostgreSQL (SSI) cho chạy song song
// rồi huỷ TX xung đột → caller phải retry khi gặp serialization failure
// Trade-off: throughput giảm, tỉ lệ retry tăng khi tranh chấp cao
}Mặc định mỗi DB một khác: PostgreSQL, Oracle, SQL Server là READ_COMMITTED, MySQL InnoDB là REPEATABLE_READ. Không khai báo isolation thì Spring dùng mức mặc định của DB. Với bài toán chuyển tiền, thứ cứu bạn thường là khoá dòng (SELECT ... FOR UPDATE, trong Spring Data là @Lock(LockModeType.PESSIMISTIC_WRITE)) hoặc @Version, chứ không phải nâng isolation level.
# mấy cái bẫy của @Transactional nên biết
// ❌ PITFALL 1: Self-invocation bypass proxy
@Service
public class OrderService {
@Transactional
public void processAll() {
for (Order order : orders) {
this.processOne(order); // GỌI TRỰC TIẾP → KHÔNG qua proxy → KHÔNG có TX
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processOne(Order order) { ... }
}
// ✅ FIX: Inject self hoặc tách service
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderItemProcessor itemProcessor; // Separate bean
@Transactional
public void processAll() {
for (Order order : orders) {
itemProcessor.processOne(order); // Qua proxy → có TX
}
}
}
// ❌ PITFALL 2: @Transactional trên private/final method
@Transactional
private void internalMethod() { } // CGLIB cannot proxy private/final
// ❌ PITFALL 3: Catch exception trong TX method
@Transactional
public void riskyOperation() {
try {
otherService.riskyCall(); // @Transactional (REQUIRED) ở bean khác, throws RuntimeException
} catch (Exception e) {
log.error("Failed", e);
// Exception đã đi qua proxy của riskyCall → TX chung bị mark rollback-only!
// Khi method return, Spring throw UnexpectedRollbackException
}
}
// ✅ FIX: REQUIRES_NEW cho từng item (hoặc TransactionTemplate) khi cần partial commit
@Transactional
public void batchProcess(List<Item> items) {
for (Item item : items) {
try {
itemProcessor.process(item); // REQUIRES_NEW → isolated TX
} catch (Exception e) {
log.warn("Item {} failed, continuing", item.getId());
// item's TX rolls back, outer TX continues
}
}
}Bẫy số 3 hay làm người ta bối rối vì nó chỉ xảy ra khi exception đi qua một proxy transactional khác đang tham gia cùng transaction (một service @Transactional khác, hoặc chính repository của Spring Data vì method của nó cũng có @Transactional). Proxy đó thấy exception thì đánh dấu transaction chung là rollback-only, bạn catch ở ngoài cũng không gỡ được. Nếu riskyCall() chỉ là method thường trong cùng class thì không có gì bị đánh dấu, transaction commit bình thường cùng với những gì đã ghi trước đó, cũng nguy hiểm theo kiểu khác.
Với bẫy số 2: từ Spring 6.0, proxy CGLIB (mặc định trong Spring Boot) chặn được cả method protected và package-private, chỉ còn private, final, static là chịu.
# spring aop: xử lý cross-cutting concerns
AOP cho phép chèn cùng 1 đoạn logic "cắt ngang" qua nhiều class mà không phải sửa code gốc. Spring làm AOP bằng proxy pattern (y như @Transactional).
# mấy khái niệm cần nắm
| Thuật ngữ | Nghĩa | Ví dụ |
|---|---|---|
| Aspect | Module chứa logic cross-cutting | LoggingAspect |
| Advice | Đoạn code chạy tại join point | @Around, @Before, @After |
| Pointcut | Biểu thức chọn method nào sẽ bị intercept | execution(* *..service..*(..)) |
| Join Point | Điểm thực thi bị intercept | Method call |
| Target | Object gốc (chưa bọc proxy) | ProductService instance |
# logging aspect
@Aspect
@Component
@Slf4j
public class ServiceLoggingAspect {
// Pointcut: mọi public method trong package service
@Around("execution(public * com.tungdadev.csp..service..*(..))")
public Object logServiceMethod(ProceedingJoinPoint joinPoint) throws Throwable {
String method = joinPoint.getSignature().toShortString();
String traceId = RequestContext.getRequestId();
long start = System.nanoTime();
log.info("[traceId={}] → {}", traceId, method);
try {
Object result = joinPoint.proceed();
long duration = (System.nanoTime() - start) / 1_000_000;
log.info("[traceId={}] ← {} | {}ms", traceId, method, duration);
return result;
} catch (Exception e) {
long duration = (System.nanoTime() - start) / 1_000_000;
log.error("[traceId={}] ✗ {} | {}ms | {}", traceId, method, duration, e.getMessage());
throw e;
}
}
}# performance monitoring aspect
@Aspect
@Component
@Slf4j
@RequiredArgsConstructor
public class PerformanceAspect {
private final MeterRegistry meterRegistry;
// Custom annotation
@Around("@annotation(monitored)")
public Object monitorPerformance(ProceedingJoinPoint pjp, Monitored monitored) throws Throwable {
String metricName = monitored.value().isEmpty()
? pjp.getSignature().toShortString()
: monitored.value();
Timer.Sample sample = Timer.start(meterRegistry);
try {
Object result = pjp.proceed();
sample.stop(Timer.builder(metricName).tag("status", "success").register(meterRegistry));
return result;
} catch (Exception e) {
sample.stop(Timer.builder(metricName).tag("status", "error").register(meterRegistry));
throw e;
}
}
}
// Custom annotation
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Monitored {
String value() default "";
}
// Usage
@Monitored("order.creation")
public OrderDTO createOrder(CreateOrderRequest request) { ... }Khi không truyền value, tên metric lấy từ toShortString() (dạng OrderService.create(..)), có cả dấu ngoặc nên khi xuất sang Prometheus sẽ bị đổi ký tự lạ thành _. Thực tế nên dùng một tên metric cố định rồi gắn tên method vào tag. Micrometer cũng có sẵn @Timed cùng TimedAspect làm đúng việc này.
# retry aspect
@Aspect
@Component
@Slf4j
public class RetryAspect {
@Around("@annotation(retryable)")
public Object retry(ProceedingJoinPoint pjp, Retryable retryable) throws Throwable {
int maxAttempts = retryable.maxAttempts();
long delay = retryable.delay();
Exception lastException = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return pjp.proceed();
} catch (Exception e) {
lastException = e;
if (attempt < maxAttempts) {
log.warn("Attempt {}/{} failed for {}: {}. Retrying in {}ms",
attempt, maxAttempts, pjp.getSignature().toShortString(),
e.getMessage(), delay);
Thread.sleep(delay);
delay *= 2; // Exponential backoff
}
}
}
throw lastException;
}
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Retryable {
int maxAttempts() default 3;
long delay() default 1000;
}
// Usage
@Retryable(maxAttempts = 3, delay = 500)
public ExternalResponse callExternalService(Request request) { ... }Ví dụ này để hiểu cơ chế; production nên dùng Spring Retry hoặc Resilience4j (đặt tên annotation trùng @Retryable của Spring Retry, hay của Spring Framework 7, dễ import nhầm). Có hai điểm cần cân nhắc. Một là chỉ retry lỗi tạm thời (timeout, 503), retry cả lỗi validation chỉ tốn thêm thời gian. Hai là thứ tự aspect: method vừa @Retryable vừa @Transactional thì retry phải nằm ngoài transaction, nếu không thì cả 3 lần thử chạy trong cùng một transaction đã bị đánh dấu rollback-only. Advisor của @Transactional mặc định có order Ordered.LOWEST_PRECEDENCE, nên đánh @Order(1) (số nhỏ = chạy ngoài) cho RetryAspect.
# pointcut expressions cheat sheet
// Mọi method trong service package
@Pointcut("execution(* com.tungdadev.csp..service..*(..))")
// Mọi public method
@Pointcut("execution(public * *(..))")
// Method có annotation cụ thể
@Pointcut("@annotation(com.tungdadev.csp.common.annotation.Monitored)")
// Class có annotation cụ thể
@Pointcut("@within(org.springframework.stereotype.Service)")
// Method nhận tham số cụ thể (phải ghi tên class đầy đủ, trừ java.lang)
@Pointcut("execution(* *..*(java.util.UUID, ..))") // First param is UUID
// Combine pointcuts (serviceLayer() và getter() là 2 method @Pointcut khai báo trước)
@Pointcut("serviceLayer() && !getter()")
public void serviceMethodsExcludeGetters() {}Mỗi @Pointcut trong annotation-style phải gắn trên một method rỗng void, các dòng ở trên viết gọn nên lược bỏ phần method. Spring AOP chỉ chặn được lời gọi method trên bean Spring đi qua proxy; muốn chặn field access, constructor hay self-invocation thì phải dùng AspectJ weaving thật (compile-time hoặc load-time).
# quick reference
| Annotation | Mục đích |
|---|---|
| @Transactional | Đánh dấu ranh giới transaction |
| @Transactional(readOnly) | Tối ưu cho thao tác chỉ đọc |
| @Transactional(propagation) | TX xử lý ra sao khi gọi nhau |
| @Transactional(isolation) | Kiểm soát truy cập đồng thời |
| @Transactional(rollbackFor) | Chỉ định exception nào rollback |
| @Transactional(timeout) | Timeout của TX, tính bằng giây |
| @Aspect | Khai báo một class aspect |
| @Around | Bọc quanh lúc method chạy |
| @Before | Chạy trước method |
| @After | Chạy sau method (luôn chạy) |
| @AfterReturning | Chạy sau khi method trả về OK |
| @AfterThrowing | Chạy khi method ném exception |
| @Pointcut | Pointcut dùng lại được |
| @EnableAspectJAutoProxy | Bật AOP proxy (Spring Boot tự bật) |
| @Order | Thứ tự chạy của các aspect |
Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!
Happy coding 😎 👍🏻 🚀 🔥.