Message Queues: Khi nào nên dùng Push, khi nào dùng Pull?
7/19/2026 · 12p đọc
title: "Message Queues: Khi nào nên dùng Push, khi nào dùng Pull?"
series: "Software Architecture Masterclass"
part: "Phần 3 — Hệ thống Phân tán & Hạ tầng"
order: 25
audience: "Solutions Architect & Technical Lead"
reading_time: "13 phút"
tags: ["message-queue", "push-vs-pull", "backpressure", "kafka", "webhook", "consumer-group", "polling", "distributed-systems"]
Message Queues: Khi nào nên dùng Push, khi nào dùng Pull?
Một đội ngũ tích hợp thanh toán quyết định "cho nhanh, cho gọn": mỗi khi có giao dịch mới, hệ thống core bắn thẳng một HTTP POST tới endpoint của service downstream — một webhook cổ điển. Mọi thứ chạy êm trong sáu tháng, tới ngày Black Friday. Traffic tăng gấp mười lần trong hai giờ. Service downstream, vốn được sizing cho tải bình thường, nhận request dồn dập vượt xa khả năng xử lý. Nó không "chậm lại" một cách duyên dáng — nó sập, kéo theo timeout hàng loạt ở phía gọi, và core system bắt đầu retry, làm tình hình tệ hơn theo cấp số nhân.
Ở một công ty khác, một đội xử lý sự kiện ngược lại: consumer định kỳ mỗi 500ms gọi API "còn message nào không?" tới broker, bất kể có message hay không. Trong giờ thấp điểm, 95% các lệnh gọi đó trả về rỗng. Hàng nghìn consumer instance, mỗi cái polling liên tục 24/7, tiêu tốn CPU, network, và hóa đơn cloud tăng đều mà không ai hiểu vì sao — hệ thống "không làm gì" nhưng vẫn ngốn tài nguyên như đang chạy hết công suất.
Hai câu chuyện, hai thất bại đối xứng, cùng một nguyên nhân gốc: chọn sai mô hình tiêu thụ hàng đợi (queue consumption model). Đây không phải lỗi cấu hình vặt — đây là một quyết định kiến trúc nền tảng, quyết định ai là bên kiểm soát nhịp độ xử lý, và hệ thống của bạn có sống sót qua một cú sốc tải hay không.
The Problem
Bất kỳ hệ thống nào có một producer sinh ra công việc (message, event, job) và một consumer xử lý công việc đó, đều phải trả lời một câu hỏi tưởng chừng đơn giản: ai chủ động khởi tạo hành động chuyển giao dữ liệu — bên gửi hay bên nhận?
Nếu chọn sai, hai kịch bản thất bại xuất hiện, và chúng nằm ở hai đầu cực đoan:
Lãng phí tài nguyên vì polling liên tục. Khi consumer chủ động hỏi broker "có gì mới không" theo chu kỳ cố định mà không có cơ chế điều tiết, nó tiêu tốn tài nguyên (network round-trip, CPU xử lý response rỗng, connection overhead) bất kể có việc để làm hay không. Polling quá dày (short interval) lãng phí tài nguyên lúc rảnh; polling quá thưa (long interval) làm tăng độ trễ xử lý lúc bận. Không có chu kỳ nào là "đúng" cho cả hai trạng thái tải.
Consumer bị quá tải vì push tràn ngập. Khi broker (hoặc producer) chủ động đẩy message tới consumer ngay khi có, tốc độ đẩy hoàn toàn do phía gửi quyết định — không liên quan gì đến khả năng xử lý thực tế của bên nhận tại thời điểm đó. Nếu producer tăng tốc (traffic spike) trong khi consumer vẫn cố định về công suất (CPU, connection pool, downstream dependency), khoảng cách giữa "tốc độ đến" và "tốc độ xử lý" tích lũy thành một hàng đợi ẩn không kiểm soát được — thường là bên trong bộ nhớ của consumer, cho tới khi nó hết RAM hoặc timeout hàng loạt.
Gốc rễ chung: thiếu backpressure — cơ chế để bên xử lý chậm hơn báo hiệu ngược lại "chậm lại, tôi chưa xử lý kịp" và bên gửi phải tôn trọng tín hiệu đó. Mô hình Push và Pull khác nhau về căn bản ở chỗ: ai nắm quyền kiểm soát backpressure, và cái giá phải trả để có quyền kiểm soát đó là gì.
The Theory
Mô hình Push (broker/producer chủ động đẩy dữ liệu tới consumer). Ví dụ kinh điển: webhook, nơi hệ thống A gọi thẳng HTTP endpoint của hệ thống B ngay khi có sự kiện; hoặc các message broker có chế độ "push subscription" nơi broker tự động gọi tới consumer đã đăng ký thay vì chờ consumer hỏi. Đặc điểm cốt lõi: quyền chủ động thuộc về bên gửi. Consumer không có tiếng nói về "khi nào" và "bao nhiêu" — nó chỉ có thể chấp nhận những gì đến, hoặc từ chối (và chấp nhận rủi ro mất dữ liệu nếu producer không có cơ chế retry đáng tin cậy).
Mô hình Pull (consumer chủ động kéo dữ liệu từ broker theo nhịp độ của chính nó). Ví dụ kinh điển: consumer group trong các hệ thống log phân tán kiểu Kafka, nơi consumer gọi poll() theo vòng lặp riêng, tự quyết định kích thước batch mỗi lần lấy, và chỉ tiến offset (con trỏ đánh dấu vị trí đã đọc) sau khi xử lý xong. Đặc điểm cốt lõi: quyền chủ động thuộc về bên nhận. Consumer quyết định khi nào sẵn sàng lấy thêm việc, lấy bao nhiêu một lần, và có thể tạm dừng hoàn toàn nếu đang quá tải mà không ảnh hưởng gì đến producer.
Về mặt lý thuyết hệ thống, đây chính là sự khác biệt giữa hai chiến lược điều tiết dòng chảy (flow control) cổ điển:
- Push giống như một đường ống áp lực cố định từ nguồn — nước chảy theo áp suất của nguồn, bể chứa ở cuối đường ống phải tự lo dung tích của mình.
- Pull giống như một cái van do bên nhận vận hành — bên nhận mở van nhanh hay chậm tùy khả năng chứa của mình tại thời điểm đó; nguồn chỉ đơn giản là chờ.
Backpressure tự nhiên (natural backpressure) là hệ quả trực tiếp của mô hình Pull: vì consumer là bên khởi tạo mỗi lần lấy dữ liệu, tốc độ xử lý thực tế của nó chính là tốc độ tiêu thụ — không có khoảng cách ẩn nào tích lũy ở phía consumer mà consumer không biết. Ngược lại, Push cần một cơ chế backpressure nhân tạo (bổ sung thêm) — ví dụ HTTP 429 (Too Many Requests) kèm Retry-After, hoặc consumer chủ động unsubscribe tạm thời — để mô phỏng lại điều mà Pull có sẵn miễn phí.
The Trade-off
Đây là phần không được phép mơ hồ: chọn Push hay Pull là đánh đổi trực tiếp giữa độ trễ và khả năng kiểm soát tải, không có lựa chọn nào thắng tuyệt đối.
Chọn Push — được gì:
- Độ trễ thấp hơn đáng kể. Dữ liệu tới consumer ngay khi có, không có khoảng chờ giữa các vòng lặp poll. Với các use case cần phản ứng gần thời gian thực (ví dụ: thông báo webhook khi thanh toán hoàn tất để hệ thống đối tác cập nhật ngay), đây là lợi thế quyết định.
- Đơn giản hóa phía consumer khi tải thấp và ổn định. Không cần vòng lặp polling, không cần quản lý offset — consumer chỉ việc expose một endpoint và xử lý khi được gọi. Chi phí triển khai ban đầu thấp.
- Không tốn tài nguyên khi không có việc. Khác với Pull, Push không tạo ra network traffic "hỏi suông" lúc không có message — chỉ có traffic khi thực sự có dữ liệu cần chuyển.
Chọn Push — mất gì (cái giá thật):
- Khó kiểm soát backpressure — đây là điểm chí mạng. Consumer không có cách nào "nói" với producer rằng mình đang quá tải, trừ khi cả hai bên chủ động cài đặt thêm một giao thức phụ (status code, retry policy, circuit breaker). Nếu không có, một traffic spike ở phía producer trực tiếp trở thành traffic spike ở phía consumer — không có lớp đệm nào ở giữa.
- Rủi ro mất dữ liệu nếu consumer down. Với webhook cổ điển, nếu endpoint downstream không phản hồi (down, timeout, lỗi 5xx), producer phải tự cài đặt logic retry với backoff và giới hạn số lần thử — nếu không, message coi như mất vĩnh viễn. Đây là trách nhiệm bị đẩy sang phía gửi, vốn không phải lúc nào cũng làm tốt.
- Không có replay tự nhiên. Một khi đã push và consumer xử lý lỗi (hoặc bỏ lỡ), việc "lấy lại" dữ liệu đã gửi đòi hỏi producer phải chủ động lưu trữ và gửi lại — không có khái niệm "quay lại đọc từ offset cũ" như trong mô hình log.
Chọn Pull — được gì:
- Backpressure tự nhiên, miễn phí. Consumer chỉ lấy dữ liệu khi đã sẵn sàng xử lý tiếp. Nếu consumer đang bận (xử lý batch trước chưa xong, downstream dependency đang chậm), nó đơn giản là không gọi
poll()— không có gì ép nó phải nhận thêm việc. Đây là cơ chế điều tiết tải mạnh nhất mà không cần code thêm bất kỳ dòng nào. - Consumer tự quyết định batch size và nhịp độ theo năng lực thực tế của chính nó, kể cả năng lực đó thay đổi theo thời gian (ví dụ: scale consumer instance lên/xuống theo giờ cao điểm mà không cần đổi gì ở producer).
- Replay dễ dàng (đặc biệt với mô hình log bất biến). Consumer có thể reset offset về một điểm trong quá khứ và đọc lại toàn bộ dữ liệu đã qua — hữu ích khi phát hiện bug xử lý sai và cần chạy lại logic trên dữ liệu cũ, hoặc khi thêm một consumer mới cần "bắt kịp" lịch sử.
Chọn Pull — mất gì (cái giá thật):
- Độ trễ cao hơn — có một khoảng chờ cố hữu giữa lúc dữ liệu tới broker và lúc consumer thực sự lấy nó, bằng khoảng cách giữa các vòng poll (dù có thể rút ngắn bằng long-polling, nhưng không bao giờ bằng 0 tuyệt đối như push).
- Overhead của việc polling khi không có dữ liệu. Nếu implement polling thô (fixed short interval, không có long-polling hay cơ chế "chờ có dữ liệu mới trả lời"), hệ thống tiêu tốn tài nguyên network và CPU cho các lệnh gọi trả về rỗng — chính là kịch bản lãng phí ở phần mở bài.
- Phức tạp hơn ở phía consumer. Cần quản lý offset/con trỏ đọc, xử lý logic commit offset đúng thời điểm (trước hay sau khi xử lý xong ảnh hưởng trực tiếp tới at-least-once hay at-most-once semantics), và xử lý rebalance khi số lượng consumer trong group thay đổi. Đây là độ phức tạp thật, không phải chi tiết vặt — nhiều bug nghiêm trọng về xử lý trùng/mất message bắt nguồn từ quản lý offset sai.
Nói ngắn gọn: Push mua độ trễ thấp bằng cái giá mất quyền kiểm soát nhịp độ tải; Pull mua quyền kiểm soát nhịp độ tải bằng cái giá thêm độ trễ và độ phức tạp quản lý trạng thái đọc. Không có công nghệ nào xóa bỏ đánh đổi này — chỉ có việc chọn đúng mô hình cho đúng đặc tính tải của use case.
The Architecture Diagram
Sơ đồ dưới đối chiếu trực tiếp hai mô hình dưới cùng một kịch bản: producer phát sinh dữ liệu dồn dập (traffic spike), consumer có công suất xử lý giới hạn.
sequenceDiagram
participant Producer
participant Broker
participant Consumer
rect rgb(255, 235, 235)
Note over Producer,Consumer: Mô hình PUSH — broker/producer chủ động đẩy
Producer->>Broker: publish message 1..N (traffic spike)
Broker->>Consumer: push message 1 (ngay lập tức)
Broker->>Consumer: push message 2 (ngay lập tức)
Broker->>Consumer: push message 3 (ngay lập tức)
Note over Consumer: Consumer quá tải - CPU/connection pool cạn kiệt
Consumer-->>Broker: timeout / 500 (không kịp xử lý)
Broker->>Consumer: retry message 3 (làm tải nặng thêm)
Note over Consumer: Không có tín hiệu "chậm lại" - phụ thuộc<br/>hoàn toàn vào retry/circuit-breaker phụ trợ
end
sequenceDiagram
participant Producer
participant Broker
participant Consumer
rect rgb(235, 245, 255)
Note over Producer,Consumer: Mô hình PULL — consumer chủ động kéo theo nhịp độ riêng
Producer->>Broker: publish message 1..N (traffic spike)
Note over Broker: Message tích lũy an toàn trong log/queue<br/>(broker chịu trách nhiệm buffer bền vững)
loop Consumer tự quyết định nhịp độ
Consumer->>Broker: poll(batchSize=10)
Broker-->>Consumer: trả về tối đa 10 message
Consumer-->>Consumer: xử lý batch theo công suất thực tế
Consumer->>Broker: commit offset (đã xử lý xong)
end
Note over Consumer: Nếu consumer chậm, nó đơn giản là<br/>không gọi poll() tiếp - backpressure tự nhiên
end
Điểm mấu chốt: ở mô hình Push, broker "áp đặt" tốc độ lên consumer và consumer phải tự vệ bằng cơ chế phụ trợ (retry, circuit breaker, rate limiting ở phía nhận). Ở mô hình Pull, consumer luôn là bên quyết định câu lệnh poll() tiếp theo được gọi khi nào — tải dồn dập của producer chỉ làm tăng độ sâu của queue trong broker (một vấn đề capacity của broker, có thể mở rộng theo chiều ngang), chứ không trực tiếp làm sập consumer.
Implementation Guardrails
- Với consumer có thông lượng cao hoặc tải biến động mạnh, ưu tiên Pull với batch size do chính consumer tự kiểm soát — không hardcode batch size cố định ở mức cấu hình chung cho mọi consumer instance; để mỗi instance tự điều chỉnh batch size theo tài nguyên khả dụng tại thời điểm chạy (CPU, memory, độ trễ downstream dependency).
- Nếu bắt buộc dùng Push (ví dụ tích hợp với hệ thống bên thứ ba chỉ hỗ trợ webhook), phải tự xây dựng lớp backpressure nhân tạo ở phía nhận — trả về HTTP 429 kèm
Retry-Afterkhi hàng đợi nội bộ đầy, và yêu cầu (hoặc kiểm chứng) rằng phía gửi tôn trọng retry-after thay vì bắn lại ngay lập tức. - Không dùng polling gián đoạn cố định (fixed short-interval polling) làm mặc định cho Pull — ưu tiên cơ chế long-polling hoặc "block cho tới khi có dữ liệu hoặc hết timeout" mà hầu hết client library của các hệ thống hàng đợi hiện đại hỗ trợ sẵn, để tránh vừa tốn tài nguyên lúc rảnh vừa tăng độ trễ lúc bận.
- Commit offset (hoặc ack message) chỉ sau khi xử lý xong, không phải ngay khi lấy được — commit sớm (trước khi xử lý) đổi lấy nguy cơ mất dữ liệu nếu consumer crash giữa chừng; commit muộn (sau xử lý) đổi lấy khả năng xử lý trùng (cần idempotency ở logic xử lý) nhưng không bao giờ mất dữ liệu. Chọn rõ ràng và tài liệu hóa semantics nào (at-least-once hay at-most-once) team đang chấp nhận, đừng để nó là hệ quả ngẫu nhiên của code.
Architect's Verdict
Mô hình Pull (kiểu Kafka consumer group) nên là lựa chọn mặc định an toàn hơn cho bất kỳ hệ thống nào có tải biến động hoặc thông lượng lớn, chính vì backpressure tự nhiên của nó triệt tiêu gần như toàn bộ lớp lỗi "consumer bị dồn tải" mà không cần code phòng thủ thêm. Push chỉ nên dành cho các tích hợp khối lượng thấp, có tính chất thông báo đơn lẻ kiểu webhook, nơi độ trễ thấp quan trọng hơn khả năng chống chịu tải — và ngay cả khi đó, đừng triển khai Push mà thiếu một chiến lược retry/backoff và rate-limiting rõ ràng ở cả hai đầu. Nhầm lẫn phổ biến nhất tôi thấy: chọn Push vì nó "dễ code hơn ban đầu", rồi phát hiện ra cái giá thật của nó đúng vào ngày traffic tăng đột biến — thời điểm tệ nhất để học bài học này.
🔗 Bài viết liên quan: Message Queues nằm trong bối cảnh rộng hơn của Event-Driven Architecture · High Availability — Design for Failure
Bài trước: Database Sharding & Partitioning · Bài tiếp theo: High Availability (HA)