Ngừng Dùng Promise.all Mù Quáng: Xử Lý Lỗi An Toàn

Ngừng Dùng Promise.all Mù Quáng: Xử Lý Lỗi An Toàn

Tìm hiểu lý do Promise.all có thể làm sập toàn bộ luồng xử lý và cách dùng Promise.allSettled để đảm bảo ứng dụng JavaScript luôn ổn định và an toàn.

Bạn đang xây dựng một dashboard cần gọi data từ ba API bên thứ ba khác nhau. Hoặc có thể bạn đang viết một script gửi email newsletter hàng tuần cho hàng ngàn người dùng. Trong cả hai trường hợp, bạn có một danh sách các tác vụ bất đồng bộ (async tasks) có thể chạy song song cùng lúc.

Khi viết JavaScript hiện đại, chúng ta liên tục phải làm việc với các Promise. Cho dù bạn đang tương tác với database, gọi API sang các microservice khác, hay viết các đoạn script hệ thống bằng Node.js, các tác vụ bất đồng bộ (async) chính là cốt lõi trong công việc hàng ngày. Thế nhưng, xử lý chạy song song (concurrency) chưa bao giờ là một bài toán dễ xơi.

Nếu giống như hầu hết các lập trình viên JavaScript hay TypeScript khác, phản xạ đầu tiên của bạn có lẽ là gõ ngay Promise.all. Đây là công cụ kinh điển, quen thuộc nhất để thực thi nhiều promise cùng một lúc. Bạn truyền vào một mảng các promise, nó đợi tất cả chạy xong, và trả về một mảng kết quả. Đơn giản, gọn gàng và tốc độ.

Nhưng có một cái bẫy chết người ẩn bên trong Promise.all thường khiến anh em dev ngã ngửa khi đưa code lên production: hành vi "fail-fast" (thất bại nhanh) cực kỳ khắt khe của nó.

Vấn đề: Cái bẫy "Fail-Fast"

Promise.all được thiết kế theo tư duy "được ăn cả, ngã về không". Nếu bạn chạy 100 tác vụ, và 99 cái thành công mỹ mãn, nhưng chỉ có một cái bị lỗi (reject), toàn bộ lệnh Promise.all sẽ lập tức báo lỗi ngay.

Hãy xem một kịch bản thực tế khi gửi email:

const users = [user1, user2, user3]; // Giả sử email của user2 bị hỏng

try {
  await Promise.all(users.map(user => sendEmail(user.email)));
  console.log('Đã gửi email thành công cho tất cả!');
} catch (error) {
  console.error('Gửi email thất bại:', error);
}

Trong ví dụ này, nếu API gọi để gửi cho user2 bị lỗi, khối catch sẽ được trigger ngay lập tức. Thành công của user1user3 hoàn toàn bị phớt lờ. Bạn không nhận được một mảng kết quả nào cả, thứ duy nhất bạn nhận được là cái lỗi từ promise đầu tiên gãy cánh.

Tệ hơn nữa, nếu email của user3 vẫn đang trong quá trình gửi đi khi user2 báo lỗi, Promise.all lập tức reject, nhưng tác vụ gửi cho user3 vẫn có thể đang chạy ngầm và sẽ thành công sau đó. Hệ thống của bạn rơi vào một trạng thái lấp lửng, không đồng nhất: bạn chẳng biết ai đã nhận được email, ai chưa, làm cho việc chạy lại (retry) trở thành một cơn ác mộng.

Giải pháp: Promise.allSettled

Được giới thiệu từ ES2020, Promise.allSettled là một phương án thay thế an toàn và bền bỉ hơn khi xử lý nhiều tác vụ async độc lập. Khác với Promise.all, nó không bao giờ bỏ cuộc giữa chừng. Nó kiên nhẫn chờ đợi từng promise một trong mảng chạy xong—bất kể kết quả là thành công hay thất bại.

Thay vì chỉ trả về giá trị thành công, nó trả về một mảng các object mô tả chính xác kết cục của từng promise.

const results = await Promise.allSettled(
  users.map(user => sendEmail(user.email))
);

results.forEach(result => {
  if (result.status === 'fulfilled') {
    console.log('Thành công:', result.value);
  } else {
    console.error('Thất bại:', result.reason);
  }
});

Mỗi object trong mảng kết quả sẽ có thuộc tính status'fulfilled' (thành công) hoặc 'rejected' (thất bại). Nếu thành công, bạn lấy được value. Nếu thất bại, bạn sẽ có reason (nguyên nhân lỗi).

Điều này thay đổi hoàn toàn cách chúng ta xử lý lỗi. Bạn có thể ung dung xử lý những tác vụ thành công, ghi log những cái thất bại, và có thể đẩy những item lỗi này vào một hàng đợi (dead-letter queue) để retry sau, mà không làm hỏng toàn bộ mẻ chạy.

Điều này đồng nghĩa với việc ứng dụng của bạn sẽ không bị tê liệt hoàn toàn chỉ vì một lỗi timeout mạng ngớ ngẩn hay do tài khoản của một người dùng bất kỳ đang bị khóa. Thay vì quăng trả về cho client một cái lỗi 500 Internal Server Error xấu xí vô hồn, bạn hoàn toàn có thể trả về một mã 207 Multi-Status báo hiệu thành công một phần. Trải nghiệm người dùng nhờ đó mà được cải thiện đáng kể.

