Đừng mặc định dùng MQTT broker làm event backbone khi hệ thống cần durable streaming, replay và independent consumer processing
· cập nhật 13/10/2026
MQTT là chuẩn de facto cho kết nối thiết bị IoT, và vì broker đã có sẵn pub/sub nên rất dễ để nó lặng lẽ trở thành event backbone cho toàn bộ hệ thống. Bản thân điều đó không sai — với nhiều hệ thống, broker làm việc này hoàn toàn ổn. Vấn đề nằm ở chữ "mặc định": khi hệ thống cần durable streaming, replay và để các consumer xử lý độc lập với nhau, broker không được thiết kế cho những yêu cầu đó, và chọn nó theo quán tính sẽ biến giới hạn của broker thành trần của sản phẩm. Bài này phân tích ba yêu cầu đó, khi nào broker vẫn là đủ, và cách tách kiến trúc khi hệ thống thực sự cần chúng.
Pub/sub là pattern, MQTT là protocol
Hai khái niệm này thường bị đánh đồng, vì các broker như EMQX, Mosquitto hay HiveMQ bán kèm chúng thành một gói: cài broker lên là có ngay pub/sub. Nhưng về bản chất:
- Pub/sub là một pattern giao tiếp: bên phát không cần biết bên nhận, message được định tuyến qua topic.
- MQTT là một application-layer messaging protocol, được thiết kế cho communication trong môi trường IoT — nơi thiết bị và mạng có nhiều hạn chế: thiết bị tài nguyên thấp, mạng yếu, gói tin nhỏ, keep-alive rẻ, kết nối qua Internet công cộng.
Tách hai khái niệm ra giúp đặt đúng câu hỏi. Câu hỏi không phải "có dùng MQTT broker cho pub/sub nội bộ được không" — được. Câu hỏi là: pub/sub nội bộ của hệ thống cần những đảm bảo gì, và broker có được thiết kế để cung cấp chúng không?
Ba yêu cầu làm thay đổi lựa chọn
Có ba yêu cầu mà khi xuất hiện, MQTT broker không còn là lựa chọn mặc định cho event backbone:
Durable streaming. Event được ghi bền vững, có thứ tự, vào một log có retention — tồn tại độc lập với việc lúc đó có consumer nào đang nghe hay không. Event là dữ liệu của hệ thống, không chỉ là tín hiệu đi qua.
Replay. Consumer chủ động chọn vị trí (offset, hoặc mốc thời gian) để đọc lại lịch sử. Đây là thứ cho phép consumer mới deploy backfill dữ liệu cũ, sửa xong bug thì reprocess, hay chạy một consumer mới song song với consumer cũ để đối chiếu kết quả — những thao tác bình thường với Kafka/Pulsar.
Independent consumer processing. Mỗi consumer (hoặc consumer group) giữ vị trí đọc riêng và đọc theo tốc độ của chính nó. Consumer chậm không kéo consumer khác chậm theo; consumer chết vài giờ thì bật lại đọc tiếp từ chỗ cũ. Backpressure khi đó là consumer-driven: consumer kéo (pull), không phải chịu tốc độ push của bên phát.
MQTT có những cơ chế nghe giống ba yêu cầu này, nhưng phục vụ mục tiêu khác:
- Retained message chỉ giữ message cuối cùng của mỗi topic — đủ cho "trạng thái hiện tại", không phải lịch sử.
- Persistent session + QoS 1/2 giữ message cho một client offline trong giới hạn queue của broker — là cơ chế đảm bảo delivery, không phải log để replay.
- Shared subscription (MQTT 5) chia tải message cho các thành viên trong một nhóm (competing consumers) — không cho mỗi nhóm một offset riêng để đọc lại.
- Flow control ở mức protocol và inflight limit của broker có tồn tại, nhưng không thay được mô hình consumer-driven consumption của các hệ distributed log.
Một số broker thương mại có thêm tính năng lưu trữ hoặc bridge sang Kafka. Điều đó càng xác nhận luận điểm: khi cần ba yêu cầu trên, bạn đang thêm một event log vào kiến trúc — chỉ khác là gắn qua broker.
Cần sòng phẳng: đây là khác biệt về abstraction giữa hai loại hệ thống, không phải "thiếu sót" của MQTT. Nó không sinh ra để làm việc đó.
Khi nào broker làm backbone là đủ
Nếu hệ thống không cần ba yêu cầu trên, dùng broker làm kênh pub/sub nội bộ là lựa chọn hợp lý, thậm chí là lựa chọn tốt — thêm Kafka lúc này là over-engineering thật. Dấu hiệu thường gặp:
- Dữ liệu chủ yếu là live state/telemetry, nơi giá trị mới nhất quan trọng hơn lịch sử, và lịch sử cần giữ thì đã ghi thẳng vào database.
- Số consumer ít, cùng một team, deploy cùng nhịp.
- Mất message trong lúc consumer down là chấp nhận được, hoặc đã có cơ chế bù khác.
- Không có nhu cầu reprocess dữ liệu cũ khi đổi logic.
Điểm mấu chốt là lựa chọn này phải là một quyết định có ý thức, kèm hiểu biết về ngày nó hết đúng — chứ không phải trạng thái hệ thống trôi dạt tới.
Con đường "tự nhiên" dẫn đến lựa chọn mặc định
Broker trở thành backbone theo quán tính không phải vì người thiết kế kém, mà vì mỗi bước đi đến đó đều hợp lý ở thời điểm quyết định:
Demo effect. POC chạy quá nhanh: device publish, backend subscribe, dashboard hiện số liệu trong một buổi chiều. Broker "đã ở đó rồi", cần service mới thì cứ subscribe thêm topic. Mỗi quyết định cục bộ đều hợp lý — tổng thể thành lock-in.
Nhầm phạm vi của "chuẩn". MQTT là chuẩn cho đoạn device ↔ cloud. Điều đó không có nghĩa nó là chuẩn cho service ↔ service trong datacenter — nơi mạng ổn định và bài toán thường là throughput, replay, ordering, những bài toán nằm ngoài mục tiêu thiết kế của MQTT.
Tiết kiệm giả. "Sao phải thêm một message bus riêng cho phức tạp, broker làm được hết" — đúng ở tháng thứ 3, và có thể sai ở năm thứ 2, khi hệ thống bắt đầu cần replay hay consumer độc lập mà kiến trúc thì đã cứng.
Ngoài ra còn một ảo giác nữa: broker có vẻ quản lý device giúp mình. Session, keep-alive, retained message, last will — nghe rất giống device management. Nhưng state model của broker ("connected/disconnected") không phải state model nghiệp vụ ("đang bảo trì", "thuộc site nào", "firmware version nào cần cảnh báo"). Phần nghiệp vụ vẫn phải tự xây, và giờ hệ thống có hai nguồn truth phải đồng bộ. Muốn customize sâu hành vi broker thì phải viết plugin bằng Erlang/Elixir — một stack thường lạc hoàn toàn khỏi stack của team — và mỗi lần nâng version broker là một lần re-verify toàn bộ phần đã móc vào.
Cái giá khi yêu cầu đã đổi mà kiến trúc chưa đổi
Ngoài việc thiếu ba đảm bảo ở trên, khi nhiều application service đã dùng broker làm event backbone, còn một nhóm chi phí tích tụ theo thời gian:
Topic hierarchy thành API công cộng không có version. Hàng chục service cùng subscribe wildcard vào một cây topic. Muốn đổi cấu trúc topic là phải phối hợp đồng loạt tất cả — nghĩa là trên thực tế không bao giờ đổi được.
Scale sai chỗ. Scale broker là scale một cụm stateful phức tạp (session state, subscription state phân tán), trong khi kiến trúc tách tầng chỉ cần scale ingress stateless ở rìa và partition ở backbone.
Trần của broker thành trần của sản phẩm. Mọi giới hạn, mọi bug, mọi lịch vá của broker giờ ảnh hưởng trực tiếp đến toàn hệ thống — không còn là chuyện của một adapter có thể thay.
Các điểm này là observation từ hệ thống production tôi vận hành: khi nhiều service phụ thuộc vào topic hierarchy, QoS, retained state và session semantics của broker cho logic nội bộ, coupling và operational complexity tăng mạnh theo thời gian.
Kiến trúc 3 tầng: broker đứng ở rìa, log đứng ở giữa
Mô hình tôi áp dụng cho hệ thống đang vận hành hơn 2.000 thiết bị IoT — nơi cả ba yêu cầu trên đều có thật — tách thành ba tầng độc lập:
┌─────────────────────────────────────────────────────────┐
│ 1. TRANSPORT / INGRESS (rìa) │
│ MQTT broker (dùng sản phẩm có sẵn: Mosquitto/EMQX) │
│ WebSocket · TCP socket · HTTP │
│ Adapter: nhận bytes, normalize, đẩy vào backbone │
│ Stateless về nghiệp vụ — không giữ logic │
└──────────────────────────┬──────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ 2. PUB/SUB BACKBONE (giữa) │
│ Event log trung tâm: replay · ordering · partition │
│ (Kafka, Redpanda, Pulsar, NATS JetStream...) │
│ Pub/sub ephemeral cho fan-out realtime │
│ Không biết message đến từ MQTT hay socket │
└──────────────────────────┬──────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────┐
│ 3. APPLICATION / STATE │
│ Consumer xử lý nghiệp vụ │
│ Database authoritative · Redis cache presence/state │
│ Device management nằm ở đây │
└─────────────────────────────────────────────────────────┘
Nguyên tắc phân vai:
- Transport chỉ biết "connection", không biết "device nghiệp vụ". Mỗi protocol là một cửa — adapter nhận dữ liệu vào và đẩy ra khỏi cửa, hết.
- Backbone là pub/sub thật của hệ thống. Mọi consumer nội bộ đọc từ event log — trong kiến trúc này, không service nghiệp vụ nào subscribe thẳng vào broker.
- Device management là bài toán ứng dụng, đặt ở tầng 3 với database làm nguồn truth duy nhất — không nhét vào broker, cũng không nhét vào socket layer.
Đây là kiến trúc tôi chọn cho bài toán production cụ thể của mình, không phải định luật phổ quát — nhưng hệ quả quan trọng nhất của nó thì rất đáng giá: nếu ngày mai một dòng sensor bắt buộc dùng MQTT, chỉ cần thêm một adapter MQTT → backbone đứng cạnh các ingress hiện có. Vai trò của broker co lại thành đúng một ingress adapter. Mất nó không chết hệ thống; thay nó không đụng đến consumer nào.
Message envelope: hợp đồng ổn định của hệ thống
Một khoản đầu tư rất rẻ nhưng trả lãi lớn khi giữ mô hình này: định nghĩa message envelope chuẩn nội bộ ngay tại adapter. Mọi transport đều normalize về envelope trước khi producer đẩy vào backbone:
{
"device_id": "sen-0421",
"ts": 1754820000123,
"schema_version": 2,
"payload": { }
}
Envelope là hợp đồng ổn định của hệ thống: protocol bên ngoài envelope có thể thay, envelope thì không. Field schema_version chính là khoản trả trước cho bài toán chắc chắn sẽ đến với mọi hệ IoT chạy đủ lâu: firmware trên field cập nhật lệch nhau, nhiều version payload cùng tồn tại trong một topic. Với một event log có replay, nó còn quan trọng hơn: consumer đọc lại lịch sử sẽ gặp đủ mọi version payload từng tồn tại.
Tự viết transport thay broker: optional, và không rẻ
Cần nói rõ để tránh hiểu lầm: tách kiến trúc 3 tầng không đồng nghĩa với việc phải tự viết socket server thay cho broker. Tách kiến trúc gần như miễn phí — chỉ là kỷ luật về vai trò. Tự viết transport là một quyết định riêng, optional, và chỉ nên làm khi có lý do cụ thể:
- Device dùng protocol ngoài MQTT (TCP binary thuần, protocol độc quyền của nhà sản xuất)
- Yêu cầu điều khiển connection ở mức mà broker off-the-shelf không expose
- Quy mô hoặc đặc thù vận hành mà đội đã đo được broker không đáp ứng
Nếu đi con đường này, những thứ broker cho không sẽ phải tự xử: reconnect/backoff khi hàng nghìn device đổ về cùng lúc sau sự cố mạng, ordered delivery và deduplication, backpressure ở tầng connection, protocol versioning khi firmware trên field lệch version. Đây là phần dễ bị đánh giá thấp nhất khi nhìn từ ngoài vào — không phải đội nào cũng đủ nguồn lực làm đúng và an toàn, và làm sai ở tầng này thì hậu quả nặng hơn nhiều so với dùng broker "chưa tối ưu".
Kết
Câu kiểm tra cho một thiết kế IoT không phải "có service nào subscribe vào broker không", mà là ba câu hỏi: hệ thống có cần event được lưu bền như một log không? Có cần đọc lại lịch sử để backfill hay reprocess không? Có cần các consumer chạy theo tốc độ và vị trí riêng, không kéo nhau xuống không? Nếu cả ba đều "không", broker làm backbone là đủ và đừng thêm phức tạp. Nếu có dù chỉ một câu "có" — hoặc bạn thấy trước nó sẽ thành "có" — thì đừng để broker trở thành backbone chỉ vì nó đã có sẵn.
Kết luận thực dụng: giữ nguyên MQTT broker (Mosquitto/EMQX/HiveMQ) ở đúng vai ingress — không cần tự viết lại broker server. Việc cần thay đổi là cách dùng broker, không phải thay broker: mọi consumer nội bộ chuyển sang đọc từ backbone, broker chỉ còn một client duy nhất phía trong là adapter đẩy message vào event log.
MQTT là chuyện giữa thiết bị và hệ thống; durable streaming là chuyện nội bộ của hệ thống. Khi hệ thống cần cả hai, đặt chúng ở hai tầng khác nhau ngay từ đầu rẻ hơn rất nhiều so với tách ra khi đã có hàng chục service móc thẳng vào cây topic của broker.