Nhiều hệ thống đơn hàng và thanh toán vẫn chạy trên Spring Boot 2.7 với Java 8 hoặc 11, vì "đang chạy ổn thì đừng đụng". Cái giá của việc đứng yên là bản vá bảo mật dừng lại, thư viện mới không còn hỗ trợ bản cũ và mỗi quý khoảng cách tới bản mới lại rộng thêm. Spring Boot 3 là bước nhảy lớn nhất của dòng Spring trong nhiều năm: nền tảng Java 17, đổi toàn bộ gói javax.* sang jakarta.*, Spring Framework 6 và một lớp quan sát (observability) mới dựa trên Micrometer Observation. Bài này đi qua một lần nâng cấp thực tế: phải đổi gì, kiểm tra bằng công cụ nào, và cách bật metric lẫn trace cho cùng một đoạn code. Mọi kết quả đều lấy từ ứng dụng chạy thật trên JDK 21, Spring Boot 3.4.4.

Bài nối tiếp Hexagonal Architecture với Spring Boot: port và adapter: kiến trúc sạch giúp việc nâng framework chỉ chạm vào lớp adapter. Phần truy cập dữ liệu có ở Repository pattern và Unit of Work với JPA, nơi Hibernate 6 gây ra nhiều thay đổi nhất.

Vì sao phải lên Spring Boot 3 và lên bản nào

Mỗi dòng Spring Boot (ví dụ dòng 3.4) chỉ được hỗ trợ mã nguồn mở khoảng 13 tháng. Theo bảng tổng hợp vòng đời trên endoflife.date, dòng 2.7 hết hỗ trợ OSS ngày 30/06/2023 (chỉ còn bản thương mại), dòng 3.0 hết ngày 31/12/2023, 3.1 hết 30/06/2024 và 3.2 hết 31/12/2024. Tại thời điểm viết bài (tháng 4/2025), dòng 3.3 còn tới 30/06/2025 và dòng 3.4 còn tới 31/12/2025. Quy tắc thực dụng khi chọn bản Spring Boot 3: đích đến là dòng mới nhất, và sau khi lên Spring Boot 3 thì cập nhật bản minor mỗi 6 tháng thay vì dồn một lần.

Thành phầnSpring Boot 2.7Spring Boot 3.4.4 (đã chạy trong bài)
Java tối thiểu817 (chạy trên 21; tài liệu ghi tương thích tới Java 24)
Spring Framework5.36.2.5
Gói Java EEjavax.* (Servlet 4, JPA 2.2)jakarta.* (Servlet 6.0, JPA 3.1)
Tomcat nhúng9.010.1.39
Hibernate5.66.6.11.Final
Micrometer1.9 (metrics)Observation 1.14.5, Tracing 1.4.4

Cột 3.4.4 là danh sách jar thật trong file boot3-demo-1.0.jar (lệnh unzip -l), cột 2.7 theo bảng dependency-versions của Spring Boot 2.7; các thay đổi hành vi ở những mục sau dựa trên Migration Guide của Spring Boot 3.0. Yêu cầu hệ thống của dòng 3.4 (Java 17 trở lên, Maven 3.6.3, Tomcat 10.1.25 trở lên) nằm trong tài liệu System Requirements.

Thứ tự nâng cấp lên Spring Boot 3: đi từng bước, không nhảy một lần

Migration Guide khuyên lên bản 2.7.x mới nhất trước, vì bản này đã đánh dấu @Deprecated hầu hết API bị xoá ở Spring Boot 3. Quy trình chúng tôi dùng khi chuyển dự án sang Spring Boot 3:

  1. Đóng băng tính năng: nhánh nâng cấp chỉ chứa thay đổi liên quan tới nâng cấp, để lỗi nào xuất hiện cũng biết nguyên nhân.
  2. Lên 2.7.x mới nhất trên Java đang dùng, sửa hết cảnh báo deprecated, chạy toàn bộ test.
  3. Nâng Java lên 17 (hoặc 21) khi vẫn còn Spring Boot 2.7; Spring Boot 2.7 chạy tốt trên Java 17, nên lỗi nào xuất hiện ở bước này là của JDK (module, reflection), không phải của Spring.
  4. Đổi javax sang jakarta và nâng parent sang 3.x.
  5. Chạy spring-boot-properties-migrator để phát hiện thuộc tính đổi tên.
  6. Xử lý thay đổi hành vi (trailing slash, Hibernate 6, Actuator, Spring Security 6) bằng test tích hợp.
  7. Bật observability sau cùng, khi ứng dụng đã ổn định.

