Hệ thống của bạn sẽ "toang" như thế nào nếu không có Queue
I. Đặt vấn đề
Giả sử bạn đang xây dựng một hệ thống nhận booking cho khách sạn.
Không cần hoành tráng - 3 sao thôi, 50 phòng, Đà Nẵng. Khách đặt phòng qua web, qua Booking.com, qua Agoda, đôi khi qua cả Zalo, Facebook vì khách Việt Nam thích nhắn tin hỏi giá trước rồi mới đặt.
Nghe đơn giản. Và bạn - với tư cách là một developer tự tin - ngồi vibe code một buổi chiều là xong:
Khách đặt phòng → API nhận request → Ghi vào DB → Gửi email confirm → Done.
Clean, Elegent. Chạy ngon trên localhost.
Rồi mùa hè đến.
Đà Nẵng tháng 6. Cái nắng 35-40 độ dẫn cả triệu người từ Hà Nội và Sài Gòn ra biển cùng một lúc.
Booking.com chạy campaign, Agoda giảm 30%. Web của bạn được share lên một group du lịch 500k thành viên vì có deal ngon.
Trong vòng 5 phút, 300 request booking đổ vào cùng một lúc
Hệ thống của bạn lúc này trông như thế này:
Request 1 → API → gọi DB → gọi email service → ...
Request 2 → API → gọi DB → gọi email service → ...
Request 3 → API → gọi DB → timeout ở email service → ???
Request 4 → API → gọi DB → DB connection pool hết → 500 Error
...
Request 300 → Connection refused
Email service của bên thứ 3 bị chậm - không phải là lỗi của bạn - nhưng vì api của bạn gọi thẳng đến dịch vụ mail đang chờ response từ Service này, toàn bộ request bị block theo. DB connection pool cạn dần. Server bắt đầu trả về lỗi. Khách refresh liên tục. Lỗi nhân lên.
Cuối cùng, 11 giờ đêm, bạn mở laptop lên với một tách coffee, và zalo thì đang có hàng chục tin nhắn chưa đọc từ cả khách hàng và owner.
Vấn đề không phải là server yếu
Đây là điểm mà nhiều developer hay nhầm - họ nghĩ giải pháp là nâng cấu hình server, scale nhiều server hơn, thêm RAM, tăng connection pool...
Nhưng vấn đề thật sự là kiến trúc: bạn đang bắt mọi thứ xảy ra đồng thời và đồng bộ trong khi thực tế không cần như vậy.
Khách đặt phòng xong - họ có cần nhận email ngay trong 200ms đó không ?
Không, 30 giây sau cũng được. Thậm chí 2 phút sau cũng chẳng ai phàn nàn.
Vậy tại sao bạn lại bắt API phải đứng chờ email service gửi xong rồi mới trả về response ?
Hãy nghĩ theo kiểu này
Bạn là lễ tân khách sạn. Khách check-in xong, bạn có hai cách xử lý:
Cách 1 - Gọi thẳng: Khách đứng ở quầy chờ bạn: photo copy CCCD, nhập hệ thống, gửi email confirm, gọi điện cho buồng phòng dọn lại, báo nhà hàng chuẩn bị breakfast, cập nhật báo cáo cuối ngày... Khách đứng chờ 15 phút. Trong lúc đó 5 khách khác xếp hàng phía sau nhìn vào với ánh mắt "ủa sao lâu vậy".
Cách 2 - Có queue:Khách check-in xong, bạn đưa chìa khóa phòng ngay — 30 giây. Rồi bạn để lại một mảnh giấy note: "Gửi email confirm, báo buồng phòng, cập nhật báo cáo." Nhân viên khác xử lý note đó theo thứ tự, không vội. Khách tiếp theo bước vào.
SQS chính là cái hộp đựng mảnh giấy note đó. Không hơn, không kém.
Kiến trúc sau khi có queue trông như thế này
flowchart TD
A([Khách đặt phòng]):::client
B[API nhận request]:::api
C[(Ghi vào DB)]:::db
D[/Đẩy vào SQS/]:::queue
E[[SQS Queue]]:::queue
F[Worker nhận message]:::worker
G[Gửi email confirm]:::task
H[Cập nhật Booking.com]:::task
I[Ghi log]:::task
A -->|HTTP request| B
B --> C
B --> D
B -.->|"response < 100ms"| A
D --> E
E -->|async, non-blocking| F
F --> G
F --> H
F --> I
classDef client fill:#1e1b3a,stroke:#534AB7,color:#b8b0f0
classDef api fill:#111e35,stroke:#185FA5,color:#8bbde8
classDef db fill:#0c2820,stroke:#0F6E56,color:#5dcaa5
classDef queue fill:#271c08,stroke:#854F0B,color:#EF9F27
classDef worker fill:#281410,stroke:#993C1D,color:#e0845a
classDef task fill:#1c1e28,stroke:#3a3e52,color:#a0a6c4
API không cần biết email service có đang bận không. Không cần biết worker đang xử lý bao nhiêu job. Nó chỉ cần làm một việc: nhận request, ghi DB, đẩy message vào queue, trả về "Đặt phòng thành công" cho khách.
Worker xử lý phần còn lại — theo tốc độ của nó, không phụ thuộc vào traffic đang vào.
Và điều thú vị là
Nếu email service bị chết lúc 2 giờ sáng, message vẫn nằm yên trong queue. Không mất. Khi service sống lại, worker nhặt lên xử lý tiếp. Khách nhận email lúc 2h15 thay vì 2h00 — họ đang ngủ, không ai để ý.
Không có queue? Email service chết đồng nghĩa với booking đó mất confirm. Bạn phải xử lý bằng tay. Lúc 2 giờ sáng.
Phần tiếp theo mình sẽ đi vào thực tế: tạo SQS queue trên AWS Console, đẩy message vào, viết consumer bằng Go chạy trên máy local - từ đầu đến cuối trong khoảng 20 phút.
Không cần khách sạn thật. Không cần 300 booking. Chỉ cần một cái terminal và AWS account
II. Thực hành - Thôi lý thuyết đủ rồi, bật terminal lên
Mình sẽ không giả vờ rằng bạn có một khách sạn ở Đà Nẵng.
Nhưng bạn có một cái terminal. Và một AWS account. Vậy là đủ để hiểu SQS hoạt động như thế nào trong thực tế - không phải qua slide, không phải qua diagram vẽ tay trên whiteboard.
Kịch bản mình dùng xuyên suốt phần này: hệ thống nhận booking khách sạn. Producer là API nhận đặt phòng. Consumer là worker gửi email confirm. Queue ở giữa là SQS.
Bắt đầu.
Bước 1 - Tạo queue trên AWS Console
Vào console.aws.amazon.com, tìm SQS, nhấn Create queue.
Điền như sau — đừng đổi gì khác lúc đầu:
| Field | Giá trị | Tại sao |
|---|---|---|
| Type | Standard | Đơn giản hơn, đủ dùng cho use case này |
| Name | hotel-booking-queue |
Đặt tên có nghĩa, sau 3 tháng vẫn nhớ cái này dùng để làm gì |
| Visibility timeout | 30 giây | Giữ nguyên default — mình giải thích ngay bên dưới |
| Receive message wait time | 20 giây | Đổi cái này — bật Long Polling, tiết kiệm tiền |
Nhấn Create queue. Xong. Bạn vừa có một message queue chạy trên infrastructure của AWS, không cần setup server, không cần cài Redis, không cần lo về uptime.
Copy lại Queue URL — trông như thế này:
https://sqs.ap-southeast-1.amazonaws.com/123456789012/hotel-booking-queue
Visibility timeout là gì - giải thích nhanh trước khi đi tiếp
Đây là khái niệm người mới hay bị nhầm nhất.
Khi worker nhận một message từ queue, message đó không bị xoá ngay. Nó bị "ẩn" khỏi các worker khác trong 30 giây — đó là visibility timeout. Trong 30 giây đó, worker xử lý xong thì gọi DeleteMessage để xoá hẳn. Nếu worker crash giữa chừng và không gọi Delete, sau 30 giây message tự động hiện trở lại — worker khác nhặt lên xử lý tiếp.
Hiểu nôm na: queue không tin tưởng worker. Nó chỉ xoá message khi worker tự tay xác nhận xong. Cơ chế này đảm bảo không bao giờ mất booking dù consumer có chết bất ngờ.
Bước 2 - Gửi message đầu tiên (Producer)
Vẫn trên Console — vào queue vừa tạo, tab Send and receive messages, kéo xuống phần Send message.
Dán vào ô Body:
{
"bookingId": "BK-2025-001",
"guestName": "Nguyen Van A",
"roomType": "Deluxe Sea View",
"checkIn": "2025-07-15",
"checkOut": "2025-07-18",
"totalAmount": 4500000,
"email": "nguyenvana@gmail.com"
}
Nhấn Send message. Thông báo xanh hiện ra.
Thử gửi thêm 2–3 message nữa, đổi bookingId thành BK-2025-002, BK-2025-003. Queue bây giờ đang có 3 booking chờ xử lý — nhưng chưa có worker nào nhận cả.
Scroll xuống phần Receive messages → nhấn Poll for messages — bạn sẽ thấy 3 message hiện ra. Nhấn vào từng cái xem nội dung. Đóng popup lại mà không nhấn Delete — chờ 30 giây, poll lại, message xuất hiện trở lại. Đó là visibility timeout vừa giải thích ở trên, đang hoạt động đúng như kỳ vọng.
Bước 3 - Viết Consumer bằng Go
Phần Console đủ để hiểu flow. Bây giờ mình viết code thật.
Tạo thư mục project:
mkdir hotel-booking-consumer
cd hotel-booking-consumer
go mod init hotel-booking-consumer
go get github.com/aws/aws-sdk-go-v2/config
go get github.com/aws/aws-sdk-go-v2/service/sqs
Tạo file main.go:
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/sqs"
)
type Booking struct {
BookingID string `json:"bookingId"`
GuestName string `json:"guestName"`
RoomType string `json:"roomType"`
CheckIn string `json:"checkIn"`
CheckOut string `json:"checkOut"`
TotalAmount float64 `json:"totalAmount"`
Email string `json:"email"`
}
func processBooking(booking Booking) error {
// Trong thực tế: gọi email service, cập nhật Booking.com, ghi log...
// Ở đây mình giả lập bằng cách in ra màn hình
fmt.Printf("\n[%s] Xử lý booking mới:\n", time.Now().Format("15:04:05"))
fmt.Printf(" Khách : %s (%s)\n", booking.GuestName, booking.Email)
fmt.Printf(" Phòng : %s\n", booking.RoomType)
fmt.Printf(" Ngày : %s → %s\n", booking.CheckIn, booking.CheckOut)
fmt.Printf(" Tổng : %,.0f VND\n", booking.TotalAmount)
fmt.Printf(" → Gửi email confirm... OK\n")
return nil
}
func main() {
queueURL := os.Getenv("QUEUE_URL")
if queueURL == "" {
log.Fatal("Thiếu QUEUE_URL — export QUEUE_URL=https://sqs.ap-southeast-1.amazonaws.com/...")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Graceful shutdown khi nhấn Ctrl+C
go func() {
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
<-sig
fmt.Println("\nĐang dừng consumer...")
cancel()
}()
cfg, err := config.LoadDefaultConfig(ctx)
if err != nil {
log.Fatalf("Không load được AWS config: %v", err)
}
client := sqs.NewFromConfig(cfg)
fmt.Printf("Consumer đang chạy. Đang lắng nghe queue...\n")
fmt.Printf("Queue: %s\n\n", queueURL)
for {
select {
case <-ctx.Done():
fmt.Println("Consumer dừng sạch.")
return
default:
}
// Long poll — chờ tối đa 20 giây nếu queue trống
result, err := client.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
QueueUrl: aws.String(queueURL),
MaxNumberOfMessages: 10,
WaitTimeSeconds: 20,
})
if err != nil {
if ctx.Err() != nil {
return
}
log.Printf("Lỗi ReceiveMessage: %v — thử lại sau 3s\n", err)
time.Sleep(3 * time.Second)
continue
}
if len(result.Messages) == 0 {
fmt.Print(".") // Dấu chấm nhỏ thay vì spam log "queue trống"
continue
}
fmt.Printf("\nNhận được %d message(s)\n", len(result.Messages))
for _, msg := range result.Messages {
var booking Booking
if err := json.Unmarshal([]byte(*msg.Body), &booking); err != nil {
log.Printf("Parse JSON thất bại: %v — bỏ qua message này\n", err)
continue
}
if err := processBooking(booking); err != nil {
// Không delete → message tự quay lại queue sau visibility timeout
log.Printf("Xử lý thất bại: %v\n", err)
continue
}
// Xử lý thành công → xoá khỏi queue
_, err = client.DeleteMessage(ctx, &sqs.DeleteMessageInput{
QueueUrl: aws.String(queueURL),
ReceiptHandle: msg.ReceiptHandle,
})
if err != nil {
log.Printf("DeleteMessage thất bại: %v\n", err)
}
}
}
}
Chạy:
export QUEUE_URL="https://sqs.ap-southeast-1.amazonaws.com/123456789012/hotel-booking-queue"
go run main.go
Output trông như thế này:
Consumer đang chạy. Đang lắng nghe queue...
Queue: https://sqs.ap-southeast-1.amazonaws.com/...
Nhận được 3 message(s)
[10:32:15] Xử lý booking mới:
Khách : Nguyen Van A (nguyenvana@gmail.com)
Phòng : Deluxe Sea View
Ngày : 2025-07-15 → 2025-07-18
Tổng : 4,500,000 VND
→ Gửi email confirm... OK
[10:32:15] Xử lý booking mới:
Khách : Tran Thi B (tranthib@gmail.com)
...
........... ← queue trống, đang chờ message mới
Mở tab terminal khác, vào Console gửi thêm booking - terminal đang chạy consumer sẽ nhận và xử lý ngay, không cần restart gì cả.
Vậy là chúng ta vừa có gì?
Một hệ thống mà:
- API nhận booking xong trả về response ngay, không phụ thuộc vào tốc độ gửi email
- Worker xử lý độc lập - muốn scale thêm chỉ cần chạy thêm instance
go run main.go - Nếu worker crash, message không mất - visibility timeout đảm bảo điều đó
- Nếu email service chết, message nằm chờ trong queue đến khi service sống lại
Và tất cả những điều này - không cần một dòng infrastructure code nào. AWS lo phần còn lại.
Một điều bạn chưa setup - và sẽ hối hận nếu bỏ qua - Dead Letter Queue.
Tưởng tượng có một booking bị lỗi JSON - worker tiếp tục nhận message, parse thất bại, không delete, message quay lại queue, worker nhận lại, lỗi lại... vòng lặp này chạy đến khi message hết retention period sau 4 ngày.
4 ngày đó worker của bạn cứ xử lý đi xử lý lại một message vô dụng, tốn tài nguyên, spam log, và quan trọng hơn - bạn không biết có vấn đề đang xảy ra.
DLQ giải quyết điều đó: sau 3 lần thất bại, message tự động chuyển sang một queue riêng để bạn debug. Worker không bị làm phiền nữa. Bạn có CloudWatch alarm báo ngay khi DLQ có message.
Setup DLQ chỉ mất 2 phút trên Console - mình sẽ đi vào chi tiết ở phần tiếp theo
III. DLQ và những cái bẫy mình ước gì có người cảnh báo sớm hơn
Mọi hệ thống đều có bug. Điều đó không tránh được.
Câu hỏi không phải là "làm sao để không có bug" — mà là "khi bug xảy ra lúc 2 giờ sáng, hệ thống của bạn tự xử lý hay để bạn xử lý?"
DLQ là câu trả lời cho câu hỏi đó.
Setup DQL
Trước tiên tạo queue cho DLQ. Vào SQS Console → Create queue
| Field | Giá trị |
|---|---|
| Name | hotel-booking-queue-dlq |
| Type | Standard — giống main queue |
| Mọi thứ khác | Để mặc định |
Nhấn Create queue.
Bây giờ quay lại hotel-booking-queue → tab Dead-letter queue → Edit → bật Enable → chọn queue vừa tạo → đặt Maximum receives = 3.
Con số 3 có nghĩa: một message bị nhận và xử lý thất bại 3 lần liên tiếp → tự động bị đẩy sang DLQ. Worker không nhìn thấy nó nữa. Bạn có thể debug thong thả.
CloudWatch Alarm - để không phải canh DLQ bằng mắt
DLQ không có giá trị nếu bạn không biết nó đang có message.
Vào CloudWatch → Alarms → Create alarm → chọn metric SQS > ApproximateNumberOfMessagesVisible của queue hotel-booking-queue-dlq → điều kiện >= 1.
Lưu ý: CloudWatch chỉ hiển thị metric của queue sau khi queue đó có ít nhất một lần activity. Queue mới toanh chưa có message nào thì tìm cả ngày cũng không ra. Fix nhanh nhất: vào hotel-booking-queue-dlq → gửi một message test bất kỳ → chờ 1–2 phút → quay lại CloudWatch, metric sẽ xuất hiện. Xoá message test sau khi setup xong.
Sang Step 2 — Configure actions, CloudWatch sẽ hỏi SNS topic để gửi notification. Đây là chỗ người mới hay bị ngợp vì nghĩ chọn email trực tiếp được - thực ra không phải. AWS bắt buộc đi qua SNS. Làm như sau:
Nhấn Create new topic → điền:
| Field | Giá trị |
|---|---|
| Topic name | dlq-alert |
| Email endpoints | email của bạn |
Nhấn Create topic → Next → đặt tên alarm ví dụ DLQ hotel-booking has messages → Create alarm.
Quan trọng: sau khi tạo xong, AWS gửi ngay một email tiêu đề "AWS Notification — Subscription Confirmation". Phải nhấn Confirm subscription trong email đó thì alarm mới thật sự hoạt động. Bỏ qua bước này thì DLQ có cháy cũng không nhận được gì.
Từ giờ hễ có message nào vào DLQ là bạn nhận email ngay. Không cần mở Console kiểm tra thủ công mỗi sáng như check phòng có khách chưa.
IV. Những cái bẫy thực tế
Đây là phần mình muốn viết nhất. Không phải vì mình thích kể chuyện buồn - mà vì những lỗi này đủ phổ biến để bất kỳ ai dùng SQS lần đầu cũng có khả năng gặp, và đủ khó chịu để khiến bạn mất vài giờ ngồi debug trong khi nguyên nhân chỉ là một con số bị set sai.
Bẫy số 1 - Visibility timeout nhỏ hơn thời gian xử lý
Tình huống:Worker nhận booking, gọi email service mất 45 giây. Visibility timeout đang set là 30 giây.
Chuyện gì xảy ra? Sau 30 giây, queue nghĩ worker đã chết - nó đưa message trở lại. Worker khác nhặt lên xử lý tiếp. Trong lúc đó worker đầu tiên vẫn đang chạy và cũng sắp xử lý xong.
Kết quả: khách nhận 2 email confirm cho cùng một booking. Trông rất nghiệp dư.
Fix: Visibility timeout phải lớn hơn thời gian xử lý thực tế — ít nhất gấp đôi cho an toàn. Job mất 45 giây thì set 120 giây. Hoặc nếu job dài không đoán trước được, dùng ChangeMessageVisibility để gia hạn timeout trong lúc đang xử lý.
Bẫy số 2 - Delete message trước khi xử lý xong
Tình huống:Developer mới vào team thấy pattern này và nghĩ sẽ tối ưu hơn:
// "Nhận xong là xoá ngay cho gọn"
client.DeleteMessage(ctx, &sqs.DeleteMessageInput{...})
// Sau đó mới xử lý
err := processBooking(booking) // Crash ở đây
Worker crash sau khi đã delete. Message biến mất. Booking đó không bao giờ được xử lý. Khách không nhận được email confirm, gọi điện hỏi, staff phải xử lý tay.
Fix: Luôn delete sau khi xử lý thành công. Thứ tự đúng:
err := processBooking(booking) // Xử lý trước
if err != nil {
continue // Không delete → message tự retry
}
client.DeleteMessage(...) // Xử lý xong mới delete
Bẫy số 3 - Standard queue và sự hiểu nhầm về thứ tự
Tình huống:
Khách sửa booking - đổi ngày check-out. Hệ thống gửi 2 message liên tiếp:
Message A: bookingId=BK-001, checkOut=2025-07-18 ← bản gốc
Message B: bookingId=BK-001, checkOut=2025-07-20 ← bản cập nhật
Với Standard queue, SQS không đảm bảo thứ tự. Rất có thể Message B được xử lý trước Message A.
Worker ghi checkOut=2025-07-20 vào DB - đúng.
Rồi Message A đến, worker ghi đè checkOut=2025-07-18 - sai.
Khách check-out ngày 20 nhưng hệ thống ghi ngày 18. Hóa đơn sai. Phòng bị đặt nhầm.
Fix: Một trong ba cách - dùng FIFO queue nếu ordering quan trọng, hoặc thêm timestamp vào message và bỏ qua message cũ hơn bản hiện tại trong DB, hoặc thiết kế message theo kiểu idempotent - xử lý cùng một message nhiều lần vẫn ra kết quả đúng.
Bẫy số 4 - Quên setup DLQ rồi để message loop mãi
Tình huống:Không có DLQ. Một booking có email format sai — worker parse JSON xong, gọi email service, service trả về lỗi validation, worker không delete, message quay lại queue.
Vòng lặp này chạy đến khi message hết retention period — mặc định là 4 ngày. Trong 4 ngày đó:
- Worker xử lý message lỗi đó hàng trăm lần
- Log bị spam
- Bạn không hay biết gì vì không có alarm
- Nếu có nhiều message lỗi kiểu này, chúng dần chiếm hết throughput của queue, làm chậm các message bình thường
Fix: Setup DLQ ngay từ đầu, trước khi đưa lên production. Đây không phải optional - đây là bắt buộc.
Bẫy số 5 - Short polling và hóa đơn AWS cuối tháng
Tình huống:Code consumer chạy vòng lặp poll liên tục, không có WaitTimeSeconds:
result, err := client.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
QueueUrl: aws.String(queueURL),
MaxNumberOfMessages: 10,
// WaitTimeSeconds không set → mặc định là 0
})
Queue thường xuyên trống vào ban đêm - 8 tiếng không có booking nào. Với short polling, consumer poll liên tục mỗi vài trăm milliseconds, nhận về response rỗng, poll tiếp.
Kết quả: hàng chục nghìn API call rỗng mỗi đêm.
SQS tính tiền theo số request. Cuối tháng mở hóa đơn AWS bạn đã tốn kha khá tiền mà đáng lý ra không nên tốn.
Fix: Luôn set WaitTimeSeconds: 20. Long polling chờ đến 20 giây nếu queue trống, chỉ trả về khi có message hoặc hết thời gian chờ. Số lượng API call giảm đáng kể, hóa đơn giảm theo.
V. Nhìn lại từ đầu Bắt đầu
từ một khách sạn 50 phòng ở Đà Nẵng với hệ thống booking gọi thẳng từ API vào email service - rồi dễ dàng gặp các vấn đề khi chỉ 300 request đổ vào cùng lúc.
Giờ thì hệ thống đó trông như thế này:
flowchart TD
A["Booking.com / Agoda / Web"]
B["API layer →
response về khách ngay"]
C["hotel-booking-queue →
buffer, retry tự động, không mất data"]
D["Worker pool →
scale độc lập"]
E["Email / DB / Log"]
F["hotel-booking-dlq →
CloudWatch alarm,
debug thong thả"]
A --> B --> C --> D --> E
D -->|"lỗi x3"| F
Hai hệ thống này có cùng chức năng. Nhưng cái sau không cần bạn thức lúc 2 giờ sáng.