Tiến hoá schema của event: đổi OrderPlaced mà không làm hỏng bốn consumer Trung cấp
Thời gian đọc ước tính: 9 phút
Cần đọc trước
Vấn đề
Phần tiêu đề “Vấn đề”Một shop online publish event OrderPlaced lên Kafka topic orders.order-placed. Bốn service consume nó: billing, inventory, email và analytics. Mỗi service thuộc một team khác nhau và deploy theo lịch riêng.
Team checkout cần hai thay đổi: amount đổi từ int sang long (đơn lớn vượt 2.147.483.647), và thêm field currency. Retention là 7 ngày, nên topic còn giữ record v1 suốt một tuần sau khi đổi.
Không có thời điểm nào cả năm service chạy cùng một version. Câu hỏi: thay đổi nào được phép, deploy theo thứ tự nào, và làm gì với thay đổi sẽ làm hỏng một consumer?
Chọn gì? (tóm tắt trước)
Phần tiêu đề “Chọn gì? (tóm tắt trước)”| Tình huống | Chọn | Điều kiện |
|---|---|---|
| Consumer có thể rewind và đọc lại record cũ | BACKWARD (mặc định của Schema Registry), deploy consumer trước | Mọi consumer đã nâng cấp trước khi producer ghi schema mới |
| Producer phải lên trước các consumer chậm | FORWARD, deploy producer trước | Reader cũ phải chịu được hình dạng mới |
| Các team deploy theo thứ tự bất kỳ | FULL / FULL_TRANSITIVE | Chỉ thêm hoặc bỏ field có default |
| Thay đổi không mode nào cho phép (đổi tên, thu hẹp kiểu, đổi nghĩa) | Expand–contract: field mới hoặc event type mới, rồi bỏ cái cũ | Contract test xác nhận mọi consumer đã chuyển |
Khái niệm cốt lõi
Phần tiêu đề “Khái niệm cốt lõi”Mỗi lần đọc có hai schema
Phần tiêu đề “Mỗi lần đọc có hai schema”Avro và Protobuf decode bằng hai schema: writer schema mà producer đã dùng, và reader schema được build sẵn trong consumer. Compatibility là một phát biểu về cặp này.
| Mode | Bảo đảm | Deploy trước | Được phép trong Avro |
|---|---|---|---|
| Backward | Reader mới đọc được data ghi bằng schema cũ | Consumer | Thêm field có default, bỏ field |
| Forward | Reader cũ đọc được data ghi bằng schema mới | Producer | Thêm field, bỏ field có default |
| Full | Cả hai chiều | Thứ tự nào cũng được | Chỉ thêm hoặc bỏ field có default |
Các mode của Schema Registry
Phần tiêu đề “Các mode của Schema Registry”Confluent Schema Registry kiểm tra version mới trước khi nhận. Các mode: BACKWARD (mặc định), BACKWARD_TRANSITIVE, FORWARD, FORWARD_TRANSITIVE, FULL, FULL_TRANSITIVE và NONE (tắt kiểm tra).
- Mode không transitive chỉ so với version mới nhất;
_TRANSITIVEso với mọi version trước. - Confluent chọn BACKWARD làm mặc định để consumer có thể rewind về đầu topic.
- Với Protobuf, Confluent khuyên dùng
BACKWARD_TRANSITIVE, vì thêm message type mới không forward compatible.
Vì sao transitive quan trọng: v1→v2 và v2→v3 có thể đều qua, trong khi v3 vẫn không đọc được record v1 còn nằm trong topic.
Cách hoạt động
Phần tiêu đề “Cách hoạt động”Đường đi của một record từ checkout tới billing, với cách đặt tên subject mặc định của registry (<topic>-value):
đăng ký v2Serializer đăng ký schema v2 vào subject orders.order-placed-value (hoặc tra ID đã có).
- Serializer đăng ký v2 (auto-registration bật theo mặc định) và nhận ID 42.
- Registry từ chối v2 nếu vi phạm mode đã cấu hình; deploy của checkout fail ở đây, không phải trên production.
- Record chứa
0, rồi42trong 4 byte, rồi Avro binary không có tên field. - billing lấy schema 42 một lần rồi cache.
- Resolve theo Avro spec: field khớp theo tên, field writer có mà reader không biết thì bỏ qua, field reader thiếu trong writer thì lấy default của reader hoặc báo lỗi.
Luật Avro quyết định mọi thay đổi
Phần tiêu đề “Luật Avro quyết định mọi thay đổi”| Thay đổi | Reader mới, data cũ | Reader cũ, data mới | Kết luận |
|---|---|---|---|
Thêm currency với "default": "VND" |
Điền default | Bỏ qua field | Full |
Thêm currency không default |
Lỗi | Bỏ qua field | Forward |
| Bỏ một field có default | Bỏ qua field | Điền default | Full |
Đổi tên amount → totalAmount, không alias |
Lỗi | Lỗi | Phá vỡ |
amount: int → long |
Promote | Lỗi | Backward |
amount: long → int |
Lỗi | Promote | Forward |
Thêm enum symbol REFUNDED, enum không có default |
Ổn | Lỗi khi gặp REFUNDED |
Backward |
Các promotion của Avro: int → long/float/double, long → float/double, float → double, string ↔ bytes. Đổi tên được xử lý bằng aliases trên field của reader: reader coi amount của writer là totalAmount.
Thứ tự deploy là một phần của thay đổi
Phần tiêu đề “Thứ tự deploy là một phần của thay đổi”Quay lại amount: int → long. Registry chấp nhận theo BACKWARD, và team quên một consumer:
Registry chấp nhận v2: reader long đọc được data int cũ, nên thay đổi là BACKWARD.
- Bước 0–1: registry và billing đều ổn; data v1 vẫn đọc được.
- Bước 2–3: khi checkout ghi
long, reader của email (int) không có cách resolve hợp lệ. - Bước 4: email kẹt ở offset đó; xem retry topic và DLQ.
Registry kiểm tra schema, không biết consumer nào đang chạy version nào. Thứ tự deploy vẫn là việc của team.
Chi tiết vận hành
Phần tiêu đề “Chi tiết vận hành”- Protobuf khớp theo field number, không theo tên. Đổi tên an toàn trên wire; dùng lại number thì không. Đánh dấu number đã xoá là
reserved. - Proto3 giữ lại unknown field, và enum value lạ được giữ nguyên (open enum). Đổi qua lại giữa
int32,uint32,int64,uint64vàbooltương thích trên wire nhưng có thể bị cắt bớt. - JSON Schema trong Schema Registry: compatibility phụ thuộc content model mở hay đóng (
additionalProperties). Thêm property vào model đóng là một phép kiểm tra khác với model mở. - Avro có enum default từ bản 1.9. Cho mọi enum một default
UNKNOWNngay từ v1: reader cũ chỉ dùng được default mà nó đã có sẵn. - Đặt mode theo từng subject (
PUT /config/<subject>), không chỉ global, và tắtauto.register.schemastrên production để CI đăng ký schema.
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Một kiểm tra nhỏ với avsc 5.7.7 tái hiện ba dòng của bảng Avro (chạy trên Node 24, output rút gọn):
import avro from 'avsc';const T = (fields) => avro.Type.forSchema({ type: 'record', name: 'OrderPlaced', namespace: 'shop', fields });const v1 = T([{ name: 'orderId', type: 'string' }, { name: 'amount', type: 'int' }]);const v2 = T([{ name: 'orderId', type: 'string' }, { name: 'amount', type: 'long' }, { name: 'currency', type: 'string', default: 'VND' }]);
const old = v1.toBuffer({ orderId: 'o-101', amount: 250000 });console.log(v2.fromBuffer(old, v2.createResolver(v1)));// { orderId: 'o-101', amount: 250000, currency: 'VND' }
v1.createResolver(v2);// Error: cannot read "long" as "int"Thêm currency không có default thì v2.createResolver(v1) báo lỗi no matching field for default-less shop.OrderPlaced.currency.
Tolerant reader, upcasting, versioned topic
Phần tiêu đề “Tolerant reader, upcasting, versioned topic”- Tolerant reader: consumer chỉ đọc field mình cần và bỏ qua phần còn lại. Nó làm việc thêm field trở nên an toàn; nó không cứu consumer khỏi đổi kiểu.
- Upcasting: consumer chuyển các version event cũ về hình dạng mới nhất tại một chỗ trước khi xử lý. Hay gặp ở event store theo event sourcing, nơi event cũ không bao giờ được ghi lại.
- Versioned event (
OrderPlacedv1, v2 trong cùng topic) giữ được thứ tự theo key. Versioned topic (orders.order-placed.v2) cho phép phá vỡ thật sự nhưng cần publish song song và mất thứ tự giữa hai topic.
Trade-offs
Phần tiêu đề “Trade-offs”Registry + compatibility mode
- Schema phá vỡ bị từ chối ngay lúc đăng ký, trước khi consumer nào nhìn thấy.
- Payload gọn: một ID thay cho tên field.
- Thứ tự deploy thành luật rõ ràng theo từng mode.
Chi phí
- Registry kiểm tra schema, không kiểm tra nghĩa: đổi
amounttừ VND sang đơn vị xu vẫn qua mọi phép kiểm tra. - Mode không transitive bỏ sót xung đột với version cũ còn trong topic.
- Đổi tên và thu hẹp kiểu cần expand–contract qua nhiều release.
Checklist production
Phần tiêu đề “Checklist production”- Chọn mode theo từng subject; ưu tiên
BACKWARD_TRANSITIVEhoặcFULL_TRANSITIVEkhi consumer có replay data cũ. - Đăng ký schema từ CI và đặt
auto.register.schemas=falseở producer. - Cho mọi field mới một default và mọi enum một default
UNKNOWNtừ version đầu. - Trong Protobuf, đánh dấu field number và tên đã xoá là
reserved. - Ghi rõ thứ tự deploy trong PR: consumer trước (BACKWARD) hay producer trước (FORWARD).
- Chạy consumer-driven contract test (ví dụ message pact của Pact) trong CI của producer.
- Cảnh báo theo lỗi deserialize và số message vào DLQ của từng consumer group.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Đổi tên field tại chỗ
Hay gặp
{ "name": "totalAmount", "type": "long" }Nên viết
{ "name": "totalAmount", "type": "long", "aliases": ["amount"] }Vì sao
Không có alias, reader mới không tìm thấy totalAmount trong data v1. Alias chỉ cứu reader mới: reader cũ vẫn cần amount, nên producer phải tiếp tục ghi nó tới khi các reader cũ nâng cấp xong.
| Cách hiểu sai | Hệ quả | Cần làm |
|---|---|---|
| “Registry đã nhận nên an toàn” | Consumer cũ hỏng với thay đổi BACKWARD | Làm đúng thứ tự deploy của mode |
| “BACKWARD so với mọi version” | v3 không đọc được record v1 còn giữ trong topic | Dùng _TRANSITIVE |
| “Schema tương thích thì nghĩa cũng tương thích” | Đơn vị hoặc ngữ nghĩa đổi âm thầm | Thêm field mới với tên mới |
Tự kiểm tra
Phần tiêu đề “Tự kiểm tra”- Schema v2 qua được BACKWARD, và checkout deploy trước analytics. analytics còn đọc được record nào, và bắt đầu lỗi từ offset nào?
- Vì sao thêm default
UNKNOWNcho enum ở v2 không giúp được reader v1? - Vì sao đổi tên field Protobuf thì an toàn còn dùng lại number thì không?
Tóm tắt
Phần tiêu đề “Tóm tắt”Tham khảo
Phần tiêu đề “Tham khảo”- Confluent — Schema Evolution and Compatibility: các mode, mặc định, thứ tự nâng cấp.
- Confluent — Formats, Serializers, and Deserializers: wire format, cách đặt tên subject.
- Avro 1.12.0 Specification: Schema Resolution, promotion, alias.
- Protobuf proto3 guide: cập nhật message, reserved number, unknown field.
- Fowler — Tolerant Reader: chỉ đọc phần mình cần.
- Pact — How Pact works: contract cho message.
- Liên quan: Expand–contract migration, contract test, Kafka như một distributed log, retention.