# Xử lý Message Failure trong Distributed Systems: Retry, Poison Message, DLQ và Replay
Khi làm backend với message broker như Azure Service Bus, RabbitMQ hoặc Kafka, một trong những sai lầm phổ biến nhất là nghĩ rằng:
> Consumer fail thì cứ retry.
Thực tế không đơn giản như vậy.
Có những lỗi **nên retry**, có những lỗi **retry bao nhiêu lần cũng không bao giờ thành công**, và có những lỗi cần **đưa message ra khỏi main flow để điều tra**.
Một hệ thống xử lý message tốt thường cần kết hợp:
“`text
Retry
+
Backoff
+
Idempotency
+
Dead Letter Queue
+
Observability
+
Replay Strategy
“`
Bài viết này tập trung vào cách tư duy khi xử lý failure trong distributed systems, đặc biệt phù hợp với .NET backend.
—
# 1. Message processing flow cơ bản
Một flow phổ biến:
“`text
Producer
↓
Message Broker
↓
Consumer
↓
Process Message
↓
Database / External API
“`
Ví dụ:
“`text
Order API
↓
OrderCreated
↓
Service Bus
↓
Payment Worker
↓
Charge Payment
“`
Nếu tất cả thành công:
“`text
Message
↓
Consumer
↓
Success
↓
Complete / Ack
“`
Nhưng production luôn có failure.
—
# 2. Điều đầu tiên phải làm: phân loại lỗi
Không phải lỗi nào cũng nên retry.
Một cách chia đơn giản:
“`text
Transient Failure
Permanent Failure
Business Failure
Poison Message
Dependency Failure
Infrastructure Failure
“`
Đây là nền tảng để quyết định:
“`text
Retry?
DLQ?
Discard?
Manual Review?
Replay?
“`
—
# 3. Transient Failure là gì?
Transient failure là lỗi tạm thời.
Ví dụ:
“`text
HTTP 503
Database connection timeout
Network timeout
Temporary DNS issue
Service overloaded
Broker connection interrupted
“`
Đặc điểm:
“`text
Retry sau một khoảng thời gian
→ có khả năng thành công
“`
Ví dụ:
“`text
Payment API
↓
503 Service Unavailable
↓
wait 2 seconds
↓
retry
↓
success
“`
Đây là loại lỗi phù hợp nhất với retry.
—
# 4. Permanent Failure là gì?
Permanent failure là lỗi mà retry lại cùng input gần như chắc chắn vẫn fail.
Ví dụ:
“`text
Invalid JSON
Missing required field
Invalid enum
Unsupported schema version
Malformed payload
“`
Ví dụ message:
“`json
{
“orderId”: null,
“amount”: “ABC”
}
“`
Consumer yêu cầu:
“`text
orderId: required UUID
amount: decimal
“`
Retry 100 lần cũng không giúp message trở nên hợp lệ.
Trong case này:
“`text
Retry
“`
chỉ làm:
“`text
waste CPU
increase broker traffic
delay healthy messages
pollute logs
“`
Tốt hơn là:
“`text
Dead Letter Queue
“`
—
# 5. Business Failure là gì?
Business failure xảy ra khi message hợp lệ về mặt kỹ thuật nhưng không hợp lệ theo business state.
Ví dụ:
“`text
Coupon already redeemed
Order already cancelled
User is not eligible
Event already ended
Wallet balance insufficient
“`
Ví dụ message:
“`json
{
“couponId”: “C123”,
“userId”: “U456”
}
“`
Payload hoàn toàn hợp lệ.
Nhưng database:
“`text
Coupon.Status = Expired
“`
Đây không phải network failure.
Retry thường không có ý nghĩa.
—
# 6. Business Failure có nên vào DLQ không?
Không phải lúc nào cũng vậy.
Đây là điểm quan trọng.
Ví dụ:
“`text
User tries to redeem expired coupon
“`
Nếu đây là expected business outcome thì có thể chỉ cần:
“`text
mark failed
log business result
complete message
“`
không nhất thiết DLQ.
Nhưng nếu business state đó **không bao giờ được phép xảy ra theo contract**, ví dụ:
“`text
OrderPaid received
but Order does not exist
“`
thì message có thể cần DLQ để investigation.
Rule quan trọng:
> DLQ nên dành cho những message không thể xử lý an toàn hoặc cần con người/system khác quyết định tiếp theo.
—
# 7. Poison Message là gì?
Poison message là message cứ được consumer xử lý là fail.
Ví dụ:
“`text
Message X
↓
Consumer
↓
Exception
↓
Retry
↓
Exception
↓
Retry
↓
Exception
“`
Nếu không có retry limit:
“`text
Message X
“`
có thể làm consumer:
“`text
retry forever
consume CPU
spam logs
block queue
increase cost
“`
Do đó cần:
“`text
Max Retry Count
“`
Sau khi vượt quá:
“`text
Poison Message
↓
DLQ
“`
—
# 8. Dead Letter Queue là gì?
Dead Letter Queue, viết tắt là:
“`text
DLQ
“`
là nơi chứa các message mà hệ thống không thể xử lý bình thường.
Flow:
“`text
Main Queue
↓
Consumer
↓
Process
↓
Fail
↓
Retry
↓
Fail again
↓
Retry exhausted
↓
DLQ
“`
DLQ giúp:
– tránh poison message làm nghẽn main queue
– không làm mất message
– giữ message để investigation
– cho phép replay sau khi fix lỗi
– hỗ trợ audit
– phát hiện contract/schema issue
– phát hiện bug consumer
—
# 9. Những lý do phổ biến khiến message vào DLQ
Một message có thể vào DLQ vì:
“`text
Retry exhausted
Invalid payload
Schema mismatch
Unhandled exception
TTL expired
Business invariant violated
Unsupported event version
Dependency unavailable quá lâu
Authorization/configuration error
Poison message
“`
—
# 10. Retry không phải lúc nào cũng tốt
Một lỗi phổ biến:
“`text
catch Exception
↓
retry
“`
cho tất cả mọi thứ.
Ví dụ:
“`text
JSON parse error
“`
retry:
“`text
1
2
3
4
5
“`
vẫn lỗi.
Trong khi một timeout:
“`text
Payment API timeout
“`
lại có khả năng retry thành công.
Do đó retry cần:
“`text
classification
“`
—
# 11. Retry Strategy đúng cách
Không nên retry liên tục:
“`text
retry immediately
retry immediately
retry immediately
“`
Vì dependency đang lỗi có thể càng bị overload.
Thay vào đó dùng:
“`text
Exponential Backoff
“`
Ví dụ:
“`text
Retry 1 → 1 second
Retry 2 → 2 seconds
Retry 3 → 4 seconds
Retry 4 → 8 seconds
“`
Thêm:
“`text
Jitter
“`
để tránh hàng nghìn consumers retry cùng lúc.
—
# 12. Jitter là gì?
Giả sử 1,000 messages fail lúc 10:00:00.
Nếu tất cả retry sau đúng 5 giây:
“`text
10:00:05
↓
1000 retry requests
“`
Dependency vừa recover có thể bị đánh sập lần nữa.
Jitter thêm randomness:
“`text
5.1s
5.8s
6.3s
4.9s
“`
giúp spread traffic.
—
# 13. Retry Storm
Retry storm xảy ra khi nhiều layer cùng retry.
Ví dụ:
“`text
API retries 3 times
Worker retries 3 times
HttpClient retries 3 times
“`
Một operation có thể tạo:
“`text
3 × 3 × 3 = 27 attempts
“`
Nếu hàng nghìn requests cùng xảy ra:
“`text
dependency outage
↓
retry storm
↓
dependency càng không recover được
“`
Senior backend engineer cần biết:
> Retry phải được thiết kế ở đúng layer.
—
# 14. Idempotency là bắt buộc khi có Retry
Giả sử message:
“`text
RedeemCoupon
“`
Consumer xử lý:
“`text
subtract points
create reward
“`
Sau khi DB commit:
“`text
consumer crashes before ACK
“`
Broker nghĩ message chưa xử lý và gửi lại.
Nếu consumer không idempotent:
“`text
points deducted twice
reward created twice
“`
Do đó:
“`text
At-least-once delivery
+
Retry
=
Need Idempotency
“`
—
# 15. Cách implement Idempotency
Message nên có một ID:
“`json
{
“messageId”: “msg-123”,
“eventType”: “CouponRedeemed”
}
“`
Consumer lưu:
“`text
ProcessedMessages
MessageId
ProcessedAt
“`
Trước khi process:
“`text
messageId exists?
“`
Nếu có:
“`text
skip
“`
Nếu chưa:
“`text
process
save processed message
“`
—
# 16. Unique Constraint cũng là một công cụ mạnh
Ví dụ:
“`text
UserCoupon
“`
business rule:
“`text
1 user cannot redeem same coupon twice
“`
Có thể dùng database constraint:
“`sql
CREATE UNIQUE INDEX UX_UserCoupon
ON UserCoupons(UserId, CouponId);
“`
Nếu duplicate message tới:
“`text
database invariant
“`
vẫn bảo vệ hệ thống.
Đây là nguyên tắc quan trọng:
> Idempotency nên được bảo vệ càng gần data invariant càng tốt.
—
# 17. Complete, Abandon và Dead Letter
Message consumer thường phải đưa ra một trong các quyết định:
“`text
Complete
Abandon / Retry
Dead Letter
“`
Concept:
### Complete
“`text
Processed successfully
“`
Broker không gửi lại.
### Abandon / Retry
“`text
Temporary failure
“`
Message có thể được deliver lại.
### Dead Letter
“`text
Do not process in main queue anymore
“`
Message chuyển sang DLQ.
—
# 18. Decision Tree xử lý lỗi
Một flow rất hữu ích:
“`text
Processing failed
|
v
Is failure transient?
| |
Yes No
| |
Retry Is payload/business state invalid?
| |
Yes No
| |
DLQ / Complete Unknown bug?
|
DLQ
“`
Tất nhiên implementation thực tế phụ thuộc business.
—
# 19. Khi nào nên Retry?
Retry khi lỗi có khả năng tự recover:
“`text
Network timeout
HTTP 429
HTTP 503
Temporary DB connection issue
Transient broker failure
“`
Nhưng cần:
“`text
retry limit
backoff
jitter
timeout
“`
—
# 20. Khi nào không nên Retry?
Không retry hoặc retry rất hạn chế khi:
“`text
Invalid JSON
Missing required data
Unsupported schema
Authentication credentials wrong
Business rule definitely invalid
Resource permanently deleted
“`
Nếu không:
“`text
same input
+
same state
=
same failure
“`
—
# 21. Dependency Failure
Giả sử consumer gọi:
“`text
Third Party API
“`
API down 30 phút.
Retry 3 lần trong vài giây không giải quyết được.
Một strategy:
“`text
Immediate retry
↓
Delayed retry
↓
DLQ / Parking Lot
“`
Ví dụ:
“`text
Attempt 1
↓ 2 seconds
Attempt 2
↓ 30 seconds
Attempt 3
↓ 5 minutes
still failed
↓
DLQ
“`
—
# 22. Parking Lot Queue là gì?
Một số systems sử dụng:
“`text
Parking Lot Queue
“`
khác với DLQ.
Có thể hiểu:
“`text
DLQ
=
failed message for investigation
“`
Trong khi:
“`text
Parking Lot
=
message intentionally parked for later processing
“`
Ví dụ:
“`text
Third-party system maintenance until tomorrow
“`
Message không hỏng.
Nó chỉ:
“`text
cannot be processed now
“`
Có thể park và replay sau.
—
# 23. Retry Queue vs DLQ vs Parking Lot
| Concept | Mục đích |
|—|—|
| Retry Queue | thử lại sau |
| DLQ | isolate message lỗi |
| Parking Lot | giữ message chờ xử lý sau |
| Main Queue | normal processing |
Flow có thể là:
“`text
Main Queue
↓
Consumer
↓
Transient Failure
↓
Retry Queue
↓
Consumer
↓
Still Failing
↓
DLQ
“`
—
# 24. Schema Mismatch
Một failure khá phổ biến trong distributed systems:
Producer deploy version mới:
“`json
{
“status”: “COMPLETED”,
“reward”: {
“type”: “NEW_TYPE”
}
}
“`
Consumer cũ:
“`text
does not understand NEW_TYPE
“`
Kết quả:
“`text
deserialization / mapping error
“`
Retry không giúp.
Cần:
“`text
versioning
backward compatibility
DLQ
“`
—
# 25. Message Contract nên có Version
Ví dụ:
“`json
{
“eventType”: “OrderCreated”,
“version”: 2,
“messageId”: “msg-123”,
“data”: {}
}
“`
Consumer:
“`text
Version 1 → supported
Version 2 → supported
Version 3 → unsupported
“`
Nếu unsupported:
“`text
DLQ
“`
thay vì parse sai hoặc silently ignore.
—
# 26. TTL Expired
Một số message chỉ có giá trị trong thời gian nhất định.
Ví dụ:
“`text
Send OTP
Send event reminder
Temporary reservation
“`
Nếu message tới consumer sau 2 ngày:
“`text
processing no longer makes sense
“`
TTL giúp broker không giữ message vô hạn.
Expired message có thể:
“`text
discard
DLQ
“`
tùy broker/configuration.
—
# 27. DLQ không được trở thành “Black Hole”
Một anti-pattern:
“`text
Main Queue
↓
DLQ
↓
Nobody checks it
“`
Điều này chỉ chuyển problem sang nơi khác.
Một DLQ tốt phải có:
“`text
Monitoring
Alerting
Dashboard
Ownership
Runbook
Replay Process
“`
—
# 28. Những metric nên monitor
Ví dụ:
“`text
Main queue length
DLQ message count
Retry count
Message age
Processing latency
Consumer failure rate
Duplicate rate
Replay success rate
“`
Một metric rất quan trọng:
“`text
Oldest Message Age
“`
Queue có 10 message nhưng oldest message 12 giờ tuổi có thể nghiêm trọng hơn queue 1,000 message mới đến.
—
# 29. Alert khi nào?
Không nên chỉ:
“`text
DLQ > 0
“`
vì một số hệ thống có thể có occasional failures.
Có thể alert theo:
“`text
DLQ count increases rapidly
Oldest message > threshold
Failure rate > threshold
Critical event type failed
Retry exhausted
Consumer stopped processing
“`
—
# 30. Message cần lưu metadata gì?
Một failed message nên có đủ thông tin để debug.
Ví dụ:
“`text
MessageId
EventType
SchemaVersion
CorrelationId
TraceId
CreatedAt
RetryCount
DeadLetterReason
ErrorDescription
ConsumerName
OriginalQueue
“`
Nếu có distributed tracing:
“`text
TraceId
“`
rất hữu ích.
—
# 31. Không lưu sensitive data vào error metadata
Không nên ghi:
“`text
Password
Access Token
Refresh Token
Credit Card
Sensitive PII
“`
vào:
“`text
DLQ reason
logs
trace tags
“`
Observability và broker storage vẫn là production data.
—
# 32. Replay là gì?
Sau khi fix bug:
“`text
message still in DLQ
“`
Bạn có thể:
“`text
Replay
“`
tức đưa message trở lại processing flow.
Ví dụ:
“`text
DLQ
↓
Fix consumer
↓
Deploy
↓
Replay message
↓
Main Queue
↓
Success
“`
—
# 33. Replay cũng nguy hiểm
Không được replay blind.
Ví dụ:
“`text
PaymentCompleted
“`
đã partially process trước khi fail.
Nếu replay:
“`text
charge twice?
reward twice?
email twice?
“`
Do đó trước khi replay phải biết:
“`text
operation idempotent?
“`
—
# 34. Manual Retry Path
Một production system tốt nên có manual retry path.
Ví dụ internal admin tool:
“`text
DLQ Messages
│
├── View Payload
├── View Failure Reason
├── View TraceId
├── Retry
├── Mark Ignored
└── Delete
“`
Nhưng retry action phải:
“`text
audited
authorized
idempotent
“`
—
# 35. Một failure taxonomy tốt
Có thể define:
“`text
TRANSIENT
BUSINESS_REJECTION
INVALID_PAYLOAD
SCHEMA_MISMATCH
DEPENDENCY_FAILURE
AUTHORIZATION_FAILURE
UNKNOWN
“`
Consumer error handling:
“`text
TRANSIENT
→ Retry
BUSINESS_REJECTION
→ Complete / record rejection
INVALID_PAYLOAD
→ DLQ
SCHEMA_MISMATCH
→ DLQ
DEPENDENCY_FAILURE
→ Retry → DLQ
UNKNOWN
→ Limited Retry → DLQ + Alert
“`
Cách này tốt hơn:
“`text
catch Exception
→ retry everything
“`
—
# 36. Ví dụ .NET pseudo-code
“`csharp
try
{
await handler.Handle(message);
await receiver.CompleteMessageAsync(message);
}
catch (TransientException ex)
{
logger.LogWarning(ex, “Transient failure”);
await receiver.AbandonMessageAsync(message);
}
catch (ValidationException ex)
{
await receiver.DeadLetterMessageAsync(
message,
deadLetterReason: “InvalidPayload”,
deadLetterErrorDescription: ex.Message);
}
catch (BusinessRejectedException ex)
{
logger.LogInformation(
ex,
“Business operation rejected”);
await receiver.CompleteMessageAsync(message);
}
catch (Exception ex)
{
await receiver.DeadLetterMessageAsync(
message,
deadLetterReason: “UnhandledError”,
deadLetterErrorDescription: ex.Message);
}
“`
Đây chỉ là concept.
Production code cần thêm:
“`text
retry policy
logging
metrics
tracing
idempotency
“`
—
# 37. Kết hợp với OpenTelemetry
Message consumer nên tạo/continue trace.
Ví dụ trace:
“`text
HTTP POST /orders
↓
Publish OrderCreated
↓
Service Bus
↓
Process OrderCreated
↓
Payment API
“`
Nếu consumer fail:
“`text
TraceId = ABC123
“`
có thể giúp bạn tìm:
“`text
producer logs
consumer logs
HTTP dependency
database trace
“`
trong cùng flow.
—
# 38. Error handling không chỉ là DLQ
Một mistake phổ biến:
> Có DLQ rồi là system reliable.
Không đủ.
Reliable messaging cần:
“`text
Retry Policy
+
Timeout
+
Idempotency
+
DLQ
+
Observability
+
Replay
+
Business Invariants
“`
DLQ chỉ là một phần.
—
# 39. Case Study: Redeem Coupon
Flow:
“`text
User
↓
API
↓
RedeemRequested
↓
Queue
↓
Reward Worker
“`
Worker:
“`text
Check Coupon
↓
Check User
↓
Deduct Points
↓
Create UserCoupon
“`
Các failure:
### Database timeout
“`text
Transient
→ Retry
“`
### Coupon does not exist
Nếu contract đảm bảo coupon phải tồn tại:
“`text
Unexpected business state
→ DLQ
“`
### Coupon expired
Nếu đây là expected business case:
“`text
Record rejected
→ Complete
“`
### Duplicate message
“`text
Idempotency check
→ Skip
“`
### Invalid JSON
“`text
DLQ
“`
—
# 40. Case Study: Event Notification
Message:
“`text
EventReminder
“`
Nếu FCM temporarily unavailable:
“`text
Retry
“`
Nếu device token invalid:
“`text
Permanent failure
→ invalidate token
→ Complete
“`
Không cần retry vô hạn.
Nếu payload thiếu event ID:
“`text
Invalid contract
→ DLQ
“`
—
# 41. Case Study: Payment
Payment đặc biệt nhạy cảm.
Nếu request tới provider timeout:
“`text
Unknown outcome
“`
Không thể đơn giản:
“`text
retry charge
“`
vì payment có thể đã thành công phía provider.
Cần:
“`text
idempotency key
payment status query
reconciliation
“`
Đây là ví dụ cho thấy:
> Retry strategy phải hiểu business semantics.
—
# 42. Interview Question: What is a Dead Letter Queue?
> A Dead Letter Queue isolates messages that cannot be processed successfully. It prevents poison messages from repeatedly blocking the main queue, preserves failed messages for investigation, and allows controlled replay after the root cause is fixed.
—
# 43. Interview Question: Should every exception go to DLQ?
> No. I first classify the failure. Transient failures should normally be retried with bounded backoff. Expected business rejections may be recorded and completed. Invalid messages, unsupported schemas, poison messages, or failures that exceed retry limits are good DLQ candidates.
—
# 44. Interview Question: Why not retry forever?
> Infinite retries waste resources, can block healthy messages, create retry storms, and do not solve permanent failures such as invalid payloads or schema mismatches.
—
# 45. Interview Question: How do you safely replay DLQ messages?
> Before replaying, I fix the root cause and verify the consumer is idempotent. I preserve the original message identity and tracing metadata, replay in controlled batches, monitor failures, and keep an audit trail of who or what initiated the replay.
—
# 46. Interview Question: What is a poison message?
> A poison message is a message that repeatedly fails processing because of its data, schema, or a deterministic bug. It should eventually be isolated instead of being retried indefinitely.
—
# 47. Interview Question: How do you handle transient failures?
> I use bounded retries with exponential backoff and jitter, appropriate timeouts, and idempotent processing. If the dependency remains unavailable beyond the retry policy, the message can be moved to a DLQ or parking-lot flow depending on the business requirement.
—
# 48. Anti-patterns cần tránh
“`text
Retry every exception
Infinite retry
No retry delay
No idempotency
DLQ without monitoring
Replay all DLQ messages blindly
Delete failed messages immediately
No message ID
No schema version
No TraceId / CorrelationId
Log sensitive payloads
Retry payment blindly after timeout
“`
—
# 49. Checklist thiết kế Consumer
Trước khi release một consumer, hãy hỏi:
“`text
What happens if processing fails?
“`
“`text
Which failures are transient?
“`
“`text
How many retries?
“`
“`text
What is the backoff strategy?
“`
“`text
Is the operation idempotent?
“`
“`text
What happens after retry exhaustion?
“`
“`text
Who monitors the DLQ?
“`
“`text
How do we replay safely?
“`
“`text
Can we correlate failure with TraceId?
“`
“`text
Do we expose sensitive data in logs?
“`
—
# 50. Mental Model để nhớ khi đi phỏng vấn
Khi interviewer đưa ra failure scenario, hãy suy nghĩ:
“`text
1. Classify failure
↓
2. Is retry useful?
↓
3. Is operation idempotent?
↓
4. Retry with backoff/jitter
↓
5. Retry exhausted?
↓
6. DLQ / Parking Lot / Business rejection
↓
7. Monitor + Alert
↓
8. Fix root cause
↓
9. Replay safely
“`
Đây là cách trả lời có hệ thống hơn nhiều so với:
> I will retry three times.
—
# Kết luận
Message failure handling không chỉ là:
“`text
try
catch
retry
“`
Một thiết kế production-ready cần hiểu:
“`text
Transient Failure
Permanent Failure
Business Failure
Poison Message
Retry
Backoff
Jitter
Idempotency
Dead Letter Queue
Replay
Observability
“`
Có thể nhớ flow tổng quát:
“`text
Message
↓
Consumer
↓
Failure
↓
Classify
│
├── Transient
│ ↓
│ Retry
│ ↓
│ Exhausted
│ ↓
│ DLQ
│
├── Invalid / Poison
│ ↓
│ DLQ
│
└── Expected Business Rejection
↓
Record Result
↓
Complete
“`
Điểm quan trọng nhất:
> **Retry chỉ phù hợp khi failure có khả năng tự recover. DLQ dùng để isolate message mà main processing flow không thể xử lý an toàn. Và mọi retry/replay đều phải được thiết kế cùng với idempotency.**
Nếu nắm được mental model này, bạn sẽ không chỉ trả lời tốt câu hỏi DLQ trong phỏng vấn mà còn thiết kế message consumer an toàn hơn khi chạy production.