Lọc kết quả một cách thanh lịch

Một pattern cực kỳ phổ biến khi dùng Promise.allSettled là tách riêng phe thành công và phe thất bại. Đây là một cách viết gọn gàng và chuẩn chỉ trong TypeScript:

const tasks = [fetchData(1), fetchData(2), fetchData(3)];
const results = await Promise.allSettled(tasks);

const successfulData = results
  .filter((r): r is PromiseFulfilledResult<Data> => r.status === 'fulfilled')
  .map(r => r.value);

const failedReasons = results
  .filter((r): r is PromiseRejectedResult => r.status === 'rejected')
  .map(r => r.reason);

console.log(`Thành công: ${successfulData.length}, Thất bại: ${failedReasons.length}`);

Cách tiếp cận này cung cấp cho bạn một cái nhìn toàn cảnh và quyền kiểm soát tuyệt đối với luồng dữ liệu của mình.

Vậy Khi Nào NÊN Dùng Promise.all?

Mình không hề nói rằng bạn nên tẩy chay Promise.all khỏi dự án. Nó vẫn có một vùng đất diễn cực kỳ quan trọng: các tác vụ phụ thuộc lẫn nhau và có tính nguyên tử (atomic operations).

Hãy dùng Promise.all khi các tác vụ gắn kết chặt chẽ với nhau, và nếu một cái hỏng, những cái kia cũng trở nên vô nghĩa. Ví dụ: khi bạn load state khởi tạo cho một trang UI, và bạn cần lấy thông tin profile, setting, và quyền (permissions) của user để có thể hiển thị bất cứ thứ gì có ý nghĩa.

// Nếu bất kỳ API nào lỗi, ta không thể render trang, 
// nên ta MUỐN nó báo lỗi ngay lập tức (fail fast).
const [profile, settings, permissions] = await Promise.all([
  fetchProfile(),
  fetchSettings(),
  fetchPermissions()
]);

Trong trường hợp này, hành vi "fail-fast" chính xác là những gì bạn cần. Chẳng có lý do gì phải tốn thời gian chờ load quyền truy cập trong khi việc lấy thông tin profile đã thất bại từ đời nào.

Vượt Xa Hơn: Giới Hạn Số Lượng Tác Vụ Đồng Thời

Cả Promise.allPromise.allSettled đều có chung một điểm yếu: chúng chạy tất cả mọi thứ đồng thời cùng một lúc. Nếu bạn ném vào đó một mảng 10,000 promise, Node.js sẽ ngay lập tức mở 10,000 kết nối để thực thi. Điều này sẽ nhanh chóng vắt kiệt RAM của server, làm nghẽn kết nối database, hoặc khiến các API bên thứ ba khóa cổ (rate-limit) hệ thống của bạn ngay lập tức.

Khi phải xử lý những mảng dữ liệu khổng lồ, chỉ dùng method mặc định của ngôn ngữ là chưa đủ. Bạn cần thiết lập giới hạn chạy đồng thời (concurrency limit). Thay vì tống tất cả vào Promise.allSettled, hãy chia nhỏ ra xử lý từng cụm, hoặc tiện nhất là dùng các thư viện như p-map để giới hạn số lượng chạy cùng lúc.

// Dùng p-map để giới hạn chỉ cho chạy tối đa 5 promise cùng lúc
import pMap from 'p-map';

const results = await pMap(users, async (user) => {
  try {
    const value = await sendEmail(user.email);
    return { status: 'fulfilled', value };
  } catch (reason) {
    return { status: 'rejected', reason };
  }
}, { concurrency: 5 });

Bằng cách bọc logic trong try/catch và tự map về cấu trúc status, bạn vừa giữ được sự an toàn của Promise.allSettled, vừa bảo vệ server khỏi tình trạng quá tải do spike traffic đột ngột.

Lời Kết: Hãy Chọn Sự Bền Bỉ

Khi bạn phải đối phó với những tác vụ độc lập nhau—như gửi thông báo hàng loạt, xử lý batch processing, hay kéo dữ liệu từ nhiều microservices khác nhau—hãy tập thói quen lấy Promise.allSettled làm công cụ mặc định. Nó giúp ngăn chặn việc một lỗi cục bộ làm sập toàn bộ hệ thống, và mang lại khả năng kiểm soát tuyệt vời để xử lý lỗi linh hoạt.

Hãy coi Promise.all là một giao dịch cứng nhắc, nơi mọi thứ phải hoàn hảo 100%. Còn Promise.allSettled giống như một cỗ máy xử lý kiên trì, làm được đến đâu hay đến đó và báo cáo lại chi tiết. Việc chọn đúng "đồ chơi" sẽ giúp hệ thống của bạn vững vàng hơn rất nhiều!

Mắt xích đơn lẻ bị đứt gãy trong một sợi dây xích đại diện cho điểm chết

Phân loại các gói hàng vào các thùng chứa khác nhau

NT

viết bởi

Nguyên Tech

0

Phản hồi

Đang tải bình luận…

Lattice.

Một không gian để viết dài, đọc chậm, và trò chuyện thật — không thuật toán, không quảng cáo.

© 2026 · Lattice · Đà Nẵng (16°03′ N, 108°12′ E) · v0.1 · system + ink + indigo