[Series Thực Chiến] Chinh phục Queue - Phần 4: Đánh thức Worker - Nghệ thuật chạy ngầm và quản lý với Supervisor
Chào anh em, hành trình "Chinh phục Queue" của chúng ta đã đi được hơn nửa chặng đường.
Ở 3 bài trước, chúng ta đã biết cách phân tích bài toán, đóng gói Job thật chuẩn, và "bắn" nó vào Redis hoặc Database. Hiện tại, hàng đợi của chúng ta đang chứa đầy những Job nằm chờ xếp lớp. Nhưng... chúng vẫn đang nằm im đó. Nếu không có ai đến lấy ra xử lý, hàng đợi sẽ phình to và trở thành một "bãi rác" dữ liệu khổng lồ.
Hôm nay, ở Phần 4, chúng ta sẽ đóng vai một kỹ sư DevOps thực thụ để thiết lập một hệ thống Worker (người xử lý) chạy ngầm 24/7, tự động scale và "bất tử" trước mọi lỗi lầm.
1. Thực thi Queue Listener (Kẻ lắng nghe hàng đợi)
Để xử lý các Job đang nằm trong Queue, ứng dụng của chúng ta cần chạy một tiến trình (process) độc lập. Tiến trình này đóng vai trò như một "nhân viên gác cổng", liên tục nhìn vào hàng đợi, hễ thấy có Job mới là lấy ra chạy. Ta gọi đó là Queue Listener hoặc Worker.
Ví dụ với Node.js (sử dụng thư viện BullMQ), một đoạn code Worker cơ bản sẽ trông như thế này:
import { Worker } from 'bullmq';
import { SendWelcomeEmailJob } from './jobs/SendWelcomeEmailJob';
// Khởi tạo một Worker lắng nghe trên hàng đợi 'emails'
const emailWorker = new Worker('emails', async (jobData) => {
console.log(`[Worker] Đã nhận được Job ID: ${jobData.id}`);
// Unserialize: Khôi phục lại Object Job từ dữ liệu JSON trong Queue
const emailJob = new SendWelcomeEmailJob(jobData.data.email, jobData.data.name);
// Thực thi công việc
await emailJob.handle();
}, { connection: redisConnection });
emailWorker.on('completed', job => {
console.log(`[Worker] Job ${job.id} đã xử lý xong!`);
});
emailWorker.on('failed', (job, err) => {
console.error(`[Worker] Job ${job?.id} toang rồi: ${err.message}`);
});
Chạy file này bằng lệnh node worker.js, terminal của bạn sẽ treo ở đó và liên tục in ra log mỗi khi có Job mới được hoàn thành. Chúc mừng, bạn đã có một Worker! Nhưng khoan, nếu bạn tắt terminal (SSH session), Worker sẽ chết ngay lập tức. Đây không phải là cách làm trên môi trường Production.
2. Daemon Queue Listener (Công nhân chạy ngầm)
Trên Production, chúng ta cần Worker chạy dưới dạng Daemon (tiến trình chạy ngầm dưới background của hệ điều hành).
Hơn thế nữa, trong các framework lớn (như Laravel), có sự khác biệt rất lớn giữa việc chạy dạng listen và dạng daemon (work):
- Listen (Chậm nhưng an toàn): Mỗi khi có 1 Job, framework sẽ tự động khởi động (bootstrap) lại toàn bộ hệ thống từ đầu (load config, kết nối DB...). Xong Job là tắt. Cách này tốn CPU và chậm chạp, nhưng không bao giờ sợ tràn RAM (Memory Leak).
- Daemon Worker (Nhanh như chớp nhưng mong manh): Framework chỉ khởi động đúng 1 lần duy nhất, nạp toàn bộ code vào RAM. Sau đó nó cứ thế mà "cắn" hàng ngàn Job liên tục. Tốc độ cực nhanh, tốn cực ít CPU. ĐÂY LÀ TIÊU CHUẨN CỦA PRODUCTION.
Tuy nhiên, rủi ro của Daemon là: Vì code cứ nằm mãi trong RAM, nếu bạn viết code bị Memory Leak, Worker sẽ phình to ra và crash. Hoặc nếu có một lỗi ngoại lệ (Unhandled Exception) mà bạn không try/catch, Worker cũng sập.
Làm sao để đảm bảo Worker luôn sống sót dù có chuyện gì xảy ra?
3. Cấu hình Supervisor (Vị cứu tinh của Daemon)
Để quản lý các tiến trình Daemon dễ crash này, trên các server Linux (Ubuntu/CentOS), giới DevOps sử dụng một công cụ huyền thoại mang tên Supervisor.
Nhiệm vụ của Supervisor rất đơn giản: "Tao không cần biết mày làm gì, nhưng nếu tao thấy mày chết (crash), tao sẽ tự động hồi sinh (restart) mày ngay lập tức!".
Dưới đây là cách bạn cấu hình Supervisor cho Worker của mình. Tạo một file tại /etc/supervisor/conf.d/my_worker.conf:
[program:my_app_email_worker]
# Lệnh để khởi chạy worker (Ví dụ với Node.js hoặc PHP Laravel)
# process_name=%(program_name)s_%(process_num)02d
command=node /var/www/my_app/worker.js
# Hoặc với Laravel: command=php /var/www/my_app/artisan queue:work redis --queue=emails --sleep=3 --tries=3
autostart=true
autorestart=true
user=www-data
# QUAN TRỌNG: Chạy bao nhiêu con Worker song song?
# Server khỏe thì quất 10, 20 con để xử lý cho nhanh!
numprocs=5
# Nơi lưu log để anh em vào debug
stdout_logfile=/var/www/my_app/logs/worker.log
stderr_logfile=/var/www/my_app/logs/worker_error.log
Sau khi lưu cấu hình, bạn chỉ cần gõ:
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start my_app_email_worker:*
Boom! Bạn vừa tạo ra 5 anh công nhân chạy ngầm song song. Dù server có khởi động lại, hay ứng dụng bị lỗi văng ra ngoài, Supervisor vẫn sẽ đảm bảo 5 anh công nhân này luôn túc trực 24/7.
4. Triển khai với Daemon Queue Listeners (Bài toán Deploy)
Có một cái bẫy cực kỳ nguy hiểm mà 90% anh em mới làm Queue đều dính phải khi đẩy code mới lên Production: Lỗi lệch Code.
Hãy tưởng tượng:
- Bạn vừa sửa code trong class
SendWelcomeEmailJob. - Bạn git
pull codemới về server. - API của bạn bắn ra một Job với giao diện/dữ liệu hoàn toàn mới.
- NHƯNG... Worker đang chạy ngầm trong RAM vẫn là cục code CŨ từ hôm qua! 5. Hậu quả: Worker bốc Job mới lên nhưng lấy logic cũ để chạy -> Crash toàn hệ thống Queue.
Cách giải quyết chuẩn Senior: Trong quá trình Deploy (CI/CD), sau khi kéo code mới về, bạn BẮT BUỘC phải ra lệnh cho Worker khởi động lại để nạp code mới vào RAM.
Tuy nhiên, không được dùng lệnh kill hoặc tắt Supervisor ngay lập tức, vì có thể Worker đang gửi mail dở dang. Chúng ta phải dùng cơ chế Graceful Restart (Dừng duyên dáng):
- Báo cho Worker biết: "Ê, làm nốt cái Job trên tay đi rồi tự sát nhé, đừng nhận thêm Job mới nữa".
- Worker làm xong Job hiện tại -> Tự thoát (Exit code 0).
- Ngay lập tức, Supervisor thấy Worker đã chết -> Nó tự động Start lại một Worker mới toanh (lúc này sẽ load code mới nhất).
Các framework thường hỗ trợ sẵn lệnh này. Ví dụ trong Laravel:
php artisan queue:restart
Lệnh này chỉ đơn giản là cập nhật một cái cờ (flag) vào Cache. Worker sau khi làm xong 1 Job sẽ check Cache, thấy cờ "restart" là nó tự động tắt đi để Supervisor gọi dậy. Cực kỳ mượt mà, không rơi rớt một byte data nào của người dùng.
Hé lộ bài tiếp theo...
Hệ thống của chúng ta giờ đã quá xịn xò. Bắn job mượt, chạy ngầm ổn định, deploy không downtime.
Nhưng cuộc đời không như là mơ. Sẽ thế nào nếu API bên thứ 3 (như server gửi mail) bị sập suốt 2 ngày? Dù cấu hình thử lại (retry) 3 lần thì Job vẫn sẽ thất bại. Chẳng lẽ chúng ta cứ để data của user biến mất mãi mãi? Làm sao để lưu trữ lại những Job lỗi này, phân tích nguyên nhân và... ấn nút "chạy lại" khi server kia đã sống lại?
Tất cả tinh hoa về xử lý rủi ro sẽ được bật mí trong bài cuối cùng của series: Xử lý các Failed Job.
All rights reserved