Nút thắt cổ chai đồng bộ: Cơn ác mộng lỗi 504 Gateway
Vào năm 2021, tôi là thành viên của một đội ngũ quản lý trang thương mại điện tử có lưu lượng truy cập cao. Chúng tôi đã ra mắt một tính năng tưởng chừng như đơn giản: cho phép người dùng xuất hóa đơn PDF cho toàn bộ lịch sử mua hàng trong ba năm. Khi thử nghiệm, nó hoạt động hoàn hảo với năm hoặc mười người dùng. Chúng tôi cảm thấy đã sẵn sàng cho cuộc chơi lớn.
Rồi ngày Black Friday đến. Chúng tôi bất ngờ đạt hơn 4.500 người dùng đồng thời. Các web worker vốn thường xử lý các yêu cầu trong 100ms, đột nhiên bị “chiếm dụng” bởi các tác vụ tạo PDF kéo dài 30 giây. Mức sử dụng CPU trên các nút API tăng vọt lên 98% chỉ trong vài phút. Trang web chậm chạp kinh khủng, và bộ cân bằng tải (load balancer) bắt đầu ném ra lỗi 504 Gateway Timeout liên tục. Chúng tôi thất bại không phải vì thiếu lưu lượng truy cập; chúng tôi thất bại vì web server đang cố gắng làm quá nhiều việc cùng một lúc.
Vấn đề “Chờ đợi”: Tại sao Web Server bị đình trệ
Vấn đề là thế này: hầu hết các web framework như Django hay FastAPI được xây dựng để tối ưu tốc độ, không phải để xử lý các tác vụ nặng. Chúng hoạt động dựa trên một **Vòng đời Yêu cầu – Phản hồi (Request-Response Lifecycle)** nghiêm ngặt. Khi bạn đẩy một tác vụ chạy lâu—như xử lý hình ảnh hoặc gửi email hàng loạt—trực tiếp vào vòng đời đó, bạn sẽ chặn hoàn toàn tiến trình worker.
Trong Python, các tác vụ tiêu tốn CPU (CPU-bound) đặc biệt “tham lam”. Nếu worker của bạn đang bận tính toán số liệu cho một file PDF, nó sẽ ngừng lắng nghe các kết nối mới đang đến. Điều này tạo ra một hàng đợi chờ đợi khổng lồ. Độ trễ tăng cao, và cuối cùng, toàn bộ hệ thống sụp đổ dưới sức nặng của chính nó. Chúng tôi cần một cách để báo cho người dùng rằng: “Chúng tôi đã nhận được yêu cầu; hãy kiểm tra email sau một phút nữa,” và giải phóng ngay lập tức web worker cho khách hàng tiếp theo.
Chọn công cụ: Threads, Redis, hay RabbitMQ?
Chúng tôi đã đánh giá ba hướng chính để tách các tác vụ này khỏi luồng chính (main thread):
- Multi-threading (Đa luồng): Đây là một giải pháp tình thế nhanh gọn nhưng là một thảm họa quản lý khi mở rộng. Nếu server khởi động lại, bạn sẽ mất mọi tác vụ đang chờ. Không có cách nào dễ dàng để theo dõi tiến độ hoặc xử lý thử lại (retry) khi mọi thứ không may bị lỗi.
- Redis (với RQ): Redis cực kỳ nhanh và hoàn hảo cho hàng triệu công việc đơn giản. Nếu bạn chỉ thỉnh thoảng gửi một email chào mừng, Redis và thư viện
rqlà lựa chọn tuyệt vời. Tuy nhiên, vì là bộ lưu trữ trong bộ nhớ (in-memory), nó thiếu các đảm bảo phân phối phức tạp cần thiết cho các dữ liệu tài chính quan trọng. - RabbitMQ: Đây là một message broker chuyên dụng sử dụng giao thức AMQP. Nó được xây dựng cho sự tin cậy. Nó lưu tin nhắn vào đĩa cứng, xử lý xác nhận từ worker (acknowledgment) một cách tự nhiên và hỗ trợ các mô hình định tuyến (routing) phức tạp mà Redis không thể sánh được nếu không có các plugin bổ sung.
Giải pháp: Triển khai RabbitMQ với Python
Sau khi kiểm tra về độ bền bỉ và khả năng mở rộng theo chiều ngang, RabbitMQ là người chiến thắng rõ ràng. Tôi đã triển khai thiết lập này trong ba môi trường production kể từ đó, và nó vẫn hoạt động cực kỳ ổn định. Nó tách biệt hoàn toàn việc xử lý nặng khỏi API giao tiếp với người dùng.
1. Khởi chạy RabbitMQ với Docker
Đừng lãng phí thời gian cài đặt các phụ thuộc Erlang một cách thủ công. Docker sẽ giúp bạn vận hành chỉ trong vài giây.
docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3-management
Lệnh này sẽ khởi chạy RabbitMQ cùng với plugin quản lý. Bạn có thể theo dõi hàng đợi của mình trong thời gian thực tại localhost:15672 (mặc định: guest/guest).
2. Producer: Gửi tác vụ
Sử dụng thư viện pika, chúng ta có thể gửi các tác vụ từ trình xử lý web mà không cần chờ chúng hoàn thành. Đây là logic chúng tôi sử dụng:
import pika
import json
def send_pdf_request(user_id, report_data):
connection = pika.BlockingConnection(pika.ConnectionParameters('localhost'))
channel = connection.channel()
# 'durable=True' đảm bảo tác vụ vẫn tồn tại nếu RabbitMQ khởi động lại
channel.queue_declare(queue='pdf_tasks', durable=True)
message = {'user_id': user_id, 'data': report_data}
channel.basic_publish(
exchange='',
routing_key='pdf_tasks',
body=json.dumps(message),
properties=pika.BasicProperties(
delivery_mode=2, # Tin nhắn bền vững (Persistent message)
)
)
print(f" [x] Đã gửi yêu cầu cho người dùng {user_id}")
connection.close()
3. Consumer: Xử lý trong nền
Consumer chạy như một dịch vụ riêng biệt. Nó lắng nghe các tin nhắn và thực hiện các tác vụ nặng trong khi web server vẫn duy trì tốc độ nhanh chóng.
import pika
import time
import json
def callback(ch, method, properties, body):
data = json.loads(body)
print(f" [x] Đang tạo PDF cho ID {data['user_id']}...")
# Giả lập tác vụ xử lý trong 10 giây
time.sleep(10)
print(" [x] Hoàn thành tác vụ!")
# ACK thủ công đảm bảo tác vụ không bị mất nếu worker bị sập
ch.basic_ack(delivery_tag=method.delivery_tag)
connection = pika.BlockingConnection(pika.ConnectionParameters('localhost'))
channel = connection.channel()
channel.queue_declare(queue='pdf_tasks', durable=True)
# Chỉ giao một tác vụ tại một thời điểm cho mỗi worker
channel.basic_qos(prefetch_count=1)
channel.basic_consume(queue='pdf_tasks', on_message_callback=callback)
print(' [*] Đang chờ tác vụ. Nhấn CTRL+C để thoát.')
channel.start_consuming()
Những bài học xương máu từ thực tế Production
Chuyển các tác vụ sang hàng đợi đòi hỏi một sự thay đổi trong tư duy. Dưới đây là ba điều tôi đã học được một cách khó khăn sau khi quản lý hơn 100.000 tác vụ mỗi ngày:
1. Tính bền bỉ (Durability) là lưới an toàn của bạn
Nếu RabbitMQ khởi động lại và bạn chưa thiết lập durable=True và delivery_mode=2, hàng đợi của bạn sẽ biến mất. Mọi yêu cầu xuất hóa đơn đang chờ xử lý? Mất hết. Trong môi trường production, bỏ qua các thiết lập này là công thức dẫn đến mất dữ liệu. Luôn xác minh các cài đặt lưu trữ của bạn.
2. Đừng bao giờ tin vào Auto-ACK
Theo mặc định, RabbitMQ có thể giả định một tác vụ đã hoàn thành ngay khi nó được gửi đến worker. Nếu worker đó bị sập giữa chừng, tin nhắn sẽ bị mất vĩnh viễn. Hãy sử dụng xác nhận thủ công (ch.basic_ack) ở cuối hàm xử lý. Điều này buộc RabbitMQ phải đưa tác vụ trở lại hàng đợi nếu worker bị dừng đột ngột.
3. Theo dõi độ dài hàng đợi
Khi các tác vụ được chuyển vào nền, bạn sẽ mất đi phản hồi tức thì từ các lỗi HTTP. Bạn cần giám sát. Sử dụng Giao diện quản lý RabbitMQ để theo dõi số lượng tin nhắn “Ready”. Nếu con số đó vượt quá 1.000 và không giảm xuống, đã đến lúc triển khai thêm năm thực thể consumer để xử lý tải.
Lời kết
Chuyển sang RabbitMQ đã biến hệ thống monolithic mỏng manh của chúng tôi thành một hệ thống bền bỉ. Chúng tôi không còn sợ những đợt tăng vọt lưu lượng nữa; chúng tôi chỉ cần mở rộng quy mô consumer để đáp ứng áp lực. Nếu người dùng của bạn đang phải nhìn vào vòng quay tải trang quá một giây, đã đến lúc ngừng để họ chờ đợi và bắt đầu sử dụng hàng đợi.

