Delivery Semantics & Ack Deadline
Delivery semantics là câu hỏi cốt lõi trong bất kỳ messaging system nào: "Nếu subscriber nhận được message, tôi có thể chắc chắn message đó chỉ được nhận một lần không?" Câu trả lời của Pub/Sub phụ thuộc vào bạn đang dùng subscription mode nào, và có những ràng buộc nghiêm ngặt mà nhiều người bỏ qua.
At-least-once delivery — cơ chế và tại sao duplicate là inevitable
At-least-once là chế độ mặc định và là chế độ duy nhất cho push subscriptions. Tên gọi mô tả chính xác guarantee: mỗi message sẽ được deliver ít nhất một lần tới subscriber. Có thể nhiều hơn một lần.
Cơ chế lease-based delivery
Toàn bộ at-least-once semantics xây trên khái niệm lease:
- Pub/Sub gửi message tới subscriber với một ack deadline (một khoảng thời gian)
- Pub/Sub giữ message trong backlog, đánh dấu "đang được deliver"
- Subscriber phải gửi ACK trước khi ack deadline hết
- Nếu ACK đến trong thời gian: message được đánh dấu "completed", không redeliver
- Nếu ack deadline hết mà không có ACK: Pub/Sub coi message là chưa được xử lý và redeliver
Bước 5 là nguồn gốc của duplicate. Pub/Sub không thể phân biệt được "subscriber đang xử lý chậm" với "subscriber đã crash" — nó chỉ biết ack deadline đã hết. Redelivery là hành động conservative đúng đắn: thà deliver duplicate còn hơn mất message.
Khi nào duplicate xảy ra trong at-least-once
Trường hợp 1: Ack deadline hết trước khi subscriber xử lý xong
T=0: Message delivered, ack_deadline = 30s
T=35s: Subscriber vẫn đang xử lý (chậm hơn dự kiến)
T=35s: Pub/Sub redeliver message
T=40s: Subscriber gửi ACK (quá muộn — ack ID đã invalid)
T=40s: Pub/Sub có thể đã deliver cho subscriber khác
→ DUPLICATE: message được xử lý 2 lầnTrường hợp 2: Mạng giữa subscriber và Pub/Sub có vấn đề
T=0: Message delivered
T=25s: Subscriber gửi ACK
T=25s: ACK packet bị drop (network partition)
T=30s: Ack deadline expire, Pub/Sub không nhận được ACK
T=30s: Pub/Sub redeliver message
→ DUPLICATE: subscriber đã xử lý nhưng vẫn nhận lạiTrường hợp 3: Subscriber restart trong khi đang xử lý
T=0: Message delivered, đang xử lý
T=15s: Subscriber crash, restart
T=30s: Ack deadline expire
T=30s: Pub/Sub redeliver message
→ Đây là trường hợp ĐÚNG: message chưa được xử lý hoàn toàn, cần redeliver
→ Nhưng subscriber không phân biệt được: partial processing đã xảy ra chưa?Hệ quả bắt buộc: Mọi subscriber với at-least-once semantics phải implement idempotent processing. Xử lý cùng một message nhiều lần phải tạo ra kết quả như xử lý một lần.
Negative acknowledgment (NACK)
Ngoài ACK, subscriber có thể gửi NACK — tín hiệu "tôi không thể xử lý message này, hãy redeliver ngay". NACK không có delay — Pub/Sub redeliver message ngay lập tức (thay vì đợi deadline hết).
NACK hữu ích khi: subscriber biết chắc nó đang trong trạng thái không thể xử lý (ví dụ database connection down), và muốn Pub/Sub redeliver cho một subscriber khác ngay.
Cẩn thận với NACK storms: Nếu tất cả subscribers đều NACK cùng một message vì message đó có lỗi format, message sẽ bounce giữa các subscribers liên tục cho đến khi Dead Letter Topic trigger (hoặc retention hết hạn).
Ack deadline — cơ chế và extension
Ack deadline là khoảng thời gian Pub/Sub đợi ACK trước khi redeliver. Hiểu đúng cơ chế này là điều kiện tiên quyết để thiết kế subscriber không bị lỗi.
Default và range
- Default ack deadline: 10 giây (khi tạo subscription qua Console) hoặc có thể cấu hình 10–600 giây
- Maximum ack deadline: 600 giây (10 phút)
- Minimum ack deadline: 10 giây
600 giây là hard cap. Bất kể message processing mất bao lâu, subscriber phải gửi ack hoặc extend ack deadline định kỳ.
ModifyAckDeadline — extension mechanism
Nếu subscriber biết processing sẽ mất lâu hơn ack deadline, nó có thể gọi ModifyAckDeadline API để extend thêm thời gian:
# Subscriber extend thêm 60 giây
subscriber.modify_ack_deadline(
request={
"subscription": subscription_path,
"ack_ids": [ack_id],
"ack_deadline_seconds": 60 # extend thêm 60s từ bây giờ
}
)Có thể extend nhiều lần, nhưng tổng thời gian từ lần delivery đầu tiên không có hard limit — chỉ có limit trên mỗi lần extend (tối đa 600 giây mỗi lần). Trong thực tế, subscriber có thể extend liên tục trong nhiều giờ nếu cần.
Client library tự động manage ack deadline: Khi dùng high-level client library (Java, Go, Python), thư viện tự động extend ack deadline trong background (gọi là "lease management") cho đến khi subscriber call ack() hoặc nack(). Nếu dùng low-level API, bạn phải tự implement.
Ack deadline expiry và re-delivery mechanics
Khi ack deadline expire:
- Pub/Sub release lease: message không còn "đang được deliver" bởi subscriber cũ
- Message được đưa vào pool để redeliver
- Ack ID cũ của message đó bị invalidate — nếu subscriber cũ gửi ACK với ack ID này sau khi expire, Pub/Sub từ chối
Điểm quan trọng: ack ID có TTL. Ack ID không phải là message ID vĩnh viễn — nó gắn với một lần delivery cụ thể. Mỗi lần redeliver tạo ra một ack ID mới.
Lần deliver đầu: ack_id = "abc123...", deadline = T+30s
T+35s: Deadline expire, ack_id "abc123" bị invalidate
Lần deliver thứ 2: ack_id = "xyz789...", deadline = T+65sExactly-once delivery — internal model và constraints
Exactly-once delivery là tính năng opt-in, available từ 2022. Đây là tính năng đáng kể vì nó loại bỏ hoàn toàn duplicate delivery cho subscriber — không cần implement idempotency ở application layer.
Cơ chế internal: deduplication state
Để implement exactly-once, Pub/Sub phải solve một bài toán distributed: "làm sao biết message đã được ack thành công hay chưa, kể cả khi ACK bị lost?"
Solution của Pub/Sub: maintain deduplication state trong cùng region. Khi subscriber gửi ACK:
- ACK được ghi vào deduplication log
- Pub/Sub trả về ack success chỉ khi write này durable
- Nếu subscriber không nhận được ack success (network issue), subscriber retry
- Pub/Sub detect duplicate ACK qua deduplication log và trả về success mà không redeliver
Nghe đơn giản, nhưng có một ràng buộc quan trọng: deduplication state chỉ tồn tại within a region. Đây là lý do cho regional constraint của exactly-once.
Regional constraint — tại sao quan trọng
Theo Cloud Pub/Sub documentation: "The exactly-once delivery guarantee only applies when subscribers connect to the service in the same region."
Nếu subscriber kết nối tới một region khác với region của subscription, deduplication state từ region kia không accessible → duplicate có thể xảy ra.
Practical implication: Khi enable exactly-once, subscriber phải dùng locational endpoint (ví dụ us-central1-pubsub.googleapis.com) thay vì global endpoint (pubsub.googleapis.com). Global endpoint có thể route request tới bất kỳ region nào.
Với subscriber chạy trên GKE trong cùng region với subscription, điều này không phải vấn đề. Nhưng với subscriber chạy on-premises hay multi-region, cần cấu hình cẩn thận.
Pull-only limitation
Exactly-once chỉ available cho pull subscriptions. Push subscriptions và export subscriptions không support.
Lý do: Push subscription deliver message qua HTTP tới endpoint do user control. Pub/Sub không thể biết liệu HTTP endpoint đã thực sự xử lý thành công hay chỉ trả về 200 vì timeout. Delivery semantic trong push là "HTTP 2xx đồng nghĩa với ack" — nhưng network issues giữa Pub/Sub và endpoint có thể làm Pub/Sub không nhận được 2xx dù endpoint đã xử lý xong.
Throughput và latency tradeoffs
Exactly-once không free:
Latency tăng đáng kể: Mỗi ACK phải được ghi durably vào deduplication log trước khi subscriber nhận ack success. Điều này add thêm một round-trip write so với at-least-once. Theo docs, "significantly higher publish-to-subscribe latency compared to regular subscriptions."
Throughput giảm khi kết hợp với ordering: Khi dùng cả exactly-once và ordering keys: "client throughput is limited to an order of thousand messages per second." Đây là hard constraint từ thiết kế — để maintain per-key order với deduplication, Pub/Sub phải process ACK sequentially per key.
Default ack deadline khác: Exactly-once subscription có default ack deadline là 60 giây (thay vì 10 giây), vì hệ thống assume processing có thể phức tạp hơn.
Khi nào thực sự cần exactly-once
Exactly-once giải quyết duplicate ở delivery layer — message chỉ delivered một lần tới subscriber. Nhưng nó không giải quyết duplicate ở processing layer nếu subscriber gặp lỗi sau khi nhận message nhưng trước khi hoàn thành xử lý.
Ví dụ: subscriber nhận message, ghi vào database, database gọi commit thành công, nhưng subscriber crash trước khi gửi ack. Với exactly-once, Pub/Sub sẽ redeliver message → subscriber cần idempotency ở database layer.
Trường hợp exactly-once thực sự giúp: Khi việc gửi message đến subscriber là idempotent nhưng việc xử lý có side effect khó idempotent (ví dụ: gọi external API, gửi email). Nếu subscriber nhận message là đủ để trigger action, exactly-once đảm bảo action chỉ trigger một lần.
Trường hợp vẫn cần idempotency: Bất kỳ khi nào subscriber có thể fail sau khi nhận message nhưng trước khi ack — đây là tất cả các production use case thực tế.
So sánh delivery semantics
| Đặc điểm | At-least-once | Exactly-once |
|---|---|---|
| Subscription type | Pull + Push + Export | Pull only |
| Duplicate delivery | Có thể xảy ra | Không (within region) |
| Regional constraint | Không | Bắt buộc cùng region |
| Latency | Thấp hơn | Cao hơn (dedup write) |
| Throughput với ordering | Cao hơn | ~thousands msgs/s |
| Default ack deadline | 10–600s | 60s |
| Idempotency requirement | Bắt buộc ở app layer | Vẫn nên có ở processing layer |
| Chi phí | Chuẩn | Chuẩn (không phụ phí) |
Anti-pattern: "Enable exactly-once là đủ để không cần idempotency"
Đây là hiểu lầm phổ biến nhất. Exactly-once đảm bảo delivery idempotency — message không bị deliver hai lần. Nhưng processing idempotency là trách nhiệm của subscriber.
Scenario điển hình gây confusion:
T=0: Message delivered (exactly-once enabled)
T=5s: Subscriber xử lý xong, gọi commit vào DB
T=6s: Subscriber crash trước khi gửi ACK
T=7s: Pub/Sub redeliver message (vì chưa nhận ACK)
T=8s: Subscriber xử lý lại → duplicate trong DBVới exactly-once, Pub/Sub vẫn redeliver nếu chưa nhận ACK — nó chỉ đảm bảo không redeliver sau khi đã nhận ACK thành công. Khoảng thời gian giữa "xử lý xong" và "ack thành công" vẫn là window của potential duplicate processing.
Kết luận: Exactly-once giảm window của potential duplicates, nhưng không loại bỏ hoàn toàn yêu cầu idempotency ở application layer trong mọi trường hợp.