Top 45+ câu hỏi phỏng vấn Java Spring Boot thường gặp 

Hiện nay, Java Spring Boot đã trở thành một framework không thể thiếu cho các nhà phát triển ứng dụng web hiện đại. Từ những khái niệm cơ bản về Spring Core đến các kiến trúc Microservices phức tạp, việc nắm vững Spring Boot là chìa khóa để bạn tự tin chinh phục mọi buổi phỏng vấn. Bài viết này sẽ tổng hợp những câu hỏi phỏng vấn Java Spring Boot thường gặp nhất, kèm theo giải thích và ví dụ thực tế, giúp bạn củng cố kiến thức và chuẩn bị tốt nhất cho hành trình sự nghiệp của mình.

Đọc bài viết để được hướng dẫn trả lời:

  • Các câu hỏi phỏng vấn về Spring Core
  • Câu hỏi phỏng vấn về Spring MVC & RESTful APIs
  • Câu hỏi phỏng vấn về Spring Data JPA & Database
  • Câu hỏi phỏng vấn về Spring Security
  • Câu hỏi phỏng vấn về microservices, testing & kiến thức nâng cao

Câu hỏi phỏng vấn về Spring Core 

1. Spring Boot là gì và giải quyết vấn đề gì của Spring Framework?

Spring Boot là một dự án con của Spring Framework, được thiết kế để đơn giản hóa quá trình khởi tạo và phát triển các ứng dụng Spring độc lập, sẵn sàng cho môi trường sản xuất. 

Nó giải quyết các vấn đề chính của Spring Framework như:

  • Cấu hình phức tạp: Spring truyền thống yêu cầu rất nhiều cấu hình XML hoặc Java Config. Spring Boot giảm đáng kể điều này thông qua cơ chế Auto-Configuration.
  • Quản lý Dependency: Việc quản lý các phiên bản dependency trong Spring có thể phức tạp. Spring Boot Starters giúp đơn giản hóa việc này bằng cách nhóm các dependency liên quan lại với nhau.
  • Triển khai: Spring Boot cho phép tạo các ứng dụng độc lập, chạy được (executable JARs), bao gồm cả máy chủ nhúng (như Tomcat, Jetty), giúp việc triển khai trở nên dễ dàng hơn rất nhiều.
  • Thời gian phát triển: Nhờ các tính năng trên, Spring Boot giúp tăng tốc độ phát triển ứng dụng lên đáng kể.

2. Dependency Injection (DI) và Inversion of Control (IoC) có lợi ích gì?

Inversion of Control (IoC): IoC là một nguyên lý thiết kế trong phát triển phần mềm, trong đó luồng điều khiển của chương trình được đảo ngược. Thay vì các đối tượng tự tạo hoặc tìm kiếm các dependency của chúng, một IoC Container (trong Spring là ApplicationContext) sẽ chịu trách nhiệm tạo và quản lý các đối tượng, sau đó “tiêm” các dependency đó vào. Điều này giúp giảm sự phụ thuộc chặt chẽ giữa các thành phần.

Dependency Injection (DI): DI là một mẫu thiết kế cụ thể để triển khai nguyên lý IoC. Nó là quá trình một đối tượng nhận các dependency của nó từ bên ngoài, thay vì tự tạo ra chúng. Trong Spring, DI có thể được thực hiện thông qua Constructor Injection, Setter Injection hoặc Field Injection.

Lợi ích:

  • Giảm sự phụ thuộc (Loose Coupling): Các thành phần không cần biết cách tạo ra các dependency của chúng, chỉ cần biết cách sử dụng chúng.
  • Tăng khả năng kiểm thử (Testability): Dễ dàng thay thế các dependency bằng các mock object trong quá trình kiểm thử đơn vị.
  • Tăng khả năng tái sử dụng (Reusability): Các thành phần có thể được tái sử dụng trong các ngữ cảnh khác nhau với các dependency khác nhau.
  • Dễ dàng quản lý và bảo trì: Thay đổi một dependency không ảnh hưởng trực tiếp đến các thành phần sử dụng nó.

3. Bean trong Spring là gì? Trình bày các cách để định nghĩa một Bean.

Trong Spring, một “Bean” là một đối tượng được khởi tạo, quản lý và cấu hình bởi Spring IoC Container. Các Bean này là xương sống của ứng dụng Spring, đại diện cho các thành phần chính của ứng dụng.

Các cách để định nghĩa một Bean:

  • Sử dụng @Component và các Annotation phái sinh (@Service, @Repository, @Controller): Đây là cách phổ biến nhất trong Spring Boot. Bạn chỉ cần đánh dấu lớp bằng một trong các annotation này, và Spring Boot sẽ tự động quét (component scanning) và đăng ký chúng như các Bean.
@Service
public class MyServiceImpl implements MyService {
    // ...}
  • Sử dụng @Bean Annotation trong lớp cấu hình (@Configuration): Bạn có thể định nghĩa một phương thức trong một lớp được đánh dấu @Configuration, và phương thức đó sẽ trả về một đối tượng. Spring sẽ gọi phương thức này và đăng ký đối tượng trả về như một Bean.
@Configuration
public class AppConfig {
    @Bean
    public MyService myService() {
        return new MyServiceImpl();
    }
}
  • Sử dụng XML Configuration (ít dùng trong Spring Boot): Trong các dự án Spring truyền thống, Bean có thể được định nghĩa trong các tệp XML.
<bean id="myService" class="com.example.MyServiceImpl"/> 

4. Sự khác biệt giữa @Component, @Service, @Repository và @Controller là gì?

Tất cả các annotation này đều là các chuyên biệt hóa của @Component, có nghĩa là chúng đều được sử dụng để đánh dấu một lớp là một Spring Bean và được Spring IoC Container quản lý. 

Sự khác biệt chính nằm ở ngữ nghĩa và mục đích sử dụng, giúp tăng tính rõ ràng và khả năng đọc mã:

  • @Component: Là một annotation chung, dùng để đánh dấu một lớp là một thành phần được Spring quản lý. Nó là annotation cơ sở cho các annotation khác. Bạn có thể sử dụng nó cho các thành phần chung không thuộc một tầng cụ thể nào.
  • @Service: Dùng để đánh dấu các lớp trong tầng dịch vụ (Service Layer). Tầng này thường chứa logic nghiệp vụ chính của ứng dụng. @Service không thêm bất kỳ hành vi kỹ thuật nào đặc biệt so với @Component, nhưng nó giúp rõ ràng hơn về vai trò của lớp trong kiến trúc.
  • @Repository: Dùng để đánh dấu các lớp trong tầng truy cập dữ liệu (Data Access Layer), thường là các DAO (Data Access Object). Annotation này có thêm một tính năng đặc biệt: nó tự động chuyển đổi các ngoại lệ cụ thể của cơ sở dữ liệu thành các ngoại lệ không được kiểm tra (unchecked exceptions) của Spring (DataAccessException), giúp xử lý lỗi nhất quán hơn.
  • @Controller: Dùng để đánh dấu các lớp trong tầng trình bày (Presentation Layer) trong kiến trúc MVC truyền thống. Các lớp này xử lý các yêu cầu HTTP đến, tương tác với tầng dịch vụ và trả về các View.
  • @RestController (thêm): Là sự kết hợp của @Controller và @ResponseBody. Nó được sử dụng cho việc xây dựng các RESTful API, nơi các phương thức của controller trực tiếp trả về dữ liệu (thường là JSON hoặc XML) thay vì tên View.

5. Spring Boot Starters là gì? Kể tên một vài starters bạn đã sử dụng (ví dụ: spring-boot-starter-web).

Spring Boot Starters là một tập hợp các dependency được cấu hình sẵn, giúp đơn giản hóa việc thêm các chức năng phổ biến vào ứng dụng Spring Boot. Mỗi Starter là một bộ mô tả dependency mà bạn có thể thêm vào pom.xml (Maven) hoặc build.gradle (Gradle) của mình. Khi bạn thêm một Starter, nó sẽ tự động kéo về tất cả các thư viện cần thiết và cấu hình chúng một cách hợp lý, loại bỏ nhu cầu phải tự quản lý từng dependency và phiên bản của chúng.

Một vài starters thường dùng:

  • spring-boot-starter-web: Để xây dựng các ứng dụng web, bao gồm RESTful API. Nó bao gồm Tomcat nhúng, Spring MVC, Jackson (để xử lý JSON), v.v.
  • spring-boot-starter-data-jpa: Để làm việc với cơ sở dữ liệu quan hệ bằng Spring Data JPA và Hibernate.
  • spring-boot-starter-security: Để thêm các tính năng bảo mật vào ứng dụng, bao gồm xác thực và ủy quyền.
  • spring-boot-starter-test: Cung cấp các thư viện cần thiết cho việc kiểm thử ứng dụng Spring Boot, bao gồm JUnit, Mockito, Spring Test, v.v.
  • spring-boot-starter-actuator: Để giám sát và quản lý ứng dụng trong môi trường sản xuất.

6. Giải thích cơ chế hoạt động Auto-Configuration của Spring Boot.

Auto-Configuration là một trong những tính năng cốt lõi của Spring Boot, giúp tự động cấu hình ứng dụng Spring dựa trên các dependency có trong classpath, các Bean đã được định nghĩa và các thuộc tính cấu hình. Mục tiêu là giảm thiểu lượng cấu hình thủ công mà nhà phát triển phải viết.

Cách hoạt động:

  • @SpringBootApplication: Annotation này (thường đặt trên lớp chính của ứng dụng) bao gồm @EnableAutoConfiguration.
  • @EnableAutoConfiguration: Annotation này kích hoạt cơ chế Auto-Configuration. Khi ứng dụng khởi động, Spring Boot sẽ tìm kiếm các lớp Auto-Configuration trong classpath.
  • Các lớp Auto-Configuration: Các lớp này thường nằm trong các thư viện Starter (ví dụ: spring-boot-autoconfigure). Mỗi lớp Auto-Configuration chứa logic để cấu hình một tính năng cụ thể (ví dụ: cấu hình DataSource, Tomcat, Spring MVC).
  • @ConditionalOn… Annotations: Đây là chìa khóa của Auto-Configuration. Các lớp Auto-Configuration sử dụng một loạt các annotation @ConditionalOn… để quyết định xem liệu một cấu hình cụ thể có nên được áp dụng hay không. Ví dụ:
    • @ConditionalOnClass: Chỉ cấu hình nếu một lớp cụ thể có trong classpath.
    • @ConditionalOnMissingBean: Chỉ cấu hình nếu một Bean có kiểu cụ thể chưa được định nghĩa.
    • @ConditionalOnProperty: Chỉ cấu hình nếu một thuộc tính cấu hình cụ thể có giá trị.
    • @ConditionalOnWebApplication: Chỉ cấu hình nếu đây là một ứng dụng web.
  • Cơ chế ưu tiên: Spring Boot sẽ áp dụng các cấu hình mặc định này, nhưng nếu nhà phát triển cung cấp cấu hình tùy chỉnh (ví dụ: định nghĩa một Bean với cùng kiểu), cấu hình tùy chỉnh đó sẽ được ưu tiên và ghi đè lên cấu hình tự động.

7. Vòng đời của một Bean (Bean Lifecycle) trong Spring diễn ra như thế nào?

Vòng đời của một Bean trong Spring là chuỗi các bước mà Spring IoC Container thực hiện từ khi khởi tạo Bean cho đến khi nó bị hủy. Các bước chính bao gồm:

  1. Khởi tạo (Instantiation): Spring IoC Container tạo một instance của Bean (gọi constructor).
  2. Điền thuộc tính (Populate Properties / Dependency Injection): Spring tiêm các dependency (các Bean khác) vào instance vừa tạo thông qua setter, constructor hoặc field injection.
  3. BeanNameAware (nếu có): Nếu Bean implement BeanNameAware, phương thức setBeanName() sẽ được gọi, cung cấp tên của Bean.
  4. BeanFactoryAware (nếu có): Nếu Bean implement BeanFactoryAware, phương thức setBeanFactory() sẽ được gọi, cung cấp tham chiếu đến BeanFactory (hoặc ApplicationContext).
  5. ApplicationContextAware (nếu có): Nếu Bean implement ApplicationContextAware, phương thức setApplicationContext() sẽ được gọi, cung cấp tham chiếu đến ApplicationContext.
  6. BeanPostProcessors – postProcessBeforeInitialization(): Các BeanPostProcessors đã đăng ký sẽ được gọi. Phương thức postProcessBeforeInitialization() của chúng sẽ được thực thi.
  7. InitializingBean – afterPropertiesSet() (nếu có): Nếu Bean implement InitializingBean, phương thức afterPropertiesSet() sẽ được gọi. Đây là nơi bạn có thể thực hiện các khởi tạo tùy chỉnh sau khi tất cả các thuộc tính đã được thiết lập.
  8. Custom init-method (nếu có): Nếu bạn đã chỉ định một phương thức init-method trong cấu hình Bean (ví dụ: thông qua @Bean(initMethod = “myInitMethod”) hoặc XML), phương thức đó sẽ được gọi.
  9. BeanPostProcessors – postProcessAfterInitialization(): Các BeanPostProcessors lại được gọi. Phương thức postProcessAfterInitialization() của chúng sẽ được thực thi. Tại thời điểm này, Bean đã sẵn sàng để sử dụng.
  10. Sử dụng Bean: Bean được sử dụng trong ứng dụng.
  11. DisposableBean – destroy() (nếu có): Khi Container bị đóng, nếu Bean implement DisposableBean, phương thức destroy() sẽ được gọi.
  12. Custom destroy-method (nếu có): Nếu bạn đã chỉ định một phương thức destroy-method trong cấu hình Bean (ví dụ: thông qua @Bean(destroyMethod = “myDestroyMethod”) hoặc XML), phương thức đó sẽ được gọi.

8. Sự khác biệt giữa các scope của Bean (Singleton, Prototype, Request, Session)? Scope mặc định là gì?

Scope của Bean xác định cách mà Spring IoC Container quản lý các instance của Bean. Scope mặc định là Singleton.

Sự khác biệt giữa các scope:

ScopeMô tảTrường hợp sử dụng
Singleton (mặc định)Chỉ có một instance duy nhất của Bean được tạo cho mỗi Spring IoC Container. Mọi yêu cầu đến Bean này đều nhận cùng một instance.Dùng cho các Bean không có trạng thái (stateless) hoặc cần chia sẻ trạng thái chung toàn ứng dụng, như @Service, @Repository, @Controller.
PrototypeMỗi khi Bean được yêu cầu, Spring tạo một instance mới hoàn toàn.Dùng cho các Bean có trạng thái (stateful), nơi mỗi client hoặc luồng cần một bản sao riêng biệt.
Request (Web-only)Một instance mới được tạo cho mỗi HTTP request. Bean tồn tại trong suốt vòng đời của request đó.Dùng cho các Bean cần lưu dữ liệu tạm trong một request (ví dụ: thông tin form).
Session (Web-only)Mỗi HTTP Session sẽ có một instance riêng biệt của Bean. Bean tồn tại suốt vòng đời phiên làm việc.Dùng cho các Bean cần lưu thông tin liên quan đến một phiên làm việc của người dùng (ví dụ: giỏ hàng).
Application (Web-only)Tương tự Singleton nhưng phạm vi ở ServletContext, một instance duy nhất cho toàn bộ ứng dụng web.Dùng khi cần chia sẻ dữ liệu hoặc cấu hình trên phạm vi toàn bộ web app.
WebSocket (Web-only)Tạo một instance duy nhất cho mỗi phiên WebSocket.Dùng cho các Bean cần lưu trạng thái xuyên suốt phiên giao tiếp WebSocket.

9. @Qualifier và @Primary được sử dụng để làm gì trong trường hợp có nhiều Bean cùng một kiểu?

Khi có nhiều Bean cùng một kiểu trong Spring IoC Container, Spring sẽ không biết Bean nào cần được tiêm vào một dependency cụ thể, dẫn đến lỗi NoUniqueBeanDefinitionException. @Qualifier và @Primary được sử dụng để giải quyết vấn đề này.

@Primary: Annotation @Primary được đặt trên một Bean để chỉ định rằng nó là Bean được ưu tiên (primary) khi có nhiều Bean cùng kiểu. Nếu không có @Qualifier cụ thể nào được chỉ định, Spring sẽ tự động chọn Bean được đánh dấu @Primary.

