Khi “Thử lại” là chưa đủ
Hầu hết các ứng dụng Node.js không hoạt động đơn độc; chúng dựa vào một mạng lưới các dịch vụ bên ngoài để vận hành. Chúng ta gọi Stripe để thanh toán, SendGrid để gửi email và Twilio để nhắn tin SMS. Nhưng mạng lưới này luôn tiềm ẩn rủi ro. Một sự cố DNS nhỏ trong 500ms, một container đang khởi động lại, hoặc một giới hạn lưu lượng (rate limit) tạm thời đều có thể làm hỏng một yêu cầu gửi đi. Nếu mã nguồn của bạn chỉ đơn giản là ném ra một lỗi và dừng lại, người dùng sẽ là người phải chịu thiệt thòi.
Hồi mới vào nghề, tôi thường bao bọc mọi lời gọi API trong một khối try-catch cơ bản. Nếu yêu cầu thất bại, tôi gửi trả lỗi 500 về cho client. Nó hoạt động được, nhưng rất mong manh. Cuối cùng tôi nhận ra rằng nhiều lỗi chỉ là tạm thời—chúng sẽ biến mất nếu bạn chỉ cần đợi một giây và thử lại. Tuy nhiên, cách bạn thử lại (retry) thường quan trọng hơn chính việc thử lại đó.
Tôi đã triển khai các mô hình này trong các môi trường production lưu lượng cao. Kết quả là gì? Tỷ lệ thất bại của các tác vụ chạy ngầm giảm đáng kể và việc tích hợp với các dịch vụ bên thứ ba cũ kỹ (vốn hay bị nghẽn vào giờ cao điểm) trở nên mượt mà hơn nhiều.
Chọn chiến lược Retry phù hợp
Không phải mọi logic retry đều giống nhau. Trước khi bắt đầu viết mã, bạn cần chọn một chiến lược phù hợp với áp lực mà hệ thống của bạn có thể chịu đựng.
1. Thử lại ngay lập tức (Immediate Retry)
Đây là cách tiếp cận cơ bản nhất. Nếu một yêu cầu thất bại, bạn kích hoạt lại nó ngay lập tức. Dù đơn giản nhưng nó thường gây nguy hiểm. Nếu một máy chủ bên ngoài đang quá tải, việc dồn dập gửi yêu cầu ngay lập tức chỉ giống như thêm dầu vào lửa. Nó giống như việc bạn đập cửa một căn phòng đang khóa; nếu không ai trả lời, việc đập nhanh hơn cũng chẳng ích gì nếu người bên trong đang bận.
2. Thử lại với độ trễ cố định (Fixed Delay Retry)
Phương pháp này đưa vào một khoảng thời gian chờ cụ thể, ví dụ 2 giây, trước lần thử tiếp theo. Điều này giúp máy chủ từ xa có một chút thời gian để hồi phục. Tuy nhiên, nếu dịch vụ đó đang trong quá trình deploy kéo dài 30 giây hoặc đang giải quyết một nút thắt cổ chai lớn, thì khoảng chờ 2 giây cố định thường không đủ để vượt qua trở ngại.
3. Lùi bước lũy thừa (Exponential Backoff)
Đây là tiêu chuẩn của ngành công nghiệp phần mềm. Thay vì một khoảng thời gian cố định, bạn gấp đôi thời gian chờ sau mỗi lần thử. Bạn có thể đợi 1 giây, sau đó là 2 giây, 4 giây và cuối cùng là 8 giây. Cách tiếp cận này giúp giảm áp lực lên hệ thống bên ngoài, đồng thời tăng cơ hội thành công cho ứng dụng của bạn khi thời gian trôi qua.
4. Exponential Backoff kết hợp Jitter
Hãy tưởng tượng 1.000 instance của một microservice cùng thất bại một lúc do cơ sở dữ liệu bị quá tải đột ngột. Nếu tất cả đều sử dụng cùng một công thức backoff, chúng sẽ cùng thử lại vào chính xác cùng một mili giây. Hiện tượng “bầy đàn” (thundering herd) này có thể làm sập một máy chủ đang trên đà hồi phục. Việc thêm ‘Jitter’ (nhiễu ngẫu nhiên) sẽ phân tán các lần thử lại này, đảm bảo hệ thống của bạn không vô tình thực hiện một cuộc tấn công DDoS vào chính đối tác của mình.
Sự đánh đổi khi triển khai Retry
Triển khai các mô hình này không phải là miễn phí. Bạn phải cân bằng giữa độ tin cậy và tài nguyên mà ứng dụng tiêu thụ.
- Ưu điểm:
- Tự chữa lành (Self-Healing): Hầu hết các lỗi 503 hoặc 429 sẽ tự giải quyết mà lập trình viên không cần phải thức giấc giữa đêm.
- Sự hài lòng của khách hàng: Người dùng không phải thấy màn hình “Đã có lỗi xảy ra” chỉ vì những sự cố mạng nhỏ.
- Giảm khối lượng hỗ trợ: Bạn sẽ thấy ít ticket hơn về các giao dịch thất bại hoặc webhook bị mất.
- Nhược điểm:
- Tích tụ độ trễ (Latency): Nếu một dịch vụ thực sự đã chết, bốn lần thử lại có thể khiến người dùng phải đợi 15 giây trước khi họ thực sự thấy thông báo lỗi.
- Áp lực bộ nhớ: Mỗi lần retry đang chờ xử lý sẽ chiếm giữ bộ nhớ và các kết nối socket trong tiến trình Node.js của bạn.
- Độ phức tạp của logic: Bạn phải phân biệt rõ giữa lỗi 503 (có thể thử lại) và lỗi 400 Bad Request (không bao giờ nên thử lại).
Xây dựng một tiện ích chuẩn Production
Khi xây dựng các hệ thống này, tôi ưu tiên cách tiếp cận hàm (functional) có thể tái sử dụng. Một bản triển khai vững chắc cần ba phần cụ thể:
- Tiện ích trì hoãn (Delay Utility): Một wrapper dựa trên Promise đơn giản cho
setTimeout. - Bộ tính toán Backoff (Backoff Calculator): Logic xử lý toán học cho việc tăng lũy thừa và tính ngẫu nhiên.
- Bộ lọc lỗi (Error Filter): Một “người gác cổng” quyết định mã trạng thái HTTP nào xứng đáng được thử lại.
Bước 1: Phép toán đằng sau thời gian chờ
Đầu tiên, chúng ta cần một cách để tính toán thời gian chờ. Chúng ta sẽ sử dụng một độ trễ cơ bản và thêm 20% jitter để giữ cho mọi thứ không thể dự đoán trước.
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
const getWaitTime = (attempt, baseDelay = 1000) => {
// Math.pow(2, 0) = 1 giây, Math.pow(2, 1) = 2 giây, v.v.
const exponent = Math.pow(2, attempt);
const delay = exponent * baseDelay;
// Thêm +/- 20% jitter để tránh tình trạng "bầy đàn" (thundering herd)
const jitter = delay * 0.2 * Math.random();
return delay + jitter;
};
Bước 2: Wrapper Logic
Hàm cốt lõi quản lý vòng lặp. Nó thực hiện thao tác và nếu thất bại, nó sẽ kiểm tra xem việc thử lại có thực sự phù hợp hay không.
async function withRetry(fn, maxRetries = 3) {
let lastError;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (!isRetryable(error) || attempt === maxRetries - 1) {
throw error;
}
const delay = getWaitTime(attempt);
console.warn(`Lần thử ${attempt + 1} thất bại. Đang thử lại sau ${Math.round(delay)}ms...`);
await sleep(delay);
}
}
throw lastError;
}
function isRetryable(error) {
if (error.response) {
const { status } = error.response;
// Thử lại nếu gặp lỗi 429 (Rate Limit) hoặc 5xx (Lỗi Server)
return status === 429 || (status >= 500 && status <= 599);
}
// Các lỗi timeout mạng hoặc lỗi DNS thường là những lỗi nên thử lại
return true;
}
Bước 3: Sử dụng trong thực tế
Đây là cách áp dụng khi lấy dữ liệu từ một CRM. Lưu ý khoảng timeout 5 giây; điều này ngăn một yêu cầu bị treo đơn lẻ làm chặn logic retry của bạn vô thời hạn.
const axios = require('axios');
async function fetchUserData(userId) {
return withRetry(async () => {
const response = await axios.get(`https://api.crm-provider.com/v1/users/${userId}`, {
timeout: 5000
});
return response.data;
});
}
fetchUserData('user_8842')
.then(data => console.log('Lấy dữ liệu thành công:', data))
.catch(err => console.error('Tất cả các lần thử đều thất bại:', err.message));
Lời kết và Công cụ
Mặc dù tự viết tiện ích riêng giúp bạn kiểm soát tốt hơn, các dự án quy mô lớn thường hưởng lợi từ các thư viện đã được kiểm chứng qua thực tế. Nếu bạn đang sử dụng Axios, axios-retry là một lựa chọn tuyệt vời có thể dùng ngay. Đối với các logic tổng quát, p-retry là tiêu chuẩn vàng trong hệ sinh thái Node.js.
Một lời khuyên quan trọng: hãy luôn log lại các lần thử lại. Nếu bạn nhận thấy log của mình tràn ngập thông báo “Lần thử 3 thất bại”, đó là một dấu hiệu cảnh báo. Nó thường có nghĩa là dịch vụ bên ngoài của bạn đang trở nên không ổn định hoặc thời gian timeout nội bộ của bạn quá ngắn. Giám sát các mô hình này cũng quan trọng như chính việc viết mã. Bằng cách xây dựng những lưới an toàn này, tôi đã giữ cho các dịch vụ đạt mức uptime 99.9% ngay cả khi các API mà chúng tôi phụ thuộc vào đang có một ngày tồi tệ.

