Bài viết thuộc chuỗi chuyên đề tối ưu Backend & Database Performance của VISEN GROUP.
⏪ Hôm qua: Tại sao bạn nên dùng HTTP Status Code 204 thay vì 200 kèm payload rỗng?
Khi cần gọi API ngoài hoặc truy vấn database cho một danh sách phần tử, hầu hết lập trình viên JavaScript sẽ nghĩ ngay đến hai cách: hoặc dùng vòng lặp for...of kèm await, hoặc bọc tất cả vào Promise.all().
Cả hai cách đều chạy được, nhưng nếu không hiểu rõ cơ chế bên dưới, bạn rất dễ rơi vào một trong hai cái bẫy: làm ứng dụng chậm chạp không cần thiết hoặc vô tình mở hàng nghìn kết nối làm sập database.
1. Vòng lặp for...of: An toàn nhưng chậm chạp (Sequential)
Khi bạn viết await bên trong for...of:
JavaScript
async function sendEmails(users) {
for (const user of users) {
await mailService.send(user.email); // Chờ gửi xong người này mới đến người tiếp theo
}
}
- Cơ chế: Xử lý tuần tự. Nếu gửi một email mất 200ms, danh sách 100 người sẽ ngốn trọn vẹn 20 giây.
- Ưu điểm: Tiêu tốn cực ít RAM, kiểm soát tài nguyên chặt chẽ.
- Nhược điểm: Lãng phí tài nguyên CPU và Network I/O vì phần lớn thời gian là chờ đợi (idle).
- Khi nào nên dùng: Khi các tác vụ bắt buộc phải chạy theo đúng thứ tự logic (tác vụ sau phụ thuộc vào kết quả của tác vụ trước).
2. Promise.all(): Tốc độ tối đa nhưng dễ gây nghẽn hệ thống (Concurrent)
Để rút ngắn thời gian, nhiều người chuyển sang dùng Promise.all():
JavaScript
async function sendEmails(users) {
const promises = users.map(user => mailService.send(user.email));
await Promise.all(promises);
}
- Cơ chế: Kích hoạt tất cả tác vụ gần như cùng một thời điểm. Thời gian hoàn thành chỉ bằng thời gian của request lâu nhất (khoảng 200–300ms).
- Cái bẫy tiềm ẩn: Nếu mảng
userscó 5.000 phần tử,Promise.allsẽ đồng loạt bắn ra 5.000 request.- Nếu là truy vấn DB: Hệ thống cạn sạch connection pool (
Too many connections), server crash ngay lập tức. - Nếu gọi API bên thứ ba: Bạn sẽ dính lỗi
429 Too Many Requests(Rate limit) hoặc bị chặn IP.
- Nếu là truy vấn DB: Hệ thống cạn sạch connection pool (
3. Giải pháp tối ưu: Kiểm soát giới hạn song song (Concurrency Limit)
Trong thực tế production, giải pháp chuẩn mực là dung hòa cả hai: chạy song song nhưng có giới hạn (ví dụ tối đa 5 hoặc 10 tác vụ cùng lúc).
Bạn có thể dùng thư viện siêu nhẹ p-limit:
JavaScript
import pLimit from 'p-limit';
// Chỉ cho phép tối đa 5 tác vụ chạy đồng thời
const limit = pLimit(5);
async function sendEmails(users) {
const tasks = users.map(user => {
return limit(() => mailService.send(user.email));
});
await Promise.all(tasks);
}
Nếu không muốn cài thêm thư viện, bạn có thể chia mảng thành từng cụm nhỏ (batching) bằng hàm thuần:
JavaScript
async function processInBatches(items, batchSize, fn) {
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
await Promise.all(batch.map(fn));
}
}
// Chạy mỗi lần 10 users
await processInBatches(users, 10, user => mailService.send(user.email));
Bảng so sánh nhanh
| Tiêu chí | for...of với await | Promise.all() | Concurrency Limit (Batching) |
| Cơ chế | Tuần tự (Sequential) | Đồng thời toàn bộ (Full Parallel) | Giới hạn đồng thời (Chunk/Pool) |
| Tốc độ | Chậm nhất (O(N x T)) | Nhanh nhất (O(T)) | Cân bằng, tối ưu |
| Rủi ro quá tải | Không bao giờ | Rất cao (Cạn connection/RAM) | Kiểm soát hoàn toàn |
| Trường hợp dùng | Tác vụ phụ thuộc thứ tự | Mảng nhỏ (< 20 items độc lập) | Mảng lớn, gọi DB/API ngoài |
Tối ưu hóa không phải là ép mọi thứ chạy song song nhanh nhất có thể, mà là biết khi nào nên phanh lại để hệ thống vận hành bền bỉ. Trước khi bọc một mảng vào Promise.all(), hãy luôn tự hỏi: Nếu danh sách này phình to lên 10.000 phần tử trong production, database của bạn có còn sống sót?