  • Sử dụng khi: Bạn muốn có một Bean mặc định được chọn trong số nhiều Bean cùng kiểu.
  • Ví dụ:
@Service
@Primary
public class SmsNotificationService implements NotificationService { ... }

@Service
public class EmailNotificationService implements NotificationService { ... }

@Component
public class MyClient {
    @Autowired
    private NotificationService notificationService; // Sẽ tiêm SmsNotificationService
}

@Qualifier: Annotation @Qualifier được sử dụng để chỉ định tên cụ thể của Bean mà bạn muốn tiêm. Bạn đặt @Qualifier trên cả Bean khi định nghĩa và trên điểm tiêm (autowired field/constructor/setter).

  • Sử dụng khi: Bạn cần kiểm soát chính xác Bean nào được tiêm, đặc biệt khi có nhiều Bean cùng kiểu và không có một Bean “mặc định” rõ ràng.
  • Ví dụ:
@Service("smsService")
public class SmsNotificationService implements NotificationService { ... }

@Service("emailService")
public class EmailNotificationService implements NotificationService { ... }

@Component
public class MyClient {
    @Autowired
    @Qualifier("emailService")
    private NotificationService notificationService; // Sẽ tiêm EmailNotificationService
}

@Qualifier có độ ưu tiên cao hơn @Primary. Nếu cả hai đều được sử dụng, @Qualifier sẽ được ưu tiên.

10. Có các loại @ConditionalOn… annotation nào? Chúng hoạt động như thế nào? 

Như đã đề cập ở câu 6, các annotation @ConditionalOn… là một phần quan trọng của cơ chế Auto-Configuration trong Spring Boot. Chúng được sử dụng để kiểm soát có điều kiện việc đăng ký Bean hoặc cấu hình. Spring Boot sẽ kiểm tra các điều kiện này trong quá trình khởi động ứng dụng. Nếu tất cả các điều kiện được đáp ứng, Bean hoặc cấu hình tương ứng sẽ được áp dụng; nếu không, chúng sẽ bị bỏ qua.

Các loại phổ biến:

  • @ConditionalOnClass: Bean/cấu hình chỉ được tạo nếu lớp được chỉ định có trong classpath.
  • @ConditionalOnMissingClass: Bean/cấu hình chỉ được tạo nếu lớp được chỉ định không có trong classpath.
  • @ConditionalOnBean: Bean/cấu hình chỉ được tạo nếu Bean có kiểu/tên được chỉ định đã tồn tại trong IoC Container.
  • @ConditionalOnMissingBean: Bean/cấu hình chỉ được tạo nếu Bean có kiểu/tên được chỉ định không tồn tại trong IoC Container.
  • @ConditionalOnProperty: Bean/cấu hình chỉ được tạo nếu một thuộc tính cấu hình cụ thể có giá trị mong muốn (hoặc tồn tại).
  • @ConditionalOnExpression: Bean/cấu hình chỉ được tạo nếu một biểu thức SpEL (Spring Expression Language) đánh giá là true.
  • @ConditionalOnResource: Bean/cấu hình chỉ được tạo nếu một tài nguyên cụ thể (ví dụ: tệp cấu hình) tồn tại.
  • @ConditionalOnWebApplication: Bean/cấu hình chỉ được tạo nếu ứng dụng là một ứng dụng web.
  • @ConditionalOnNotWebApplication: Bean/cấu hình chỉ được tạo nếu ứng dụng không phải là một ứng dụng web.

Cơ chế hoạt động: Spring Boot sử dụng SpringFactoriesLoader để tìm các lớp cấu hình tự động được liệt kê trong tệp META-INF/spring.factories trong classpath. Sau đó, nó duyệt qua từng lớp cấu hình này và kiểm tra các điều kiện @ConditionalOn… trước khi quyết định có tạo các Bean bên trong lớp cấu hình đó hay không.

11. Làm thế nào để tạo một custom auto-configuration cho riêng mình?

Để tạo một custom auto-configuration, bạn cần thực hiện các bước sau:

  • Tạo một lớp cấu hình (@Configuration class):

    Lớp này sẽ chứa các @Bean definitions mà bạn muốn tự động cấu hình. Sử dụng @ConditionalOn… annotations để kiểm soát khi nào các Bean này nên được tạo.
// MyServiceAutoConfiguration.java
@Configuration
@ConditionalOnClass(MyService.class) // Chỉ cấu hình nếu MyService có trong classpath
@ConditionalOnMissingBean(MyService.class) // Chỉ cấu hình nếu chưa có Bean MyService nào được định nghĩa
@ConditionalOnProperty(prefix = "my.service", name = "enabled", havingValue = "true", matchIfMissing = true)
public class MyServiceAutoConfiguration {

    @Bean
    public MyService myService() {
        return new MyServiceImpl();
    }
}

Trong ví dụ này:

  • @ConditionalOnClass(MyService.class): Đảm bảo rằng MyService tồn tại trong classpath.
  • @ConditionalOnMissingBean(MyService.class): Đảm bảo rằng nếu người dùng đã tự định nghĩa một MyService Bean, cấu hình này sẽ không ghi đè.
  • @ConditionalOnProperty: Cho phép người dùng bật/tắt cấu hình này thông qua thuộc tính my.service.enabled=true (mặc định là true nếu không có).
  • Tạo một tệp spring.factories:

Trong thư mục src/main/resources/META-INF/, tạo một tệp có tên spring.factories. Trong tệp này, bạn sẽ liệt kê lớp cấu hình tự động của mình dưới khóa. 

org.springframework.boot.autoconfigure.EnableAutoConfiguration.

# src/main/resources/META-INF/spring.factories
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.example.autoconfiguration.MyServiceAutoConfiguration

(Lưu ý: \ được sử dụng để ngắt dòng nếu bạn có nhiều lớp auto-configuration.)

  • Đóng gói và sử dụng:

Đóng gói module chứa custom auto-configuration này thành một JAR. Khi bạn thêm JAR này vào classpath của một ứng dụng Spring Boot khác, Spring Boot sẽ tự động phát hiện và áp dụng cấu hình của bạn dựa trên các điều kiện đã định.

Câu hỏi phỏng vấn về Spring MVC & RESTful APIs

12. @RestController khác gì so với @Controller?

Sự khác biệt chính giữa @RestController@Controller nằm ở cách chúng xử lý kết quả trả về từ các method.

  • @Controller là một annotation cơ bản của Spring MVC, dùng để đánh dấu một class là Controller. 

Khi dùng @Controller, các method thường trả về một String là tên của View (ví dụ: tên file JSP, Thymeleaf). Spring sẽ dựa vào ViewResolver để tìm và render ra giao diện HTML cho người dùng. Nếu muốn trả về dữ liệu dạng JSON/XML, ta phải kết hợp thêm annotation @ResponseBody trên từng method.

@Controller
public class UserController {
    @RequestMapping("/user-ui")
    public String getUserPage() {
        return "user-view"; // Trả về file user-view.html
    }

    @RequestMapping("/api/user")
    @ResponseBody // Cần thêm @ResponseBody để trả về JSON
    public User getUserData() {
        return new User("john", "john@example.com"); 
    }
}
  • @RestController là một annotation tiện lợi được giới thiệu từ Spring 4.0. Nó thực chất là sự kết hợp của @Controller và @ResponseBody

Khi dùng @RestController, tất cả các method trong class đó sẽ mặc định có @ResponseBody, nghĩa là kết quả trả về (thường là Object) sẽ được tự động chuyển đổi thành JSON hoặc XML bởi HttpMessageConverter và gửi trực tiếp về cho client. Annotation này được sinh ra chuyên để xây dựng RESTful APIs.

@RestController
@RequestMapping("/api")
public class UserRestController {
    @GetMapping("/user") // Không cần @ResponseBody
    public User getUserData() {
        return new User("john", "john@example.com"); // Tự động chuyển thành JSON
    }
}

Tóm lại: Dùng @Controller khi xây dựng web application truyền thống trả về view, và dùng @RestController khi xây dựng RESTful API chỉ trả về dữ liệu.

13. Các annotation @RequestMapping, @GetMapping, @PostMapping dùng để làm gì?

Các annotation này dùng để ánh xạ (map) các HTTP request từ client tới các method xử lý (handler method) trong Controller.

@RequestMapping: Đây là annotation tổng quát nhất. Nó có thể map với bất kỳ phương thức HTTP nào như GET, POST, PUT, DELETE… Mặc định nếu không chỉ định method, nó sẽ chấp nhận tất cả. ta có thể dùng nó ở cả cấp độ class (để định nghĩa một đường dẫn gốc cho tất cả các method bên trong) và cấp độ method.

@RestController
@RequestMapping("/api/v1/tasks") // Áp dụng cho cả class
public class TaskController {
    // Sẽ map với request tới /api/v1/tasks
    @RequestMapping(method = RequestMethod.GET) 
    public List<Task> getAllTasks() { /* ... */ }
}

@GetMapping@PostMapping: Đây là các annotation rút gọn và chuyên biệt hơn cho các phương thức HTTP cụ thể.

  • @GetMapping là viết tắt của @RequestMapping(method = RequestMethod.GET). Nó chuyên dùng để xử lý các request GET, thường dùng cho các tác vụ lấy dữ liệu.
  • @PostMapping là viết tắt của @RequestMapping(method = RequestMethod.POST). Nó chuyên dùng để xử lý các request POST, thường dùng cho các tác vụ tạo mới dữ liệu.

Việc sử dụng các annotation chuyên biệt này giúp code của mình trở nên rõ ràng, dễ đọc và ngắn gọn hơn rất nhiều.

@RestController
@RequestMapping("/api/v1/tasks")
public class TaskController {
    @GetMapping
    public List<Task> getAllTasks() { /* Lấy danh sách task */ }

    @PostMapping
    public Task createTask(@RequestBody Task task) { /* Tạo mới task */ }
}

Ngoài ra còn có @PutMapping, @DeleteMapping, @PatchMapping với công dụng tương tự cho các phương thức HTTP tương ứng.

14. @PathVariable và @RequestParam khác nhau như thế nào?

Cả hai annotation này đều dùng để lấy dữ liệu từ URL của request, nhưng chúng lấy từ hai phần khác nhau của URL.

@PathVariable dùng để trích xuất giá trị từ một phần của đường dẫn URL (URI template). Nó rất hữu ích trong RESTful API khi chúng ta muốn định danh một tài nguyên cụ thể.

  • Ví dụ URL: http://localhost:8080/users/123
  • Mapping trong code:
@GetMapping("/users/{id}")
public User getUserById(@PathVariable("id") Long userId) {
    // userId sẽ có giá trị là 123
    return userService.findById(userId);
}

Ở đây, {id} là một biến trong đường dẫn, và @PathVariable sẽ lấy giá trị 123 gán vào biến userId.

@RequestParam dùng để trích xuất giá trị từ query parameter (phần sau dấu ?) của URL. Nó thường được dùng cho việc lọc, sắp xếp hoặc phân trang.

  • Ví dụ URL: http://localhost:8080/users?page=1&sort=name
  • Mapping trong code:
@GetMapping("/users")
public List<User> getUsers(
    @RequestParam(value = "page", defaultValue = "0") int page,
    @RequestParam(value = "sort", required = false) String sort) {
    // page sẽ là 1, sort sẽ là "name"
    return userService.findAll(page, sort);
}

@RequestParam có các thuộc tính hữu ích như required (mặc định là true) để quy định parameter có bắt buộc hay không, và defaultValue để gán giá trị mặc định nếu parameter không được truyền lên.

Tóm lại: @PathVariable lấy dữ liệu từ chính đường dẫn, còn @RequestParam lấy dữ liệu từ phần query string sau dấu ?.

15. Trình bày luồng xử lý một HTTP Request trong Spring MVC (từ DispatcherServlet đến Controller).

Luồng xử lý một HTTP Request trong Spring MVC có thể được tóm gọn qua các bước sau, với DispatcherServlet đóng vai trò là trung tâm điều phối: Request -> DispatcherServlet -> HandlerMapping -> Controller -> HandlerAdapter -> ModelAndView/ResponseData -> ViewResolver/HttpMessageConverter -> Response.

Giải thích cụ thể:

