Trong một hệ message queue, producer và consumer là hai ứng dụng độc lập, deploy không đồng bộ. Khi bạn cần đổi định dạng message — thêm một trường, đổi một kiểu — bạn không thể cập nhật cả hai cùng một lúc. Sẽ luôn có một khoảng thời gian producer mới gửi message phiên bản mới trong khi consumer cũ còn chạy (và ngược lại). Nếu không cẩn thận, một thay đổi tưởng vô hại làm vỡ hàng loạt consumer đang chạy giữa production. Đây là bài toán schema evolution: làm sao để message tiến hoá qua thời gian mà các phiên bản đọc được của nhau. Bài này (phần 11 loạt Message Queue) chạy thật bằng Go để thấy thay đổi nào an toàn và thay đổi nào phá vỡ — kể cả kiểu phá vỡ âm thầm nguy hiểm nhất.
Hai chiều tương thích
Có hai hướng cần đảm bảo, và chúng khác nhau:
- Tương thích ngược (backward): consumer mới đọc được message cũ. Cần khi bạn nâng cấp consumer trước (hoặc consumer mới xử lý lại message cũ trong log — nhớ event sourcing bài 9: event cũ sống mãi). Đạt được bằng: field mới phải optional và có giá trị mặc định hợp lý.
- Tương thích xuôi (forward): consumer cũ đọc được message mới. Cần khi producer nâng cấp trước consumer. Đạt được bằng: consumer bỏ qua field nó không biết, thay vì lỗi.
JSON (với cách parse của Go) cho cả hai gần như miễn phí: field thiếu → nhận zero value (không lỗi), field thừa → bị bỏ qua (không lỗi). Đây là lý do JSON phổ biến cho message dù "nặng" hơn binary.
// struct CŨ: chỉ biết id, amount
type OrderOld struct { ID string; Amount int }
// struct MỚI: thêm currency (optional)
type OrderNew struct { ID string; Amount int; Currency string }
// JSON của Go: field thiếu → zero value; field thừa → bỏ qua
if a.Currency == "" { a.Currency = "VND" } // áp default cho field mới

Hình 1: Hai chiều tương thích — ngược (consumer mới đọc message cũ, nhờ field optional + default) và xuôi (consumer cũ đọc message mới, nhờ bỏ qua field lạ). JSON của Go cho cả hai gần như miễn phí. Thay đổi phá vỡ: đổi kiểu (lỗi to) hoặc đổi tên/xoá field (mất dữ liệu âm thầm). Quy tắc an toàn: chỉ thêm field optional.
Đo thật: hai chiều tương thích, hai kiểu phá vỡ
Mình viết một chương trình Go trên go-lab parse bốn message khác nhau bằng struct cũ và mới:

