Bỏ qua để đến nội dung
Knowledge Hub

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

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?

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

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

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; _TRANSITIVE so 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.

Đườ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):

Record chỉ mang schema ID; reader tự resolve với schema của mình
Record chỉ mang schema ID; reader tự resolve với schema của mìnhBước 1/5: Serializer đăng ký schema v2 vào subject orders.order-placed-value (hoặc tra ID đã có).checkoutproducer, schema v2Schema Registryorders.order-placed-valueorders.order-placedKafka topicbillingconsumer, reader v2đăng ký v2
checkout → Schema Registryđăng ký v2
Bước 1/5

Serializer đăng ký schema v2 vào subject orders.order-placed-value (hoặc tra ID đã có).

  1. Serializer đăng ký v2 (auto-registration bật theo mặc định) và nhận ID 42.
  2. 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.
  3. Record chứa 0, rồi 42 trong 4 byte, rồi Avro binary không có tên field.
  4. billing lấy schema 42 một lần rồi cache.
  5. 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.
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:

Thay đổi BACKWARD còn một consumer chưa nâng cấp: registry cho qua, email hỏng
Thay đổi BACKWARD còn một consumer chưa nâng cấp: registry cho qua, email hỏngBước 1/5: Registry chấp nhận v2: reader long đọc được data int cũ, nên thay đổi là BACKWARD.thời gianso v2 với v1int → long: okcheckoutproducerRegistryBACKWARDbillingđã lên v2emailvẫn ở v1
Bước 1/5

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.

  • 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, uint64 và bool tươ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 UNKNOWN ngay 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ắt auto.register.schemas trên production để CI đăng ký schema.

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: 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 (OrderPlaced v1, 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.

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 amount từ 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.
  1. Chọn mode theo từng subject; ưu tiên BACKWARD_TRANSITIVE hoặc FULL_TRANSITIVE khi consumer có replay data cũ.
  2. Đăng ký schema từ CI và đặt auto.register.schemas=false ở producer.
  3. Cho mọi field mới một default và mọi enum một default UNKNOWN từ version đầu.
  4. Trong Protobuf, đánh dấu field number và tên đã xoá là reserved.
  5. Ghi rõ thứ tự deploy trong PR: consumer trước (BACKWARD) hay producer trước (FORWARD).
  6. Chạy consumer-driven contract test (ví dụ message pact của Pact) trong CI của producer.
  7. Cảnh báo theo lỗi deserialize và số message vào DLQ của từng consumer group.

Đổ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
  1. 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?
  2. Vì sao thêm default UNKNOWN cho enum ở v2 không giúp được reader v1?
  3. Vì sao đổi tên field Protobuf thì an toàn còn dùng lại number thì không?