  1. Request đến DispatcherServlet: Khi một HTTP Request được gửi đến ứng dụng, DispatcherServlet là thành phần đầu tiên tiếp nhận. Nó hoạt động như một Front Controller, chịu trách nhiệm điều phối request này đến các thành phần xử lý phù hợp.
  2. DispatcherServlet tìm Handler (Controller Method): DispatcherServlet sẽ hỏi HandlerMapping để tìm ra handler (Controller và method) nào sẽ xử lý request này. HandlerMapping sẽ dựa vào thông tin của request (như URL, HTTP method) và các annotation (@RequestMapping, @GetMapping…) trong các Controller để tìm ra handler tương ứng. Nếu tìm thấy, nó sẽ trả về một HandlerExecutionChain (bao gồm handler và các interceptor nếu có) cho DispatcherServlet.
  3. DispatcherServlet gọi HandlerAdapter: Sau khi có handler, DispatcherServlet không gọi trực tiếp method đó. Thay vào đó, nó sẽ chuyển HandlerExecutionChain cho HandlerAdapter. HandlerAdapter có nhiệm vụ thực thi method của handler đó. Lý do cần HandlerAdapter là để Spring MVC có thể linh hoạt hỗ trợ nhiều loại handler khác nhau mà không cần thay đổi DispatcherServlet.
  4. HandlerAdapter thực thi Controller Method: HandlerAdapter sẽ thực thi method trong Controller. Tại đây, các quá trình như binding dữ liệu từ request vào parameter của method (sử dụng @PathVariable, @RequestParam, @RequestBody…) sẽ diễn ra. Method này sẽ thực hiện logic business và trả về một đối tượng ModelAndView (nếu là @Controller) hoặc một đối tượng bất kỳ (nếu là @RestController).
  5. Xử lý kết quả trả về:
    • Đối với @Controller (trả về View): DispatcherServlet nhận ModelAndView và chuyển nó cho ViewResolver. ViewResolver sẽ phân giải tên view logic (ví dụ: “home”) thành một đối tượng View cụ thể (ví dụ: home.jsp).
    • Đối với @RestController (trả về Data): Kết quả trả về (ví dụ: một User object) sẽ được xử lý bởi các HttpMessageConverter để chuyển đổi thành định dạng dữ liệu như JSON trước khi ghi vào HttpResponse.
  6. Render View và gửi Response: Cuối cùng, đối tượng View sẽ được render (kết hợp model data vào template) để tạo ra HttpResponse, hoặc dữ liệu JSON/XML được ghi trực tiếp vào HttpResponse và gửi về cho client.

16. @ControllerAdvice và @ExceptionHandler được sử dụng để làm gì? 

@ControllerAdvice@ExceptionHandler là hai annotation cực kỳ mạnh mẽ trong Spring, dùng để xử lý exception một cách tập trung và toàn cục (globally).

@ExceptionHandler: Annotation này được dùng để đánh dấu một method sẽ chịu trách nhiệm xử lý một loại exception cụ thể. Chúng ta có thể đặt method này ngay trong một Controller để xử lý exception chỉ phát sinh từ Controller đó.

@RestController
public class UserController {
// ...
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<String> handleUserNotFound() {
return new ResponseEntity<>("User not found", HttpStatus.NOT_FOUND);
}
}

Tuy nhiên, cách này sẽ dẫn đến việc lặp code nếu nhiều Controller cần xử lý cùng một loại exception.

@ControllerAdvice: Đây là giải pháp cho vấn đề trên. ControllerAdvice cho phép ta tách biệt logic xử lý exception ra một class riêng. Class được đánh dấu @ControllerAdvice sẽ “lắng nghe” các exception xảy ra ở tất cả các Controller trong ứng dụng. Khi kết hợp với @ExceptionHandler, nó tạo thành một cơ chế xử lý lỗi tập trung, giúp code sạch sẽ và dễ bảo trì hơn.

17. Trình bày cách bạn xử lý Exception trong một REST API

  • Tạo các Custom Exception: Tôi sẽ tạo các class exception riêng cho các trường hợp nghiệp vụ cụ thể, ví dụ: ResourceNotFoundException, InvalidInputException, DuplicateEmailException. Việc này giúp code ở tầng service rõ ràng hơn, thay vì chỉ throw new Exception(“…”)

Ví dụ:

public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String message) {
        super(message);
    }
}
  • Tạo một Global Exception Handler: ta sẽ tạo một class có tên như GlobalExceptionHandler hoặc RestResponseEntityExceptionHandler và đánh dấu nó với annotation @ControllerAdvice.
  • Định nghĩa các phương thức xử lý Exception: Bên trong class này, ta sẽ viết các method được đánh dấu với @ExceptionHandler cho từng loại custom exception.
    • Mỗi method sẽ nhận vào exception tương ứng làm tham số.
    • Bên trong method, ta sẽ tạo một đối tượng ErrorResponse (một POJO class do ta tự định nghĩa, thường chứa các thông tin như timestamp, status, error, message, path).
    • Cuối cùng, ta trả về một ResponseEntity<ErrorResponse> với HttpStatus phù hợp.
@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFoundException(
        ResourceNotFoundException ex, WebRequest request) {

        ErrorResponse errorDetails = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.NOT_FOUND.value(),
            "Resource Not Found",
            ex.getMessage(),
            request.getDescription(false)
        );
        return new ResponseEntity<>(errorDetails, HttpStatus.NOT_FOUND);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class) // For @Valid
    public ResponseEntity<ErrorResponse> handleValidationExceptions(
        MethodArgumentNotValidException ex, WebRequest request) {

        String errorMessage = ex.getBindingResult().getFieldError().getDefaultMessage();
        ErrorResponse errorDetails = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.BAD_REQUEST.value(),
            "Validation Failed",
            errorMessage,
            request.getDescription(false)
        );
        return new ResponseEntity<>(errorDetails, HttpStatus.BAD_REQUEST);
    }

    // Một handler "bắt tất cả" cho các exception chưa được xử lý cụ thể
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGlobalException(
        Exception ex, WebRequest request) {

        ErrorResponse errorDetails = new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.INTERNAL_SERVER_ERROR.value(),
            "Internal Server Error",
            ex.getMessage(),
            request.getDescription(false)
        );
        return new ResponseEntity<>(errorDetails, HttpStatus.INTERNAL_SERVER_ERROR);
    }
}

Cách tiếp cận này giúp ta chuẩn hóa format lỗi trả về cho client, tách biệt logic xử lý lỗi ra khỏi logic nghiệp vụ, và làm cho code trong Controller gọn gàng hơn rất nhiều.

18. HTTP Status Code là gì? Nêu ý nghĩa của các code thường dùng.

HTTP Status Code là một mã số gồm 3 chữ số được server trả về trong HTTP response sau khi nhận một HTTP request từ client. Mã này cung cấp cho client thông tin về kết quả xử lý request, ví dụ như thành công, thất bại, cần chuyển hướng, hay có lỗi xảy ra.

Các status code được chia thành 5 nhóm dựa trên chữ số đầu tiên:

  • 1xx: Informational (Thông tin)
  • 2xx: Success (Thành công)
  • 3xx: Redirection (Chuyển hướng)
  • 4xx: Client Error (Lỗi phía Client)
  • 5xx: Server Error (Lỗi phía Server)

Ý nghĩa của các code phổ biến mà ta hay dùng trong phát triển API là:

  • 200 OK: Đây là code thành công phổ biến nhất. Nó có nghĩa là request đã được xử lý thành công.

Ví dụ: Client gửi GET /users/123 và server tìm thấy user, trả về thông tin user kèm status 200.

  • 201 Created: Code này cũng là thành công, nhưng cụ thể hơn là request đã tạo thành công một tài nguyên mới.

Ví dụ: Client gửi POST /users với thông tin user mới. Server tạo user thành công trong database và trả về status 201. Thường response sẽ chứa thông tin của tài nguyên vừa tạo và một header Location trỏ đến URL của tài nguyên đó.

  • 400 Bad Request: Lỗi này xảy ra khi request của client không hợp lệ hoặc sai cú pháp. Server không thể hiểu hoặc xử lý request.

Ví dụ: Client gửi POST /users nhưng thiếu một trường bắt buộc trong JSON body, hoặc gửi một giá trị sai định dạng (ví dụ: gửi chữ cho một trường số).

  • 404 Not Found: Lỗi này có nghĩa là server không tìm thấy tài nguyên mà client yêu cầu. Đây là một trong những lỗi phổ biến nhất.

Ví dụ: Client gửi GET /users/999 nhưng không có user nào với ID là 999 trong hệ thống.

  • 500 Internal Server Error: Đây là một lỗi chung chung phía server. Nó chỉ ra rằng đã có một lỗi bất ngờ xảy ra trên server khiến nó không thể hoàn thành request. Lỗi này không phải do client gây ra.

Ví dụ: Trong quá trình xử lý request, code logic gặp phải NullPointerException hoặc không thể kết nối đến database. Client không cần biết chi tiết lỗi là gì, chỉ cần biết server đang gặp sự cố.

19. So sánh việc xử lý bất đồng bộ trong Spring MVC bằng Callable và DeferredResult.

Cả CallableDeferredResult đều là hai cơ chế mà Spring MVC cung cấp để xử lý các request một cách bất đồng bộ. Mục tiêu chung của chúng là giải phóng thread của web server (ví dụ: Tomcat) trong khi chờ đợi một tác vụ tốn thời gian hoàn thành (như gọi API bên ngoài, truy vấn database phức tạp), từ đó giúp ứng dụng có khả năng chịu tải tốt hơn.

Tuy nhiên, chúng khác nhau về cách thức hoạt động và người chịu trách nhiệm quản lý thread.

Tiêu chíCallable<T>DeferredResult<T>
Quản lý ThreadSpring quản lý hoàn toànLập trình viên quản lý
Luồng hoạt độngController trả về một Callable. DispatcherServlet sẽ gọi startAsync và chuyển Callable này cho một TaskExecutor (do Spring quản lý) để thực thi ở một thread khác. Thread của Tomcat được giải phóng ngay lập tức. Khi Callable hoàn thành, Spring sẽ dispatch một request mới để xử lý kết quả.Controller tạo một đối tượng DeferredResult và lưu nó ở đâu đó (ví dụ: một hàng đợi). Sau đó trả về DeferredResult này. Thread của Tomcat được giải phóng. Một thread do lập trình viên tự quản lý (ví dụ từ một message queue listener, một scheduled task) sẽ thực hiện tác vụ và khi có kết quả, nó sẽ gọi deferredResult.setResult(result). Việc gọi setResult sẽ trigger Spring dispatch một request mới để xử lý kết quả.
Cách sử dụngĐơn giản, trực tiếp. Phù hợp cho các tác vụ tính toán hoặc I/O blocking có thể được gói gọn trong một khối lệnh.Linh hoạt hơn, nhưng phức tạp hơn. Phù hợp cho các kịch bản dựa trên sự kiện (event-driven), nơi mà kết quả được tạo ra từ một tiến trình hoàn toàn khác, không liên quan trực tiếp đến luồng request ban đầu.
Ví dụ Kịch bảnGọi một microservice khác và chờ kết quả. – Thực hiện một truy vấn database phức tạp.Chờ một message từ Kafka/RabbitMQ. – Một tác vụ chạy ngầm hoàn thành và đẩy kết quả. – Xây dựng ứng dụng chat long-polling.

Tóm tắt sự khác biệt cốt lõi:

Với Callable, ta nói với Spring: “Đây là một khối công việc, hãy lấy nó và chạy trên một thread khác do anh quản lý. Khi nào xong thì báo tôi.” Toàn bộ quá trình nằm trong sự kiểm soát của Spring MVC và TaskExecutor của nó.

@GetMapping("/callable")
public Callable<String> processCallable() {
    log.info("Controller thread starts");
    Callable<String> callable = () -> {
        Thread.sleep(2000); // Giả lập tác vụ dài
        log.info("Async task completed");
        return "Callable result";
    };
    log.info("Controller thread returns");
    return callable;
}

Với DeferredResult, ta nói với Spring: “Tôi sẽ trả lời anh sau. Hãy giữ request này lại. Một lúc nữa, một thread nào đó ở nơi khác sẽ cung cấp kết quả cho đối tượng DeferredResult này.” Em, với tư cách là lập trình viên, phải chịu trách nhiệm hoàn toàn cho việc tính toán và set kết quả từ một thread bên ngoài.

private final Queue<DeferredResult<String>> resultQueue = new ConcurrentLinkedQueue<>();

@GetMapping("/deferred")
public DeferredResult<String> processDeferred() {
    log.info("Controller thread starts");
    DeferredResult<String> deferredResult = new DeferredResult<>(3000L, "Timeout!"); // 3s timeout
    resultQueue.add(deferredResult);
    log.info("Controller thread returns");
    return deferredResult;
}

// Một tác vụ chạy định kỳ hoặc một listener nào đó
@Scheduled(fixedRate = 2000)
public void processQueue() {
    if (!resultQueue.isEmpty()) {
        DeferredResult<String> result = resultQueue.poll();
        result.setResult("Deferred result");
        log.info("Result set for a deferred request");
    }
}

Lựa chọn giữa hai cơ chế này phụ thuộc vào bài toán cụ thể. Nếu tác vụ có thể được thực thi ngay lập tức trong một luồng nền, Callable là lựa chọn đơn giản và an toàn hơn. Nếu tác vụ phụ thuộc vào một sự kiện bên ngoài và không biết khi nào sẽ hoàn thành, DeferredResult mang lại sự linh hoạt cần thiết.

Câu hỏi phỏng vấn về Spring Data JPA & Database

Các câu hỏi kiểm tra kỹ năng làm việc với cơ sở dữ liệu thông qua Spring Data JPA và Hibernate.

20. Trình bày hiểu biết của bạn về JpaRepositor.

Vai trò & Cách hoạt động

JpaRepository là một interface trong Spring Data JPA. Nó là một tầng trừu tượng cấp cao hơn, kế thừa từ PagingAndSortingRepository và CrudRepository. Vai trò chính của nó là giảm thiểu đáng kể lượng code lặp đi lặp lại (boilerplate code) khi làm việc với các thao tác CRUD (Create, Read, Update, Delete) và truy vấn dữ liệu.

Khi mình tạo một interface và cho nó kế thừa JpaRepository, Spring Data JPA sẽ tự động cung cấp một implementation cho interface đó lúc runtime. Mình chỉ cần khai báo interface là có thể sử dụng ngay các phương thức có sẵn mà không cần viết code triển khai.

Một số phương thức cơ bản mà JpaRepository cung cấp:

  • Lưu và cập nhật dữ liệu:
    • save(S entity): Lưu một entity mới hoặc cập nhật một entity đã tồn tại.
    • saveAll(Iterable<S> entities): Lưu một danh sách các entity.
  • Truy vấn dữ liệu:
    • findById(ID id): Tìm một entity theo khóa chính (primary key), trả về một Optional<T>.
    • findAll(): Lấy tất cả các entity.
    • findAllById(Iterable<ID> ids): Lấy tất cả các entity theo danh sách các khóa chính.
    • existsById(ID id): Kiểm tra xem một entity có tồn tại hay không.
    • count(): Đếm tổng số entity.
  • Xóa dữ liệu:
    • deleteById(ID id): Xóa một entity theo khóa chính.
    • delete(T entity): Xóa một entity.
    • deleteAll(): Xóa tất cả các entity.

Điểm nổi bật

Một trong những sức mạnh lớn nhất của JpaRepository là khả năng tự động sinh câu truy vấn từ tên phương thức (derived query methods). Ví dụ, ta chỉ cần định nghĩa một method như findByEmail(String email) trong repository, Spring Data sẽ tự hiểu và tạo ra câu lệnh SELECT * FROM users WHERE email = ? tương ứng.

21. Sự khác biệt giữa save(), saveAndFlush() và flush() trong JpaRepository?

