Mini-challenge: TaskFlow v4 — hiện đại hoá tầng Java
Refactor TaskFlow: DTO thành record, client ra ngoài thành @HttpExchange, kết quả service thành sealed Result, rồi bật virtual threads và đo lại.
🎯 Đề bài
Sáu bài trước dạy bạn năm kỹ thuật rời rạc trên những mẩu code minh hoạ nhỏ của TaskFlow. Bài này ghép chúng lại trên một lát cắt thật của codebase — phần chưa ai đụng tới kể từ ngày viết đầu tiên.
TaskFlow đang ở trạng thái "v3": chạy tốt, traffic thấp thì không ai để ý, nhưng ba mảng dưới đây vẫn giữ nguyên pattern cũ mà bài 01, 04, 05, 06 đã chỉ ra là có vấn đề.
- Tính năng bình luận task (
TaskCommentService) dùng một DTO class mutable có setter — y hệt lỗi bài 01 đã mổ xẻ, chỉ khác chỗ đứng. TaskFlowOrchestrator(bài 02 đã dùng làm ví dụ virtual threads) gọi ba service ngoài — thanh toán, tồn kho, giao hàng — bằngRestTemplatetrần, không timeout.OrderService.placeOrdervẫn là "chữ ký nói dối" bài 06 đã chỉ ra: némIllegalStateExceptioncho hai lỗi nghiệp vụ hoàn toàn dự đoán được, và chưa từng được sửa trong bài đó.
Nhiệm vụ của bạn: refactor cả ba mảng qua đúng năm bước, rồi bật virtual threads cho luồng đặt hàng và đo xem quyết định đó có thật sự đáng làm hay không. Không có lời giải nào đúng duy nhất cho từng dòng code — nhưng có tiêu chí quan sát được cho từng bước, nêu ở mục "Yêu cầu" dưới đây.
🔍 Phân tích I-P-O
| Mô tả | |
|---|---|
| Input | TaskFlow "v3": TaskCommentService với DTO mutable, ba client RestTemplate không timeout trong TaskFlowOrchestrator, OrderService.placeOrder ném IllegalStateException |
| Process | Năm bước tuần tự: record hoá DTO, RestClient có timeout, gộp thành @HttpExchange, sealed Result thay exception, bật virtual threads và đo lại |
| Output | TaskFlow "v4": DTO bất biến đúng chỗ, client outbound có timeout + dịch lỗi, kết quả nghiệp vụ là kiểu dữ liệu compiler kiểm được, cấu hình luồng đúng với hồ sơ tải thật, kèm số đo throughput trước/sau |
Điều kiện đúng (invariant) cần giữ xuyên suốt cả năm bước:
- Hành vi nghiệp vụ không đổi: bình luận vẫn lưu đúng nội dung, đơn hàng hết hàng hoặc vượt hạn mức vẫn bị từ chối — chỉ đổi cách báo lỗi, không đổi quy tắc.
- Không transaction nào commit dữ liệu của một đơn hàng đã bị từ chối.
- Timeout và cách dịch lỗi cấu hình ở bước 2 không được mất đi khi chuyển sang bước 3.
📦 Concept mapping
| Bước | Kỹ thuật | Bài gốc | Đúng learningOutcome |
|---|---|---|---|
| 1 | DTO mutable → record, validation trên component | Bài 01 — Record làm DTO | Implement record làm DTO request/response |
| 2 | RestTemplate → RestClient với timeout + onStatus | Bài 04 — RestClient | Implement HTTP client outbound với timeout và xử lý lỗi tường minh |
| 3 | RestClient thủ công → interface @HttpExchange | Bài 05 — HTTP Interface | Implement HTTP client outbound với timeout và xử lý lỗi tường minh |
| 4 | IllegalStateException → sealed Result → ProblemDetail | Bài 06 — Sealed Result | Design kết quả nghiệp vụ bằng sealed type và pattern matching |
| 5 | Bật virtual threads, đo throughput, soát ba cạm bẫy | Bài 02 + Bài 03 | Choose bật/không bật virtual threads theo hồ sơ tải |
▶️ Starter — trạng thái xuất phát của TaskFlow v3
Mảng 1 — bình luận task, DTO mutable:
public class TaskCommentRequest {
private Long taskId;
private String authorEmail;
private String body;
public Long getTaskId() { return taskId; }
public void setTaskId(Long taskId) { this.taskId = taskId; }
public String getAuthorEmail() { return authorEmail; }
public void setAuthorEmail(String authorEmail) { this.authorEmail = authorEmail; }
public String getBody() { return body; }
public void setBody(String body) { this.body = body; }
}
@RestController
@RequestMapping("/api/tasks")
public class TaskCommentController {
private final TaskCommentService taskCommentService;
TaskCommentController(TaskCommentService taskCommentService) {
this.taskCommentService = taskCommentService;
}
@PostMapping("/{taskId}/comments")
public TaskCommentResponse addComment(@PathVariable Long taskId, @RequestBody TaskCommentRequest request) {
request.setTaskId(taskId); // gan them sau khi Jackson da dung constructor rong
return taskCommentService.addComment(request);
}
}
Không có @Valid nào ở đây — bình luận rỗng hoặc email sai định dạng vẫn lưu được vào DB, chỉ phát hiện khi ai đó đọc lại và thấy dữ liệu bẩn.
Mảng 2 — ba client gọi ngoài, không timeout:
@Component
public class PaymentClient {
private final RestTemplate restTemplate;
PaymentClient(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
public PaymentResult charge(String orderId) {
return restTemplate.postForObject(
"http://payment-service.internal/charge/" + orderId, null, PaymentResult.class);
}
}
InventoryClient.reserve(orderId) và ShippingClient.schedule(orderId) viết theo đúng khuôn trên, chỉ đổi URL đích (inventory-service.internal, shipping-service.internal) — ba downstream khác nhau, ba service khác nhau, không phải một service gọi ba lần. Cả ba đều được TaskFlowOrchestrator.process từ bài 02 gọi tuần tự, và cả ba đều thiếu đúng thứ Profile Service ở bài 04 từng thiếu: connect timeout, read timeout.
Bài 03 kể chuyện TaskFlow gãy vào đợt Cyber Monday khi traffic nhảy từ khoảng 300 lên 3.000 request/giây. Với ba client RestTemplate không timeout ở trên, nếu Payment Service ở downstream chậm đi một chút (không chết hẳn) trong đúng đợt burst đó, chuyện gì sẽ xảy ra cho TaskFlow? Viết dự đoán của bạn trước khi đọc bước 2.
Mảng 3 — chữ ký nói dối chưa được sửa:
public class OrderService {
public void placeOrder(Order order) {
Order locked = orderRepository.lockForUpdate(order.orderId());
orderRepository.markPending(locked); // ghi truoc de tranh 2 request cung dat 1 don
if (!inventory.hasStock(order.productId(), order.quantity())) {
throw new IllegalStateException("khong du hang");
}
if (creditLimit.exceeds(order.customerId(), order.total())) {
throw new IllegalStateException("vuot han muc tin dung");
}
orderRepository.confirm(locked);
}
}
Order đã là record từ trước (bài 06 dùng nó qua order.productId(), order.quantity()...), nên record hoá không phải việc của mảng này. Vấn đề nằm ở chữ ký void và hai throw — đúng như bài 06 mục 1 đã chỉ ra, chỉ khác một chi tiết: markPending khoá đơn hàng và ghi trạng thái "đang xử lý" trước khi hai điều kiện nghiệp vụ được kiểm tra, để tránh hai request cùng đặt một đơn. Giữ chi tiết này trong đầu — nó quay lại ở bước 4.
Yêu cầu — năm bước
Bước 1 — DTO → record
Refactor TaskCommentRequest thành record bất biến, đặt Bean Validation đúng chỗ theo cơ chế bài 01.
Tiêu chí hoàn thành quan sát được: không còn method set* nào trên DTO; authorEmail/body có constraint (@NotBlank, @Email, @Size) đặt trên record component; controller dùng @Valid; build fail rõ ràng nếu bạn giữ nguyên dòng request.setTaskId(taskId) cũ (record không có setter — đây là tín hiệu compiler, không phải thứ bạn tự nhớ để tránh).
Bước 2 — RestTemplate → RestClient
Chuyển cả ba client (Payment, Inventory, Shipping) sang RestClient, khai connect timeout và read timeout tường minh cho từng client, dịch lỗi 4xx/5xx bằng onStatus.
Tiêu chí hoàn thành quan sát được: cả ba client — không chỉ một — có connect timeout và read timeout khai riêng; gọi một downstream trả 4xx/5xx không còn ném thẳng HttpClientErrorException/HttpServerErrorException lên controller; RestClient dựng qua bean @Configuration, không có RestClient.create() rải rác trong service.
Bước 3 — RestClient thủ công → @HttpExchange
Gộp mỗi client thành một interface gắn @PostExchange, đăng ký bean qua HttpServiceProxyFactory bọc đúng RestClient đã cấu hình ở bước 2.
Tiêu chí hoàn thành quan sát được: PaymentClient/InventoryClient/ShippingClient là interface, không còn implementation viết tay; TaskFlowOrchestrator.process không đổi một dòng logic nghiệp vụ nào, chỉ đổi kiểu bean tiêm vào; gọi một downstream trả lỗi vẫn nhận đúng exception nghiệp vụ đã dịch ở bước 2 — không "mất" cấu hình khi đổi sang interface.
Bước 4 — IllegalStateException → sealed Result
OrderService.placeOrder trả về sealed PlaceOrderResult thay vì ném exception; controller switch map từng nhánh sang ProblemDetail.
Tiêu chí hoàn thành quan sát được: PlaceOrderResult là sealed interface, permits đủ nhánh khớp hai điều kiện nghiệp vụ cộng nhánh thành công; switch ở controller không có default — thêm một nhánh Result mới mà quên chỗ xử lý phải làm build gãy ngay; viết một test gọi placeOrder với input vượt hạn mức tín dụng, rồi tự kiểm tra (query lại, không chỉ đọc code): đơn hàng có bị kẹt ở trạng thái "đang xử lý" trong DB hay không.
Bước 5 — Bật virtual threads, đo throughput, soát ba cạm bẫy
Bật spring.threads.virtual.enabled=true cho luồng chứa TaskFlowOrchestrator, đo throughput ở ít nhất hai mức concurrency trước và sau khi bật, rồi tự kiểm ba cạm bẫy bài 03 đã dạy.
Tiêu chí hoàn thành quan sát được: có bảng số đo throughput thật (req/giây) ở tối thiểu hai mức tải đồng thời, cả trước lẫn sau khi bật — không phải latency của một request; bật JFR trong lúc load test và không có event jdk.VirtualThreadPinned nào trỏ vào code TaskFlow; có một giới hạn tường minh (Semaphore hoặc tương đương) đứng trước bước ghi DB trong OrderService, kích thước khớp maximumPoolSize của HikariCP.
Ít nhất một trong năm bước trên chứa đúng cái bẫy bài trước đã cảnh báo mà không nhắc lại tên. Đừng coi việc code biên dịch và chạy được là bằng chứng đã xong — quan sát hiệu ứng phụ (dữ liệu ghi trong DB, stack trace lúc pin, throughput thật) mới là tiêu chí hoàn thành, đúng như liệt kê ở trên.
💡 Gợi ý theo bậc
Đọc gợi ý 1 trước, tự thử, chỉ mở gợi ý 2 nếu vẫn bí. Đừng nhảy thẳng xuống Lời giải.
Bước 1:
- Gợi ý 1:
taskIdtới từ URL,authorEmail/bodytới từ JSON body — hai nguồn dữ liệu khác nhau bị nhét vào một object mutable vì tiện. Record bất biến không cho bạn "gán thêm sau" như dòngrequest.setTaskId(taskId). Có cần giữtaskIdtrong chính DTO nhận từ body không, hay tham số@PathVariableở method controller đã đủ? - Gợi ý 2: Ôn lại callout pitfall mục 3 của bài 01 — khai canonical constructor tường minh làm mất copy constraint tự động. Bạn có cần compact constructor ở đây không, hay
@NotBlank/@Email/@Sizetrên component là đủ?
Bước 2:
- Gợi ý 1: Ba downstream có cùng SLA không? Payment nằm trên đường quyết định đơn hàng có thành công hay không; Shipping chỉ đặt lịch, sai vài giây không ai chết. Connect/read timeout của cả ba có nhất thiết phải bằng nhau?
- Gợi ý 2: Đếm lại: bạn vừa khai timeout cho đúng mấy trong ba client? Một lỗi hay gặp là sửa xong client đầu tiên rồi quên hai client còn lại — kiểm bằng cách đọc lại từng file, đừng tin trí nhớ.
Bước 3:
- Gợi ý 1:
HttpServiceProxyFactorycần mộtRestClientđã cấu hình sẵn để bọc quaRestClientAdapter. Bạn tiêm bean nào ở bước 3 — bean đã có timeout từ bước 2, hay mộtRestClient.create()mặc định mới? Nhầm 1 của bài 05 nói đúng chỗ này. - Gợi ý 2: Ba downstream có ba base URL khác nhau. Một
RestClient.Builderbean chung có đủ, hay mỗi client cần một beanRestClientriêng vớibaseUrlkhác nhau rồi mới bọcRestClientAdapterquanh từng cái?
Bước 4:
- Gợi ý 1: Hai câu
throwtrong code gốc ứng với đúng hai nhánh lỗi. Nhánh thành công cần mang dữ liệu gì để controller trả về đúng response — cảOrderhay chỉ một phần? - Gợi ý 2: Đọc lại thứ tự dòng code trong starter:
markPendingđứng ở đâu so với hai điều kiệnif? Nếu giữ nguyên thứ tự đó khi đổithrowthànhreturn, dòng ghi đó có kịp chạy trước khi bạn biết kết quả là thất bại không? Đọc lại mục Pitfall của bài 06 (đoạn nói về "khi write bắt buộc xảy ra trước") trước khi quyết định sửa bằng cách nào.
Bước 5:
- Gợi ý 1: Gửi một request, so latency trước/sau khi bật cờ — phép đo đó có trả lời đúng câu hỏi "virtual threads có ích cho TaskFlow không" không? Bài 02 mục 5 đã cảnh báo đúng sai lầm này bằng tên.
- Gợi ý 2: Ba cạm bẫy bài 03 xảy ra ở phần nào của TaskFlow bạn vừa chạm tới trong bốn bước trên?
AuditLogger(đã sửa ở bài 03) và pool HikariCP đứng sauOrderService— chỗ nào trong số đó cần soát lại sau khi bạn vừa đổi executor?
✅ Lời giải tham khảo
Phần dưới là một cách làm hợp lý cho cả năm bước — không phải cách duy nhất. Chỉ mở phần này sau khi đã tự thử với Gợi ý ở trên.
Bước 1 — record hoá TaskCommentRequest
public record TaskCommentRequest(
@NotBlank @Email String authorEmail,
@NotBlank @Size(max = 2000) String body
) {
public TaskCommentRequest {
authorEmail = authorEmail == null ? null : authorEmail.strip().toLowerCase();
body = body == null ? null : body.strip();
}
}
@PostMapping("/{taskId}/comments")
public TaskCommentResponse addComment(@PathVariable Long taskId, @RequestBody @Valid TaskCommentRequest request) {
return taskCommentService.addComment(taskId, request);
}
taskId không còn nằm trong DTO nhận JSON nữa — nó tới từ URL, một nguồn dữ liệu khác, và @PathVariable là đúng chỗ khai báo cho nó. Record chỉ giữ đúng phần dữ liệu thật sự tới từ body. TaskCommentService.addComment(taskId, request) ghép hai nguồn lại ở tầng service, nơi cả hai đều đã có mặt:
public TaskCommentResponse addComment(Long taskId, TaskCommentRequest request) {
TaskComment comment = new TaskComment(taskId, request.authorEmail(), request.body(), Instant.now());
taskCommentRepository.save(comment);
return TaskCommentResponse.from(comment);
}
Bước 2 — RestClient với timeout riêng từng downstream
@Configuration
public class DownstreamClientConfig {
@Bean
RestClient paymentRestClient(RestClient.Builder builder) {
return buildClient(builder, "http://payment-service.internal",
Duration.ofSeconds(1), Duration.ofSeconds(3));
}
@Bean
RestClient inventoryRestClient(RestClient.Builder builder) {
return buildClient(builder, "http://inventory-service.internal",
Duration.ofSeconds(1), Duration.ofSeconds(2));
}
@Bean
RestClient shippingRestClient(RestClient.Builder builder) {
return buildClient(builder, "http://shipping-service.internal",
Duration.ofSeconds(2), Duration.ofSeconds(5));
}
private RestClient buildClient(RestClient.Builder builder, String baseUrl,
Duration connectTimeout, Duration readTimeout) {
ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.defaults()
.withConnectTimeout(connectTimeout)
.withReadTimeout(readTimeout);
ClientHttpRequestFactory requestFactory = ClientHttpRequestFactoryBuilder.detect().build(settings);
return builder.baseUrl(baseUrl).requestFactory(requestFactory).build();
}
}
Payment nằm trên đường quyết định thành công của đơn hàng nên read timeout ngắn hơn Shipping, thứ chỉ đặt lịch và có thể chấp nhận chờ lâu hơn — không có một cặp số đúng cho mọi downstream, đúng như bài 04 đã nói.
@Component
public class PaymentClient {
private final RestClient paymentRestClient;
PaymentClient(RestClient paymentRestClient) {
this.paymentRestClient = paymentRestClient;
}
public PaymentResult charge(String orderId) {
return paymentRestClient.post()
.uri("/charge/{orderId}", orderId)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, (req, res) -> throwDownstream("payment", orderId, res))
.onStatus(HttpStatusCode::is5xxServerError, (req, res) -> throwDownstream("payment", orderId, res))
.body(PaymentResult.class);
}
private void throwDownstream(String service, String orderId, ClientHttpResponse response) throws IOException {
throw new DownstreamServiceException(service, response.getStatusCode(),
service + " tra loi cho don " + orderId + ": " + response.getStatusCode());
}
}
InventoryClient/ShippingClient theo đúng khuôn trên, chỉ đổi client tiêm vào và path. DownstreamServiceException là một exception nghiệp vụ dùng chung cho cả ba, mang theo tên service gốc để @RestControllerAdvice log rõ downstream nào vừa hỏng — cùng pattern ProfileServiceException bài 04 đã dựng.
Bước 3 — chuyển thành HTTP Interface
public interface PaymentClient {
@PostExchange("/charge/{orderId}")
PaymentResult charge(@PathVariable String orderId);
}
@Bean
PaymentClient paymentClient(RestClient paymentRestClient) {
RestClientAdapter adapter = RestClientAdapter.create(paymentRestClient);
HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build();
return factory.createClient(PaymentClient.class);
}
inventoryClient/shippingClient đăng ký theo đúng khuôn, mỗi bean bọc đúng RestClient riêng của downstream đó — ba adapter khác nhau bọc ba RestClient khác nhau, không dùng chung một adapter cho cả ba base URL. TaskFlowOrchestrator.process không đổi gì: paymentClient.charge(orderId) vẫn đúng lời gọi đó, chỉ khác paymentClient giờ là instance proxy thay vì @Component viết tay. Timeout và onStatus sống trong bean RestClient bị bọc, không mất đi khi đổi tầng gọi.
Bước 4 — sealed Result thay exception
public sealed interface PlaceOrderResult
permits PlaceOrderResult.Success,
PlaceOrderResult.OutOfStock,
PlaceOrderResult.CreditLimitExceeded {
record Success(Order order) implements PlaceOrderResult {}
record OutOfStock(String productId, int requested) implements PlaceOrderResult {}
record CreditLimitExceeded(String customerId, long total, long limit) implements PlaceOrderResult {}
}
@Transactional
public PlaceOrderResult placeOrder(Order order) {
Order locked = orderRepository.lockForUpdate(order.orderId());
orderRepository.markPending(locked);
if (!inventory.hasStock(order.productId(), order.quantity())) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return new PlaceOrderResult.OutOfStock(order.productId(), order.quantity());
}
if (creditLimit.exceeds(order.customerId(), order.total())) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return new PlaceOrderResult.CreditLimitExceeded(
order.customerId(), order.total(), creditLimit.limitOf(order.customerId()));
}
orderRepository.confirm(locked);
return new PlaceOrderResult.Success(locked);
}
Đây đúng là chỗ bẫy nằm. markPending phải chạy trước hai điều kiện (bỏ nó xuống dưới sẽ mở lại race hai request cùng đặt một đơn, đúng lý do dòng comment trong starter đã giải thích) nên reorder không dùng được ở đây, khác với ví dụ assign trong bài 06 vốn chưa ghi gì trước khi kiểm quyền. Cách đúng là gọi setRollbackOnly() ngay trước mỗi return nhánh lỗi: transaction vẫn commit dòng markPending là return bình thường với proxy, nhưng setRollbackOnly() buộc nó rollback dù không có exception nào được ném. Thiếu dòng này, locked kẹt vĩnh viễn ở trạng thái "đang xử lý" cho một đơn hàng chưa từng thật sự đặt được — đúng hệt cơ chế bài 06 đã cảnh báo, chỉ khác do không thể reorder nên phải dùng công cụ còn lại.
@PostMapping("/orders")
ResponseEntity<Object> placeOrder(@RequestBody @Valid Order order) {
return switch (orderService.placeOrder(order)) {
case PlaceOrderResult.Success s ->
ResponseEntity.ok(OrderResponse.from(s.order()));
case PlaceOrderResult.OutOfStock r ->
problem(HttpStatus.CONFLICT, "out-of-stock",
"San pham " + r.productId() + " khong du " + r.requested() + " don vi ton kho");
case PlaceOrderResult.CreditLimitExceeded r ->
problem(HttpStatus.CONFLICT, "credit-limit-exceeded",
"Khach hang " + r.customerId() + " vuot han muc: " + r.total() + "/" + r.limit());
};
}
Bước 5 — bật virtual threads và đo đúng
# application.yml cua module chua TaskFlowOrchestrator
spring:
threads:
virtual:
enabled: true
Đo throughput ở hai mức concurrency, trước và sau khi bật cờ — dùng công cụ load-test bất kỳ (wrk, hey, JMeter), miễn đo được số request thành công mỗi giây chứ không phải latency một request đơn lẻ:
wrk -t8 -c200 -d30s http://localhost:8080/api/orders/checkout-flow
wrk -t8 -c2000 -d30s http://localhost:8080/api/orders/checkout-flow
| Concurrency | Req/giây — trước (pool 200) | Req/giây — sau (virtual threads) | Tỉ lệ lỗi |
|---|---|---|---|
| 200 | điền số đo thật của bạn | điền số đo thật của bạn | điền số đo thật |
| 2000 | điền số đo thật của bạn | điền số đo thật của bạn | điền số đo thật |
Không có con số "đúng" để chép vào bảng này — đây là bảng mẫu, số liệu phải tới từ lần chạy thật trên máy bạn. Kỳ vọng đúng theo bài 02 mục 4: chênh lệch chỉ lộ rõ ở mức 2.000, còn ở mức 200 hai cấu hình nên gần như ngang nhau.
Soát pinning bằng đúng công cụ bài 03 đã dạy — JFR, chạy được trên mọi bản từ JDK 21 — song song với đợt load test ở mức 2.000:
java -XX:StartFlightRecording=duration=120s,filename=taskflow.jfr -jar taskflow.jar
jfr summary taskflow.jfr | grep VirtualThreadPinned
AuditLogger đã đổi sang ReentrantLock ở bài 03 nên không còn xuất hiện ở đây — nếu bạn thấy một stack trace mới trỏ vào code TaskFlow, đó là một synchronized khác chưa ai phát hiện.
Cuối cùng, dựng lại van chặn tải mà pool 200 cũ từng vô tình đảm nhiệm, khớp maximumPoolSize của HikariCP:
private final Semaphore dbGate = new Semaphore(10);
@Transactional
public PlaceOrderResult placeOrder(Order order) {
dbGate.acquireUninterruptibly();
try {
// than method giu nguyen nhu Buoc 4
} finally {
dbGate.release();
}
}
Không có dbGate này, đúng kịch bản bài 03 mục 3 lặp lại: virtual threads bỏ hàng đợi 200 của Tomcat, cả nghìn request cùng chạm HikariCP 10 kết nối cùng lúc, phần lớn timeout sau 30 giây dù DB hoàn toàn khoẻ mạnh.
🎓 Mở rộng
- Circuit breaker cho ba downstream: Resilience4j đứng trước
PaymentClient/InventoryClient/ShippingClientđể cả ba tự ngắt tạm thời khi tỉ lệ lỗi vượt ngưỡng, thay vì để mỗi request tự chờ hết timeout rồi mới biết downstream đang hỏng. ScopedValuethay vìThreadLocaltự viết cho request-id truyền quaTaskFlowOrchestrator— đúng hướng dài hạn bài 03 mục 2 đã nhắc, áp dụng cho phần code bạn tự kiểm soát được (khácSecurityContextHolder/MDCcủa framework).- Instrument throughput đo ở bước 5 bằng Micrometer thay vì chỉ đọc output của
wrk— biến số đo một lần thành dashboard theo dõi liên tục. Đây là chủ đề module tiếp theo của khoá.
✨ Checklist tự nghiệm thu
Đối chiếu với learningOutcomes của cả module — mỗi mục dưới nên trả lời được bằng bằng chứng cụ thể từ code bạn vừa viết, không chỉ "tôi nghĩ là đúng":
- Record làm DTO:
TaskCommentRequestlà record; bạn giải thích được vì saoTaskComment(entity JPA phía dưới) vẫn phải là class mutable thường, không thể áp cùng công thức. - Bật/không bật virtual threads đúng hồ sơ tải: bạn nêu được lý do cụ thể (không phải "vì bài học nói vậy") khiến
TaskFlowOrchestratorđủ điều kiện I/O-bound để bật, dựa theo bảng quyết định bài 02. - HTTP client outbound có timeout + xử lý lỗi: cả ba downstream đều có connect/read timeout riêng và
onStatusdịch lỗi; sau khi chuyển sang@HttpExchange, cấu hình đó vẫn nguyên hiệu lực — bạn kiểm được bằng cách gọi thử một downstream trả lỗi. - Sealed Result cho kết quả nghiệp vụ:
PlaceOrderResultexhaustive, controller switch khôngdefault; bạn đã tự kiểm bằng test rằng nhánh lỗi không để lại dữ liệu "đang xử lý" kẹt trong DB. - Chẩn đoán ba hỏng hóc virtual threads: bản ghi JFR sạch event
jdk.VirtualThreadPinned, cóSemaphorechặn đúng kích thước pool, và có số đo throughput thật ở ít nhất hai mức concurrency — không phải suy đoán.
Bài tiếp theo: Tổng kết module — Modern Java trong Spring
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên