Schema Drift trong Event-Driven Systems: versioning, compatibility và cách tránh làm gãy consumer trong production

Trong monolith, đổi một field thường là chuyện nội bộ. Trong event-driven system, cùng thay đổi đó có thể làm gãy nhiều consumer ở những service khác nhau, theo thời gian khác nhau, với tốc độ deploy khác nhau. Đó là lúc schema drift trở thành rủi ro production thật sự chứ không còn là “chi tiết format message”.

Schema drift xảy ra khi producer, consumer, data contract và dữ liệu thực tế trên stream không còn tiến hóa đồng bộ. Team producer nghĩ mình chỉ thêm một field. Nhưng ở phía consumer, field mới có thể làm parser fail, thay đổi default behavior, phá analytics job, hoặc làm replay pipeline đọc sai meaning của sự kiện cũ.

Điểm nguy hiểm là drift thường không nổ ngay lúc merge. Nó phát nổ lúc:

  • một consumer cũ chưa deploy kịp vẫn đang đọc topic;
  • một batch job replay lại event 3 tháng trước bằng code mới;
  • một downstream service phụ thuộc implicit contract chưa từng được document;
  • dữ liệu cũ và mới cùng tồn tại trong một retention window dài.

Bài này phân tích schema drift dưới góc nhìn system design và production engineering: vì sao event schema khó đổi hơn API request/response, backward/forward/full compatibility khác nhau thế nào, khi nào nên dùng version mới trong payload hay topic, vai trò của schema registry, contract test và observability, cùng một playbook migration đủ thực dụng để không làm gãy consumer giữa production.

Schema drift thực chất là drift của contract, không chỉ drift của JSON hay Avro

So sánh shape drift và semantic drift trong event contract
So sánh shape drift và semantic drift trong event contract

Nhiều team định nghĩa schema drift quá hẹp: chỉ nhìn việc thêm/xóa/đổi kiểu field trong JSON, Avro hoặc Protobuf. Thực tế contract của event rộng hơn nhiều, gồm ít nhất 4 lớp:

  1. Shape: field nào tồn tại, kiểu dữ liệu gì, field nào required.
  2. Semantics: field đó có ý nghĩa nghiệp vụ gì.
  3. Temporal behavior: event đến theo thứ tự nào, có thể duplicate không, có thể đến trễ không.
  4. Operational assumptions: topic retention, replay, dead-letter, schema validation, default fallback.

Một thay đổi không động vào shape vẫn có thể là breaking change nếu semantics đổi. Ví dụ status="approved" trước đây nghĩa là đơn hàng sẵn sàng fulfill, nhưng sau redesign nó chỉ nghĩa là đã qua fraud check. Parser không vỡ, nhưng consumer business logic vỡ âm thầm.

Vì vậy, bài toán thật không phải “schema có parse được không”, mà là “consumer còn hiểu đúng sự kiện đó không”.

Vì sao event schema khó đổi hơn REST API

So sánh backward forward và full compatibility trong event versioning
So sánh backward forward và full compatibility trong event versioning

REST API thường có vài đặc điểm giúp thay đổi an toàn hơn:

  • call có tính request/response rõ ràng;
  • producer và consumer tương tác gần thời gian thực;
  • versioning path/header có thể áp dụng tương đối minh bạch;
  • lỗi incompatibility thường lộ ngay trên request mới.

Event-driven system thì khác:

  • producer và consumer tách rời theo thời gian;
  • một event có thể được nhiều consumer đọc với mục tiêu khác nhau;
  • retention giữ dữ liệu cũ trong nhiều ngày hoặc nhiều tháng;
  • replay có thể khiến code mới xử lý event cũ;
  • không phải consumer nào cũng được team producer biết đến.

Nói ngắn gọn: API incompatibility hay lộ sớm và gần điểm thay đổi. Event incompatibility hay lộ muộn, xa điểm thay đổi, và khó trace ngược hơn nhiều.

Các kiểu schema compatibility team nào cũng nên phân biệt rõ

Playbook migration schema event an toàn với dual write replay test và metrics theo version
Playbook migration schema event an toàn với dual write replay test và metrics theo version

Nếu chỉ nói “compatible” chung chung, team rất dễ hiểu sai nhau. Trong production, nên phân biệt ít nhất 3 kiểu sau.

1. Backward compatibility

Schema mới có thể đọc dữ liệu cũ.

Ví dụ:

  • consumer hoặc processor được deploy với schema mới;
  • nó vẫn parse được các event đã sinh ra từ schema cũ.

Điều này rất quan trọng cho replay, reprocessing, bootstrap projection và analytics job đọc historical data.

2. Forward compatibility

Schema cũ có thể đọc dữ liệu mới.

Ví dụ:

  • một consumer cũ chưa deploy;
  • producer mới đã bắt đầu phát event với schema mới;
  • consumer cũ vẫn chạy được, có thể bỏ qua field không biết.

Điều này quan trọng khi producer rollout nhanh hơn consumer.

3. Full compatibility

Cả hai chiều đều an toàn trong một cửa sổ version nhất định.

Đây thường là mục tiêu tốt nhất cho hệ thống có nhiều consumer độc lập và deploy không đồng bộ.

Nhầm lẫn phổ biến là team producer kiểm tra backward compatibility ở schema registry rồi tưởng như vậy là đủ. Nhưng nếu consumer cũ ngoài kia không forward-compatible, rollout vẫn có thể làm hỏng production.

Những thay đổi tưởng vô hại nhưng rất dễ thành breaking change

Dashboard quan sát schema drift với message version parse fail DLQ và consumer lag
Dashboard quan sát schema drift với message version parse fail DLQ và consumer lag

Thêm field required

Nếu consumer cũ không biết field mới nhưng producer lại bắt downstream bắt buộc xử lý field đó để behavior đúng, thì về semantics đây là breaking change dù parser không fail.

Đổi type

amount: int thành string, timestamp từ epoch millis sang ISO-8601, hoặc user_id từ number sang UUID string là những cú đổi nhìn đơn giản nhưng thường phá parser, indexer, warehouse job và rule engine.

Đổi meaning của field cũ

Đây là loại nguy hiểm nhất vì khó phát hiện bằng schema validator. Tên field giữ nguyên nhưng business meaning đổi.

Xóa field vì “không ai dùng nữa”

Trong event-driven systems, câu “không ai dùng nữa” thường có nghĩa là “không ai trong team producer biết ai đang dùng nữa”. Analytics, data platform, fraud, compliance hoặc batch enrichment thường là nạn nhân đầu tiên.

Gộp hai event thành một event generic hơn

Việc này có thể làm consumer mất branching logic rõ ràng, tăng coupling vào field enum, và khiến historical replay trở nên lẫn lộn.

Event versioning: nên version ở đâu?

Không có một đáp án duy nhất, nhưng có vài chiến lược phổ biến.

1. Version trong payload

Ví dụ:

{
  "event_type": "order_created",
  "event_version": 2,
  "payload": {
    "order_id": "ord_123",
    "customer_id": "cus_789",
    "currency": "VND"
  }
}

Ưu điểm:

  • cùng topic có thể chứa nhiều version;
  • replay và migration linh hoạt hơn;
  • producer không cần tạo quá nhiều topic.

Nhược điểm:

  • consumer phức tạp hơn vì phải branch theo version;
  • nếu topic lẫn quá nhiều version, logic xử lý và observability khó hơn;
  • dễ sinh tình trạng “support tạm” nhưng kéo dài mãi mãi.

2. Version trong topic name

Ví dụ: order-created.v1, order-created.v2

Ưu điểm:

  • ranh giới version rất rõ;
  • consumer subscribe có chủ đích;
  • metrics, retention, migration pipeline dễ tách.

Nhược điểm:

  • tăng số lượng topic/stream;
  • producer và router phức tạp hơn;
  • dual-write / fan-out trong giai đoạn chuyển đổi cần kiểm soát tốt.

3. Version trong schema registry subject

Với Avro/Protobuf/JSON Schema registry, version thường được quản trị ở mức schema artifact thay vì lộ trực tiếp trong payload. Đây là cách tốt để enforce compatibility tự động, nhưng vẫn cần quy ước rõ ở ứng dụng để consumer biết đang xử lý contract nào.

Kết luận thực dụng

  • Nếu thay đổi nhỏ, additive, có thể giữ cùng topic và dùng registry + compatibility rule.
  • Nếu thay đổi semantics lớn hoặc domain event đổi meaning, tách event mới thường sạch hơn nhiều so với cố kéo version cũ đi tiếp.
  • Đừng dùng versioning để kéo dài một event design tệ vô hạn.

Additive change không phải lúc nào cũng safe

Nhiều tài liệu nói “chỉ thêm field optional là an toàn”. Điều này đúng một phần, nhưng production thường phức tạp hơn.

Ví dụ producer thêm field discount_type và downstream pricing engine nếu không có field này sẽ default sai sang FIXED_AMOUNT. Parser vẫn chạy, nhưng business outcome sai.

Một additive change chỉ thật sự an toàn khi:

  • consumer cũ có thể bỏ qua field mới mà không đổi meaning quan trọng;
  • field mới không kéo theo invariant mới bắt buộc;
  • default behavior cũ vẫn hợp lệ;
  • analytics và warehouse mapping không gãy do schema-on-read;
  • documentation và registry được cập nhật đồng bộ.

Nói cách khác, additive ở shape chưa chắc additive ở semantics.

Schema registry giúp được gì, và không giúp được gì

Schema registry là một lớp rất đáng giá khi hệ thống bắt đầu có nhiều producer/consumer. Nó giúp:

  • centralize schema definition;
  • enforce compatibility rule trước khi producer publish schema mới;
  • cho phép generate code hoặc validate tự động;
  • giảm việc “field nào cũng optional vì sợ gãy”.

Nhưng registry không tự giải quyết các vấn đề sau:

  • semantics drift;
  • consumer business logic hiểu sai event;
  • replay code mới trên dữ liệu cũ nhưng side effect khác;
  • undocumented downstream consumer ngoài vùng kiểm soát;
  • migration sequence deploy sai thứ tự.

Nói cách khác, registry là guardrail tốt cho shape compatibility, không phải bảo hiểm toàn diện cho contract integrity.

Contract test cho event-driven systems nên kiểm gì?

Bài về contract testing cho microservices thường được nói ở HTTP boundary, nhưng tư duy đó rất hữu ích cho event boundary nữa.

Ít nhất nên có ba lớp kiểm tra:

1. Producer schema validation

Producer chỉ được publish event hợp lệ với schema đã đăng ký.

2. Consumer expectation test

Consumer cung cấp expectation rõ ràng về field, enum, invariant, default behavior và dữ liệu mẫu. Đây là nơi phát hiện thay đổi “parse được nhưng hiểu sai”.

3. Compatibility test trên fixture lịch sử

Một regression suite tốt nên chứa:

  • event thật đã anonymize từ production;
  • fixture của nhiều version trước;
  • corner case như missing field, unknown enum, duplicate event, delayed event.

Nếu chỉ test với happy-path schema mới nhất, drift sẽ lọt rất nhiều.

Một playbook migration an toàn khi cần đổi event schema

Đây là flow thực dụng cho đa số team backend.

Bước 1: phân loại thay đổi

Tự hỏi trước:

  • thay đổi này là additive, deprecating hay semantic rewrite?
  • consumer cũ có cần hiểu field mới không?
  • historical replay có bị ảnh hưởng không?
  • data warehouse / stream processor / ML feature pipeline có đọc event này không?

Nếu là semantic rewrite lớn, thường nên tạo event mới thay vì sửa event cũ.

Bước 2: inventory consumer thật sự

Đây là chỗ nhiều team bỏ qua nhất. Đừng chỉ nhìn service đồng cấp. Hãy rà:

  • service online;
  • batch job;
  • data lake / warehouse ingest;
  • fraud/risk rules;
  • audit/compliance export;
  • dashboard near-real-time;
  • replayer hoặc backfill tool.

Nếu không biết đầy đủ ai đang đọc event, rollout đang mù một nửa.

Bước 3: đăng schema mới với rule compatibility phù hợp

  • additive change nhỏ: enforce backward + forward nếu platform hỗ trợ;
  • deprecate field: giữ field cũ đủ lâu, document rõ sunset plan;
  • semantic rewrite: schema mới, event mới, subject/topic mới nếu cần.

Bước 4: dual-write hoặc dual-read trong cửa sổ chuyển đổi

Tùy hướng migration:

  • producer phát cả v1 và v2 trong thời gian ngắn;
  • hoặc consumer đọc được cả hai version;
  • hoặc có translation layer tạm thời ở giữa.

Dual-write nghe đắt nhưng thường rẻ hơn một incident âm thầm lan xuống 5 hệ thống.

Bước 5: instrument metrics theo version

Bắt buộc nên có:

  • message count theo event version;
  • parse failure theo consumer/version;
  • unknown field / unknown enum count nếu có thể;
  • DLQ count theo schema version;
  • lag theo consumer group trong giai đoạn migration.

Không có metrics theo version thì rollback rất mù.

Bước 6: replay thử trên dữ liệu cũ trước khi cut over

Nếu consumer mới không xử lý ổn event cũ, bạn vừa tự tạo lỗi latent sẽ nổ khi backfill hoặc rehydrate state.

Bước 7: xóa version cũ có kỷ luật

Chỉ sunset version cũ khi:

  • metrics chứng minh consumer cũ đã về 0 hoặc đã migrated;
  • retention window chứa event cũ đã qua hoặc đã có translator;
  • dashboard, batch, warehouse, ML jobs đã xác nhận ổn;
  • documentation và runbook đã cập nhật.