Để hiểu sự khác biệt giữa ba phương thức này, trước hết cần phải nói về Persistence Context. Trong Hibernate (implementation mặc định của JPA), Persistence Context giống như một cái cache (first-level cache) chứa các entity đang được quản lý trong một transaction. Mọi thay đổi trên entity sẽ được ghi nhận vào cache này trước.

Sự khác biệt của 3 method này nằm ở cách chúng tương tác với Persistence Context và database:

save(S entity):

  1. Khi gọi save(), entity sẽ được đưa vào Persistence Context. Nếu là entity mới, nó sẽ được gán một ID. Nếu entity đã tồn tại, trạng thái của nó trong context sẽ được cập nhật.
  2. Quan trọng là nó chưa chắc đã đẩy ngay lập tức câu lệnh SQL INSERT hoặc UPDATE xuống database. Việc đồng bộ hóa với database (tức là thực thi câu lệnh SQL) sẽ chỉ xảy ra khi transaction được commit, hoặc khi Persistence Context đầy, hoặc khi một câu truy vấn cần dữ liệu mới nhất được thực thi.

flush():

  1. Method này có nhiệm vụ ép buộc (force) Persistence Context phải đồng bộ hóa trạng thái của nó với database. Nó sẽ duyệt qua tất cả các thay đổi đang “chờ” trong context (dirty checking) và sinh ra các câu lệnh SQL (INSERT, UPDATE, DELETE) tương ứng để thực thi ngay lập tức.
  2. Tuy nhiên, flush() không commit transaction. Nếu sau khi flush() mà có lỗi xảy ra, transaction vẫn có thể được rollback, và các thay đổi vừa được đẩy xuống database cũng sẽ được hoàn tác.

saveAndFlush(S entity):

  1. Đây là sự kết hợp của hai phương thức trên. Về cơ bản, nó tương đương với việc gọi save(entity) rồi ngay sau đó gọi flush().
  2. Nó sẽ lưu entity vào Persistence Context và ngay lập tức thực thi câu lệnh SQL để đồng bộ với database.
  3. Em thường dùng saveAndFlush() trong những trường hợp cần lấy kết quả vừa được thay đổi ở database để sử dụng ngay trong cùng một transaction, ví dụ như khi cần ID vừa được sinh ra bởi database trigger hoặc cần dữ liệu mới để thực hiện một câu native query khác.

Tóm lại:

  • save(): Chỉ đưa vào cache, chờ commit để xuống DB.
  • flush(): Ép cache đồng bộ xuống DB, nhưng chưa commit.
  • saveAndFlush(): Vừa đưa vào cache, vừa ép đồng bộ xuống DB ngay lập tức.

22. Giải thích về @Transactional annotation. Các thuộc tính propagation và isolation của nó có ý nghĩa gì?

@Transactional là một annotation của Spring Framework, dùng để khai báo một method (hoặc tất cả các method trong một class) được thực thi bên trong một biên giới giao dịch (transactional boundary).

Nói đơn giản, khi một method được đánh dấu @Transactional, Spring sẽ lo việc bắt đầu một transaction trước khi method được gọi và commit transaction đó sau khi method thực thi thành công. Nếu có bất kỳ RuntimeException nào xảy ra trong quá trình thực thi, Spring sẽ tự động rollback toàn bộ các thay đổi đã thực hiện trong transaction đó. Điều này đảm bảo tính toàn vẹn dữ liệu (ACID).

@Transactional có hai thuộc tính rất quan trọng là propagation và isolation.

Propagation

Thuộc tính này định nghĩa hành vi của một method transactional khi nó được gọi từ một method transactional khác. Nó quyết định xem method sẽ tham gia vào transaction hiện có, hay tạo một transaction mới, hay thực thi mà không cần transaction.

Một số mức propagation phổ biến là:

  • REQUIRED (Mặc định): Nếu đã có một transaction đang chạy, method sẽ tham gia vào transaction đó. Nếu không, nó sẽ tạo một transaction mới. Đây là mức phổ biến nhất.
  • REQUIRES_NEW: Method sẽ luôn bắt đầu một transaction mới hoàn toàn, tạm dừng transaction hiện có (nếu có). Mức này hữu ích khi mình muốn logic của method phải được commit hoặc rollback một cách độc lập, không phụ thuộc vào transaction bên ngoài. Ví dụ: ghi log giao dịch vào DB bất kể giao dịch chính có thành công hay không.
  • SUPPORTS: Nếu có transaction đang chạy, method sẽ tham gia. Nếu không, nó sẽ thực thi mà không cần transaction.
  • NOT_SUPPORTED: Tạm dừng transaction hiện có (nếu có) và thực thi method mà không có transaction.
  • NESTED: Tạo một transaction lồng bên trong transaction hiện có. Transaction lồng này có thể rollback độc lập mà không ảnh hưởng đến transaction cha. Tuy nhiên, nếu transaction cha rollback thì nó cũng sẽ bị rollback. Mức này không phải lúc nào cũng được hỗ trợ bởi tất cả các TransactionManager.

Isolation 

Thuộc tính này định nghĩa mức độ mà một transaction được cô lập khỏi các transaction khác đang chạy đồng thời. Nó giải quyết các vấn đề về tương tranh dữ liệu như Dirty Read, Non-repeatable Read, và Phantom Read.

Các mức isolation theo tiêu chuẩn SQL là:

  • READ_UNCOMMITTED: Mức thấp nhất. Một transaction có thể đọc cả những dữ liệu chưa được commit bởi một transaction khác (Dirty Read). Mức này hiệu năng cao nhất nhưng kém an toàn nhất.
  • READ_COMMITTED: Một transaction chỉ có thể đọc những dữ liệu đã được commit. Mức này giải quyết được Dirty Read. Đây là mức mặc định của hầu hết các CSDL như PostgreSQL, SQL Server.
  • REPEATABLE_READ: Đảm bảo rằng nếu một transaction đọc một dòng dữ liệu nhiều lần, nó sẽ luôn nhận được kết quả giống nhau. Mức này giải quyết được Dirty Read và Non-repeatable Read. Đây là mức mặc định của MySQL.
  • SERIALIZABLE: Mức cao nhất và an toàn nhất. Các transaction được thực thi tuần tự, giống như chúng chạy nối đuôi nhau. Mức này giải quyết tất cả các vấn đề về tương tranh nhưng ảnh hưởng nhiều nhất đến hiệu năng.

Việc lựa chọn propagation và isolation phù hợp phụ thuộc rất nhiều vào yêu cầu nghiệp vụ cụ thể của ứng dụng.

23. Phân biệt giữa Fetch Type LAZY và EAGER. Ưu và nhược điểm của từng loại là gì?

So sánh Fetch Type EAGER và LAZY:

Tiêu chíEAGER LoadingLAZY Loading
Cách hoạt độngTải entity cha và tất cả entity con liên quan ngay lập tức trong cùng một truy vấn.Chỉ tải entity cha. Dữ liệu của entity con chỉ được tải khi có truy cập đến lần đầu tiên.
Số lượng truy vấnThường là 1 câu truy vấn lớn (với JOIN).Ban đầu là 1 truy vấn, sau đó có thể phát sinh thêm các truy vấn khác khi cần.
Ưu điểmĐơn giản, dễ sử dụng. – Luôn có sẵn dữ liệu, không lo lỗi LazyInitializationException.Hiệu năng cao: Chỉ tải dữ liệu khi thực sự cần thiết, giúp truy vấn ban đầu nhanh hơn. – Tiết kiệm bộ nhớ và tài nguyên hệ thống.
Nhược điểmHiệu năng kém: Dễ dàng tải thừa dữ liệu không cần thiết, làm chậm ứng dụng. – Có nguy cơ gây ra vấn đề N+1 nếu cấu hình sai.– Dễ gặp lỗi LazyInitializationException nếu truy cập dữ liệu ngoài phạm vi session/transaction. – Đòi hỏi lập trình viên phải quản lý cẩn thận hơn.
Mặc địnhÁp dụng cho các quan hệ @ManyToOne và @OneToOne.Áp dụng cho các quan hệ @OneToMany và @ManyToMany.
Khi nào dùngKhi bạn chắc chắn luôn luôn cần dữ liệu con mỗi khi tải dữ liệu cha.Trong hầu hết các trường hợp để tối ưu hiệu năng. Đây được coi là best practice.

Lời khuyên: Cách tốt nhất là luôn đặt fetch = FetchType.LAZY cho tất cả các mối quan hệ. Khi cần dữ liệu liên quan, hãy chủ động tải chúng bằng các kỹ thuật như JOIN FETCH hoặc @EntityGraph để kiểm soát hoàn toàn các câu truy vấn được sinh ra.

24. Vấn đề N+1 query là gì? Trình bày một số cách để giải quyết nó (ví dụ: JOIN FETCH, @EntityGraph).

Vấn đề N+1 query là một trong những vấn đề hiệu năng phổ biến nhất khi làm việc với ORM như Hibernate. Nó xảy ra khi ứng dụng thực thi 1 câu query để lấy danh sách các đối tượng cha (N đối tượng), và sau đó lại thực thi thêm N câu query nữa để lấy các đối tượng con liên quan cho mỗi đối tượng cha. Tổng cộng, hệ thống phải thực hiện N+1 câu query để lấy đủ dữ liệu.

Ví dụ kinh điển:

Giả sử ta có 2 entity Author và Book với quan hệ @OneToMany từ Author đến Book (Fetch Type là LAZY).

// Trong Author.java
@OneToMany(mappedBy = "author", fetch = FetchType.LAZY)
private List<Book> books;
Bây giờ, ta muốn lấy danh sách tất cả các tác giả và hiển thị tên cùng với tên các cuốn sách của họ.
List<Author> authors = authorRepository.findAll(); // Query 1: SELECT * FROM authors
for (Author author : authors) {
    System.out.println("Author: " + author.getName());
    // Dòng dưới đây trigger N queries còn lại
    System.out.println("Books: " + author.getBooks().stream().map(Book::getTitle).collect(Collectors.joining(", "))); 
}

authorRepository.findAll() sẽ thực thi 1 câu query đầu tiên để lấy danh sách tất cả các tác giả.

Trong vòng lặp, khi author.getBooks() được gọi lần đầu cho mỗi author, Hibernate sẽ phải thực thi thêm 1 câu query nữa để lấy danh sách sách của tác giả đó. Nếu có N tác giả, sẽ có thêm N câu query.

=> Tổng cộng là N+1 câu query, gây ảnh hưởng nghiêm trọng đến hiệu năng.

Các cách để giải quyết vấn đề này:

Tôi thường sử dụng 2 cách chính sau:

  • Sử dụng JOIN FETCH trong JPQL:

Đây là cách phổ biến và hiệu quả nhất. ta sẽ viết một câu truy vấn tùy chỉnh trong Repository sử dụng từ khóa JOIN FETCH để chỉ thị cho JPA tải cả entity cha và các entity con liên quan trong cùng một câu query duy nhất.

Ví dụ:

// Trong AuthorRepository.java
@Query("SELECT a FROM Author a JOIN FETCH a.books")
List<Author> findAllWithBooks();

Khi gọi findAllWithBooks(), JPA sẽ sinh ra một câu SQL duy nhất có LEFT JOIN giữa bảng authors và books, giải quyết triệt để vấn đề N+1.

  • Sử dụng @EntityGraph:

Đây là một cách tiếp cận khác, linh hoạt hơn, được giới thiệu từ JPA 2.1. Nó cho phép ta định nghĩa một “biểu đồ” các thuộc tính liên quan cần được tải cùng với entity gốc mà không cần phải viết lại câu JPQL.

Tôi có thể định nghĩa @EntityGraph ngay trên entity hoặc trực tiếp trên phương thức của Repository.

Ví dụ:

// Trong AuthorRepository.java
@EntityGraph(attributePaths = {"books"})
@Query("SELECT a FROM Author a") // Hoặc dùng method có sẵn
List<Author> findAllAuthorsAndTheirBooks();

// Hoặc dùng với phương thức có sẵn của JpaRepository
@Override
@EntityGraph(attributePaths = {"books"})
List<Author> findAll();

@EntityGraph rất mạnh mẽ khi ta muốn có nhiều “gói” dữ liệu khác nhau cho cùng một entity. Ví dụ, một method tải author kèm sách, một method khác tải author kèm nhà xuất bản.

Cả hai cách trên đều nhằm mục tiêu chung là tải tất cả dữ liệu cần thiết trong một hoặc một số ít câu query thay vì N+1 câu.

25. So sánh giữa cách tiếp cận Optimistic Locking và Pessimistic Locking trong quản lý xung đột dữ liệu.

Optimistic LockingPessimistic Locking là hai chiến lược chính để quản lý xung đột dữ liệu khi có nhiều transaction cùng cố gắng sửa đổi một bản ghi tại cùng một thời điểm.

Pessimistic Locking 

Tư tưởng: “Xung đột rất có thể sẽ xảy ra, vì vậy hãy khóa bản ghi lại ngay khi đọc để không ai khác có thể sửa nó cho đến khi tôi xong việc.”

Cách hoạt động: Khi một transaction đọc một bản ghi và có ý định cập nhật, nó sẽ yêu cầu database khóa bản ghi đó lại (SELECT … FOR UPDATE). Các transaction khác muốn đọc hoặc ghi trên bản ghi này sẽ phải chờ cho đến khi transaction đầu tiên commit hoặc rollback và nhả khóa.

Triển khai trong JPA: Sử dụng LockModeType.PESSIMISTIC_WRITE hoặc PESSIMISTIC_READ.

// Sẽ tạo câu lệnh SELECT ... FOR UPDATE
entityManager.find(Product.class, productId, LockModeType.PESSIMISTIC_WRITE);

Ưu điểm: Đảm bảo tính toàn vẹn dữ liệu rất cao, xung đột được ngăn chặn ngay từ đầu, không có chuyện hai người cùng sửa một lúc.

Nhược điểm:

  • Ảnh hưởng nghiêm trọng đến hiệu năng và khả năng mở rộng: Việc giữ khóa trong thời gian dài có thể gây ra deadlock và làm giảm thông lượng (throughput) của hệ thống vì các transaction khác phải chờ đợi.
  • Phụ thuộc nhiều vào cơ chế khóa của database.

Khi nào dùng: Phù hợp với các hệ thống có độ tương tranh cao trên cùng một dữ liệu, nơi mà xung đột xảy ra thường xuyên và việc đảm bảo toàn vẹn dữ liệu là tuyệt đối quan trọng (ví dụ: hệ thống tài chính, ngân hàng).

Optimistic Locking

Tư tưởng: “Xung đột hiếm khi xảy ra, cứ để mọi người cùng đọc và sửa. Trước khi commit, tôi sẽ kiểm tra xem có ai đã sửa bản ghi này trong lúc tôi đang làm việc hay không. Nếu có, tôi sẽ báo lỗi.”

