N+1 HTTP Requests: Sát Thủ Vô Hình Bóp Nghẹt Kiến Trúc Microservices
Hồi mới tập tành đập bỏ hệ thống Monolithic (nguyên khối) để chuyển sang Microservices, mình mang một sự hưng phấn tột độ. Nhìn các services nhỏ xinh (User Service, Order Service, Product Service) chạy độc lập, cái "vibe" lúc đó nó chuyên nghiệp và "tương lai" kinh khủng.
Cho đến khi team làm cái trang Admin Dashboard...
Yêu cầu rất đơn giản: "Hiển thị danh sách 50 đơn hàng mới nhất, kèm theo Tên người mua và Tên sản phẩm". Thay vì load mất 50ms như hồi Monolithic, hệ thống mới tốn tận... 5 giây để rặn ra cái danh sách. CPU server bình thường, Database load rất thấp. Vậy thời gian biến đi đâu?
Chào mừng bạn đến với địa ngục của N+1 HTTP Requests.
1. N+1 HTTP: Quái vật tiến hóa từ N+1 Database
Anh em Backend chắc không lạ gì lỗi N+1 Query. Lấy 50 đơn hàng (1 query), sau đó lặp qua từng đơn hàng để lấy thông tin User (50 queries). Tổng cộng 51 queries.
Nhưng trong Microservices, mọi thứ tệ hơn gấp trăm lần.
Order Service lấy 50 đơn hàng. Sau đó, với mỗi đơn hàng, nó gọi một cái HTTP GET sang User Service, rồi gọi thêm một cái HTTP GET sang Product Service.
Tại sao nó chí mạng?
- Latency (Độ trễ): Thời gian chênh lệch giữa việc code gọi DB nội bộ (thường < 1ms) so với gọi HTTP qua mạng (Network overhead, DNS lookup, TLS handshake, TCP connection...) là cực kỳ lớn. Một request HTTP nội bộ bèo nhất cũng tốn 10-20ms. 50 requests tuần tự x 20ms = 1000ms (1 giây) chỉ để chờ mạng phản hồi.
- Cạn kiệt Connection: Mỗi request mở ra một connection. Gọi 100 requests cùng lúc, bạn sẽ ăn cạn Connection Pool của server đích, gây ra lỗi
ECONNREFUSEDhoặc502 Bad Gateway. Bạn tự DDOS chính hệ thống của mình!
2. Cảnh sát bắt quả tang: Đoạn code đi vào lòng đất
Nhiều anh em (đặc biệt là Frontend gánh logic hoặc Backend làm API Gateway) rất hay viết code kiểu này để ráp data:
// Đừng bao giờ làm thế này!
const orders = await fetchOrders(); // 1 request lấy 50 đơn (Order Service)
// Bắn 50 request lên User Service
for (let order of orders) {
const user = await axios.get(`http://user-service/api/users/${order.userId}`);
order.userName = user.data.name;
}
Một số anh em "tinh ranh" hơn thì xài Promise.all để chạy song song:
// Đỡ hơn xíu về thời gian, nhưng lại tàn phá Server địch!
await Promise.all(orders.map(async (order) => {
const user = await axios.get(`http://user-service/api/users/${order.userId}`);
order.userName = user.data.name;
}));
Promise.all bắn đùng đùng 50 cái request cùng một tíc tắc. User Service của bạn sẽ phải chịu một cú "đấm" traffic bất ngờ, CPU giật ngược lên và có nguy cơ sập nếu lượng user truy cập lúc đó đang đông.
3. Vibe Coder "Giải nghiệp" thế nào?
Để giữ cho luồng data chảy mượt mà và không bị nghẽn ở nút thắt cổ chai HTTP, chúng ta có 3 chiến thuật chính:
Cách 1: Thiết kế Bulk/Batch API (Bắt buộc phải có) Thay vì bắt các Service khác gọi lẻ tẻ từng ID, hãy thiết kế các endpoint có khả năng nhận một mảng IDs và trả về danh sách.
- API mới: GET
/api/users?ids=1,5,9,12,50hoặcPOST /api/users/bulk - Cách gọi: Gom toàn bộ userId từ 50 đơn hàng thành 1 mảng. Bắn ĐÚNG 1 REQUEST sang User Service.
- Kết quả: Từ 51 requests giảm xuống còn... 2 requests (1 lấy orders, 1 lấy users).
Cách 2: Áp dụng BFF Pattern (Backend For Frontend)
Đừng bắt Frontend (Client) tự đi gọi 3-4 API rồi tự ráp lại (Frontend bị N+1 HTTP là thảm họa vì mạng của user rất yếu).
Hãy tạo một lớp trung gian (BFF hoặc API Gateway). Client chỉ gọi đúng 1 API /api/dashboard-orders lên BFF. BFF (nằm chung mạng LAN nội bộ tốc độ cao với các Service khác) sẽ chịu trách nhiệm gọi Bulk API, ráp data lại và trả về cục JSON cuối cùng cho Client.
Cách 3: Dùng Dataloader (Vũ khí tối thượng của Node.js / GraphQL)
Nếu bạn code Node.js hoặc dùng GraphQL, thư viện dataloader của Facebook là vị cứu tinh. Nó sử dụng cơ chế gom lô (batching) tự động trong cùng một Tick của Event Loop. Bạn cứ viết code lấy từng user, DataLoader sẽ âm thầm gom 50 cái request đó lại, nhét vào 1 cái Bulk API và trả kết quả về đúng chỗ. Code vừa đẹp, hệ thống vừa nhanh.
4. Lời kết
Microservices hay Tách biệt Frontend/Backend là những kiến trúc xịn, nhưng chúng đòi hỏi tư duy thiết kế hệ thống cao hơn rất nhiều. Đừng bê nguyên xi tư duy "Join bảng" hay "Vòng lặp query" của Monolithic sang môi trường mạng lưới rời rạc. Một Vibe Coder sẽ luôn nhận thức được chi phí của mỗi cú click mạng (Network Cost) để không tự đưa mình vào ngõ cụt.
Chủ đề tiếp theo: Đồng bộ Data trong Microservices - Khi HTTP REST trở nên bất lực
Cách dùng Bulk API ở trên rất tốt để đọc dữ liệu. Nhưng hãy tưởng tượng tình huống ghi dữ liệu: Một user đặt hàng thành công. Order Service gọi HTTP sang Payment Service trừ tiền (thành công), gọi tiếp sang Inventory Service trừ kho (thành công), gọi tiếp sang Notification Service gửi email... đùng cái Notification Service bị sập ngang (Timeout 504).
Hệ thống của bạn bị kẹt ở trạng thái lấp lửng: Khách đã bị trừ tiền, kho đã giảm, nhưng khách không nhận được email xác nhận. Đơn hàng thành công hay thất bại? Bạn có rollback (hoàn tiền, trả lại kho) bằng HTTP được không?
Ở bài viết tới, chúng ta sẽ bước sang một kỷ nguyên mới của System Design: Event-Driven Architecture (Kiến trúc hướng sự kiện) với Kafka/RabbitMQ. Chúng ta sẽ ngừng việc bắt các Service "nói chuyện trực tiếp" bằng HTTP, mà chuyển sang cách làm ngầu hơn nhiều: "Gào thét vào không trung và Lắng nghe". Anh em nhớ theo dõi nhé!
All Rights Reserved