Với dự án lớn, công cụ OpenRewrite có các recipe UpgradeSpringBoot_3_x tự động đổi import javax.persistence, đổi tên thuộc tính trong application.properties và nâng java.version trong pom.xml. Bài này không chạy OpenRewrite (hướng dẫn của họ yêu cầu cấu hình kho recipe riêng), nên các phần dưới đây làm tay để thấy rõ từng thay đổi; dù dùng công cụ, bạn vẫn phải đọc git diff và chạy test.

Spring Boot 3 và Jakarta EE: đổi tên gói không phải chuyện thẩm mỹ

Khi Java EE chuyển sang Eclipse Foundation, quyền dùng tên javax không được chuyển theo, nên từ Jakarta EE 9 mọi gói đổi thành jakarta.*. Spring Boot 3 yêu cầu Jakarta EE 10 (Servlet 6.0, JPA 3.1), nghĩa là javax.persistence, javax.validation, javax.servlet đều phải đổi. Để thấy lỗi thực tế, chúng tôi giữ nguyên ứng dụng mẫu (entity JPA có @NotBlank, controller có @Valid), đổi ngược import về javax rồi biên dịch bằng script migrate-demo.sh:

$ mvn -q compile   # mã còn import javax.*
src/main/java/demo/sb3/OrderController.java:[3,24] package javax.validation does not exist
src/main/java/demo/sb3/OrderController.java:[4,36] package javax.validation.constraints does not exist
src/main/java/demo/sb3/OrderController.java:[5,36] package javax.validation.constraints does not exist
src/main/java/demo/sb3/OrderEntity.java:[3,25] package javax.persistence does not exist
... (9 lỗi)

$ sed -i "s/javax\./jakarta./" ...; mvn -q compile
exit=0

Biên dịch báo 9 lỗi thiếu package ở các dòng import (kéo theo 9 lỗi cannot find symbol ở nơi dùng annotation, nên bản trích chỉ lọc dòng does not exist); sau khi thay javax.persistence và javax.validation bằng jakarta.*, mvn -q compile thoát với mã 0. Phần dễ bỏ sót nằm ngoài code của bạn:

  • Thư viện bên thứ ba còn phụ thuộc javax.* (bản cũ của Swagger/springfox, một số thư viện báo cáo, driver cũ) sẽ không chạy; phải lên bản có hậu tố jakarta hoặc đổi thư viện. Hãy chạy mvn dependency:tree và tìm javax.
  • Không đổi mọi javax: javax.sql, javax.crypto, javax.net thuộc JDK, giữ nguyên. Thay thế hàng loạt bằng sed trên toàn dự án sẽ làm hỏng chúng.
  • File cấu hình XML và persistence.xml chứa tên lớp hoặc namespace cũ cũng cần đổi.

Thuộc tính đổi tên khi lên Spring Boot 3: để công cụ tìm giúp

Spring Boot 3 xoá hoặc đổi tên nhiều khoá cấu hình, và Spring Boot 3 khởi động vẫn thành công dù bạn còn khoá cũ, chỉ là giá trị bị bỏ qua. Thay vì tự đối chiếu bảng, hãy thêm tạm spring-boot-properties-migrator (phạm vi runtime) rồi khởi động ứng dụng. Chúng tôi cố ý thêm bốn thuộc tính kiểu Spring Boot 2.x vào file cấu hình và nhận được báo cáo sau:

$ thêm spring-boot-properties-migrator + 4 thuộc tính cũ, chạy ứng dụng
Property source 'Config resource 'class path resource [application.properties]' via location 'optional:classpath:/'':
	Key: management.metrics.export.prometheus.enabled
		Replacement: management.prometheus.metrics.export.enabled
	Key: spring.redis.host
		Replacement: spring.data.redis.host
Property source 'Config resource 'class path resource [application.properties]' via location 'optional:classpath:/'':
	Key: server.max-http-header-size
		Reason: Replacement key 'server.max-http-request-header-size' uses an incompatible target type
	Key: spring.jpa.hibernate.use-new-id-generator-mappings
		Reason: Hibernate no longer supports disabling the use of new ID generator mappings.

Hai thuộc tính được tự động ánh xạ sang tên mới (management.prometheus.metrics.export.enabled, spring.data.redis.host); hai thuộc tính còn lại cần bạn tự quyết, ví dụ server.max-http-header-size thành server.max-http-request-header-size nhưng kiểu dữ liệu đích khác nên công cụ không tự chuyển. Nhớ gỡ migrator sau khi xong, vì nó phân tích cấu hình lúc khởi động và không nên có trong bản production.