Khi nào nên tạo event mới thay vì cố tương thích ngược

Một heuristic hữu ích: nếu team phải viết quá nhiều câu kiểu “field này vẫn tên cũ nhưng giờ nghĩa mới”, thì đó không còn là evolution đẹp nữa. Đó là dấu hiệu nên tạo event mới.

Tạo event mới thường đáng cân nhắc khi:

  • meaning nghiệp vụ đổi đáng kể;
  • lifecycle event đổi;
  • cardinality đổi mạnh;
  • ordering assumption đổi;
  • idempotency key đổi;
  • nhiều consumer phải branch phức tạp theo version.

Versioning tốt là versioning làm hệ thống đơn giản hơn theo thời gian, không phải đẩy độ phức tạp xuống mọi consumer mãi mãi.

Observability cho schema drift: đừng đợi parse error mới biết có chuyện

Nhiều sự cố drift không tạo parse error rõ ràng. Chúng tạo data quality issue âm thầm. Vì vậy observability nên có cả hai lớp.

Lớp kỹ thuật

  • deserialization error;
  • schema validation fail;
  • DLQ rate;
  • retry / redelivery rate;
  • consumer lag sau rollout.

Lớp dữ liệu/nghiệp vụ

  • tỷ lệ null bất thường ở field quan trọng;
  • phân phối enum thay đổi đột ngột;
  • số bản ghi dropped do unknown state;
  • metric nghiệp vụ lệch sau rollout nhưng không có error hệ thống;
  • replay output khác biệt lớn giữa version cũ và mới.

Một dashboard tốt không chỉ hỏi “consumer có crash không?” mà còn hỏi “consumer có hiểu đúng dữ liệu không?”.

Những anti-pattern rất hay gặp

“JSON flexible mà, cứ thêm field thôi”

Tính linh hoạt của JSON không miễn trừ trách nhiệm quản trị contract.

“Consumer nào gãy thì tự fix sau”

Đây là cách nhanh nhất để biến platform event thành vùng rủi ro tổ chức. Producer có quyền thay đổi không đồng nghĩa producer được externalize cost sang mọi consumer.

“Schema registry lo hết rồi”

Registry không hiểu business meaning, replay semantics hay downstream data model.

“Không cần document vì code là tài liệu”

Trong hệ thống có nhiều consumer thuộc nhiều team, code không phải tài liệu dùng chung. Event catalog, invariant và migration note phải được viết ra.

“Version cũ support tạm vài tuần thôi”

Nếu không có expiry owner và kế hoạch sunset, “tạm vài tuần” thường biến thành 18 tháng.

Gợi ý áp dụng cho team Việt đang dùng Kafka, RabbitMQ hoặc Pub/Sub

Công cụ khác nhau, nhưng nguyên tắc chung vẫn giống nhau:

  • với Kafka: tận dụng schema registry, compatibility policy, consumer lag metrics, replay testing;
  • với RabbitMQ: vì message contract thường lỏng hơn, càng phải bù bằng validation và fixture test rõ ràng;
  • với Pub/Sub hoặc event bus managed: đừng chủ quan rằng managed transport sẽ tự xử lý contract drift giúp bạn.

Nếu team chưa đủ nguồn lực để làm platform bài bản ngay, tối thiểu hãy làm 5 việc:

  1. định nghĩa owner cho từng event quan trọng;
  2. document consumer inventory;
  3. có fixture lịch sử để regression test;
  4. có metrics theo event version;
  5. cấm semantic breaking change trá hình dưới cùng event name.

Kết nối schema drift với reliability engineering rộng hơn

Schema drift không phải chuyện riêng của data contract. Nó giao với nhiều bài toán production khác:

Kết luận

Trong event-driven systems, schema không chỉ là format trao đổi dữ liệu. Nó là một phần của kiến trúc tổ chức giữa producer, consumer và dữ liệu lịch sử. Vì vậy schema drift không nên được xử lý như bug lặt vặt ở serialization layer.

Cách tiếp cận lành mạnh hơn là:

  • coi event như public contract;
  • phân biệt rõ backward, forward và semantic compatibility;
  • dùng schema registry nhưng không ảo tưởng vào nó;
  • migration có dual-read/dual-write khi cần;
  • đo lường theo version và kiểm trên dữ liệu lịch sử;
  • tạo event mới khi meaning đã đổi quá xa.

Nếu làm tốt, team không chỉ tránh được vài parse error. Team sẽ tránh được một loại incident rất đắt: hệ thống vẫn “chạy”, nhưng dữ liệu chảy sai âm thầm qua nhiều service trong nhiều ngày.