Cách hoạt động: Không có khóa nào được đặt ở database. Thay vào đó, mỗi bản ghi sẽ có thêm một cột phiên bản (version), thường là một số nguyên hoặc timestamp.

  • Khi transaction 1 đọc bản ghi, nó cũng đọc luôn giá trị version (ví dụ: version = 1).
  • Khi transaction 1 muốn cập nhật, nó sẽ gửi câu lệnh UPDATE kèm điều kiện WHERE id = ? AND version = 1.
  • Nếu không có ai thay đổi bản ghi, điều kiện version = 1 đúng, update thành công và giá trị version được tăng lên 2.
  • Nếu transaction 2 đã vào cập nhật bản ghi này trước và commit (làm version tăng lên 2), thì câu lệnh UPDATE của transaction 1 sẽ thất bại (vì version = 1 không còn đúng).
  • Khi đó, JPA sẽ ném ra một OptimisticLockException, và ứng dụng phải xử lý lỗi này (ví dụ: thông báo cho người dùng, tải lại dữ liệu và thử lại).

Triển khai trong JPA: Sử dụng annotation @Version trên một thuộc tính của entity.

@Entity
public class Product {
    // ...
    @Version
    private Integer version;
}

Ưu điểm: Hiệu năng và khả năng mở rộng cao. Không có khóa ở database, không có blocking, cho phép thông lượng cao hơn.

Nhược điểm:

  • Phải xử lý xung đột ở tầng ứng dụng: Cần phải có code để bắt và xử lý OptimisticLockException.
  • Nếu xung đột xảy ra thường xuyên, việc phải thử lại liên tục có thể làm giảm hiệu năng.

Khi nào dùng: Phù hợp với hầu hết các ứng dụng web thông thường, nơi dữ liệu chủ yếu được đọc và xung đột ghi xảy ra không thường xuyên.

26. Khi nào bạn sẽ chọn sử dụng JdbcTemplate thay vì Spring Data JPA?

Tôi xem Spring Data JPA là công cụ mặc định cho các lớp truy cập dữ liệu thông thường. Nhưng khi đối mặt với các bài toán đòi hỏi sự kiểm soát truy vấn ở mức độ thấp, hiệu năng tối đa cho tác vụ batch, hoặc sự linh hoạt của SQL, JdbcTemplate là một công cụ bổ sung mạnh mẽ và là lựa chọn phù hợp hơn.

Tôi sẽ cân nhắc dùng JdbcTemplate trong các trường hợp sau:

Khi cần thực thi các câu SQL phức tạp hoặc Native Query:

Đôi khi có những câu truy vấn phức tạp sử dụng các tính năng đặc thù của một hệ quản trị CSDL (ví dụ: CONNECT BY trong Oracle, các hàm window function phức tạp, Common Table Expressions – CTE) mà việc biểu diễn chúng bằng JPQL hoặc Criteria API là rất khó khăn, thậm chí là không thể. JdbcTemplate cho phép ta viết và thực thi các câu native SQL một cách trực tiếp và dễ dàng.

Khi cần hiệu năng tối đa cho các tác vụ Bulk Operations:

  1. Đối với các tác vụ cập nhật hoặc xóa hàng loạt (bulk update/delete) trên hàng nghìn, hàng triệu bản ghi, việc sử dụng JPA có thể không hiệu quả. JPA sẽ phải tải các entity vào Persistence Context, thực hiện dirty checking rồi mới sinh câu lệnh UPDATE.
  2. JdbcTemplate cho phép thực thi các câu UPDATE hoặc DELETE trực tiếp trên database, bỏ qua toàn bộ overhead của ORM, mang lại hiệu năng cao hơn đáng kể. Chức năng batchUpdate() của JdbcTemplate cũng được tối ưu rất tốt cho các kịch bản này.

Khi làm việc với các Stored Procedures và Functions:

Mặc dù JPA có hỗ trợ gọi stored procedures, nhưng cú pháp có thể hơi rườm rà. JdbcTemplate (cùng với SimpleJdbcCall) cung cấp một API trực quan và mạnh mẽ hơn để làm việc với stored procedures, bao gồm việc xử lý các tham số IN, OUT và các tập kết quả (ResultSets).

Khi xây dựng các báo cáo (Reporting) phức tạp:

Các truy vấn để tạo báo cáo thường join rất nhiều bảng, tính toán tổng hợp trên nhiều cột và không nhất thiết phải map kết quả trả về thành một Entity cụ thể. Việc dùng JdbcTemplate và map kết quả vào các DTO (Data Transfer Object) đơn giản thông qua RowMapper thường linh hoạt và hiệu quả hơn là cố gắng “ép” kết quả vào cấu trúc Entity của JPA.

Khi làm việc với Legacy Database:

Trong trường hợp phải tích hợp với một schema database cũ không được thiết kế theo các quy tắc của ORM (ví dụ: không có khóa chính, tên cột và bảng không nhất quán), việc ánh xạ thành các Entity có thể rất vất vả. JdbcTemplate cho phép tương tác với database mà không cần đến tầng ánh xạ này.

Câu hỏi phỏng vấn về Spring Security

Kiểm tra kiến thức về bảo mật ứng dụng, một phần không thể thiếu trong các hệ thống thực tế.

27. Authentication (Xác thực) và Authorization (Ủy quyền) khác nhau như thế nào?

Có thể dùng một ví dụ đơn giản để phân biệt như sau. Hãy tưởng tượng một nhân viên đến một tòa nhà văn phòng:

Authentication (Xác thực) 

  1. Đây là quá trình chứng minh danh tính. Khi đến cửa, nhân viên quẹt thẻ nhân viên. Hệ thống sẽ kiểm tra xem thẻ này có hợp lệ và thuộc về ai trong hệ thống hay không.
  2. Trong ứng dụng web, đây là lúc người dùng cung cấp username và password. Hệ thống sẽ kiểm tra xem thông tin này có khớp với dữ liệu trong database hay không. Nếu khớp, quá trình xác thực thành công.

Authorization (Ủy quyền)

  1. Đây là quá trình kiểm tra quyền hạn sau khi danh tính đã được xác thực. Sau khi quẹt thẻ thành công (đã xác thực), hệ thống sẽ kiểm tra xem nhân viên có được phép vào một phòng ban cụ thể (ví dụ: phòng Kế toán) hay không. Một nhân viên IT có thể vào được phòng server, nhưng nhân viên kinh doanh thì không.
  2. Trong ứng dụng, sau khi đăng nhập thành công, hệ thống sẽ kiểm tra xem người dùng đó có vai trò (role) hoặc quyền (permission) để truy cập một API hay thực hiện một hành động cụ thể hay không. Ví dụ, một user có vai trò ADMIN mới được phép truy cập trang quản trị, còn user có vai trò MEMBER thì không.

28. Giải thích kiến trúc cơ bản của Spring Security (FilterChain, AuthenticationManager, SecurityContextHolder).

Kiến trúc của Spring Security dựa trên một chuỗi các Servlet Filter, hoạt động như những “chốt chặn” an ninh cho mọi request gửi đến ứng dụng. 

3 thành phần cốt lõi là:

  • SecurityFilterChain (hay FilterChainProxy):

Đây là cửa ngõ chính của Spring Security. Khi một request đến, thay vì đi thẳng tới DispatcherServlet, nó sẽ được chặn lại bởi FilterChainProxy.

FilterChainProxy sẽ ủy quyền cho một chuỗi các filter (SecurityFilterChain) do Spring Security quản lý. Mỗi filter trong chuỗi này có một nhiệm vụ riêng biệt, ví dụ:

  • CsrfFilter: Chống tấn công CSRF.
  • UsernamePasswordAuthenticationFilter: Xử lý việc xác thực từ form đăng nhập.
  • BearerTokenAuthenticationFilter: Xử lý xác thực bằng JWT.
  • AuthorizationFilter: Kiểm tra quyền truy cập tài nguyên.

Request sẽ đi lần lượt qua chuỗi filter này. Nếu một filter nào đó xác định request không hợp lệ (ví dụ: xác thực thất bại), nó sẽ từ chối request ngay lập tức mà không cần đi tiếp.

  • AuthenticationManager:

Đây là bộ não của quá trình xác thực. Nhiệm vụ chính của nó là nhận một đối tượng Authentication (thường chứa username và password chưa được xác thực) và quyết định xem thông tin đó có hợp lệ hay không.

AuthenticationManager thường ủy quyền cho một hoặc nhiều AuthenticationProvider. Mỗi Provider sẽ thực hiện một logic xác thực cụ thể. Ví dụ, DaoAuthenticationProvider sẽ lấy thông tin user từ database (thông qua UserDetailsService), so sánh hash của mật khẩu, và trả về kết quả.

Nếu xác thực thành công, AuthenticationManager sẽ trả về một đối tượng Authentication đã được “làm đầy” thông tin, bao gồm cả danh sách các quyền (authorities) của người dùng.

  • SecurityContextHolder:

Đây là nơi lưu trữ thông tin của người dùng đã được xác thực trong suốt vòng đời của một request.

Sau khi AuthenticationManager xác thực thành công, đối tượng Authentication sẽ được lưu vào SecurityContext, và SecurityContext lại được đặt vào SecurityContextHolder.

SecurityContextHolder sử dụng một ThreadLocal để lưu trữ SecurityContext. Điều này đảm bảo rằng thông tin xác thực của một request chỉ có thể truy cập được bởi chính thread đang xử lý request đó.

Bất kỳ đâu trong ứng dụng, ta cũng có thể lấy thông tin người dùng đang đăng nhập bằng cách gọi: SecurityContextHolder.getContext().getAuthentication();.

Luồng tóm tắt: Request -> FilterChainProxy -> SecurityFilterChain -> UsernamePasswordAuthenticationFilter -> AuthenticationManager -> AuthenticationProvider (làm việc với UserDetailsService) -> Nếu thành công, Authentication object được lưu vào SecurityContextHolder -> Request đi tiếp.

29. JWT (JSON Web Token) là gì và cấu trúc của nó như thế nào? 

JWT (JSON Web Token) là một tiêu chuẩn mở (RFC 7519) định nghĩa một cách nhỏ gọn và khép kín (self-contained) để truyền thông tin an toàn giữa các bên dưới dạng một đối tượng JSON. Thông tin này có thể được xác minh và tin cậy vì nó đã được ký điện tử. JWT rất phù hợp để xây dựng các hệ thống xác thực stateless cho RESTful API.

Cấu trúc của JWT

Một JWT bao gồm 3 phần, được ngăn cách bởi dấu chấm (.): header.payload.signature.

  • Header (Tiêu đề): Chứa thông tin về thuật toán mã hóa được sử dụng và loại token.
    • Ví dụ: { “alg”: “HS256”, “typ”: “JWT” }.
    • Phần này sẽ được mã hóa Base64Url để tạo thành phần đầu tiên của JWT.
  • Payload (Dữ liệu): Chứa các “claims” (tuyên bố). Claims là các thông tin về một thực thể (thường là người dùng) và các dữ liệu bổ sung. Có 3 loại claims:
    • Registered claims: Các claim đã được đăng ký sẵn (ví dụ: iss – nhà phát hành, sub – chủ đề/user id, exp – thời gian hết hạn).
    • Public claims: Các claim tự định nghĩa nhưng nên tránh xung đột tên.
    • Private claims: Các claim tự định nghĩa riêng giữa các bên.
    • Ví dụ: { “sub”: “12345”, “name”: “John Doe”, “roles”: [“ADMIN”, “USER”], “iat”: 1516239022 }.
    • Phần này cũng được mã hóa Base64Url.
  • Signature (Chữ ký): Đây là phần quan trọng nhất để đảm bảo tính toàn vẹn của token. Chữ ký được tạo ra bằng cách:
    • Lấy header và payload đã được mã hóa Base64Url.
    • Sử dụng thuật toán được chỉ định trong header (ví dụ: HMAC-SHA256).
    • Ký chúng bằng một secret key (khóa bí mật) hoặc một cặp public/private key.
    • Công thức: HMACSHA256(base64UrlEncode(header) + “.” + base64UrlEncode(payload), secret)

30. Trình bày luồng xác thực sử dụng JWT trong một ứng dụng Spring Boot.

Đây là luồng stateless phổ biến mà tôi thường triển khai:

  • Người dùng đăng nhập: Client gửi request đến một endpoint /api/auth/login với username và password trong request body.
  • Server xác thực:
    • Endpoint này sẽ gọi AuthenticationManager của Spring Security để xác thực thông tin đăng nhập.
    • Nếu xác thực thành công, server sẽ tạo ra một JWT.
  • Tạo JWT: Server sẽ lấy thông tin của người dùng (user ID, roles…) để đưa vào phần payload của JWT, đặt thời gian hết hạn (exp), và dùng một secret key lưu trên server để tạo ra chữ ký.
  • Server trả JWT về cho Client: Server trả về một response chứa JWT (ví dụ: trong một JSON object { “accessToken”: “…” }).
  • Client lưu trữ JWT: Client (ví dụ: trình duyệt web) sẽ lưu JWT này lại, thường là trong localStorage hoặc sessionStorage.
  • Client gửi request kèm JWT: Đối với tất cả các request tiếp theo cần xác thực, client sẽ đính kèm JWT vào Authorization header theo định dạng Bearer .
    • Ví dụ: Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…
  • Server xác thực JWT:
    • Em sẽ tạo một filter tùy chỉnh (ví dụ: JwtAuthenticationFilter) và chèn nó vào SecurityFilterChain (thường là trước UsernamePasswordAuthenticationFilter).
    • Với mỗi request đến, filter này sẽ:
      • Đọc Authorization header để lấy token. 
      • Xác minh chữ ký: Dùng chính secret key đã lưu trên server để kiểm tra xem chữ ký của token có hợp lệ không. Nếu không, token đã bị chỉnh sửa -> từ chối request. 
      • Kiểm tra tính hợp lệ: Kiểm tra xem token đã hết hạn hay chưa. 
      • Nếu token hợp lệ, filter sẽ trích xuất thông tin từ payload (user ID, roles). 
      • Tạo một đối tượng Authentication (ví dụ: UsernamePasswordAuthenticationToken) từ các thông tin này và lưu vào SecurityContextHolder.
  • Ủy quyền và xử lý request: Sau khi thông tin xác thực được lưu vào SecurityContextHolder, các bước tiếp theo của Spring Security (như kiểm tra quyền hạn @PreAuthorize) và logic trong Controller sẽ hoạt động như bình thường.

Luồng này là stateless vì server không cần lưu trữ bất kỳ thông tin session nào của người dùng. Mọi thông tin cần thiết đều nằm trong JWT mà client gửi lên.

31. CORS (Cross-Origin Resource Sharing) là gì và làm thế nào để cấu hình nó trong Spring Boot?