Những thay đổi hành vi âm thầm của Spring Boot 3

Lỗi biên dịch dễ xử lý; lỗi nguy hiểm là code vẫn biên dịch nhưng hành vi đổi. Ba thay đổi sau gây sự cố nhiều nhất khi một ứng dụng chuyển sang Spring Boot 3.

Trailing slash không còn khớp

Từ Spring Framework 6, /orders/1/ không còn khớp với route /orders/{id}. Migration Guide ghi mặc định này là false. Kết quả trên ứng dụng mẫu:

$ curl -s -o /dev/null -w "/orders/1  -> %{http_code}\n" localhost:8085/orders/1
/orders/1  -> 200
$ curl -s -o /dev/null -w "/orders/1/ -> %{http_code}\n" localhost:8085/orders/1/
/orders/1/ -> 404

Với Spring Boot 3, nếu client, cron job hoặc cấu hình health check của load balancer đang gọi URL có dấu / ở cuối, bạn sẽ nhận 404 hàng loạt ngay sau khi triển khai. Cách sửa tốt nhất là sửa client; giải pháp tạm là khai báo thêm route hoặc cấu hình PathMatchConfigurer cho phép khớp dấu / cuối, rồi lên kế hoạch gỡ.

Lỗi trả về dạng ProblemDetail

Spring Framework 6 hỗ trợ RFC 7807 (ProblemDetail); đặt spring.mvc.problemdetails.enabled=true thì các lỗi do framework sinh ra có Content-Type application/problem+json. So sánh hai chế độ trên cùng một endpoint:

$ curl -si -X POST localhost:8085/orders -d '{"customer":"","amountVnd":0}' ...
HTTP/1.1 400 
Content-Type: application/problem+json

{"type":"about:blank","title":"Bad Request","status":400,"detail":"Invalid request content.","instance":"/orders"}

$ curl -si localhost:8085/orders/999
HTTP/1.1 404 
Content-Type: application/problem+json

{"type":"about:blank","title":"Not Found","status":404,"detail":"Không có đơn 999","instance":"/orders/999"}

$ java -jar target/boot3-demo-1.0.jar --spring.mvc.problemdetails.enabled=false
$ curl -si localhost:8085/orders/999
HTTP/1.1 404 
Content-Type: application/json
{"timestamp":"...","status":404,"error":"Not Found","path":"/orders/999"}

Bật ProblemDetail trong Spring Boot 3 đổi hình dạng body lỗi (thêm type, title, detail, instance, bỏ timestamp, error, path). Nếu mobile app hay frontend đang đọc trường error hoặc path, đây là thay đổi phá vỡ hợp đồng API, nên phải thống nhất với bên dùng trước khi bật. Ngoài ra lưu ý ở ví dụ trên: lỗi validate trả về detail: "Invalid request content." mà không liệt kê trường sai; muốn có danh sách trường, hãy viết @RestControllerAdvice riêng.

Hibernate 6, Actuator và Security

  • Hibernate 6 đổi cách sinh câu SQL, mặc định chiến lược sinh id (ví dụ @GeneratedValue không chỉ định thì dùng sequence theo từng entity) và một số quy tắc HQL chặt hơn. Test repository với dữ liệu thật và đọc kỹ DDL sinh ra; thuộc tính use-new-id-generator-mappings đã bị loại, như báo cáo ở trên.
  • Actuator: httptrace đổi tên thành httpexchanges; /env và /configprops che toàn bộ giá trị mặc định; chỉ health mở qua JMX.
  • Spring Security 6 áp dụng cho mọi loại dispatch và bỏ WebSecurityConfigurerAdapter; phải chuyển sang bean SecurityFilterChain. Hãy lên Spring Security 5.8 trước (khi còn Boot 2.7) để thấy cảnh báo.

Observability trong Spring Boot 3: một lần khai báo, có cả metric và trace

Ở Spring Boot 2.x, metric dùng Micrometer, trace dùng Spring Cloud Sleuth, log dùng MDC: ba hệ thống riêng. Spring Boot 3 hợp nhất chúng bằng Micrometer Observation API. Theo tài liệu Micrometer, code chỉ báo các sự kiện vòng đời (start, stop, error) cho một Observation, còn các ObservationHandler đã đăng ký quyết định tạo timer, span hay log. Sleuth được thay bằng Micrometer Tracing. Theo tài liệu Spring Framework, nếu không có ObservationRegistry thì mọi observation là no-op, nên không tốn chi phí khi chưa bật.

