Chương 4. Spring và Spring Boot
Với ứng viên 2–4 năm, tôi không hỏi "Spring là gì" nữa. Tôi hỏi bạn đã từng bị @Transactional "lừa" chưa, vì sao bean của bạn không được proxy, vì sao ứng dụng khởi động báo circular dependency sau khi nâng cấp Spring Boot. Chương này đi vào đúng những chỗ mà code chạy được nhưng chạy sai, vì đó là nơi phân biệt người dùng framework theo thói quen và người hiểu framework.
Người phỏng vấn muốn kiểm tra
- Hiểu IoC/DI ở mức thiết kế: vì sao constructor injection, cách xử lý circular dependency.
- Nắm bean lifecycle và biết proxy AOP được tạo ở bước nào.
- Giải thích được Spring Boot auto-configuration hoạt động ra sao, debug được khi nó "tự làm sai".
- Dùng
@Transactionalđúng: proxy, self-invocation, rollback rule, propagation, transaction dài. - Tổ chức xử lý lỗi, validation, và chọn đúng giữa Filter, Interceptor, AOP.
4.1 IoC, DI và ApplicationContext
IoC (Inversion of Control) nghĩa là object không tự tạo và tự tìm dependency của nó; container (ApplicationContext) làm việc đó. DI (Dependency Injection) là cách hiện thực IoC: dependency được "tiêm" qua constructor, setter hoặc field. Điểm cần nói được ở mức 2–4 năm: DI là cơ chế, còn Dependency Inversion Principle (chữ D trong SOLID) là nguyên tắc thiết kế. Bạn có thể dùng Spring DI mà vẫn vi phạm DIP nếu service phụ thuộc thẳng vào class cụ thể thay vì abstraction.
Nhắc lại kiến thức
IoC nghĩa là quyền tạo và nối các object được giao cho container; DI là cách đưa dependency vào từ bên ngoài, thường qua constructor (xem N2.7). Constructor là đoạn code chạy đúng một lần khi object được tạo bằng new, dùng để gán giá trị ban đầu cho field (xem N1.4).
Bean, container và ApplicationContext bằng lời thường
Trước khi đi tiếp, cần thống nhất ba từ mà cả chương này dùng liên tục:
- Bean chỉ là một object Java bình thường. Điểm khác duy nhất: nó do Spring tạo ra và quản lý, thay vì bạn tự
new.OrderService,OrderRepository,ObjectMappertrong ứng dụng của bạn đều là bean. - Container là "nhà kho kiêm xưởng lắp ráp" giữ các bean: nó biết phải tạo những object nào, tạo theo thứ tự nào, đưa object nào vào constructor của object nào, và dọn dẹp khi ứng dụng tắt.
- ApplicationContext là tên interface của container trong Spring. Khi
SpringApplication.run(...)chạy xong, bạn nhận về một ApplicationContext chứa toàn bộ bean của ứng dụng.
So sánh đời thường: ở nhà hàng, bạn gọi món (khai báo mình cần gì) chứ không tự vào bếp nấu (tự new); nhà bếp là container. Đoạn code Java thuần dưới đây tự viết một "container" tí hon để thấy container làm gì bên trong: nó giữ một Map từ tên sang object và tự nối dependency.
import java.util.HashMap;
import java.util.Map;
public class MiniContainerDemo {
interface Repo { String find(int id); }
static class DbRepo implements Repo {
public String find(int id) { return "order-" + id; }
}
static class OrderService {
private final Repo repo; // the dependency comes from outside
OrderService(Repo repo) { this.repo = repo; }
String load(int id) { return repo.find(id); }
}
public static void main(String[] args) {
Map<String, Object> beans = new HashMap<>(); // the "container"
Repo repo = new DbRepo(); // create dependencies first
beans.put("repo", repo);
beans.put("orderService", new OrderService(repo)); // then wire them together
OrderService svc = (OrderService) beans.get("orderService"); // like context.getBean(...)
System.out.println(svc.load(7)); // prints: order-7
System.out.println(beans.size()); // prints: 2
}
}Giải thích: Chạy chương trình in ra order-7 rồi 2. OrderService không tự new DbRepo(); "ai đó" (ở đây là main) tạo DbRepo trước rồi đưa vào constructor. Spring làm đúng việc này nhưng tự động: nó quét các class có @Component/@Service/@Repository, đọc constructor để biết class nào cần class nào, sắp thứ tự tạo, rồi lưu kết quả vào một Map tên → bean bên trong ApplicationContext. context.getBean(OrderService.class) tương đương dòng beans.get(...). Khác biệt quan trọng nhất: Spring có thể cất vào Map một proxy bọc object thật thay vì chính object thật (mục 4.4), và đó là nền tảng của @Transactional, @Async, @Cacheable.
Constructor injection là mặc định
Đoạn code dưới đây là cách khai báo dependency chuẩn cho một service: mọi dependency bắt buộc đi qua constructor và được đánh dấu final.
@Service
public class OrderService {
private final OrderRepository orderRepository; // final: assigned exactly once, never reassigned
private final PaymentClient paymentClient;
// A single constructor needs no @Autowired (since Spring 4.3)
public OrderService(OrderRepository orderRepository, PaymentClient paymentClient) {
this.orderRepository = orderRepository; // Spring passes in the bean from the context
this.paymentClient = paymentClient;
}
}Giải thích: Khi khởi động, Spring thấy OrderService chỉ có một constructor nên dùng chính constructor đó để tạo bean: nó tìm (hoặc tạo trước) bean OrderRepository và PaymentClient trong ApplicationContext rồi truyền vào. Nếu thiếu một trong hai bean, ứng dụng dừng ngay lúc startup với UnsatisfiedDependencyException thay vì chạy được rồi mới NullPointerException giữa chừng. Vì hai field là final, khi constructor chạy xong object luôn ở trạng thái đầy đủ và không ai gán lại được.
Lý do chọn constructor injection:
- Dependency bắt buộc được đảm bảo có mặt ngay khi object tồn tại, field có thể
final, object bất biến sau khi tạo. - Unit test không cần Spring: chỉ việc
new OrderService(mockRepo, mockClient). - Constructor quá nhiều tham số là tín hiệu class đang ôm quá nhiều trách nhiệm (vi phạm SRP), thứ mà field injection che giấu.
- Circular dependency lộ ra ngay lúc khởi động thay vì bị che đi.
Field injection (@Autowired trên field) có nhược điểm: không final được, phải dùng reflection hoặc dựng Spring context để test, dependency bị ẩn khỏi API công khai của class, và object có thể tồn tại ở trạng thái thiếu dependency nếu ai đó tạo bằng new. Setter injection chỉ hợp cho dependency tùy chọn.
Đoạn code dưới đây cho thấy nhược điểm "object tồn tại ở trạng thái thiếu dependency" của field injection khi viết unit test không dùng Spring:
@Service
public class InvoiceQueryService {
@Autowired
private InvoiceRepository repo; // field injection: Spring sets it via reflection
public long count() { return repo.count(); }
}
// Plain unit test, no Spring context:
// new InvoiceQueryService().count(); -> NullPointerException, repo is nullGiải thích: Compiler cho phép new InvoiceQueryService() vì class có constructor mặc định, nhưng không ai gán repo, nên lời gọi count() ném NullPointerException. Người viết test phải biết "bí mật" rằng class cần InvoiceRepository và dùng reflection (hoặc @InjectMocks) để nhét vào. Với constructor injection, new InvoiceQueryService(mockRepo) là cách duy nhất để tạo object, compiler buộc bạn truyền đủ dependency, nên lỗi này không thể xảy ra.
Circular dependency
A cần B, B cần A. Với constructor injection, Spring không thể tạo bean nào trước nên ném BeanCurrentlyInCreationException. Với field/setter injection, Spring từng "cứu" được nhờ cơ chế early singleton reference (cache 3 cấp), nhưng từ Spring Boot 2.6, circular reference bị cấm mặc định (spring.main.allow-circular-references=false). Cách xử lý đúng theo thứ tự ưu tiên:
- Thiết kế lại: tách phần logic dùng chung ra bean thứ ba, hoặc gộp nếu hai class thực chất là một trách nhiệm.
- Dùng event (
ApplicationEventPublisher) để đảo chiều phụ thuộc. - Tạm thời dùng
@Lazytrên một tham số constructor (Spring tiêm proxy lazy). Bật lạiallow-circular-referenceschỉ là giải pháp chữa cháy.
Ví dụ cụ thể: OrderService cần CustomerService để lấy hạng thành viên khi tính giảm giá, còn CustomerService lại cần OrderService để tính tổng chi tiêu của khách. Cả hai dùng constructor injection, và khi khởi động, Spring Boot dừng với thông báo:
The dependencies of some of the beans in the application context form a cycle:
orderService defined in file [.../OrderService.class]
↓
customerService defined in file [.../CustomerService.class]
↓
(back to orderService)Cách sửa theo hướng thiết kế lại: phần "tính tổng chi tiêu" thực chất chỉ cần dữ liệu đơn hàng, nên tách ra bean thứ ba chỉ phụ thuộc repository.
@Service
public class CustomerStatsService { // new bean: the shared logic moves here
private final OrderRepository orderRepository;
public CustomerStatsService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
public BigDecimal totalSpent(long customerId) {
return orderRepository.sumTotalByCustomerId(customerId);
}
}
// Now: OrderService -> CustomerService -> CustomerStatsService -> OrderRepository (no cycle)Giải thích: Trước khi sửa, Spring muốn tạo OrderService thì phải có CustomerService, mà muốn tạo CustomerService thì phải có OrderService, nên không bean nào tạo được trước. Sau khi sửa, CustomerService phụ thuộc CustomerStatsService thay vì OrderService, đồ thị phụ thuộc thành một đường thẳng và Spring tạo được theo thứ tự từ dưới lên. Vòng phụ thuộc thường là dấu hiệu hai class đang chia nhau một trách nhiệm chưa được đặt tên; tách nó ra làm code rõ nghĩa hơn chứ không chỉ để "qua được lỗi".
ApplicationContext và BeanFactory
BeanFactory là interface gốc, cung cấp DI cơ bản và khởi tạo bean lazy. ApplicationContext mở rộng nó: khởi tạo sẵn singleton lúc startup (phát hiện lỗi cấu hình sớm), tự đăng ký BeanPostProcessor, hỗ trợ event, i18n (MessageSource), Environment/profile, load resource. Trong thực tế bạn luôn dùng ApplicationContext; trả lời được một câu so sánh là đủ.
4.2 Bean lifecycle và scope
Thứ tự vòng đời một singleton bean:
- Instantiate: gọi constructor (constructor injection xảy ra ở đây).
- Populate properties: tiêm dependency qua field/setter.
- Aware callbacks:
BeanNameAware,BeanFactoryAware,ApplicationContextAware... - BeanPostProcessor.postProcessBeforeInitialization.
- Init callbacks:
@PostConstruct→InitializingBean.afterPropertiesSet()→initMethodkhai báo trong@Bean. - BeanPostProcessor.postProcessAfterInitialization: đây là chỗ AOP proxy được tạo (cho
@Transactional,@Async,@Cacheable...). Bean trong context từ đây là proxy, không phải object gốc. - Bean sẵn sàng sử dụng.
- Khi context đóng:
@PreDestroy→DisposableBean.destroy()→destroyMethod.
Hệ quả thực tế: trong @PostConstruct, proxy chưa bọc bean, nên gọi một method @Transactional của chính nó từ @PostConstruct sẽ không có transaction. Nếu cần chạy logic khởi tạo có transaction, dùng ApplicationRunner hoặc lắng nghe ApplicationReadyEvent. Ngoài ra, với bean prototype, Spring không gọi destroy callback; bạn tự chịu trách nhiệm giải phóng tài nguyên.
Vài thuật ngữ trong danh sách trên, nói bằng lời thường:
- Callback là method bạn viết sẵn, còn Spring là bên gọi nó vào đúng thời điểm (bạn không tự gọi).
- Aware callback: nếu bean implement
BeanNameAware, Spring "báo" cho bean biết tên của nó;ApplicationContextAwarethì đưa luôn container cho bean. Ít dùng trong code nghiệp vụ. - BeanPostProcessor là một bean đặc biệt mà Spring gọi cho mọi bean khác, một lần trước và một lần sau bước init. Nó được phép trả về một object khác thay cho bean gốc. Spring dùng chính cơ chế này để thay bean gốc bằng proxy.
Ví dụ in thứ tự vòng đời
Đoạn code dưới đây khai báo một bean implement các callback và một BeanPostProcessor chỉ in log cho bean đó, để thấy thứ tự thật khi ứng dụng khởi động rồi tắt.
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.springframework.beans.factory.BeanNameAware;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.stereotype.Component;
@Component
public class LifecycleDemo implements BeanNameAware, InitializingBean, DisposableBean {
public LifecycleDemo() { System.out.println("1. constructor"); }
@Override public void setBeanName(String name) { System.out.println("2. setBeanName: " + name); }
@PostConstruct void init() { System.out.println("4. @PostConstruct"); }
@Override public void afterPropertiesSet() { System.out.println("5. afterPropertiesSet"); }
@PreDestroy void cleanup() { System.out.println("7. @PreDestroy"); }
@Override public void destroy() { System.out.println("8. destroy"); }
}import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.stereotype.Component;
@Component
public class TracingPostProcessor implements BeanPostProcessor {
@Override
public Object postProcessBeforeInitialization(Object bean, String name) {
if (name.equals("lifecycleDemo")) System.out.println("3. BPP before init");
return bean;
}
@Override
public Object postProcessAfterInitialization(Object bean, String name) {
if (name.equals("lifecycleDemo")) System.out.println("6. BPP after init (proxies are created here)");
return bean; // AOP post-processors return a proxy here instead of the original bean
}
}1. constructor
2. setBeanName: lifecycleDemo
3. BPP before init
4. @PostConstruct
5. afterPropertiesSet
6. BPP after init (proxies are created here)
... application runs ...
7. @PreDestroy
8. destroyGiải thích: Thứ tự in ra khớp đúng danh sách 8 bước ở trên. Bước 6 là chỗ đáng nhớ nhất: các post-processor của AOP (ví dụ bộ xử lý @Transactional) được gọi ở đây và trả về proxy thay cho object gốc, nên ở bước 4 (@PostConstruct) bean vẫn chỉ là object gốc chưa được bọc. Dòng 7 và 8 chỉ in khi context đóng bình thường (Ctrl+C, SIGTERM), không in nếu process bị kill -9. Chú ý @PostConstruct/@PreDestroy trong Spring Boot 3 thuộc package jakarta.annotation, không còn là javax.annotation.
Hệ quả trên thành một bug quen thuộc: gọi method @Transactional từ @PostConstruct. Đoạn dưới đây muốn nạp sẵn dữ liệu vào cache khi khởi động:
@Service
public class CatalogCache {
private final ProductRepository repo;
public CatalogCache(ProductRepository repo) { this.repo = repo; }
@PostConstruct
void init() { warmUp(); } // WRONG: raw object, no proxy yet, and a call via this
@Transactional(readOnly = true)
public void warmUp() {
repo.findAll().forEach(p -> p.getTags().size()); // LazyInitializationException: no session
}
}@Component
public class CatalogWarmUpRunner implements ApplicationRunner {
private final CatalogCache cache;
public CatalogWarmUpRunner(CatalogCache cache) { this.cache = cache; }
@Override
public void run(ApplicationArguments args) {
cache.warmUp(); // FIXED: context fully started, call goes through the proxy -> transaction opens
}
}Giải thích: Ở bản sai, init() chạy ở bước 4, khi chưa có proxy, và lời gọi warmUp() lại đi qua this, nên không có transaction; truy cập collection lazy getTags() ngoài transaction ném LazyInitializationException và ứng dụng không khởi động được. Ở bản sửa, ApplicationRunner chạy sau khi context khởi động xong, gọi warmUp() qua biến cache (là proxy) từ một bean khác, nên transaction được mở đúng cách. Bỏ @PostConstruct khỏi CatalogCache khi dùng cách này.
Các scope
| Scope | Vòng đời instance | Ghi chú |
|---|---|---|
| singleton | 1 instance mỗi ApplicationContext | Mặc định, không phải "1 instance mỗi JVM" |
| prototype | Mỗi lần lấy bean tạo mới | Container không quản lý destroy |
| request | 1 instance mỗi HTTP request | Chỉ có trong web context |
| session | 1 instance mỗi HTTP session | Cẩn thận khi scale nhiều node |
| application | 1 instance mỗi ServletContext | Khác singleton khi có nhiều context |
| websocket | 1 instance mỗi WebSocket session | Dùng với STOMP |
Đoạn code dưới đây lấy cùng một bean hai lần để thấy khác biệt giữa singleton và prototype:
@SpringBootApplication
public class ScopeDemo {
@Bean
@Scope("prototype")
StringBuilder draft() { return new StringBuilder(); } // new instance on every lookup
public static void main(String[] args) {
var ctx = SpringApplication.run(ScopeDemo.class, args);
System.out.println(ctx.getBean(ScopeDemo.class) == ctx.getBean(ScopeDemo.class)); // prints: true
System.out.println(ctx.getBean(StringBuilder.class) == ctx.getBean(StringBuilder.class)); // prints: false
}
}Giải thích: ScopeDemo là singleton (mặc định), nên hai lần getBean trả về đúng một object, so sánh == ra true. Bean draft khai báo @Scope("prototype"), nên mỗi lần getBean Spring gọi lại method draft() và trả về object mới, so sánh ra false.
Prototype tiêm vào singleton
Singleton chỉ được tạo một lần, nên prototype tiêm qua constructor cũng chỉ được lấy một lần và dùng mãi. Ví dụ: ReportBuilder được khai báo @Scope("prototype") vì nó giữ state (dữ liệu của báo cáo đang dựng). Nếu tiêm thẳng ReportBuilder vào ReportService, mọi request sẽ dùng chung một builder. Đoạn code dưới đây tiêm ObjectProvider để mỗi lần dùng có instance mới:
@Service
public class ReportService {
// Inject a "provider", not ReportBuilder itself
private final ObjectProvider<ReportBuilder> builderProvider;
public ReportService(ObjectProvider<ReportBuilder> builderProvider) {
this.builderProvider = builderProvider;
}
public Report build(ReportRequest req) {
ReportBuilder builder = builderProvider.getObject(); // each call: a brand-new prototype instance
return builder.from(req).build();
}
}Giải thích: ObjectProvider không phải là builder mà là một "tay cầm" trỏ vào container. Mỗi lần gọi getObject(), Spring tra định nghĩa bean ReportBuilder, thấy scope prototype nên tạo một instance mới (chạy constructor, tiêm dependency, gọi @PostConstruct). Hai request song song gọi build() sẽ nhận hai builder khác nhau, không ghi đè dữ liệu của nhau. Nếu tiêm ReportBuilder trực tiếp qua constructor, builder chỉ được tạo đúng một lần lúc ReportService được tạo, và hai request dùng chung nó thì báo cáo của người này lẫn dữ liệu của người kia. Lưu ý Spring không gọi destroy callback cho prototype, nên nếu builder giữ tài nguyên (file, stream) thì bạn phải tự đóng.
Các cách khác: @Lookup trên method abstract/non-final (Spring override bằng CGLIB, không áp dụng cho bean tạo từ @Bean method), hoặc scoped proxy. Với bean request/session tiêm vào singleton, Spring dùng scoped proxy (các annotation @RequestScope, @SessionScope bật sẵn proxyMode = TARGET_CLASS), mỗi lời gọi method được chuyển tới instance của request hiện tại.
Singleton không tự động thread-safe
Singleton bean được mọi request thread dùng chung. Spring không đồng bộ gì cho bạn. Quy tắc: service/controller phải stateless — chỉ giữ dependency final và cấu hình bất biến. Không lưu dữ liệu theo request vào field (kiểu private User currentUser). Nếu buộc phải có state dùng chung, dùng cấu trúc thread-safe (ConcurrentHashMap, AtomicLong) hoặc đẩy state ra ngoài (DB, Redis).
Nhắc lại kiến thức
Race condition xảy ra khi nhiều thread cùng đọc/ghi một dữ liệu chung mà không đồng bộ, kết quả phụ thuộc vào thứ tự chạy ngẫu nhiên (xem mục 3.4 và 3.11). Singleton bean chính là "dữ liệu chung" nếu nó có field thay đổi được.
Đoạn code dưới đây lưu mức giảm giá của request hiện tại vào field của một singleton:
@Service
public class PriceService {
private BigDecimal discount; // WRONG: one field shared by every request thread
public BigDecimal finalPrice(Order order) {
discount = order.isVip() ? new BigDecimal("0.10") : BigDecimal.ZERO; // thread A writes 0.10
// thread B (normal customer) may write 0 right here
return order.total().multiply(BigDecimal.ONE.subtract(discount)); // A now reads B's value
}
}@Service
public class PriceService {
public BigDecimal finalPrice(Order order) {
BigDecimal discount = order.isVip() ? new BigDecimal("0.10") : BigDecimal.ZERO; // FIXED: local variable
return order.total().multiply(BigDecimal.ONE.subtract(discount));
}
}Giải thích: Ở bản sai, thread A (khách VIP) ghi 0.10 vào field, nhưng trước khi A đọc lại, thread B (khách thường) ghi đè thành 0, nên khách VIP bị tính giá đầy đủ. Lỗi chỉ xuất hiện dưới tải, không tái hiện được khi test một mình. Ở bản sửa, discount là biến cục bộ, nằm trên stack riêng của từng thread, nên không thread nào nhìn thấy giá trị của thread khác.
4.3 Spring Boot auto-configuration
Nhắc lại kiến thức
Spring Boot là Spring cộng thêm cấu hình mặc định, server nhúng (Tomcat) và các starter; Maven kéo dependency khai báo trong pom.xml cùng mọi thư viện mà chúng cần (dependency bắc cầu) vào classpath (xem N2.9). Classpath là danh sách các jar/thư mục mà JVM tìm class trong đó.
Auto-configuration nghĩa là Boot tự tạo sẵn các bean hạ tầng (kết nối DB, ObjectMapper, Tomcat...) dựa trên thư viện nào đang có trên classpath và bạn cấu hình property gì, để bạn không phải tự viết class @Configuration cho từng thứ.
@SpringBootApplication = @SpringBootConfiguration + @EnableAutoConfiguration + @ComponentScan (quét từ package của class main trở xuống — đặt class main sai package là lý do kinh điển khiến bean "không được tìm thấy").
Ví dụ bố cục package sai:
com.shop.app.ShopApplication <- @SpringBootApplication: scans com.shop.app and below
com.shop.order.OrderService <- NOT scanned: sibling package, not a sub-packageGiải thích: OrderService nằm ở com.shop.order, không phải package con của com.shop.app, nên component scan bỏ qua nó. Bean nào inject OrderService sẽ làm app dừng với NoSuchBeanDefinitionException / "Parameter 0 of constructor ... required a bean of type 'com.shop.order.OrderService' that could not be found". Cách sửa đúng là chuyển ShopApplication lên com.shop, thay vì rải scanBasePackages.
Cơ chế auto-configuration:
- Starter (
spring-boot-starter-web,-data-jpa...) chỉ là gói dependency; nó kéo thư viện vào classpath. - Boot đọc danh sách class auto-configuration từ file
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports(từ Boot 2.7; Boot 3 không còn đọc auto-configuration từspring.factories). - Mỗi class
@AutoConfigurationđược bảo vệ bởi điều kiện:@ConditionalOnClass(có thư viện trên classpath),@ConditionalOnMissingBean(bạn chưa tự định nghĩa bean đó),@ConditionalOnProperty,@ConditionalOnWebApplication...
Nhờ @ConditionalOnMissingBean, khi bạn tự khai báo một ObjectMapper hay DataSource, bean mặc định của Boot lùi lại. Đó là triết lý "convention over configuration nhưng bạn luôn override được". Khi auto-config làm điều bạn không ngờ, chạy với --debug để xem Condition Evaluation Report, hoặc xem endpoint /actuator/conditions.
Đi từng bước: từ lúc thêm một starter đến khi có bean
Giả sử bạn thêm spring-boot-starter-data-jpa và driver PostgreSQL vào pom.xml, cấu hình ba dòng spring.datasource.url/username/password, rồi viết interface OrderRepository extends JpaRepository<Order, Long>. Không viết thêm dòng cấu hình nào mà vẫn inject được OrderRepository. Chuyện xảy ra theo thứ tự:
- Maven kéo thư viện: starter chỉ là một
pomrỗng khai báo dependency, nên classpath có thêmhibernate-core,spring-data-jpa,HikariCP(connection pool),spring-orm... - Boot đọc danh sách ứng viên:
@EnableAutoConfigurationđọc fileAutoConfiguration.importstrong jarspring-boot-autoconfigure, được khoảng vài trăm class auto-configuration ứng viên. - Lọc theo classpath:
DataSourceAutoConfigurationcó@ConditionalOnClass(DataSource.class, EmbeddedDatabaseType.class); hai class này có mặt nên điều kiện đúng.MongoAutoConfigurationcần class của MongoDB driver, không có nên bị loại. - Tạo DataSource: Boot đọc property
spring.datasource.*và tạoHikariDataSource, trừ khi bạn đã tự khai báo beanDataSource(@ConditionalOnMissingBean). - Tạo tầng JPA:
HibernateJpaAutoConfigurationtạoEntityManagerFactorytừ DataSource ở bước 4 vàJpaTransactionManager(thứ mà@Transactionaldùng). - Tạo repository:
JpaRepositoriesAutoConfigurationquét các interface kế thừaJpaRepositoryvà sinh class hiện thực lúc runtime. - Kết quả: bean
OrderRepositorycó sẵn để inject. Nếu bước 4 thiếuspring.datasource.url, app dừng với "Failed to configure a DataSource: 'url' attribute is not specified".
Đoạn code dưới đây là một auto-configuration tự viết (ví dụ cho starter SMS nội bộ của công ty), rút gọn để thấy các điều kiện hoạt động ra sao:
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
@AutoConfiguration
@ConditionalOnClass(SmsClient.class) // only when the SMS library is on the classpath
@ConditionalOnProperty(prefix = "app.sms", name = "enabled", havingValue = "true", matchIfMissing = true)
public class SmsAutoConfiguration {
@Bean
@ConditionalOnMissingBean // back off if the application defines its own SmsClient
SmsClient smsClient() {
return new SmsClient("https://sms.example.com");
}
}# file: src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
com.example.sms.SmsAutoConfigurationGiải thích: Ứng dụng nào thêm jar starter này sẽ tự có bean SmsClient, với ba điều kiện: class SmsClient có trên classpath; property app.sms.enabled không bị đặt thành false (matchIfMissing = true nghĩa là không khai báo cũng tính là bật); và ứng dụng chưa tự định nghĩa SmsClient. Nếu team tự khai báo @Bean SmsClient với URL khác, bean của starter lùi lại, không có hai bean đánh nhau. File .imports là nơi đăng ký; thiếu nó thì class trên không bao giờ được nạp.
Khi cần biết vì sao một auto-configuration có hoặc không được áp dụng, chạy app với --debug, log in ra báo cáo dạng:
Positive matches:
DataSourceAutoConfiguration matched:
- @ConditionalOnClass found required classes 'javax.sql.DataSource', ... (OnClassCondition)
Negative matches:
MongoAutoConfiguration:
Did not match:
- @ConditionalOnClass did not find required class 'com.mongodb.client.MongoClient' (OnClassCondition)Giải thích: "Positive matches" liệt kê auto-configuration đã được áp dụng và điều kiện nào đúng; "Negative matches" liệt kê cái bị loại và lý do. Chú ý javax.sql.DataSource là API chuẩn của JDK nên vẫn mang tên javax, khác với các API Jakarta EE đã đổi sang jakarta.*.
Externalized configuration và profile
Boot gộp cấu hình từ nhiều nguồn, nguồn ưu tiên cao ghi đè nguồn thấp. Thứ tự đáng nhớ (từ cao xuống thấp): tham số command line → system properties (-D) → biến môi trường OS → file config bên ngoài jar (profile-specific rồi mặc định) → file config trong jar (profile-specific rồi mặc định) → giá trị mặc định. Biến môi trường dùng relaxed binding: SPRING_DATASOURCE_URL map vào spring.datasource.url, đây là cách chuẩn khi chạy trên Docker/Kubernetes.
Profile (spring.profiles.active=prod) chọn file application-prod.yml và bean có @Profile("prod"). Đừng nhét secret vào file trong repo; dùng biến môi trường, Vault hoặc secret của Kubernetes.
Ví dụ cùng một key được khai báo ở nhiều nơi:
# application.yml packaged inside the jar
app:
payment:
base-url: https://sandbox.pay.example.com
timeout: 5s# start command on the server
export SPRING_PROFILES_ACTIVE=prod
export APP_PAYMENT_TIMEOUT=10s # env var, relaxed binding -> app.payment.timeout
java -jar shop.jar --app.payment.base-url=https://pay.example.com # command-line argumentGiải thích: Giá trị cuối cùng là base-url = https://pay.example.com (tham số command line thắng mọi nguồn khác) và timeout = 10s (biến môi trường thắng file trong jar). Profile prod được bật nên Boot đọc thêm application-prod.yml nếu có, và giá trị trong đó ghi đè application.yml. Khi nghi ngờ app đang dùng giá trị nào, endpoint /actuator/env (chỉ bật ở môi trường nội bộ) cho biết mỗi key đến từ nguồn nào.
@ConfigurationProperties thay cho rải @Value
Thay vì rải @Value("${app.payment.base-url}") khắp các class, bạn gom mọi cấu hình của một module vào một record có kiểu rõ ràng và được validate ngay khi khởi động:
@ConfigurationProperties(prefix = "app.payment") // binds every app.payment.* key
@Validated // runs Bean Validation right after binding
public record PaymentProperties(
@NotBlank String baseUrl, // app.payment.base-url
@NotNull Duration timeout, // app.payment.timeout=5s -> Duration of 5 seconds
@Min(0) int maxRetries) { // app.payment.max-retries
}Giải thích: Lúc khởi động, Boot đọc các key app.payment.* từ mọi nguồn cấu hình (file yml, biến môi trường...), ánh xạ kiểu kebab-case base-url sang component baseUrl, đổi chuỗi 5s thành Duration, rồi gọi constructor của record. Sau đó @Validated kích hoạt Bean Validation: nếu thiếu base-url hoặc đặt max-retries=-1, ứng dụng không khởi động được và log chỉ rõ property nào sai. Record chỉ được bind khi đã được đăng ký bằng @ConfigurationPropertiesScan hoặc @EnableConfigurationProperties(PaymentProperties.class). Ưu điểm so với @Value: type-safe, gom nhóm, validate ngay lúc khởi động (sai cấu hình thì app không lên, thay vì lỗi lúc 2 giờ sáng), hỗ trợ Duration, DataSize, và IDE gợi ý khi có spring-boot-configuration-processor. Record có một constructor được Boot 3 bind qua constructor tự động, không cần @ConstructorBinding.
4.4 AOP và proxy
Spring AOP hoạt động bằng proxy lúc runtime: bean được bọc bởi một object trung gian, lời gọi từ bên ngoài đi qua proxy, proxy chạy advice (mở transaction, kiểm tra quyền, cache...) rồi mới gọi object thật.
Nhắc lại kiến thức
AOP tách logic cắt ngang nhiều nơi (log, transaction, phân quyền) ra khỏi code nghiệp vụ; advice là đoạn code chạy chèn vào quanh lời gọi method (xem N2.10). Một class implement interface thì phải có đủ các method mà interface khai báo, nên ai giữ biến kiểu interface sẽ không phân biệt được object thật với object khác cùng implement interface đó (xem N1.7).
Proxy là gì: tự viết một proxy bằng Java thuần
Proxy là "người đứng thay": một object có cùng kiểu với object thật, nhận lời gọi thay cho nó, làm thêm việc gì đó trước và sau, rồi chuyển lời gọi sang object thật. So sánh đời thường: thư ký nhận điện thoại thay giám đốc, ghi sổ trước khi chuyển máy và ghi chú sau khi cuộc gọi kết thúc; người gọi không cần biết có thư ký. Đoạn code dưới đây tự viết tay đúng thứ Spring sinh ra cho @Transactional, chỉ khác là "transaction" ở đây là dòng in ra.
public class HandProxyDemo {
interface PaymentService {
void pay(String orderId);
void payAll(String... ids);
}
static class RealPaymentService implements PaymentService {
public void pay(String orderId) { System.out.println(" paying " + orderId); }
public void payAll(String... ids) {
for (String id : ids) this.pay(id); // 'this' is the real object, not the proxy
}
}
static class TxProxy implements PaymentService { // same interface as the real object
private final PaymentService target;
TxProxy(PaymentService target) { this.target = target; }
public void pay(String orderId) { around("pay", () -> target.pay(orderId)); }
public void payAll(String... ids) { around("payAll", () -> target.payAll(ids)); }
private void around(String method, Runnable call) {
System.out.println("BEGIN tx for " + method); // work before
call.run(); // delegate to the real object
System.out.println("COMMIT tx for " + method); // work after
}
}
public static void main(String[] args) {
PaymentService svc = new TxProxy(new RealPaymentService()); // callers only ever see the proxy
svc.pay("A1");
svc.payAll("B1", "B2");
}
}BEGIN tx for pay
paying A1
COMMIT tx for pay
BEGIN tx for payAll
paying B1
paying B2
COMMIT tx for payAllGiải thích:
svccó kiểuPaymentServicenhưng thực chất làTxProxy. Mỗi lời gọi từmainđi vào proxy trước, proxy inBEGIN, gọi object thật, rồi inCOMMIT. Đây chính là việc Spring làm với@Transactional: thay dòng in bằng mở, commit hoặc rollback transaction.- Khi gọi
payAll("B1", "B2"), bên trong object thật gọithis.pay(id).thislàRealPaymentService, không phải proxy, nên hai lầnpaybên trong không có dòngBEGIN tx for paynào. Đây là toàn bộ bản chất của lỗi self-invocation ở mục 4.5: proxy chỉ chặn được lời gọi đi từ bên ngoài vào qua nó. - Trong Spring bạn không viết class
TxProxy; Spring tự sinh nó lúc runtime bằng một trong hai kỹ thuật dưới đây.
JDK dynamic proxy và CGLIB: hai cách Spring tự sinh proxy
Viết tay một proxy cho từng service là không khả thi, nên Spring sinh proxy lúc chạy chương trình. JDK dynamic proxy là tính năng có sẵn trong JDK (java.lang.reflect.Proxy): nó tạo lúc runtime một class mới implement các interface bạn chỉ định, và dồn mọi lời gọi method về một InvocationHandler duy nhất do bạn viết.
import java.lang.reflect.Proxy;
public class JdkProxyDemo {
interface Greeter { String greet(String name); }
public static void main(String[] args) {
Greeter real = name -> "Hello " + name;
Greeter proxy = (Greeter) Proxy.newProxyInstance(
Greeter.class.getClassLoader(),
new Class<?>[] { Greeter.class }, // the generated class implements Greeter
(p, method, margs) -> { // every call on the proxy lands here
System.out.println("before " + method.getName());
Object result = method.invoke(real, margs); // forward to the real object
System.out.println("after " + method.getName());
return result;
});
System.out.println(proxy.greet("An")); // prints: before greet, after greet, Hello An
System.out.println(proxy instanceof Greeter); // prints: true
System.out.println(proxy.getClass().getSimpleName().startsWith("$Proxy")); // prints: true
}
}Giải thích: Proxy.newProxyInstance sinh ra một class tên kiểu $Proxy0 implement Greeter. Gọi proxy.greet("An") thì handler in before greet, chuyển tiếp sang real, in after greet, rồi main in Hello An. Giới hạn: proxy chỉ là "một thứ implement Greeter", không phải subclass của class thật, nên chỉ chặn được method có trong interface, và nếu chỗ khác inject theo class cụ thể (ví dụ RealGreeter) thì Spring báo lỗi kiểu bean không khớp.
CGLIB là thư viện sinh bytecode (Spring đóng gói sẵn trong spring-core): thay vì implement interface, nó tạo lúc runtime một subclass của class thật và override các method để chèn advice. Vì dựa trên kế thừa, class không cần interface, nhưng method final (và class final) thì không override được. Đoạn code Java thuần dưới đây viết tay tương đương thứ CGLIB sinh ra:
public class SubclassProxyDemo {
static class ReportService { // no interface at all
public void export() { System.out.println(" exporting"); }
public final void audit() { System.out.println(" auditing"); } // final: cannot be overridden
}
static class ReportServiceCglibLike extends ReportService { // roughly what CGLIB generates
@Override
public void export() {
System.out.println("BEGIN tx");
super.export(); // run the real logic
System.out.println("COMMIT tx");
}
// audit() cannot be overridden, so the proxy cannot intercept it
}
public static void main(String[] args) {
ReportService svc = new ReportServiceCglibLike();
svc.export(); // prints: BEGIN tx, exporting, COMMIT tx
svc.audit(); // prints only: auditing (no tx)
}
}Giải thích: export() được subclass override nên có BEGIN/COMMIT bao quanh. audit() là final, subclass không override được, lời gọi chạy thẳng code gốc và không có "transaction" nào: nếu đó là method @Transactional thật, annotation bị bỏ qua mà không có cảnh báo. Trong Spring thật, proxy CGLIB không gọi super mà giữ tham chiếu tới một object gốc riêng (target) và chuyển tiếp lời gọi sang đó, nhưng nguyên tắc "chỉ chặn được method override được" là như nhau. Bảng dưới tóm tắt hai cách:
| Tiêu chí | JDK dynamic proxy | CGLIB proxy |
|---|---|---|
| Cơ chế | Implement interface của bean | Tạo subclass của bean |
| Yêu cầu | Bean phải có interface | Class và method không được final |
| Chặn được | Method khai báo trên interface | Method public/protected/package không final |
| Spring Boot | Chỉ khi tắt proxy-target-class | Mặc định (spring.aop.proxy-target-class=true) |
Giới hạn chung của cả hai: không chặn được lời gọi nội bộ qua this và không chặn method private. Đây là gốc rễ của hầu hết bug @Transactional, @Async, @Cacheable, @PreAuthorize "không chạy".
4.5 @Transactional: những chỗ hay sai
Transaction là gì và @Transactional làm gì
Nhắc lại kiến thức
Transaction gom nhiều lệnh SQL thành một đơn vị "tất cả hoặc không gì cả". ACID: Atomicity (tất cả hoặc không), Consistency (trước và sau luôn thỏa ràng buộc), Isolation (transaction đồng thời không thấy trạng thái dở dang của nhau), Durability (đã commit thì không mất). Xem N2.4; isolation level chi tiết ở Chương 6.
Ví dụ kinh điển: chuyển 500 từ tài khoản 1 sang tài khoản 2 gồm hai lệnh UPDATE. Nếu lệnh thứ nhất chạy xong mà ứng dụng chết trước lệnh thứ hai, tiền biến mất. Transaction bảo đảm hai lệnh cùng có hiệu lực hoặc cùng bị hủy. Trong Spring, bạn không tự viết commit/rollback mà đánh dấu method bằng @Transactional:
@Service
public class TransferService {
private final AccountRepository accounts;
public TransferService(AccountRepository accounts) { this.accounts = accounts; }
@Transactional // the proxy opens a tx before and commits/rolls back after
public void transfer(long fromId, long toId, BigDecimal amount) {
Account from = accounts.findById(fromId).orElseThrow();
Account to = accounts.findById(toId).orElseThrow();
from.withdraw(amount); // throws InsufficientFundsException (a RuntimeException) if balance is too low
to.deposit(amount);
// no save() needed: JPA dirty checking flushes both changes at commit
}
}[proxy] get a connection from the pool, setAutoCommit(false)
[method] SELECT ... FROM account WHERE id = 1
[method] SELECT ... FROM account WHERE id = 2
[commit] UPDATE account SET balance = 1500 WHERE id = 1
[commit] UPDATE account SET balance = 2500 WHERE id = 2
[proxy] COMMIT, return the connection to the pool
(ROLLBACK instead if a RuntimeException escapes the method)Giải thích: Controller gọi transferService.transfer(...) qua proxy (mục 4.4). Proxy (cụ thể là TransactionInterceptor) lấy một connection, tắt auto-commit, gắn connection đó vào thread hiện tại, rồi mới gọi method thật. Mọi repository dùng bên trong đều dùng chung connection này. Method kết thúc bình thường thì proxy commit, Hibernate flush hai lệnh UPDATE trước khi commit; nếu withdraw ném InsufficientFundsException thì proxy rollback và không số dư nào thay đổi. Mọi bẫy trong mục này đều quy về một câu hỏi: lời gọi có thực sự đi qua proxy không, và proxy nhìn thấy exception gì.
Self-invocation
Tình huống: bạn import một danh sách hóa đơn và muốn mỗi hóa đơn nằm trong một transaction riêng, để hóa đơn lỗi không kéo các hóa đơn khác rollback theo. Đoạn code dưới đây trông hợp lý nhưng thực tế không mở transaction nào:
@Service
public class InvoiceService {
public void importAll(List<InvoiceDto> items) {
items.forEach(this::importOne); // called via this, bypasses the proxy
}
@Transactional // ignored when called from importAll
public void importOne(InvoiceDto dto) { /* ... */ }
}Giải thích: Controller gọi invoiceService.importAll(...) thông qua proxy CGLIB mà Spring tạo cho InvoiceService. Proxy thấy importAll không có @Transactional nên chuyển lời gọi thẳng vào object gốc. Bên trong object gốc, this::importOne trỏ tới chính object gốc chứ không phải proxy, nên TransactionInterceptor không bao giờ được chạy và importOne không có transaction nào được mở. Hậu quả: mỗi lời gọi repository bên trong tự chạy trong transaction ngắn của riêng nó (method của SimpleJpaRepository có @Transactional sẵn), một hóa đơn gồm nhiều lần ghi không còn nguyên tử, lỗi ở giữa để lại dữ liệu dở dang, và không có exception hay cảnh báo nào báo cho bạn biết. Cách sửa sạch nhất là tách importOne sang bean khác (ví dụ InvoiceImporter), để importAll gọi invoiceImporter.importOne(dto): lời gọi lúc này đi qua proxy của bean kia nên mỗi hóa đơn có transaction riêng. Các cách như tự inject chính mình (@Lazy self) hay AopContext.currentProxy() chạy được nhưng làm code khó đọc.
Kịch bản từng bước với 3 hóa đơn, trong đó hóa đơn thứ 2 lỗi khi lưu các dòng chi tiết:
- Controller gọi
invoiceService.importAll(list). BiếninvoiceServicethực chất là proxy. - Proxy xem
importAll: không có@Transactional, nên gọi thẳng object gốc, không mở transaction. - Object gốc chạy
this.importOne(hd1).thislà object gốc, proxy không hề biết có lời gọi này. - Hóa đơn 1:
invoiceRepository.save(header)tự mở và commit một transaction ngắn;lineRepository.saveAll(lines)cũng vậy. Kết quả đúng, nhưng chỉ là nhờ may mắn. - Hóa đơn 2: header được lưu và đã commit, sau đó
saveAll(lines)ném exception. Header không bị rollback vì nó nằm ở transaction riêng đã kết thúc. - Exception bay ra khỏi
importAll, hóa đơn 3 không được xử lý. DB còn lại hóa đơn 2 có header mà không có dòng chi tiết.
Code sửa: tách method cần transaction sang bean khác để lời gọi đi qua proxy.
@Service
public class InvoiceService {
private static final Logger log = LoggerFactory.getLogger(InvoiceService.class);
private final InvoiceImporter importer; // another bean: calls go through its proxy
public InvoiceService(InvoiceImporter importer) { this.importer = importer; }
public void importAll(List<InvoiceDto> items) {
for (InvoiceDto dto : items) {
try {
importer.importOne(dto); // proxy call: one transaction per invoice
} catch (RuntimeException e) {
log.warn("Invoice {} failed", dto.number(), e); // one bad invoice does not stop the rest
}
}
}
}
@Service
class InvoiceImporter {
@Transactional
public void importOne(InvoiceDto dto) { /* save header, then lines */ }
}Giải thích: Giờ bước 3 đi qua proxy của InvoiceImporter, nên mỗi hóa đơn có một transaction bao trọn header và dòng chi tiết. Hóa đơn 2 lỗi thì cả header lẫn dòng chi tiết của nó rollback, exception được catch ở importAll và vòng lặp tiếp tục với hóa đơn 3. Lưu ý: cách này đúng vì importAll không có transaction. Nếu bạn gắn thêm @Transactional lên importAll, importOne (REQUIRED) sẽ tham gia transaction ngoài; hóa đơn 2 lỗi làm cả transaction bị đánh dấu rollback-only và bạn nhận UnexpectedRollbackException ở cuối (xem mục Propagation). Khi đó phải dùng REQUIRES_NEW cho importOne.
Visibility của method
Với JDK proxy, chỉ method public khai báo trên interface được áp transaction. Từ Spring 6.0, với proxy dạng class (CGLIB), @Transactional trên method protected và package-private cũng được hỗ trợ. Method private thì không bao giờ. Nhiều tài liệu vẫn nói "chỉ public" — đúng với Spring 5, còn Spring 6 đã nới ra. Dù vậy, quy ước an toàn và dễ review nhất vẫn là đặt @Transactional trên method public của service.
@Service
public class CouponService {
public void redeemAll(List<Long> ids) { ids.forEach(this::redeem); }
@Transactional // never applied: private methods cannot be intercepted by any proxy
private void redeem(long id) { /* ... */ }
}Giải thích: Code biên dịch và chạy bình thường nhưng redeem không bao giờ có transaction: subclass do CGLIB sinh ra không override được method private, và lời gọi lại đi qua this. IntelliJ có cảnh báo cho trường hợp này, nhưng Spring không báo lỗi lúc khởi động, nên review code là tuyến phòng thủ chính.
Rollback rule
Mặc định Spring chỉ rollback với RuntimeException và Error. Checked exception (ví dụ IOException, exception nghiệp vụ extends Exception) sẽ commit. Muốn rollback: @Transactional(rollbackFor = Exception.class), hoặc thiết kế exception nghiệp vụ kế thừa RuntimeException. Một bẫy khác: bạn catch exception bên trong method rồi nuốt đi, proxy không thấy exception nên commit như bình thường.
Nhắc lại kiến thức
Checked exception là exception kế thừa Exception (nhưng không kế thừa RuntimeException); compiler bắt bạn catch hoặc khai báo throws. Unchecked exception kế thừa RuntimeException, compiler không bắt buộc xử lý (xem N1.12).
Đoạn code dưới đây thay toàn bộ tồn kho bằng dữ liệu từ file, và ném checked exception khi gặp dòng không hợp lệ:
public class InvalidRowException extends Exception { // checked: extends Exception
public InvalidRowException(String msg) { super(msg); }
}
@Service
public class StockImportService {
private final StockRepository stockRepository;
public StockImportService(StockRepository stockRepository) { this.stockRepository = stockRepository; }
@Transactional // WRONG for this case: checked exceptions do not trigger rollback
public void replaceAll(List<String> rows) throws InvalidRowException {
stockRepository.deleteAll(); // 1. old stock deleted
for (String row : rows) {
if (row.isBlank()) throw new InvalidRowException("blank row"); // 2. thrown halfway
stockRepository.save(Stock.parse(row));
}
}
}@Transactional(rollbackFor = InvalidRowException.class) // FIXED: roll back on this checked exception too
public void replaceAll(List<String> rows) throws InvalidRowException { /* same body */ }Giải thích: File có 5 dòng, dòng thứ 3 trống. Ở bản sai, deleteAll() và 2 lệnh save đầu đã chạy, rồi InvalidRowException được ném ra. Proxy thấy đây là checked exception, theo quy tắc mặc định thì commit: kho mất toàn bộ dữ liệu cũ và chỉ còn 2 dòng mới, trong khi controller vẫn báo lỗi cho người dùng. Ở bản sửa, rollbackFor khai báo rằng exception này cũng phải rollback, nên kho giữ nguyên dữ liệu cũ. Cách thay thế là cho InvalidRowException kế thừa RuntimeException. Nhiều team đặt quy ước rollbackFor = Exception.class cho mọi method ghi để khỏi phải nhớ.
Biến thể "nuốt exception" cũng dẫn tới commit ngoài ý muốn:
@Transactional(rollbackFor = Exception.class)
public void replaceAllQuietly(List<String> rows) {
try {
replaceAllInternal(rows); // throws InvalidRowException at row 3
} catch (InvalidRowException e) {
log.warn("Bad file", e); // WRONG: swallowed, the proxy sees a normal return -> COMMIT
}
}Giải thích: rollbackFor chỉ có tác dụng khi exception bay ra khỏi method để proxy nhìn thấy. Ở đây exception bị catch và chỉ ghi log, method trả về bình thường, proxy commit dữ liệu dở dang. Sửa bằng cách ném lại (throw new StockImportException(e) với một RuntimeException), hoặc nếu thực sự không muốn ném thì đánh dấu thủ công TransactionAspectSupport.currentTransactionStatus().setRollbackOnly().
Propagation
| Propagation | Khi đã có transaction | Khi chưa có |
|---|---|---|
| REQUIRED (mặc định) | Tham gia transaction hiện tại | Tạo mới |
| REQUIRES_NEW | Tạm dừng cái cũ, tạo transaction độc lập | Tạo mới |
| NESTED | Tạo savepoint trong transaction hiện tại | Tạo mới |
| MANDATORY | Tham gia | Ném exception |
Propagation là quy tắc "khi method này được gọi trong lúc đã có một transaction đang chạy thì làm gì". Bảng kịch bản dưới đây dùng cùng một tình huống: OrderService.cancel() có @Transactional (REQUIRED), bên trong gọi auditService.record() ở bean khác với propagation thay đổi theo từng dòng.
Propagation của record() | Chuyện gì xảy ra | Thay đổi của đơn hàng | Dòng audit log |
|---|---|---|---|
| REQUIRED | cancel() lỗi sau khi ghi log | Rollback | Rollback theo (chung transaction) |
| REQUIRES_NEW | cancel() lỗi sau khi ghi log | Rollback | Vẫn còn (đã commit riêng) |
| REQUIRES_NEW | record() lỗi, cancel() catch | Commit bình thường | Không có (rollback riêng) |
| REQUIRED | record() lỗi, cancel() catch | Rollback, ném UnexpectedRollbackException | Không có |
| NESTED | record() lỗi, cancel() catch | Commit (chỉ rollback về savepoint) | Không có |
| MANDATORY | Gọi record() từ nơi không có transaction | Không áp dụng | IllegalTransactionStateException |
Đoạn code dưới đây là dòng thứ hai của bảng, trường hợp dùng REQUIRES_NEW phổ biến nhất:
@Service
public class AuditService {
private final AuditRepository auditRepository;
public AuditService(AuditRepository auditRepository) { this.auditRepository = auditRepository; }
@Transactional(propagation = Propagation.REQUIRES_NEW) // own transaction, own connection
public void record(String action) {
auditRepository.save(new AuditLog(action)); // committed as soon as this method returns
}
}
@Service
public class OrderService {
private final AuditService audit; // constructor omitted
private final OrderRepository orderRepository;
@Transactional
public void cancel(long orderId) {
audit.record("CANCEL " + orderId); // outer tx suspended, inner tx commits
orderRepository.markCancelled(orderId);
throw new IllegalStateException("refund failed"); // order change rolls back, audit row stays
}
}Giải thích: Khi cancel() gọi audit.record(), proxy của AuditService tạm treo transaction của cancel(), lấy connection thứ hai từ pool, mở transaction mới, lưu audit log và commit ngay. Quay lại cancel(), lệnh đánh dấu hủy đơn chạy trong transaction ngoài, rồi exception làm transaction ngoài rollback. Kết quả: đơn hàng không bị hủy, nhưng audit log ghi lại rằng đã có người thử hủy, đúng điều bạn muốn cho mục đích kiểm toán. Nếu đổi record() về REQUIRED, dòng audit log cũng mất theo.
REQUIRES_NEW dùng cho việc phải được lưu dù transaction ngoài rollback, như ghi audit log. Nó cần connection thứ hai từ pool trong khi connection đầu vẫn bị giữ; dưới tải cao có thể cạn pool và treo cả hệ thống. NESTED dựa vào JDBC savepoint: rollback phần con không kéo cha rollback, nhưng cha rollback thì con mất theo; với JPA/Hibernate hỗ trợ khá hạn chế nên ít gặp trong thực tế.
Bẫy kinh điển với REQUIRED: method con (cùng transaction) ném RuntimeException, method cha catch và tiếp tục. Transaction đã bị đánh dấu rollback-only, nên lúc commit bạn nhận UnexpectedRollbackException.
@Transactional
public void checkout(Cart cart) {
try {
couponService.apply(cart); // REQUIRED: joins this tx, throws CouponExpiredException (runtime)
} catch (CouponExpiredException e) {
cart.clearCoupon(); // looks harmless: continue without the coupon
}
orderRepository.save(Order.from(cart));
} // commit attempt -> UnexpectedRollbackException, the order is NOT savedGiải thích: Khi exception bay qua proxy của couponService.apply(), proxy đó đánh dấu transaction chung là rollback-only (nó không biết bạn sẽ catch). checkout() catch và chạy tiếp, nhưng đến lúc proxy ngoài định commit thì thấy cờ rollback-only, nên rollback và ném UnexpectedRollbackException. Cách sửa: để apply() trả về kết quả (ví dụ boolean hoặc Optional) thay vì ném exception cho một tình huống nghiệp vụ bình thường; hoặc khai báo @Transactional(noRollbackFor = CouponExpiredException.class) trên apply(); hoặc kiểm tra coupon trước khi vào transaction.
readOnly
@Transactional(readOnly = true) là gợi ý tối ưu: Hibernate tắt dirty checking và không flush (không cần giữ snapshot entity), driver có thể đặt connection read-only, và bạn có thể dựa vào nó để định tuyến sang read replica. Đừng coi nó là cơ chế bảo vệ chống ghi; hành vi thực sự phụ thuộc vào driver và DB.
@Service
@Transactional(readOnly = true) // default for every method of this class: reads only
public class ReportQueryService {
private final OrderRepository orders;
public ReportQueryService(OrderRepository orders) { this.orders = orders; }
public List<OrderSummary> lastMonth() { return orders.findSummariesSince(LocalDate.now().minusMonths(1)); }
@Transactional // overrides the class-level setting for the one method that writes
public void markExported(long orderId) { orders.markExported(orderId); }
}Giải thích: @Transactional đặt ở class áp dụng cho mọi method public; annotation trên method ghi đè annotation ở class. Kiểu "class read-only, method ghi khai báo riêng" giúp Hibernate bỏ qua dirty checking cho các truy vấn báo cáo lớn, đồng thời người review nhìn là biết method nào có ghi.
Transaction dài và gọi API bên ngoài
Gọi HTTP sang cổng thanh toán hay gửi email bên trong @Transactional là lỗi thiết kế tôi gặp rất nhiều:
- Connection DB bị giữ suốt thời gian chờ mạng; API chậm 10 giây là 10 giây connection bị chiếm, pool cạn rất nhanh.
- Lock trên row bị giữ lâu, tăng tranh chấp và deadlock.
- Không nhất quán: API bên ngoài đã thành công nhưng transaction DB rollback (hoặc ngược lại), và không có cách rollback lời gọi HTTP.
@Service
public class PaymentFlowWrong {
// fields and constructor omitted
@Transactional // WRONG: a DB connection is held during the whole HTTP call
public void pay(long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
PaymentResult r = paymentClient.charge(order); // up to 10s of network wait, connection still held
order.markPaid(r.transactionId());
}
}
@Service
public class PaymentFlow {
// fields and constructor omitted
public void pay(long orderId) { // FIXED: no transaction around the HTTP call
Order order = orderTx.loadPending(orderId); // short tx #1 (method of another bean)
PaymentResult r = paymentClient.charge(order); // no connection held while waiting
orderTx.markPaid(orderId, r.transactionId()); // short tx #2
}
}Giải thích: Với pool 10 connection và cổng thanh toán chậm 10 giây, bản sai chỉ cần 10 request thanh toán đồng thời là mọi request khác của hệ thống (kể cả trang xem sản phẩm) phải chờ connection và timeout. Bản sửa chia thành hai transaction ngắn ở bean orderTx, lời gọi HTTP nằm giữa không giữ connection nào. Cái giá phải trả: nếu app chết giữa hai bước, đơn nằm ở trạng thái PENDING, nên cần idempotency key khi gọi cổng thanh toán và job đối soát, như câu hỏi phỏng vấn ở cuối chương.
Hướng xử lý: giữ transaction ngắn, chỉ chứa thao tác DB; gọi API ngoài trước hoặc sau transaction; dùng @TransactionalEventListener(phase = AFTER_COMMIT) cho side effect sau commit; với yêu cầu tin cậy cao, dùng Transactional Outbox (ghi event vào bảng outbox cùng transaction, worker gửi đi sau) kết hợp idempotency ở phía nhận.
4.6 Xử lý lỗi, validation, Filter/Interceptor/AOP
Global exception handling với ProblemDetail
Mục tiêu: mọi lỗi trả về client có cùng một format, lỗi nghiệp vụ mang status code đúng nghĩa, còn lỗi bất ngờ không làm lộ chi tiết nội bộ. Đoạn code dưới đây gom việc đó về một chỗ thay vì try/catch rải rác trong từng controller:
@RestControllerAdvice // applies to every @RestController
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(OrderNotFoundException.class) // known business error -> 404
public ProblemDetail handleNotFound(OrderNotFoundException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
pd.setTitle("Order not found");
pd.setProperty("orderId", ex.getOrderId()); // extension field, becomes a JSON key
return pd;
}
@ExceptionHandler(Exception.class) // safety net for everything else -> 500
public ProblemDetail handleUnexpected(Exception ex) {
log.error("Unexpected error", ex); // full details stay in the server log
return ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR, "Internal error"); // no internal details leaked
}
}Giải thích: Khi controller ném OrderNotFoundException, DispatcherServlet giao exception cho ExceptionHandlerExceptionResolver. Resolver tìm trong advice handler khớp gần nhất theo cây kế thừa, nên OrderNotFoundException đi vào handleNotFound chứ không rơi vào handler Exception. Client nhận HTTP 404, Content-Type: application/problem+json, body dạng {"type":"about:blank","title":"Order not found","status":404,"detail":"...","instance":"/api/orders/42","orderId":42} (Spring tự điền instance bằng đường dẫn request). Mọi exception khác (ví dụ NullPointerException, lỗi DB) rơi vào handleUnexpected: stack trace được ghi vào log ở server, còn client chỉ nhận 500 với thông điệp chung "Internal error".
Spring 6 đưa vào ProblemDetail theo chuẩn Problem Details (RFC 7807, nay là RFC 9457), trả về application/problem+json. Bật spring.mvc.problemdetails.enabled=true để các lỗi chuẩn của Spring MVC cũng trả về định dạng này, hoặc cho advice kế thừa ResponseEntityExceptionHandler. Nguyên tắc: một format lỗi thống nhất toàn hệ thống, log chi tiết ở server, không trả stack trace hay câu SQL cho client.
Validation
Cần thêm spring-boot-starter-validation (từ Boot 2.3 không còn nằm sẵn trong starter web). Annotation thuộc jakarta.validation.constraints (@NotBlank, @Email, @Size...). Đặt @Valid trên @RequestBody → lỗi ném MethodArgumentNotValidException (400). Validate tham số đơn (@PathVariable, @RequestParam) cần method validation: truyền thống là @Validated trên class (ném ConstraintViolationException); từ Spring 6.1 controller có method validation tích hợp và ném HandlerMethodValidationException. Hãy xử lý cả hai trong advice để format lỗi nhất quán. Validation ở DTO chỉ kiểm tra định dạng; quy tắc nghiệp vụ (tồn kho đủ không, email đã tồn tại chưa) thuộc về service.
Đoạn code dưới đây validate body của request tạo user:
public record CreateUserRequest(
@NotBlank String name, // null, "" and " " are rejected
@NotBlank @Email String email,
@NotNull @Size(min = 8) String password) {}
@RestController
@RequestMapping("/api/users")
public class UserController {
@PostMapping
public ResponseEntity<Void> create(@Valid @RequestBody CreateUserRequest req) { // @Valid triggers validation
// reached only when every constraint passes
return ResponseEntity.status(HttpStatus.CREATED).build();
}
}{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Invalid request content.",
"instance": "/api/users"
}Giải thích: Gửi {"name":"","email":"abc","password":"123"} thì cả ba ràng buộc đều sai, Spring ném MethodArgumentNotValidException trước khi thân method chạy, và với spring.mvc.problemdetails.enabled=true client nhận 400 như JSON trên. Body mặc định không liệt kê field nào sai; trong thực tế bạn thêm handler trong advice đọc ex.getBindingResult().getFieldErrors() và đưa vào một property errors của ProblemDetail, để frontend hiển thị lỗi cạnh từng ô nhập. Các annotation này nằm trong jakarta.validation.constraints, không phải javax.validation như Boot 2.
Filter vs Interceptor vs AOP
| Tiêu chí | Filter | HandlerInterceptor | AOP |
|---|---|---|---|
| Tầng | Servlet, trước DispatcherServlet | Spring MVC, quanh handler | Quanh method của bất kỳ bean |
| Biết handler nào xử lý | Không | Có | Biết method và tham số |
| Dùng cho | Security, CORS, request logging, trace id | Kiểm tra tenant, locale, đo thời gian handler | Transaction, audit, cache, retry |
Nhắc lại kiến thức
Request HTTP đi vào Tomcat (servlet container), qua các Filter (class chặn request ở tầng Servlet), rồi tới DispatcherServlet là "cổng vào" duy nhất của Spring MVC; DispatcherServlet tìm method controller phù hợp (handler) để gọi (xem N2.6).
Đường đi của một request qua Filter, Interceptor và AOP
Ba cơ chế đều "chèn code vào trước và sau", khác nhau ở chỗ chúng đứng ở đâu trên đường đi của request. HandlerInterceptor là cơ chế của Spring MVC, chạy sau khi DispatcherServlet đã chọn được method controller. Sơ đồ cho request GET /api/orders/42:
Client GET /api/orders/42
-> Tomcat
-> Filter: TraceIdFilter (Servlet layer, sees only the raw request)
-> Filter: Spring Security chain (401/403 decided here)
-> DispatcherServlet (Spring MVC entry, picks the handler)
-> Interceptor.preHandle (knows it is OrderController#get)
-> OrderController.get(42)
-> proxy of OrderService (AOP: @Transactional, @Cacheable, custom @Aspect)
-> OrderService.find(42) -> repository -> DB
<- Interceptor.afterCompletion
<- code after chain.doFilter(...) in TraceIdFilter
Client 200 OKBa đoạn code dưới đây cài một Filter, một Interceptor và một Aspect, mỗi cái in một dòng để thấy thứ tự thực tế.
@Component
public class TraceIdFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
System.out.println("1. filter before: " + req.getRequestURI());
chain.doFilter(req, res); // pass the request on; skipping this line blocks it
System.out.println("6. filter after: status " + res.getStatus());
}
}@Component
public class TimingInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
System.out.println("2. interceptor preHandle: " + handler); // e.g. OrderController#get(Long)
return true; // returning false stops the request here
}
@Override
public void afterCompletion(HttpServletRequest req, HttpServletResponse res, Object handler, Exception ex) {
System.out.println("5. interceptor afterCompletion");
}
}
@Configuration
class WebConfig implements WebMvcConfigurer {
private final TimingInterceptor timing;
WebConfig(TimingInterceptor timing) { this.timing = timing; }
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(timing).addPathPatterns("/api/**"); // interceptors must be registered
}
}@Aspect
@Component
public class ServiceLogAspect {
@Around("execution(* com.shop.order..*Service.*(..))") // every method of *Service classes
public Object log(ProceedingJoinPoint pjp) throws Throwable {
System.out.println("3. aspect before: " + pjp.getSignature().getName());
Object result = pjp.proceed(); // call the real service method
System.out.println("4. aspect after");
return result;
}
}1. filter before: /api/orders/42
2. interceptor preHandle: com.shop.order.OrderController#get(Long)
3. aspect before: find
4. aspect after
5. interceptor afterCompletion
6. filter after: status 200Giải thích: Filter đứng ngoài cùng, chỉ biết URL và header; nó thấy cả request bị Spring Security từ chối, nên hợp cho trace id và log truy cập. Interceptor chạy sau khi Spring đã chọn handler, nên biết request sẽ vào OrderController#get, hợp cho logic gắn với controller (tenant, locale, đo thời gian handler); Filter là @Component được Boot tự đăng ký, còn Interceptor phải đăng ký qua WebMvcConfigurer. Aspect bọc method của bean bất kỳ, kể cả khi không có HTTP request (job định kỳ, Kafka consumer), nên hợp cho audit và transaction ở tầng service.
Spring Security là một chuỗi Filter, nên request bị chặn trước khi tới controller. Exception ném trong Filter không đi qua @RestControllerAdvice vì chưa tới DispatcherServlet.
Actuator
spring-boot-starter-actuator cung cấp /actuator/health (kèm liveness/readiness cho Kubernetes probe), /actuator/metrics, /actuator/prometheus (với Micrometer registry), /actuator/info. Từ Boot 2.5, qua HTTP chỉ health được expose mặc định. Không bao giờ expose công khai env, heapdump, configprops; tách cổng management hoặc chặn bằng Spring Security.
management:
server:
port: 8081 # separate port, not routed through the public ingress
endpoints:
web:
exposure:
include: health,info,prometheus # never "*" in production
endpoint:
health:
probes:
enabled: true # /actuator/health/liveness and /actuator/health/readinessGiải thích: Cấu hình này đưa actuator sang cổng 8081 chỉ mở trong mạng nội bộ, chỉ expose ba endpoint cần cho Kubernetes probe và Prometheus. Nếu lỡ đặt include: "*" trên cổng công khai, ai cũng tải được /actuator/heapdump, file chứa mọi thứ trong bộ nhớ, kể cả mật khẩu DB và token của người dùng đang đăng nhập.
4.7 Câu hỏi phỏng vấn thường gặp
Câu hỏi (Cơ bản): Vì sao nên dùng constructor injection thay vì @Autowired trên field?
Constructor injection đảm bảo dependency có mặt ngay khi object được tạo, cho phép final và bất biến, test được bằng new mà không cần Spring, và làm lộ vấn đề thiết kế: constructor 8 tham số nghĩa là class làm quá nhiều việc. Circular dependency cũng bị phát hiện ngay khi khởi động.
Bẫy thường gặp
Nói "field injection ngắn gọn hơn nên team em dùng" mà không nêu được nhược điểm. Ở mức 2–4 năm, người phỏng vấn muốn nghe trade-off, không phải thói quen.
Câu hỏi đào sâu
"Sau khi nâng cấp lên Boot 2.6, app báo circular reference, bạn xử lý thế nào?" — Không bật lại allow-circular-references như phản xạ đầu tiên; phân tích vì sao hai service phụ thuộc lẫn nhau, tách logic chung ra bean thứ ba hoặc dùng event, @Lazy chỉ là giải pháp tạm.
Câu hỏi (Hiểu sâu): Mô tả vòng đời của một Spring bean. Proxy AOP được tạo lúc nào?
Instantiate → populate properties → các Aware callback → postProcessBeforeInitialization → @PostConstruct, afterPropertiesSet, init-method → postProcessAfterInitialization (proxy được tạo ở đây) → sử dụng → @PreDestroy, destroy() khi context đóng.
Câu hỏi đào sâu
"Gọi method @Transactional trong @PostConstruct có transaction không?" — Không, vì tại thời điểm đó proxy chưa bọc bean và lời gọi lại là qua this. Dùng ApplicationRunner hoặc @EventListener(ApplicationReadyEvent.class).
Câu hỏi (Cơ bản): Singleton bean có thread-safe không?
Không tự động. Singleton chỉ nói về số instance trong container, không liên quan đồng bộ hóa. Mọi request thread dùng chung instance, nên bean phải stateless. Tôi thường kể một bug thực tế: lưu kết quả trung gian vào field của service, dưới tải thì dữ liệu của user này lẫn sang user khác.
Bẫy thường gặp
Nhầm singleton của Spring với Singleton pattern "1 instance mỗi JVM". Spring singleton là 1 instance mỗi ApplicationContext; hai context vẫn có hai instance.
Câu hỏi (Hiểu sâu): Tiêm prototype bean vào singleton thì chuyện gì xảy ra? Xử lý thế nào?
Prototype chỉ được tạo một lần lúc singleton được tạo, nên mất ý nghĩa "mỗi lần một instance mới". Xử lý bằng ObjectProvider<T>.getObject(), @Lookup, hoặc scoped proxy. Tôi ưu tiên ObjectProvider vì tường minh và test dễ.
Câu hỏi (Hiểu sâu): Spring Boot auto-configuration hoạt động thế nào?
@EnableAutoConfiguration nạp danh sách class trong file AutoConfiguration.imports của các thư viện trên classpath. Mỗi class có các điều kiện @ConditionalOnClass, @ConditionalOnMissingBean, @ConditionalOnProperty; điều kiện thỏa thì bean mặc định được tạo. Bean do bạn tự khai báo được ưu tiên nhờ @ConditionalOnMissingBean.
Câu hỏi đào sâu
"Làm sao biết vì sao một auto-configuration không được áp dụng?" — Chạy với --debug để xem Condition Evaluation Report, hoặc xem /actuator/conditions. "Viết starter riêng thì đăng ký ở đâu?" — Khai báo class trong META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports, không phải spring.factories như Boot 2 cũ.
Câu hỏi (Hiểu sâu): JDK dynamic proxy khác CGLIB thế nào? Spring Boot dùng cái nào?
JDK proxy implement interface, chỉ chặn method của interface. CGLIB tạo subclass, không proxy được class/method final. Spring Boot mặc định dùng CGLIB (proxy-target-class=true) kể cả khi bean có interface, để tránh lỗi inject theo class cụ thể.
Bẫy thường gặp
Khai báo service là final (hoặc dùng Kotlin class mặc định final) rồi thắc mắc vì sao @Transactional không chạy hoặc app không khởi động được.
Câu hỏi (Hiểu sâu): Gọi một method @Transactional từ method khác trong cùng class thì sao?
Lời gọi qua this không đi qua proxy, nên thuộc tính transaction của method được gọi bị bỏ qua. Nếu method ngoài không có transaction thì không có transaction nào cả; nếu method ngoài có transaction thì method trong chạy chung transaction đó, và mọi thiết lập như REQUIRES_NEW bị phớt lờ. Cách sửa: tách sang bean khác.
Câu hỏi đào sâu
"Với @Async và @Cacheable có bị tương tự không?" — Có, cùng là AOP proxy. Ứng viên nối được vấn đề này sang các annotation khác cho thấy hiểu bản chất chứ không học thuộc.
Câu hỏi (Cơ bản): @Transactional rollback khi nào?
Mặc định chỉ với RuntimeException và Error. Checked exception thì commit. Muốn rollback cho checked exception phải khai báo rollbackFor. Exception bị catch và nuốt bên trong method thì không rollback.
Bẫy thường gặp
Trả lời "có exception là rollback". Đây là câu trả lời làm tôi đánh giá thấp nhất ở mức 2–4 năm, vì nó dẫn tới bug mất tiền thật trong dự án.
Câu hỏi đào sâu
"UnexpectedRollbackException xuất hiện khi nào?" — Khi method con cùng transaction (REQUIRED) ném RuntimeException làm transaction bị đánh dấu rollback-only, method cha catch lại và cố commit.
Câu hỏi (Hiểu sâu): REQUIRED, REQUIRES_NEW, NESTED khác nhau thế nào? Khi nào dùng REQUIRES_NEW?
REQUIRED tham gia transaction hiện có; REQUIRES_NEW tạm dừng transaction hiện có và mở transaction độc lập, commit/rollback riêng; NESTED dùng savepoint trong cùng transaction. REQUIRES_NEW hợp cho audit log hoặc ghi nhận lần thử thất bại phải được lưu dù nghiệp vụ chính rollback.
Câu hỏi đào sâu
"REQUIRES_NEW có rủi ro gì?" — Chiếm thêm một connection trong khi connection cũ vẫn giữ; nhiều thread cùng làm vậy có thể cạn pool và treo. Ngoài ra transaction con không thấy dữ liệu chưa commit của transaction cha, dễ gây deadlock nếu cùng đụng một row.
Câu hỏi (Thực chiến): Trong method @Transactional, bạn cần gọi API thanh toán bên ngoài. Bạn thiết kế thế nào?
Tôi không để lời gọi HTTP nằm trong transaction DB. Luồng hợp lý: transaction ngắn tạo đơn ở trạng thái PENDING → commit → gọi cổng thanh toán với idempotency key → transaction ngắn thứ hai cập nhật trạng thái. Nếu cần đảm bảo không mất sự kiện, dùng outbox pattern, và có job đối soát cho các đơn kẹt ở PENDING.
Bẫy thường gặp
Tăng maximum-pool-size hay timeout để "chữa" pool cạn thay vì rút ngắn transaction.
Câu hỏi (Hiểu sâu): Filter, Interceptor và AOP khác nhau thế nào? Bạn đặt logic log request, kiểm tra quyền, audit ở đâu?
Filter ở tầng Servlet, chạy trước DispatcherServlet, hợp cho security, CORS, gắn trace id, log request thô. Interceptor ở tầng Spring MVC, biết handler nào xử lý, hợp cho logic gắn với controller. AOP bọc method của bean bất kỳ, hợp cho transaction, audit ở tầng service. Log request: Filter. Audit nghiệp vụ: AOP hoặc event ở service.
Câu hỏi đào sâu
"Exception ném ra trong Filter có được @RestControllerAdvice bắt không?" — Không, vì chưa tới DispatcherServlet. Phải xử lý trong filter hoặc qua cơ chế error của Spring Security (AuthenticationEntryPoint, AccessDeniedHandler).
Câu hỏi (Thực chiến): Bạn tổ chức xử lý lỗi và validation cho REST API ra sao?
Một @RestControllerAdvice duy nhất trả về ProblemDetail, map exception nghiệp vụ sang status code phù hợp, validation lỗi trả danh sách field lỗi, lỗi không lường trước trả 500 với thông điệp chung và log đầy đủ ở server kèm trace id. DTO dùng @Valid với jakarta.validation, quy tắc nghiệp vụ kiểm tra ở service.
Bẫy thường gặp
Trả ex.getMessage() của mọi exception về client: lộ tên bảng, câu SQL, đường dẫn nội bộ. Đây vừa là lỗi thiết kế vừa là lỗ hổng bảo mật.
Câu hỏi (Cơ bản): Bean là gì? Nó khác một object tạo bằng new ở đâu?
Bean là object bình thường nhưng do container của Spring tạo, nối dependency và quản lý vòng đời. Object tạo bằng new thì bạn tự lo mọi thứ: tự truyền dependency, không có proxy, không có @PostConstruct, @Transactional trên nó không có tác dụng. Ví dụ new OrderService(repo) trong code nghiệp vụ tạo ra một object không đi qua proxy, nên gọi method @Transactional của nó sẽ không mở transaction nào.
Câu hỏi (Cơ bản): @Component, @Service, @Repository và @Controller khác nhau thế nào?
Cả bốn đều đánh dấu class để component scan tạo bean; khác nhau ở ý nghĩa tầng và vài hành vi đi kèm. @Service, @Repository, @Controller đều được đánh dấu bằng @Component bên trong. @Repository có thêm việc dịch exception của JDBC/JPA sang DataAccessException của Spring. @Controller (và @RestController) được Spring MVC nhận làm nơi chứa handler. @Service hiện không thêm hành vi gì, chỉ giúp người đọc biết đây là tầng nghiệp vụ.
Câu hỏi (Cơ bản): @Bean khác @Component thế nào? Khi nào phải dùng @Bean?
@Component đặt trên class của bạn để Spring tự quét; @Bean đặt trên method trong class @Configuration, bạn tự viết code tạo object. Dùng @Bean khi không sửa được class (thư viện bên ngoài như ObjectMapper, RestClient, Clock) hoặc khi việc tạo cần logic (đọc cấu hình, chọn implementation). Ví dụ @Bean Clock clock() { return Clock.systemUTC(); } giúp service inject Clock và test được với Clock.fixed(...).
Câu hỏi (Cơ bản): Proxy là gì và vì sao Spring cần nó?
Proxy là object đứng thay object thật, cùng kiểu, nhận lời gọi, làm thêm việc trước và sau rồi chuyển tiếp. Spring cần proxy để thêm hành vi (transaction, cache, kiểm tra quyền, chạy bất đồng bộ) vào method mà không sửa code nghiệp vụ: bạn chỉ viết @Transactional, proxy lo BEGIN/COMMIT/ROLLBACK. Hệ quả cần nhớ: chỉ lời gọi đi qua proxy mới có các hành vi đó (xem ví dụ HandProxyDemo ở mục 4.4).
Câu hỏi (Cơ bản): @Transactional thực sự làm gì bên dưới?
Nó yêu cầu proxy mở transaction trước khi method chạy và commit hoặc rollback sau khi method kết thúc. Cụ thể TransactionInterceptor lấy connection từ pool, tắt auto-commit, gắn connection vào thread hiện tại để mọi repository dùng chung, gọi method thật, rồi commit nếu method trả về bình thường hoặc rollback nếu có RuntimeException/Error bay ra. Không có proxy (gọi qua this, method private, object tự new) thì không có gì trong số đó xảy ra.
Câu hỏi (Hiểu sâu): Vì sao mặc định checked exception không làm rollback?
Spring giữ quy ước từ EJB: checked exception được xem là một kết quả nghiệp vụ "đã lường trước", người gọi có thể xử lý, nên dữ liệu đã ghi vẫn có giá trị. Còn RuntimeException được xem là lỗi lập trình hoặc hệ thống, không thể tiếp tục an toàn. Trong thực tế quy ước này thường gây bất ngờ, nên nhiều team đặt rollbackFor = Exception.class hoặc chỉ dùng exception nghiệp vụ kế thừa RuntimeException. Điều quan trọng là bạn biết quy tắc và chọn có chủ đích.
Câu hỏi đào sâu
"Có cách nào đổi mặc định cho cả ứng dụng không?" — Các bản Spring mới (từ 6.2, bạn nên kiểm tra tài liệu đúng phiên bản đang dùng) có tùy chọn rollbackOn trên @EnableTransactionManagement để đổi mặc định cho cả ứng dụng; với phiên bản cũ hơn thì dùng quy ước team hoặc annotation tự định nghĩa có @Transactional(rollbackFor = Exception.class).
Câu hỏi (Hiểu sâu): Vì sao @Transactional trên method private không có tác dụng, và vì sao Spring không báo lỗi?
Proxy (JDK hay CGLIB) chỉ chặn được method mà nó override hoặc implement được; method private không nằm trong số đó. CGLIB tạo subclass, mà subclass không override được method private; ngoài ra method private chỉ gọi được qua this từ bên trong class, luôn bỏ qua proxy. Spring không báo lỗi vì annotation chỉ là metadata, việc quét tìm annotation không nằm trên đường tạo proxy cho method đó. Đây là lý do cần review và cảnh báo của IDE.
Câu hỏi (Hiểu sâu): @Configuration khác @Component chứa method @Bean thế nào?
Class @Configuration được Spring bọc bằng CGLIB để mọi lời gọi giữa các method @Bean trả về đúng bean singleton trong container; class @Component thì không. Ví dụ method orderService() gọi dataSource() hai lần: trong @Configuration, cả hai lần nhận cùng một DataSource; trong @Component (hay @Configuration(proxyBeanMethods = false)), mỗi lần gọi tạo một DataSource mới, có thể mở hai connection pool. Spring Boot dùng proxyBeanMethods = false cho auto-configuration để khởi động nhanh hơn, vì chúng không gọi chéo method @Bean.
Câu hỏi (Hiểu sâu): So sánh @Value và @ConfigurationProperties. Khi nào dùng cái nào?
@Value hợp cho một hai giá trị lẻ; @ConfigurationProperties hợp cho một nhóm cấu hình của cùng một module. @ConfigurationProperties type-safe, bind được Duration/DataSize/list/map, validate lúc khởi động với @Validated, và dễ test vì chỉ là record. @Value rải khắp class thì khó biết module cần những key nào, gõ sai tên key chỉ phát hiện lúc chạy (hoặc dùng giá trị mặc định mà không ai hay). @Value vẫn tiện khi cần biểu thức SpEL.
Câu hỏi (Hiểu sâu): BeanPostProcessor là gì và liên quan gì tới @Transactional?
BeanPostProcessor là hook Spring gọi cho mọi bean trước và sau bước khởi tạo, được phép thay bean bằng object khác. Bộ xử lý AOP của Spring (AnnotationAwareAspectJAutoProxyCreator) là một BeanPostProcessor: ở postProcessAfterInitialization, nó kiểm tra bean có method nào khớp advice (@Transactional, @Async, @Cacheable, @Aspect) không, nếu có thì trả về proxy. Vì vậy bean trong container là proxy, còn bên trong @PostConstruct thì chưa.
Câu hỏi (Hiểu sâu): @Transactional(readOnly = true) có chặn được lệnh ghi không?
Không chắc chắn; nó là gợi ý tối ưu, không phải cơ chế bảo vệ. Hibernate đặt flush mode thủ công nên entity bị sửa sẽ không được flush tự động, driver có thể gọi Connection.setReadOnly(true). Nhưng một câu @Modifying @Query("update ...") vẫn có thể chạy thành công trên một số DB/driver, còn PostgreSQL thì báo lỗi "cannot execute UPDATE in a read-only transaction". Muốn chặn ghi thật, dùng user DB chỉ có quyền đọc hoặc read replica.
Câu hỏi (Hiểu sâu): Gọi method @Async từ trong một method @Transactional, method async có chạy trong transaction đó không?
Không. Transaction của Spring gắn với thread hiện tại, còn @Async chạy method trên thread khác của executor.
Nhắc lại kiến thức
ThreadLocal cho mỗi thread một bản giá trị riêng; thread khác không nhìn thấy (xem mục 3.9). Spring lưu connection và trạng thái transaction trong ThreadLocal (qua TransactionSynchronizationManager).
Thread của executor không có connection của thread gọi, nên method async chạy không có transaction, hoặc mở transaction mới nếu nó tự có @Transactional. Hơn nữa nó có thể chạy trước khi transaction gọi nó commit, nên đọc DB sẽ không thấy dữ liệu vừa ghi. Cách đúng: phát event và xử lý bằng @TransactionalEventListener(phase = AFTER_COMMIT) kèm @Async.
Câu hỏi đào sâu
"SecurityContext trong method @Async cũng mất, vì sao?" — Cùng lý do: nó cũng nằm trong ThreadLocal. Dùng DelegatingSecurityContextAsyncTaskExecutor hoặc truyền thông tin user tường minh.
Câu hỏi (Thực chiến): Log production cho thấy một đơn hàng có header mà không có dòng chi tiết, dù code có @Transactional. Bạn điều tra thế nào?
Nghi ngay tới việc transaction không thực sự bao trọn các lần ghi: self-invocation, method không public, exception bị nuốt, hoặc ghi ở thread khác. Các bước: bật logging.level.org.springframework.transaction.interceptor=TRACE (hoặc org.springframework.orm.jpa.JpaTransactionManager=DEBUG) ở môi trường tái hiện để thấy "Creating new transaction with name [...]" hay "Participating in existing transaction"; nếu method nghi ngờ không xuất hiện trong log thì lời gọi không qua proxy. Kiểm tra tiếp: có catch nuốt exception không, exception là checked không, có @Async/CompletableFuture chạy phần ghi ở thread khác không. Sửa gốc rễ rồi viết test tích hợp giả lập lỗi giữa chừng để chứng minh rollback.
Câu hỏi (Thực chiến): Hệ thống báo "HikariPool-1 - Connection is not available, request timed out after 30000ms" vào giờ cao điểm. Bạn xử lý thế nào?
Pool cạn nghĩa là connection bị giữ quá lâu hoặc bị giữ gấp đôi, tăng kích thước pool chỉ là giải pháp cuối. Điều tra: xem metric hikaricp.connections.active/pending trên Actuator/Prometheus; bật spring.datasource.hikari.leak-detection-threshold để log stack trace của nơi giữ connection lâu. Thủ phạm hay gặp: gọi HTTP bên ngoài trong @Transactional, REQUIRES_NEW lồng nhau (mỗi request giữ 2 connection), transaction bao cả vòng lặp xử lý hàng nghìn bản ghi, hoặc Open Session In View giữ connection tới lúc render xong response. Sửa bằng cách rút ngắn transaction, tắt spring.jpa.open-in-view, rồi mới tính lại kích thước pool theo số connection DB chịu được.
Câu hỏi (Thực chiến): Sau khi thêm module mới, app không khởi động và báo "required a bean of type '...' that could not be found". Bạn kiểm tra những gì?
Kiểm tra theo thứ tự: class có được quét không, có đúng annotation không, và bean có bị điều kiện loại bỏ không. Class nằm ngoài package của class @SpringBootApplication sẽ không được component scan (ví dụ ở mục 4.3). Thiếu @Service/@Component, hoặc interface repository nằm ngoài package được quét. Bean có @Profile("prod") mà đang chạy profile dev, hoặc @ConditionalOnProperty chưa bật. Với bean đến từ starter, chạy --debug và tìm tên auto-configuration trong "Negative matches".
Câu hỏi (Thực chiến): Bạn thêm spring-boot-starter-data-jpa vào một service chưa dùng DB, app không khởi động được nữa. Vì sao và sửa thế nào?
Starter kéo JPA và Hikari vào classpath, nên DataSourceAutoConfiguration được kích hoạt và đòi spring.datasource.url. Thông báo là "Failed to configure a DataSource: 'url' attribute is not specified and no embedded datasource could be configured". Đây chính là auto-configuration hoạt động đúng thiết kế (bước 3–4 ở mục 4.3). Sửa: cấu hình datasource nếu thật sự cần DB; nếu chỉ lỡ thêm dependency thì bỏ nó đi; còn loại trừ bằng @SpringBootApplication(exclude = DataSourceAutoConfiguration.class) chỉ nên dùng khi bạn hiểu mình đang tắt gì.
Câu hỏi (Đọc code): Gọi refund(10), bản ghi Refund có còn trong DB không?
@Service
public class RefundService {
private final RefundRepository refunds;
public RefundService(RefundRepository refunds) { this.refunds = refunds; }
@Transactional
public void refund(long orderId) throws Exception {
refunds.save(new Refund(orderId));
if (orderId % 2 == 0) {
throw new Exception("bank timeout");
}
}
}Đáp án: Có, bản ghi vẫn còn. Từng bước: proxy mở transaction; save đưa Refund vào persistence context; 10 % 2 == 0 nên ném Exception("bank timeout"). Đây là checked exception, quy tắc mặc định là commit, nên Hibernate flush lệnh INSERT và proxy commit, rồi exception mới được ném tiếp cho người gọi. Người gọi thấy lỗi nhưng DB có refund, một bug có thể làm hoàn tiền hai lần. Đổi thành throw new IllegalStateException(...) hoặc thêm rollbackFor = Exception.class thì bản ghi bị rollback.
Câu hỏi (Đọc code): generateOne có chạy trong transaction riêng không?
@Service
public class ReportService {
@Transactional
public void generateAll(List<Long> ids) {
ids.forEach(this::generateOne);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void generateOne(Long id) { /* insert report rows, may throw RuntimeException */ }
}Đáp án: Không. generateAll được gọi qua proxy nên có một transaction. Các lời gọi generateOne đi qua this, không qua proxy, nên REQUIRES_NEW bị bỏ qua hoàn toàn và mọi report chạy trong một transaction chung. Nếu report thứ 5 ném RuntimeException, exception bay ra khỏi generateAll và 4 report trước cũng bị rollback, trái với ý định "mỗi report độc lập". Sửa: chuyển generateOne sang một bean khác, và bắt exception cho từng report nếu muốn các report còn lại tiếp tục.
Câu hỏi (Đọc code): Mỗi tham số của AlertService nhận bean nào?
public interface Notifier { void send(String msg); }
@Component
@Primary
class EmailNotifier implements Notifier { public void send(String msg) { /* ... */ } }
@Component("sms")
class SmsNotifier implements Notifier { public void send(String msg) { /* ... */ } }
@Service
class AlertService {
AlertService(Notifier notifier, @Qualifier("sms") Notifier urgent, List<Notifier> all) { /* ... */ }
}Đáp án: notifier nhận EmailNotifier, urgent nhận SmsNotifier, all nhận cả hai. Từng bước: có hai bean kiểu Notifier; với tham số không có qualifier, Spring chọn bean @Primary là EmailNotifier. @Qualifier("sms") chỉ đích danh bean tên sms và được ưu tiên hơn @Primary. Tham số kiểu List<Notifier> nhận mọi bean cùng kiểu. Nếu bỏ @Primary, tham số notifier sẽ làm app dừng với NoUniqueBeanDefinitionException, trừ khi tên tham số trùng tên một bean (ví dụ đặt tên là emailNotifier), vì Spring dùng tên tham số làm qualifier dự phòng.
Câu hỏi (Đọc code): Chương trình Java thuần dưới đây in ra gì?
public class ProxyQuiz {
interface Counter { int next(); int nextTwice(); }
static class RealCounter implements Counter {
private int value;
public int next() { return ++value; }
public int nextTwice() { next(); return next(); }
}
static class CountingProxy implements Counter {
private final Counter target;
int intercepted;
CountingProxy(Counter target) { this.target = target; }
public int next() { intercepted++; return target.next(); }
public int nextTwice() { intercepted++; return target.nextTwice(); }
}
public static void main(String[] args) {
CountingProxy proxy = new CountingProxy(new RealCounter());
proxy.next();
int v = proxy.nextTwice();
System.out.println(v + " " + proxy.intercepted);
}
}Đáp án: In ra 3 2. proxy.next() đi qua proxy (intercepted = 1), value = 1. proxy.nextTwice() đi qua proxy (intercepted = 2), bên trong RealCounter gọi next() hai lần qua this, nâng value lên 2 rồi 3 và trả về 3. Hai lời gọi nội bộ không đi qua proxy nên intercepted vẫn là 2, không phải 4. Đây là self-invocation thu nhỏ: nếu proxy là @Transactional, hai lời gọi next() bên trong không có transaction riêng.
Câu hỏi (Đọc code): Controller dưới đây có lỗi gì khi chạy trên production?
@RestController
public class CartController {
private final List<String> items = new ArrayList<>();
@PostMapping("/cart/{sku}")
public List<String> add(@PathVariable String sku) {
items.add(sku);
return items;
}
}Đáp án: Controller là singleton nên items là một giỏ hàng dùng chung cho mọi người dùng: An thêm sản phẩm thì Bình cũng thấy. Thêm nữa ArrayList không thread-safe, hai request đồng thời có thể làm mất phần tử hoặc ném ArrayIndexOutOfBoundsException, và danh sách lớn dần vô hạn gây tràn bộ nhớ. Sửa: controller không giữ state; giỏ hàng lưu theo user (DB, Redis, hoặc session nếu là web truyền thống), controller chỉ gọi service với id người dùng hiện tại.
4.8 Tóm tắt trọng tâm
- Bean là object do container tạo và quản lý; container có thể cất proxy thay cho object thật, và đó là nền tảng của
@Transactional,@Async,@Cacheable. - Dùng constructor injection với field
final, vì dependency được bảo đảm có mặt, test bằngnewđược và vòng phụ thuộc lộ ra ngay lúc khởi động. - Circular dependency nên được giải bằng thiết kế lại (bean thứ ba, event), vì
@Lazyhayallow-circular-referenceschỉ che vấn đề. - Proxy AOP được tạo ở
postProcessAfterInitialization, nên trong@PostConstructbean chưa được bọc và mọi annotation dựa trên proxy chưa có tác dụng. - Singleton bean dùng chung cho mọi thread, nên không được giữ state theo request trong field.
- Auto-configuration là các class
@AutoConfigurationcó điều kiện; bean bạn tự khai báo thắng nhờ@ConditionalOnMissingBean, và--debugcho biết vì sao một cấu hình có hoặc không được áp dụng. - Proxy chỉ chặn được lời gọi từ bên ngoài đi qua nó; gọi qua
this, methodprivatehoặcfinalđều bỏ qua proxy mà không có cảnh báo. - JDK proxy implement interface còn CGLIB tạo subclass; Spring Boot mặc định dùng CGLIB, nên class và method có annotation không được
final. @Transactionalmặc định chỉ rollback vớiRuntimeExceptionvàError, vì checked exception được coi là kết quả nghiệp vụ; exception bị nuốt thì luôn commit.- REQUIRED làm method con chung số phận với transaction cha, nên con lỗi mà cha catch vẫn gây
UnexpectedRollbackException. - REQUIRES_NEW cho kết quả độc lập (audit log) nhưng chiếm thêm một connection, dễ cạn pool dưới tải.
- Không gọi HTTP bên ngoài trong transaction, vì connection và lock bị giữ suốt thời gian chờ mạng; tách thành các transaction ngắn, dùng AFTER_COMMIT hoặc outbox.
- Filter đứng trước DispatcherServlet, Interceptor biết handler, AOP bọc method của bean; exception trong Filter không tới
@RestControllerAdvice. - Mọi lỗi trả về một format
ProblemDetail, log chi tiết ở server, không trả stack trace hay SQL cho client.