Hình 2: Kết quả thật. (1) Backward: consumer mới đọc message cũ {id,amount} → OK, currency thiếu nên áp default VND. (2) Forward: consumer cũ đọc message mới {id,amount,currency} → OK, field currency lạ bị bỏ qua. (3) Phá vỡ đổi kiểu: amount thành "300" (string) → lỗi parse to và dừng ngay. (4) Phá vỡ đổi tên: amount→amt → parse OK nhưng amount=0 — mất dữ liệu âm thầm, không lỗi nào.
Đọc kết quả:
- (1) Backward OK: consumer mới (struct
OrderNewcóCurrency) đọc message cũ không có currency. JSON parse không lỗi,Currencynhận zero value"", rồi code áp default →VND. Message cũ được xử lý đúng bởi consumer mới. Đây là điều kiện để nâng cấp consumer an toàn, và để xử lý lại message cũ trong log. - (2) Forward OK: consumer cũ (struct
OrderOldchỉ có id, amount) đọc message mới có currency. Fieldcurrencykhông có trong struct → Go bỏ qua nó. Consumer cũ xử lý id và amount bình thường, không vỡ. Đây là điều kiện để nâng cấp producer trước mà không làm chết consumer cũ. - (3) Phá vỡ đổi kiểu → lỗi to: đổi
amounttừ int thành string"300". Parse thất bại với lỗi rõ ràng (cannot unmarshal string into ... type int). Đây là kiểu phá vỡ may mắn — nó to tiếng, dừng ngay, bạn phát hiện được (consumer crash, alert kêu). Tệ, nhưng nhìn thấy được. - (4) Phá vỡ đổi tên → mất dữ liệu âm thầm: đổi
amountthànhamt. Parse thành công (không lỗi!), nhưngAmount = 0vì struct tìm fieldamountkhông thấy. Đây là kiểu phá vỡ nguy hiểm nhất: không exception, không crash, không alert — chỉ là amount bằng 0 lặng lẽ. Mọi đơn hàng xử lý với giá trị sai, và không ai biết cho tới khi báo cáo tài chính lệch. Một thay đổi "đổi tên trường cho đẹp" có thể gây mất dữ liệu hàng loạt mà hệ thống báo "mọi thứ ổn".
Thông điệp cốt lõi: chỉ thêm field optional là an toàn; đổi tên, đổi kiểu, hay xoá field đang dùng là phá vỡ — và kiểu phá vỡ tệ nhất không phải crash, mà là mất dữ liệu trong im lặng.
JSON linh hoạt, nhưng Avro/Protobuf + Schema Registry mới ép được kỷ luật
JSON cho tương thích gần như miễn phí, nhưng cũng không ép bạn giữ tương thích — không gì ngăn một dev đổi tên field và gây ra case (4). Ở quy mô lớn, giải pháp là schema registry (với Avro hoặc Protobuf): schema của mỗi message được đăng ký tập trung, và registry từ chối một schema mới nếu nó không tương thích với schema cũ theo quy tắc đã cấu hình (backward/forward/full). Nghĩa là case (4) — đổi tên field — sẽ bị chặn ngay lúc deploy, trước khi gây hại. Avro/Protobuf cũng gọn hơn JSON (binary) và có cơ chế default/alias chính thức. Đánh đổi: thêm một thành phần hạ tầng (registry) và quy trình. JSON hợp hệ nhỏ/vừa nơi kỷ luật con người đủ; schema registry cần cho hệ lớn nơi nhiều team và không thể dựa vào kỷ luật.
Đánh đổi cần cân nhắc
Tương thích miễn phí của JSON cũng là cái bẫy. Chính vì JSON không báo lỗi khi field thiếu/thừa, nó che giấu case (4) — mất dữ liệu âm thầm. Binary format với schema (Avro) bắt bạn khai báo rõ, nên khó gây mất dữ liệu kiểu đó hơn. Sự "dễ dãi" của JSON tiện lúc phát triển nhưng nguy hiểm lúc tiến hoá. Nếu dùng JSON, phải tự bù lại bằng kỷ luật: test tương thích, review mọi thay đổi schema, và không bao giờ đổi tên/kiểu field.
Field optional cần default đúng ngữ nghĩa, không chỉ zero value. Case (1) hoạt động vì mình áp default VND cho currency thiếu. Nhưng zero value mặc định của Go ("", 0, false) không phải lúc nào cũng đúng nghĩa. Một field discount int thiếu → 0 (hợp lý); nhưng active bool thiếu → false (có thể sai nếu mặc định đúng phải là true). Phải cố ý chọn default cho mỗi field mới, không phó mặc zero value — nếu không, message cũ bị diễn giải sai.
Xoá field là hành động hai pha, không bao giờ một bước. Muốn xoá một field đang dùng, không được xoá thẳng (sẽ vỡ consumer còn đọc nó). Quy trình đúng: (1) ngừng ghi field đó ở producer nhưng consumer vẫn chấp nhận nó thiếu; (2) đợi mọi message cũ chứa field hết hạn khỏi log/hệ; (3) mới gỡ field khỏi consumer. Tương tự với mọi thay đổi lớn: tiến hoá schema là quá trình nhiều bước qua thời gian, không phải một lần đổi — đặc biệt với event log bất biến (bài 9) nơi message cũ sống rất lâu.
Ba ý mang về
- Hai chiều tương thích giữ consumer sống khi deploy lệch: đo thật consumer mới đọc message cũ OK (currency → default VND), consumer cũ đọc message mới OK (bỏ qua field lạ) — đạt bằng field optional + default và bỏ qua field không biết.
- Đổi tên/kiểu là phá vỡ, và kiểu âm thầm tệ nhất: đo thật đổi kiểu cho lỗi parse to (phát hiện được), nhưng đổi tên
amount→amtcho parse OK mà amount=0 — mất dữ liệu không một tiếng báo, nguy hiểm hơn cả crash. - Chỉ thêm field optional; dùng schema registry khi cần kỷ luật: JSON cho tương thích miễn phí nhưng không ép giữ nó (dễ gây mất dữ liệu âm thầm); Avro/Protobuf + schema registry chặn thay đổi phá vỡ ngay lúc deploy; và xoá field luôn là quá trình nhiều pha qua thời gian.
Nguồn
- Confluent — Schema Registry & compatibility types: https://docs.confluent.io/platform/current/schema-registry/fundamentals/schema-evolution.html
- Martin Kleppmann — Designing Data-Intensive Applications, ch.4 (Encoding and Evolution)
- Go docs — encoding/json: https://pkg.go.dev/encoding/json
Phần sau là bài tổng kết loạt Message Queue: ghép mọi mảnh — ngữ nghĩa, idempotency, outbox, DLQ, lag, ordering, event sourcing, saga, schema — thành một khung quyết định và checklist thực chiến để chọn và vận hành message queue.