Cấu hình tối thiểu để bật observability trong Spring Boot 3 gồm bốn phần: spring-boot-starter-actuator (metric), micrometer-tracing-bridge-brave (cầu nối trace; có thể thay bằng bridge OpenTelemetry), spring-boot-starter-aop (cho @Observed) và hai dòng cấu hình. Bean ObservedAspect là bắt buộc để @Observed có tác dụng, theo bài giới thiệu observability của Spring:

package demo.sb3;

import io.micrometer.observation.ObservationRegistry;
import io.micrometer.observation.aop.ObservedAspect;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

@SpringBootApplication
public class Application {
    public static void main(String[] args) { SpringApplication.run(Application.class, args); }

    // Cần để @Observed có tác dụng (theo tài liệu Micrometer/Spring Boot)
    @Bean
    ObservedAspect observedAspect(ObservationRegistry registry) { return new ObservedAspect(registry); }
}

Gắn @Observed vào phương thức nghiệp vụ. Tag channel có tập giá trị nhỏ, cố định, nên là low cardinality và được đưa vào metric:


    OrderService(OrderRepository repo) { this.repo = repo; }

    @Observed(name = "order.create", contextualName = "tao-don-hang",
              lowCardinalityKeyValues = {"channel", "web"})
    public OrderEntity create(OrderEntity e) {
        OrderEntity saved = repo.save(e);
        log.info("Đã lưu đơn {} cho khách {}", saved.getId(), saved.getCustomer());
        return saved;
    }

    @Observed(name = "order.find")
    public OrderEntity find(long id) {
        return repo.findById(id).orElseThrow(() ->
            new ResponseStatusException(HttpStatus.NOT_FOUND, "Không có đơn " + id));
    }
}

Phần cấu hình liên quan (file đầy đủ ở code/s6-25/src/main/resources/application.properties):

management.endpoints.web.exposure.include=health,metrics
management.tracing.sampling.probability=1.0
spring.mvc.problemdetails.enabled=true

Gửi hai yêu cầu tạo đơn: yêu cầu đầu mang header traceparent theo chuẩn W3C Trace Context (giả lập một dịch vụ gọi tới), yêu cầu sau thì không. Kết quả thật, trích từ log ứng dụng và Actuator:

$ curl -X POST /orders  -H "traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
{"id":1,"customer":"Tran Thi B","amountVnd":99000}
$ curl /orders (không traceparent)
{"id":2,"customer":"Le Van C","amountVnd":1000}

$ log ứng dụng
INFO 71438 --- [orders] [nio-8085-exec-2] [4bf92f3577b34da6a3ce929d0e0e4736-ba65d6544110ad60] demo.sb3.OrderService                    : Đã lưu đơn 1 cho khách Tran Thi B
INFO 71438 --- [orders] [nio-8085-exec-4] [6af1d9ab8bc9ecf52dfa6619852fcf6c-56a75f617e14897c] demo.sb3.OrderService                    : Đã lưu đơn 2 cho khách Le Van C

$ curl /actuator/metrics/order.create   # (rút gọn: COUNT, TOTAL_TIME, MAX và tên các tag)
order.create {'COUNT': 2.0, 'TOTAL_TIME': 0.0299, 'MAX': 0.0286}
['method', 'channel', 'error', 'class']
$ curl /actuator/health
{"status":"UP"}

Có ba điều đáng quan sát. Thứ nhất, log của lớp OrderService mang sẵn trace id và span id trong dấu ngoặc vuông, không cần cấu hình logging.pattern.level riêng. Thứ hai, trace id của đơn 1 là 4bf92f3577b34da6a3ce929d0e0e4736, đúng bằng giá trị trong header traceparent nhận vào, tức ngữ cảnh trace được kế thừa xuyên dịch vụ; đơn 2 không có header nên Spring sinh trace id mới. Thứ ba, metric order.create có COUNT 2.0 và các tag method, channel, error, class: cùng một annotation sinh ra cả timer lẫn span. Danh sách tag này lấy từ lệnh không lọc; khi thêm ?tag=channel:web, Actuator bỏ channel khỏi availableTags vì tag đã được lọc, còn COUNT vẫn là 2.0.

Khi cần đẩy trace đi, thêm reporter tương ứng (Zipkin hoặc OTLP) và Boot tự cấu hình; bài này dừng ở phía ứng dụng. Cách gom metric về Prometheus và Grafana có trong Giám sát Kubernetes với kube-prometheus và Grafana, còn log tập trung xem Logging trên Kubernetes: từ promtail, fluentd đến Loki; trace id trong log chính là khoá để nhảy từ dòng log sang trace.

