VISEN GROUP
Technology & Engineering

Bẫy hiệu năng Promise.all vs for…of khi xử lý mảng Async trong Node.js

September 15, 20264 min readUncategorized
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 users có 5.000 phần tử, Promise.all sẽ đồ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.

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 awaitPromise.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ảiKhông bao giờRất cao (Cạn connection/RAM)Kiểm soát hoàn toàn
Trường hợp dùngTá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?

Leave a Reply

Your email address will not be published. Required fields are marked *