CORS (Cross-Origin Resource Sharing) là một cơ chế bảo mật của trình duyệt. Nó cho phép hoặc ngăn chặn các trang web được tải từ một “origin” (gốc – bao gồm scheme, domain, port) này yêu cầu tài nguyên từ một “origin” khác.

Mặc định, chính sách Same-Origin Policy (SOP) của trình duyệt sẽ chặn các request như vậy. Ví dụ, một trang frontend chạy ở http://localhost:3000 sẽ không thể gọi API ở http://localhost:8080 nếu server không cho phép. CORS được sinh ra để nới lỏng chính sách này một cách có kiểm soát.

Trong Spring Boot, ta có thể cấu hình CORS theo hai cách chính:

  • Cấu hình ở mức Controller/Method với @CrossOrigin:

Đây là cách đơn giản và nhanh chóng nhất, phù hợp khi chỉ cần mở CORS cho một vài endpoint cụ thể. Tôi có thể đặt annotation @CrossOrigin trên một class Controller hoặc trên một method riêng lẻ.

Ví dụ:

@RestController
@RequestMapping("/api/tasks")
// Mở CORS cho tất cả các method trong class này từ mọi origin
@CrossOrigin(origins = "*") 
public class TaskController {

    @GetMapping("/{id}")
    // Ghi đè cấu hình, chỉ cho phép origin cụ thể và method GET
    @CrossOrigin(origins = "http://localhost:3000", methods = RequestMethod.GET)
    public Task getTask(@PathVariable Long id) {
        // ...
    }
}
  • Cấu hình toàn cục (Global Configuration):

Đây là cách làm được khuyến khích khi cần áp dụng một chính sách CORS nhất quán cho toàn bộ ứng dụng. Tôi sẽ tạo một class Configuration và implement interface WebMvcConfigurer, sau đó override method addCorsMappings.

Ví dụ:

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**") // Áp dụng cho các path bắt đầu bằng /api/
                .allowedOrigins("http://localhost:3000", "https://my-prod-domain.com") // Các origin được phép
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") // Các method được phép
                .allowedHeaders("*") // Các header được phép
                .allowCredentials(true) // Cho phép gửi cookie
                .maxAge(3600); // Thời gian cache của pre-flight request
    }
}

Cách này mạnh mẽ hơn vì nó cho phép cấu hình chi tiết và tập trung logic CORS ở một nơi duy nhất. Khi kết hợp với Spring Security, ta thường sẽ định nghĩa một CorsConfigurationSource bean để tích hợp.

32. Giải thích vai trò của DelegatingFilterProxy trong việc tích hợp Spring Security với Servlet Container.

DelegatingFilterProxy đóng vai trò là một cây cầu nối giữa vòng đời của Servlet Container (ví dụ: Tomcat) và Application Context của Spring.

Trong kiến trúc Servlet chuẩn, các filter là một phần của Servlet Container. Chúng được khởi tạo và quản lý bởi container, không phải bởi Spring. Điều này có nghĩa là một filter thông thường không thể @Autowired các Spring bean khác.

Tuy nhiên, Spring Security lại là một framework được xây dựng hoàn toàn dựa trên các Spring bean. Toàn bộ chuỗi filter, AuthenticationManager, UserDetailsService… đều là các bean được quản lý bởi Spring IoC container.

Đây chính là lúc DelegatingFilterProxy phát huy vai trò:

  1. Nó là một Servlet Filter tiêu chuẩn: DelegatingFilterProxy được đăng ký với Servlet Container như bất kỳ filter nào khác (thông qua web.xml ngày xưa hoặc FilterRegistrationBean trong các ứng dụng Spring Boot hiện đại). Vì vậy, Servlet Container biết cách khởi tạo và sử dụng nó.
  2. Nó không thực hiện logic gì cả: Nhiệm vụ duy nhất của DelegatingFilterProxy là tìm kiếm một Spring bean trong Application Context có cùng tên với nó và ủy quyền (delegate) toàn bộ công việc xử lý request cho bean đó.
  3. Bean đích thường là FilterChainProxy: Trong Spring Security, bean mà DelegatingFilterProxy tìm kiếm mặc định có tên là springSecurityFilterChain. Bean này chính là FilterChainProxy, là điểm khởi đầu thực sự của chuỗi filter trong Spring Security.

Tóm lại: DelegatingFilterProxy cho phép một request từ thế giới “Servlet Container” đi qua một “cổng trung gian” để vào thế giới “Spring Application Context”. Nhờ có nó, chuỗi filter của Spring Security có thể tận dụng toàn bộ sức mạnh của Spring như Dependency Injection, AOP, quản lý transaction… mà vẫn tích hợp liền mạch vào luồng xử lý request chuẩn của một ứng dụng Java web.

Luồng tóm tắt: Tomcat -> DelegatingFilterProxy (quản lý bởi Tomcat) -> tìm bean “springSecurityFilterChain” trong Spring Context -> FilterChainProxy (quản lý bởi Spring) -> bắt đầu xử lý request.

33. So sánh các cách tiếp cận bảo mật ở mức phương thức: @Secured, @RolesAllowed, và @PreAuthorize/@PostAuthorize. 

Cả ba nhóm annotation này đều dùng để bảo mật ở mức phương thức (method-level security), nhưng chúng khác nhau về nguồn gốc, khả năng và độ linh hoạt.