Cardinality: cái bẫy khiến Prometheus quá tải

Micrometer phân biệt tag low cardinality (tập giá trị hữu hạn, như method, status, channel) và high cardinality (không giới hạn, như user id hay URL đầy đủ). Low cardinality vào cả metric lẫn trace; high cardinality chỉ nên vào trace. Nếu bạn đưa customerId vào lowCardinalityKeyValues, mỗi khách hàng tạo một chuỗi thời gian riêng và bộ nhớ của Prometheus tăng theo số khách. Lưu ý thêm: Spring MVC dùng mẫu route (/orders/{id}) làm tag uri, đúng như danh sách uri mà Actuator báo cho chúng tôi (/orders/{id}, không phải /orders/1), còn http.url đầy đủ là high cardinality.

Một chi tiết nữa: ngoại lệ được @ExceptionHandler hoặc ProblemDetail xử lý sẽ không tự được ghi vào observation của HTTP; muốn tag error phản ánh đúng, tài liệu Spring Framework hướng dẫn gọi ServerHttpObservationFilter.findObservationContext(request) rồi setError.

Khi nào chưa nên (hoặc không cần) lên Spring Boot 3

  • Phụ thuộc không có bản Jakarta: thư viện nội bộ hoặc hãng thứ ba đã ngừng bảo trì và còn dùng javax. Hãy thay thế trước, hoặc tách thành tiến trình riêng.
  • Ứng dụng sắp ngừng: nếu hệ thống sẽ bị thay trong 6 tháng tới, chi phí nâng cấp lớn hơn rủi ro; nhưng khi đó cần ghi rõ ngày ngừng và chặn truy cập từ internet.
  • Chưa có test tự động: nâng cấp lúc này là đánh cược. Viết vài test tích hợp cho luồng chính trước (đặt đơn, thanh toán, đăng nhập) rồi mới đổi phiên bản.
  • Server ứng dụng Java EE cũ (WAR chạy trên container Servlet 4): cần container hỗ trợ Servlet 6 hoặc chuyển sang jar nhúng.

Lỗi thường gặp khi lên Spring Boot 3

  • Quên ObservedAspect: @Observed không báo lỗi nhưng cũng không có metric. Kiểm tra bằng /actuator/metrics xem tên observation đã xuất hiện chưa.
  • Thiếu spring-boot-starter-aop: aspect không được tạo, kết quả giống trên.
  • Đặt tag high cardinality ở mức low: làm bùng nổ số chuỗi thời gian.
  • Sửa javax bằng sed toàn dự án: làm hỏng javax.crypto, javax.sql.
  • Bật ProblemDetail mà không báo client: body lỗi đổi hình dạng.
  • Để lại spring-boot-properties-migrator trong bản phát hành.
  • Test Mockito/JUnit cũ: JUnit 4 không còn được starter test mặc định hỗ trợ, cần đổi sang JUnit 5.

Checklist nâng cấp lên Spring Boot 3

  • Trước khi lên Spring Boot 3, đã lên 2.7.x mới nhất, hết cảnh báo deprecated, test xanh.
  • JDK 17 hoặc 21 ở cả máy build, CI và container chạy.
  • mvn dependency:tree không còn thư viện kéo javax.persistence, javax.servlet hay javax.validation.
  • Với Spring Boot 3, đã chạy properties migrator, xử lý từng khoá, rồi gỡ migrator.
  • Đã kiểm tra trailing slash, định dạng lỗi, câu SQL và DDL của Hibernate 6.
  • /actuator/health vẫn trả UP (cấu hình readiness probe xem ở Health endpoint cho Laravel trên Kubernetes, nguyên tắc tương tự cho Spring).
  • Ứng dụng Spring Boot 3 có metric nghiệp vụ gắn @Observed với tag low cardinality; log có trace id.
  • Có kế hoạch cập nhật minor theo lịch hỗ trợ 6 tháng.

Tóm lại, nâng cấp Spring Boot 3 không khó vì từng thay đổi, mà vì chúng dồn lại một lần. Đi theo thứ tự (2.7.x, Java 17, Jakarta, thuộc tính, hành vi, observability), để công cụ phát hiện những gì có thể phát hiện, và dùng test tích hợp cho phần còn lại.

Bài tiếp theo

Khi ứng dụng đã chạy trên nền mới và quan sát được, câu hỏi tiếp theo là mô hình dữ liệu có còn phù hợp khi đọc nhiều hơn ghi rất nhiều. Mời bạn đọc tiếp CQRS và Event Sourcing trong Java: khi nào nên dùng.