Tiêu chí@Secured@RolesAllowed@PreAuthorize / @PostAuthorize
Nguồn gốcSpring Security-specific (cũ nhất)JSR-250 Standard (chuẩn Java EE)Spring Security-specific (mới và mạnh nhất)
Khả năngĐơn giản: Chỉ kiểm tra vai trò (authority) chính xác.Đơn giản: Gần giống @Secured, chỉ kiểm tra vai trò.Cực kỳ linh hoạt: Sử dụng SpEL (Spring Expression Language).
Cú pháp@Secured(“ROLE_ADMIN”)  @Secured({“ROLE_USER”, “ROLE_VIEWER”})@RolesAllowed(“ADMIN”)  @RolesAllowed({“USER”, “VIEWER”})@PreAuthorize(“hasRole(‘ADMIN’)”)  @PreAuthorize(“hasAuthority(‘WRITE_PRIVILEGE’)”)  @PreAuthorize(“isAuthenticated() and #user.name == principal.username”)
Yêu cầu ROLE_ prefix?Có, theo quy ước.Không.Không (khi dùng hasRole()), nhưng vẫn có thể cấu hình.
Độ phức tạpDễ nhất.Dễ Phức tạp hơn nhưng mạnh mẽ hơn.

34. Khi nào nên dùng SpEL (Spring Expression Language) trong các biểu thức bảo mật?

Tôi sẽ chọn sử dụng SpEL khi yêu cầu về ủy quyền vượt ra ngoài việc kiểm tra vai trò đơn giản. SpEL cho phép viết các biểu thức logic phức tạp để đưa ra quyết định ủy quyền. Cụ thể:

  • Ủy quyền dựa trên tham số của phương thức: Khi cần kiểm tra xem người dùng hiện tại có liên quan gì đến dữ liệu đang được truy cập hay không. Đây là trường hợp phổ biến nhất.

Ví dụ: Chỉ cho phép người dùng chỉnh sửa thông tin của chính họ.

@PreAuthorize("#username == authentication.principal.username or hasRole('ADMIN')")
public void updateUser(String username, @RequestBody UserDto userDto) { ... }
  • Ủy quyền dựa trên kết quả trả về của phương thức (@PostAuthorize): Khi quyết định ủy quyền phụ thuộc vào nội dung của dữ liệu sau khi phương thức đã thực thi.

Ví dụ: Chỉ trả về một đơn hàng nếu người dùng hiện tại là chủ của đơn hàng đó.

@PostAuthorize("returnObject.owner == authentication.principal.username")
public Order getOrderDetails(Long id) { ... }
  • Kết hợp nhiều điều kiện logic phức tạp: Khi cần các toán tử AND, OR, NOT để kết hợp nhiều điều kiện kiểm tra.
@PreAuthorize("hasRole('ADMIN') and hasAuthority('READ_SENSITIVE_DATA')")
  • Gọi các bean khác để kiểm tra quyền: SpEL cho phép gọi các phương thức từ các Spring bean khác để thực hiện logic kiểm tra quyền tùy chỉnh.

Ví dụ: Kiểm tra xem user có phải là thành viên của một project hay không bằng cách gọi một PermissionService bean.

@PreAuthorize("@permissionService.isProjectMember(authentication, #projectId)")
public Project getProject(Long projectId) { ... }

Tóm tắt:

  • Dùng @RolesAllowed (hoặc @Secured) khi nhu cầu chỉ đơn giản là “user này phải có vai trò X”.
  • Dùng @PreAuthorize / @PostAuthorize khi cần bất kỳ logic ủy quyền động, phức tạp nào liên quan đến dữ liệu hoặc ngữ cảnh của request. Theo em, @PreAuthorize là lựa chọn mạnh mẽ và nên được ưu tiên trong hầu hết các dự án hiện đại.

35. CSRF (Cross-Site Request Forgery) là gì và Spring Security giúp chúng ta chống lại nó như thế nào?

CSRF (Cross-Site Request Forgery) là một loại tấn công bảo mật, trong đó kẻ tấn công lừa một người dùng đã được xác thực (đang đăng nhập) thực hiện một hành động không mong muốn trên một trang web.

Luồng tấn công diễn ra như sau:

  1. Người dùng đăng nhập vào một trang web đáng tin cậy, ví dụ my-bank.com. Trình duyệt của họ sẽ lưu lại cookie session cho trang này.
  2. Kẻ tấn công lừa người dùng truy cập một trang web độc hại, ví dụ evil-site.com.
  3. Trên trang evil-site.com, có một đoạn code (ví dụ: một form ẩn hoặc một thẻ <img>) tự động gửi một request đến my-bank.com, ví dụ như một request chuyển tiền: POST /transfer?to=attacker&amount=1000.
  4. Vì người dùng vẫn đang đăng nhập vào my-bank.com, trình duyệt sẽ tự động đính kèm cookie session vào request này.
  5. Server của my-bank.com nhận được request, thấy có cookie hợp lệ, và thực hiện hành động chuyển tiền mà không hề biết rằng request này không xuất phát từ ý muốn của người dùng.

Cách Spring Security chống lại CSRF

Spring Security triển khai một trong những cơ chế phòng chống CSRF hiệu quả nhất, đó là Synchronizer Token Pattern. Cơ chế này hoạt động như sau:

  • Bật tính năng chống CSRF: Trong các ứng dụng web truyền thống (sử dụng session và cookie), Spring Security bật tính năng này theo mặc định.
  • Tạo và lưu trữ CSRF Token:
    • Khi người dùng truy cập trang web lần đầu (hoặc đăng nhập), server sẽ tạo ra một token ngẫu nhiên, không thể đoán trước, gọi là CSRF token.
    • Token này sẽ được lưu trữ ở phía server, thường là trong HttpSession.
  • Gửi Token đến Client:
    • Server sẽ gửi token này đến cho client. Đối với các ứng dụng web truyền thống, Spring Security sẽ tự động chèn token này như một trường ẩn (<input type=”hidden”>) vào tất cả các form được tạo bởi Spring Form tags hoặc Thymeleaf.
    • Token này cũng có thể được gửi qua một cookie (ví dụ: XSRF-TOKEN) để các JavaScript framework như Angular có thể đọc và sử dụng.
  • Client gửi lại Token: Khi người dùng submit một form hoặc gửi một request thay đổi trạng thái (như POST, PUT, DELETE), client phải gửi lại CSRF token này cho server. Token thường được gửi dưới dạng một request parameter (từ trường ẩn trong form) hoặc một HTTP header (ví dụ: X-XSRF-TOKEN).
  • Server xác minh Token:

Trước khi thực hiện request, CsrfFilter của Spring Security sẽ chặn request lại. Nó sẽ so sánh token được gửi lên từ client với token đã được lưu trong session của người dùng trên server.

  • Nếu hai token khớp nhau, request được coi là hợp lệ và được xử lý tiếp.
  • Nếu token thiếu hoặc không khớp, request sẽ bị từ chối với lỗi 403 Forbidden.

Tại sao cơ chế này hiệu quả? Trang web độc hại của kẻ tấn công (evil-site.com) không thể đọc hoặc đoán được giá trị của CSRF token của trang my-bank.com do chính sách Same-Origin Policy của trình duyệt. Vì vậy, nó không thể gửi một request hợp lệ có chứa đúng CSRF token.

Lưu ý quan trọng: Cơ chế chống CSRF này chỉ thực sự cần thiết cho các ứng dụng stateful (sử dụng session/cookie). Đối với các ứng dụng stateless sử dụng JWT để xác thực qua Authorization header, tấn công CSRF không còn là một mối đe dọa lớn, và tính năng này thường sẽ được tắt (.csrf(csrf -> csrf.disable())) để tránh các rắc rối không cần thiết.

Câu hỏi phỏng vấn về microservices, testing & kiến thức nâng cao

Phần này dành cho các ứng viên có kinh nghiệm, tập trung vào kiến trúc hệ thống, khả năng đảm bảo chất lượng và các chủ đề nâng cao.

36. Spring Boot Actuator là gì? Nó cung cấp những endpoint hữu ích nào? 

Spring Boot Actuator là một sub-project của Spring Boot, cung cấp các tính năng sẵn có để giám sát (monitor) và quản lý (manage) ứng dụng của mình khi nó đang chạy ở môi trường production.

Khi ta thêm dependency spring-boot-starter-actuator vào dự án, nó sẽ tự động cung cấp một loạt các endpoint qua HTTP hoặc JMX. Các endpoint này giúp các DevOps hoặc Lập trình viên có thể “nhìn” vào bên trong ứng dụng để kiểm tra trạng thái, xem metrics, và thu thập thông tin mà không cần phải đọc log hay debug trực tiếp.

Một số endpoint hữu ích và phổ biến nhất là:

  • /health: Đây là endpoint quan trọng nhất. Nó cung cấp thông tin tổng quan về “sức khỏe” của ứng dụng. Trạng thái chung sẽ là UP nếu mọi thứ đều ổn. Nó có thể tích hợp để kiểm tra trạng thái của các thành phần khác như kết nối database, disk space, message broker… Các công cụ như Kubernetes thường dùng endpoint này để thực hiện health check.

Ví dụ response:

{"status": "UP", "components": {"db": {"status": "UP"}, "diskSpace": {"status": "UP"}}}
  • /info: Hiển thị các thông tin chung về ứng dụng do mình tự định nghĩa, ví dụ như tên ứng dụng, phiên bản build, thông tin git commit…

Ví dụ:

{"app": {"name": "My App", "version": "1.0.2-SNAPSHOT"}}
  • /metrics: Cung cấp các số liệu chi tiết về hiệu năng của ứng dụng, ví dụ như bộ nhớ (JVM memory usage), số luồng (threads), thời gian xử lý request của các endpoint (http.server.requests), tài nguyên CPU… Các hệ thống giám sát như Prometheus thường lấy dữ liệu từ đây để vẽ biểu đồ và cảnh báo.

Ví dụ:

{"name": "jvm.memory.used", "measurements": [{"statistic": "VALUE", "value": 1.23E8}]}

Ngoài ra còn có các endpoint khác như /loggers (xem và thay đổi level log), /env (xem các biến môi trường), /threaddump

37. Trong kiến trúc Microservices, API Gateway và Service Discovery (ví dụ: Eureka, Consul) có vai trò gì?

Trong kiến trúc Microservices, API Gateway và Service Discovery là hai thành phần hạ tầng cực kỳ quan trọng, giúp hệ thống hoạt động một cách linh hoạt và có tổ chức.

  • API Gateway (Ví dụ: Spring Cloud Gateway, Netflix Zuul)

API Gateway đóng vai trò là một cửa ngõ (entry point) duy nhất cho tất cả các request từ client (web, mobile app) đi vào hệ thống microservices.

Vai trò chính của nó bao gồm:

  • Routing (Định tuyến): Nó nhận request từ client và định tuyến đến service phù hợp ở bên trong. Client không cần biết địa chỉ của từng service, chỉ cần biết địa chỉ của Gateway.
  • Single Point of Entry: Thay vì client phải gọi đến 10 service khác nhau với 10 địa chỉ IP/port, giờ đây chúng chỉ cần gọi đến 1 địa chỉ duy nhất của API Gateway.
  • Cross-Cutting Concerns (Xử lý các mối quan tâm chung): API Gateway là nơi lý tưởng để xử lý các logic chung cho toàn hệ thống, giúp các microservice bên trong chỉ tập trung vào nghiệp vụ của mình. Các logic đó là:
  • Authentication & Authorization: Xác thực người dùng (ví dụ: kiểm tra JWT) và kiểm tra quyền hạn trước khi cho request đi vào bên trong.
  • Rate Limiting: Giới hạn số lượng request từ một client để chống tấn công DoS.
  • Logging & Monitoring: Ghi log và theo dõi tất cả các request đi vào hệ thống.
  • Response Caching: Cache lại các response thường xuyên được yêu cầu.
  • Service Discovery (Ví dụ: Eureka, Consul)

Service Discovery đóng vai trò như một “cuốn danh bạ điện thoại” động cho các microservice.

Trong môi trường microservices, các service có thể được khởi chạy, tắt đi, hoặc di chuyển liên tục (ví dụ: do auto-scaling, deploy phiên bản mới, hoặc lỗi). Địa chỉ IP và port của chúng là không cố định.

Vai trò của Service Discovery là:

  • Service Registration (Đăng ký): Khi một instance của microservice (ví dụ: product-service) khởi động, nó sẽ tự động “gọi điện” đến Service Discovery server và đăng ký thông tin của mình, bao gồm tên service, địa chỉ IP và port.
  • Service Discovery (Khám phá): Khi order-service cần gọi đến product-service, thay vì hardcode địa chỉ, nó sẽ hỏi Service Discovery server: “Cho tôi xin địa chỉ của một instance product-service đang hoạt động?”.
  • Health Check: Service Discovery server sẽ liên tục kiểm tra “sức khỏe” của các instance đã đăng ký. Nếu một instance nào đó không phản hồi, nó sẽ bị loại khỏi danh bạ để các service khác không gọi đến nó nữa.

Tóm lại: API Gateway là cổng vào cho thế giới bên ngoài, còn Service Discovery giúp các service ở thế giới bên trong có thể tìm thấy và nói chuyện được với nhau.

38. RestTemplate và WebClient dùng để làm gì? Tại sao WebClient được khuyến khích sử dụng trong các ứng dụng mới?

Cả RestTemplate và WebClient đều là các HTTP client trong Spring, dùng để giúp một service có thể gọi đến các API của service khác.

  • RestTemplate là HTTP client truyền thống của Spring. Nó hoạt động theo cơ chế đồng bộ (synchronous) và chặn (blocking). Nghĩa là khi restTemplate.getForObject(…) được gọi, thread hiện tại sẽ bị block và phải chờ cho đến khi nhận được response từ service kia.
  • WebClient là HTTP client hiện đại, là một phần của Spring WebFlux framework. Nó hoạt động theo cơ chế bất đồng bộ (asynchronous) và không chặn (non-blocking). Khi webClient.get().retrieve().bodyToMono(…) được gọi, nó sẽ không block thread hiện tại. Thay vào đó, nó trả về một Publisher (cụ thể là Mono hoặc Flux). Việc xử lý response sẽ được thực hiện sau khi dữ liệu thực sự trả về, thông qua các callback.

Tại sao WebClient được khuyến khích sử dụng?

Lý do chính là vì cách nó quản lý tài nguyên, đặc biệt là thread, hiệu quả hơn rất nhiều, giúp tăng khả năng chịu tải (throughput) của ứng dụng.

Hãy tưởng tượng một service cần gọi đồng thời 100 API khác.

  • Nếu dùng RestTemplate, nó sẽ cần 100 thread để thực hiện 100 lời gọi này. Mỗi thread sẽ bị block cho đến khi có kết quả. Nếu hệ thống có nhiều request tương tự, số lượng thread sẽ tăng lên rất nhanh và trở thành điểm nghẽn của hệ thống.
  • Nếu dùng WebClient, nó chỉ cần một số lượng nhỏ các thread (event loop thread) để quản lý tất cả 100 lời gọi này. Khi một lời gọi được gửi đi, thread đó được giải phóng để làm việc khác. Khi có response trả về, một thread sẽ được dùng để xử lý kết quả.

Tóm lại: WebClient được khuyến khích cho các ứng dụng mới, đặc biệt là trong kiến trúc microservices (nơi việc gọi qua lại giữa các service diễn ra thường xuyên) vì nó sử dụng tài nguyên hiệu quả hơn, cho phép ứng dụng xử lý nhiều request đồng thời hơn với cùng một lượng tài nguyên phần cứng.

39. Phân biệt Unit Test và Integration Test. 

Unit Test và Integration Test là hai cấp độ kiểm thử khác nhau trong kim tự tháp kiểm thử. Các điểm khác nhau chính:

Tiêu chíUnit TestIntegration Test
Phạm viNhỏ và cô lập: Chỉ kiểm thử một đơn vị code duy nhất (một method hoặc một class) một cách độc lập.Rộng: Kiểm thử sự tương tác và hoạt động phối hợp của nhiều thành phần với nhau.
DependenciesĐược “mock” (giả lập). Mọi phụ thuộc bên ngoài như database, API khác, file system… đều được thay thế bằng các đối tượng giả.Sử dụng dependencies thật hoặc các phiên bản test gần giống thật (ví dụ: database trong bộ nhớ H2, Testcontainers).
Tốc độRất nhanh. Có thể chạy hàng trăm, hàng nghìn test trong vài giây.Chậm hơn vì phải khởi động nhiều thành phần, kết nối database…
Mục đíchĐảm bảo một đơn vị code hoạt động đúng như thiết kế.Đảm bảo các thành phần khi kết hợp lại với nhau vẫn hoạt động đúng
Ví dụTest một method tính toán trong Service. – Test logic validation trong một class.Test luồng từ Controller -> Service -> Repository. – Test việc ghi và đọc dữ liệu từ database.

40. Làm thế nào để viết một Unit Test cho Service Layer bằng Mockito?

Giả sử ta có một UserService phụ thuộc vào UserRepository để tìm người dùng:

// Class cần test
public class UserService {
    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public UserDto findUserById(Long id) {
        User user = userRepository.findById(id)
                .orElseThrow(() -> new UserNotFoundException("User not found"));
        return convertToDto(user);
    }
}

Tôi sẽ viết Unit Test cho method findUserById bằng Mockito như sau:

  1. Thiết lập môi trường test: Sử dụng JUnit 5 và Mockito.
  2. Mock Dependency: ta sẽ dùng Mockito để tạo một đối tượng giả (mock object) của UserRepository. ta không muốn test kết nối đến database thật, chỉ muốn kiểm tra logic của UserService.
  3. Định nghĩa hành vi cho Mock: ta sẽ “dạy” cho userRepository mock biết phải trả về cái gì khi method findById() của nó được gọi.
  4. Gọi phương thức cần test: Gọi userService.findUserById() với đối tượng userService đã được tiêm (inject) mock repository.
  5. Kiểm tra kết quả (Assertion): Dùng các hàm của AssertJ hoặc JUnit để kiểm tra xem kết quả trả về có đúng như mong đợi không.
  6. Kiểm tra tương tác (Verification): Đảm bảo rằng method findById() của mock repository đã thực sự được gọi.
// File test: UserServiceTest.java
@ExtendWith(MockitoExtension.class) // Tích hợp Mockito với JUnit 5
class UserServiceTest {

    @Mock // 2. Tạo một mock object cho UserRepository
    private UserRepository userRepository;

    @InjectMocks // Tự động inject các mock ở trên vào đối tượng này
    private UserService userService;

    @Test
    void whenFindUserById_givenUserExists_thenReturnUserDto() {
        // 1. ARRANGE (Sắp đặt)
        User user = new User(1L, "John Doe", "john@example.com");
        UserDto expectedDto = new UserDto(1L, "John Doe");

        // 3. Định nghĩa hành vi cho mock
        // Khi userRepository.findById(1L) được gọi, hãy trả về một Optional chứa user
        when(userRepository.findById(1L)).thenReturn(Optional.of(user));

        // 2. ACT (Hành động)
        // 4. Gọi phương thức cần test
        UserDto actualDto = userService.findUserById(1L);

        // 3. ASSERT (Kiểm tra)
        // 5. Kiểm tra kết quả
        assertThat(actualDto).isNotNull();
        assertThat(actualDto.getId()).isEqualTo(expectedDto.getId());
        assertThat(actualDto.getName()).isEqualTo(expectedDto.getName());

        // 6. Kiểm tra tương tác (tùy chọn nhưng nên có)
        // Đảm bảo rằng method findById(1L) của repository đã được gọi đúng 1 lần
        verify(userRepository, times(1)).findById(1L);
    }
    
    @Test
    void whenFindUserById_givenUserDoesNotExist_thenThrowException() {
        // ARRANGE
        when(userRepository.findById(anyLong())).thenReturn(Optional.empty());
        
        // ACT & ASSERT
        assertThatThrownBy(() -> userService.findUserById(99L))
            .isInstanceOf(UserNotFoundException.class)
            .hasMessage("User not found");
    }
}

41. So sánh và giải thích trường hợp sử dụng của các annotation testing: @SpringBootTest, @WebMvcTest, và @DataJpaTest

Ba annotation này đều dùng để viết test trong Spring Boot, nhưng chúng khác nhau ở phạm vi của Application Context mà chúng khởi tạo, giúp tối ưu hóa việc test cho từng layer cụ thể.

Annotation@SpringBootTest@WebMvcTest@DataJpaTest
Mục đíchIntegration Test toàn diện.Unit Test cho Web Layer.Unit/Integration Test cho Persistence Layer.
Context được tảiToàn bộ Application Context. Tải tất cả các bean, giống như khi chạy ứng dụng thật.Chỉ Web Layer: Controllers, @ControllerAdvice, Filters, WebMvcConfigurer… Không tải @Service, @Repository.Chỉ Persistence Layer: @Repository, Entities, EntityManager… Không tải @Service, @Controller.
Tốc độChậm nhất (vì phải tải mọi thứ).Nhanh.Nhanh.
DependenciesThường dùng dependencies thật hoặc Testcontainers.Các service/repository phụ thuộc phải được mock (dùng @MockBean).Mặc định sử dụng in-memory database (như H2) và tự động rollback transaction sau mỗi test.
Trường hợp sử dụngTest luồng chạy từ đầu đến cuối (end-to-end). – Test sự tích hợp giữa Service, Repository và Database thật. – Kiểm tra việc load configuration, profile có đúng không.Test logic của Controller (validation, request mapping, response status…). – Test security rules ở tầng web. – Test JSON serialization/deserialization.Test các phương thức custom query trong Repository. – Test các mối quan hệ Entity, cascading. – Kiểm tra việc lưu và đọc dữ liệu có đúng không.

Tóm lại:

  • Khi muốn test một luồng hoàn chỉnh, ví dụ như “gửi một request HTTP và kiểm tra xem dữ liệu có được lưu đúng vào DB không?”, hãy dùng @SpringBootTest.
  • Khi chỉ muốn test Controller, ví dụ như “gửi một request đến endpoint X thì có trả về status 200 và JSON đúng định dạng không?”, hãy dùng @WebMvcTest và mock các service phụ thuộc.
  • Khi chỉ muốn test Repository, ví dụ như “phương thức findByEmail() có trả về đúng User không?”, hãy dùng @DataJpaTest.

Việc lựa chọn đúng annotation sẽ giúp bộ test của mình chạy nhanh hơn và tập trung hơn vào đúng đối tượng cần kiểm thử.

42. Circuit Breaker Pattern là gì? (ví dụ: Resilience4j). Giải quyết vấn đề gì trong hệ thống microservices?

Circuit Breaker là một design pattern dùng để xây dựng các hệ thống có khả năng chịu lỗi (fault-tolerant). Tên của nó được lấy cảm hứng từ cái cầu dao (circuit breaker) trong hệ thống điện.

Vấn đề cần giải quyết:

Trong kiến trúc microservices, các service gọi lẫn nhau liên tục. Nếu một service B bị chậm hoặc lỗi, các service A gọi đến B sẽ phải chờ đợi (có thể đến khi timeout). Nếu có nhiều request đến service A, số lượng thread bị treo để chờ B sẽ tăng lên, dẫn đến cạn kiệt tài nguyên (thread pool) của A. Dần dần, lỗi từ B sẽ lan truyền (cascade) sang A, rồi từ A sang các service khác gọi đến A, gây sụp đổ cả một hệ thống.

Cách Circuit Breaker hoạt động:

Circuit Breaker Pattern hoạt động như một proxy giám sát các lời gọi đến một service có nguy cơ bị lỗi. Nó có 3 trạng thái:

CLOSED:

  • Đây là trạng thái bình thường. Mọi request đều được cho phép đi qua đến service đích.
  • Circuit Breaker sẽ đếm số lần thất bại. Nếu tỉ lệ lỗi trong một khoảng thời gian vượt qua một ngưỡng nào đó (ví dụ: 50% lỗi trong 10 request gần nhất), nó sẽ “nhảy” sang trạng thái OPEN.

OPEN:

  • Khi mạch “mở”, Circuit Breaker sẽ từ chối ngay lập tức tất cả các request đến service đích mà không cần thực hiện lời gọi mạng. Nó sẽ trả về lỗi ngay lập tức (fail-fast).
  • Điều này giúp bảo vệ service gọi (service A) khỏi việc bị treo và cạn kiệt tài nguyên.
  • Quan trọng hơn, nó cho service bị lỗi (service B) có thời gian để “nghỉ ngơi” và phục hồi mà không bị quá tải bởi các request mới.
  • Sau một khoảng thời gian chờ (cooldown/wait duration), Circuit Breaker sẽ chuyển sang trạng thái HALF-OPEN.

HALF-OPEN:

  • Ở trạng thái này, Circuit Breaker sẽ cho phép một số lượng request giới hạn đi qua để “thăm dò” xem service đích đã phục hồi hay chưa.
  • Nếu các request thử nghiệm này thành công, Circuit Breaker sẽ kết luận rằng service đã ổn định và chuyển về trạng thái CLOSED.
  • Nếu chúng thất bại, nó sẽ quay lại trạng thái OPEN và bắt đầu lại thời gian chờ.

Các thư viện như Resilience4j hay Hystrix giúp triển khai pattern này một cách dễ dàng trong Spring Boot. Ngoài ra, nó thường được kết hợp với các phương án dự phòng (fallback), ví dụ: khi Circuit Breaker mở, thay vì báo lỗi, hệ thống sẽ trả về dữ liệu từ cache hoặc một giá trị mặc định.

43. Trình bày một chiến lược caching hiệu quả cho ứng dụng Spring Boot (ví dụ: sử dụng Redis). 

Một chiến lược caching hiệu quả cho ứng dụng Spring Boot thường bao gồm việc sử dụng một distributed cache như Redis và tận dụng framework Spring Cache Abstraction.

Chiến lược chung để giảm tải đáng kể cho database, cải thiện latency và tăng khả năng chịu tải của ứng dụng:

  • Xác định dữ liệu cần cache: Tôi sẽ ưu tiên cache những dữ liệu:
    • Ít thay đổi nhưng được đọc thường xuyên (read-heavy), ví dụ: danh mục sản phẩm, thông tin cấu hình, thông tin hồ sơ người dùng.
    • Tốn nhiều tài nguyên để tính toán hoặc truy vấn.
  • Chọn Cache Provider: Sử dụng Redis vì nó là một in-memory data store cực kỳ nhanh, hỗ trợ nhiều cấu trúc dữ liệu và có thể hoạt động như một distributed cache, cho phép nhiều instance của cùng một service chia sẻ chung một cache.
  • Tích hợp Spring Cache: Bật tính năng cache trong Spring Boot bằng annotation @EnableCaching và cấu hình các thông số kết nối đến Redis trong file application.properties.
  • Áp dụng Caching Annotations: Sử dụng các annotation của Spring Cache (@Cacheable, @CachePut, @CacheEvict) để khai báo logic caching một cách tự động trên các method của Service layer.

44. Các annotation @Cacheable, @CachePut, @CacheEvict hoạt động ra sao?

@Cacheable(cacheNames=”products”, key=”#id”):

Trước khi thực thi method, Spring sẽ kiểm tra trong cache products xem có tồn tại một entry với key được sinh ra từ #id hay không.

  • Cache Hit (Tìm thấy): Nếu có, kết quả sẽ được trả về trực tiếp từ cache, và method sẽ không được thực thi.
  • Cache Miss (Không tìm thấy): Nếu không, method sẽ được thực thi. Kết quả trả về từ method sẽ được lưu vào cache với key tương ứng trước khi được trả về cho người gọi.

Trường hợp sử dụng: Dùng cho các method lấy dữ liệu (read operations).

@CachePut(cacheNames=”products”, key=”#product.id”):

Annotation này không kiểm tra cache trước khi chạy. Nó sẽ luôn luôn thực thi method. Sau khi method thực thi thành công, kết quả trả về sẽ được dùng để cập nhật hoặc thêm mới vào cache products.

Trường hợp sử dụng: Dùng cho các method cập nhật dữ liệu. Nó giúp giữ cho cache luôn đồng bộ với database sau khi có sự thay đổi.

@CacheEvict(cacheNames=”products”, key=”#id”):

Annotation này dùng để xóa (evict) dữ liệu khỏi cache. Khi method được gọi, entry tương ứng với key được chỉ định sẽ bị xóa.

Trường hợp sử dụng: Dùng cho các method xóa dữ liệu.

Nó còn có thuộc tính allEntries=true (@CacheEvict(cacheNames=”products”, allEntries=true)) để xóa tất cả các entry trong một cache, hữu ích khi có một thay đổi lớn làm cho toàn bộ cache không còn hợp lệ.

Ví dụ:

@Service
public class ProductService {

    @Cacheable(cacheNames="products", key="#id")
    public Product getProductById(Long id) {
        // Chỉ chạy khi cache miss
        return productRepository.findById(id).orElse(null);
    }

    @CachePut(cacheNames="products", key="#product.id")
    public Product updateProduct(Product product) {
        // Luôn chạy và cập nhật cache
        return productRepository.save(product);
    }

    @CacheEvict(cacheNames="products", key="#id")
    public void deleteProduct(Long id) {
        // Luôn chạy và xóa cache
        productRepository.deleteById(id);
    }
}

45. Hãy trình bày hiểu biết của bạn về Distributed Transaction, các thách thức khi triển khai và giải pháp để xử lý?

Giải thích về Distributed Transaction

Distributed Transaction là một giao dịch (transaction) mà các thao tác của nó trải dài trên nhiều hệ thống tài nguyên khác nhau, thường là nhiều microservice với các database riêng biệt.

Ví dụ kinh điển: Trong một hệ thống e-commerce, hành động “Đặt hàng” có thể bao gồm:

  • Service Order: Tạo một đơn hàng mới (ghi vào DB của Order).
  • Service Payment: Trừ tiền trong tài khoản khách hàng (ghi vào DB của Payment).
  • Service Inventory: Giảm số lượng tồn kho của sản phẩm (ghi vào DB của Inventory).

Một distributed transaction phải đảm bảo tính nguyên tử (Atomicity), tức là hoặc cả 3 bước trên đều thành công, hoặc nếu có bất kỳ bước nào thất bại thì tất cả các bước đã thực hiện trước đó phải được hoàn tác (rollback).

Thách thức khi triển khai

Triển khai distributed transaction rất khó khăn, đặc biệt trong kiến trúc microservices, vì:

  • Mất tính ACID: Giao thức commit truyền thống như Two-Phase Commit (2PC) yêu cầu tất cả các service tham gia phải khóa tài nguyên cho đến khi transaction coordinator đưa ra quyết định cuối cùng.
    • Tight Coupling: Tất cả các service phải online và sẵn sàng. Nếu một service bị chậm, cả hệ thống sẽ bị treo.
    • Giảm tính sẵn sàng (Availability): Việc khóa tài nguyên làm giảm hiệu năng và khả năng chịu tải của hệ thống.
    • Trong thực tế, 2PC gần như không được sử dụng trong các hệ thống microservices hiện đại vì những nhược điểm này.
  • Độ phức tạp trong việc quản lý trạng thái, xử lý lỗi, và đảm bảo dữ liệu nhất quán trên nhiều service.

Giải pháp: Saga Pattern

Thay vì cố gắng đạt được tính nguyên tử tuyệt đối như 2PC, cộng đồng microservices thường chấp nhận một trạng thái gọi là BASE (Basically Available, Soft state, Eventually consistent). Giải pháp phổ biến nhất để xử lý distributed transaction là Saga Pattern.

Saga Pattern là một chuỗi các giao dịch cục bộ (local transaction). Mỗi giao dịch cục bộ sẽ cập nhật database của chính service đó và sau đó xuất bản (publish) một sự kiện để kích hoạt giao dịch cục bộ tiếp theo trong saga.

Nếu một giao dịch cục bộ nào đó thất bại, saga sẽ thực thi một chuỗi các giao dịch bù trừ (compensating transaction) để hoàn tác lại công việc đã được thực hiện bởi các giao dịch cục bộ trước đó.

Ví dụ luồng “Đặt hàng” theo Saga:

Order Service: Bắt đầu CreateOrderSaga.

  1. (Local Tx 1) Tạo đơn hàng với trạng thái PENDING.
  2. Publish sự kiện OrderCreated.

Payment Service: Lắng nghe sự kiện OrderCreated.

  1. (Local Tx 2) Xử lý thanh toán.
  2. Nếu thành công -> Publish sự kiện PaymentProcessed.
  3. Nếu thất bại -> Publish sự kiện PaymentFailed.

Inventory Service: Lắng nghe sự kiện PaymentProcessed.

  1. (Local Tx 3) Trừ kho.
  2. Nếu thành công -> Publish sự kiện InventoryUpdated. Saga kết thúc thành công.
  3. Nếu thất bại (hết hàng) -> Publish sự kiện InventoryUpdateFailed.

Luồng bù trừ (khi InventoryUpdateFailed):

Payment Service: Lắng nghe InventoryUpdateFailed.

  1. (Compensating Tx 2) Hoàn tiền cho khách hàng.
  2. Publish sự kiện PaymentRefunded.

Order Service: Lắng nghe PaymentRefunded.

(Compensating Tx 1) Cập nhật trạng thái đơn hàng thành FAILED.

Có hai cách để điều phối Saga:

  • Choreography: Các service tự lắng nghe sự kiện của nhau và hành động (như ví dụ trên). Cách này linh hoạt nhưng khó theo dõi luồng.
  • Orchestration: Có một service trung tâm (Orchestrator) điều phối toàn bộ luồng, ra lệnh cho các service khác thực hiện hành động và bù trừ. Cách này dễ quản lý hơn.

Saga Pattern giúp duy trì tính nhất quán cuối cùng (eventual consistency) và giữ cho các service được découple, phù hợp với triết lý của microservices.

46. Testcontainers là gì và nó giúp ích gì trong việc viết Integration Test?

Testcontainers là một thư viện Java cho phép lập trình viên khởi tạo và quản lý các instance Docker container dùng một lần (throwaway) ngay từ trong code Java test.

Nói đơn giản, thay vì phải cài đặt và cấu hình một database PostgreSQL, một message broker RabbitMQ, hay một Redis server trên máy local hoặc trên CI server để chạy integration test, ta có thể dùng Testcontainers để khởi tạo các dịch vụ này dưới dạng Docker container ngay khi bộ test bắt đầu chạy, và tự động xóa chúng đi khi test kết thúc.

Testcontainers giúp ích rất nhiều trong việc viết Integration Test, cụ thể là:

  • Tạo môi trường test đáng tin cậy và gần giống Production:

Vấn đề lớn của integration test truyền thống là thường phải dùng các phiên bản “nhẹ” hoặc in-memory của các dịch vụ, ví dụ dùng H2 database để test cho code chạy với PostgreSQL. Điều này tiềm ẩn rủi ro vì H2 và PostgreSQL có những khác biệt về cú pháp SQL, kiểu dữ liệu, và hành vi.

Testcontainers cho phép ta chạy test với phiên bản chính xác của database (PostgreSQL 14, MySQL 8) hoặc message broker mà ứng dụng sẽ sử dụng ở môi trường production. Điều này giúp tăng độ tin cậy của bộ test lên rất nhiều.

  • Đảm bảo các bài test được cô lập và có thể lặp lại (Reproducible):

Mỗi lần chạy test, Testcontainers sẽ khởi tạo một container hoàn toàn mới, sạch sẽ. Điều này đảm bảo rằng kết quả của một lần chạy test không bị ảnh hưởng bởi “dữ liệu rác” từ lần chạy trước, giúp các bài test trở nên ổn định và dễ dự đoán hơn.

  • Đơn giản hóa việc thiết lập môi trường:

Lập trình viên mới tham gia dự án không cần phải mất hàng giờ để cài đặt và cấu hình một loạt các dịch vụ trên máy của họ. Họ chỉ cần có Docker và chạy ./mvnw test. Testcontainers sẽ lo phần còn lại.

Điều này cũng áp dụng tương tự cho môi trường CI/CD, giúp đơn giản hóa pipeline một cách đáng kể.

  • Hỗ trợ đa dạng các công nghệ:

Testcontainers có các module được xây dựng sẵn cho rất nhiều công nghệ phổ biến như PostgreSQL, MySQL, Kafka, RabbitMQ, Redis, Elasticsearch… và cũng cho phép sử dụng bất kỳ Docker image nào có sẵn trên Docker Hub.

Ví dụ sử dụng trong một bài test @DataJpaTest:

// Tích hợp Testcontainers với JUnit 5
@Testcontainers 
// Thay vì dùng H2, ta dùng @DataJpaTest với cấu hình để chạy trên container thật
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class UserRepositoryIntegrationTest {

    // Khai báo container sẽ được khởi tạo
    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14-alpine");

    // Tự động cấu hình Spring Boot để kết nối đến container vừa khởi tạo
    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired
    private UserRepository userRepository;

    @Test
    void whenSaveAndFindUser_thenSuccess() {
        User user = new User("testuser", "test@example.com");
        userRepository.save(user);

        Optional<User> foundUser = userRepository.findByEmail("test@example.com");

        assertThat(foundUser).isPresent();
        assertThat(foundUser.get().getUsername()).isEqualTo("testuser");
    }
}

Tóm lại, Testcontainers là một công cụ cực kỳ mạnh mẽ, giúp nâng tầm các bài integration test từ việc chạy trên các môi trường giả lập sang chạy trên các môi trường thực sự, đáng tin cậy, từ đó giúp phát hiện lỗi sớm hơn và tự tin hơn khi deploy sản phẩm.

Tổng kết

Từ Spring Core, Spring Data, Spring Security cho đến Microservices, hy vọng bộ câu hỏi này đã giúp bạn nhìn lại toàn bộ “bức tranh” về Java Spring Boot một cách hệ thống, từ nền tảng đến ứng dụng thực tế.

Điều quan trọng không nằm ở việc thuộc lòng câu trả lời, mà là hiểu được bản chất vấn đề. Khi đó, bạn sẽ tự tin xem buổi phỏng vấn như một cuộc trao đổi kỹ thuật. Chúc bạn chuẩn bị tốt và đạt được thành công trong buổi phỏng vấn sắp tới!

TÁC GIẢ
Tien Tran
Tien Tran

iOS Developer

Có 4 năm kinh nghiệm trong lĩnh vực phát triển ứng dụng mobile, được chứng minh qua lịch sử làm việc ở các công ty lớn (VCCorp, KiotViet, Vega Fintech). Với đam mê tìm hiểu, nghiên cứu những kiến thức chuyên môn cần có của một lập trình viên mobile hiện nay như Swift, Objective C, Flutter, Kotlin,... Tiến mong muốn chia sẻ kinh nghiệm làm việc và truyền cảm hứng cho mọi người muốn theo đuổi con đường trở thành một nhà phát triển ứng dụng di động chuyên nghiệp trong tương lai.