Lập trình · 21/09/2026

Tiến hóa Schema sự kiện cho Backend: Tương thích ngược mà không làm gián đoạn consumer

Một sự kiện đã được publish không còn là chi tiết nội bộ của producer. Nó có thể nằm trong hàng đợi, được lưu nhiều ngày để replay, đi vào data lake hoặc được xử lý bởi những consumer mà đội phát triển producer không nhìn thấy. Vì vậy, đổi tên một field, thay kiểu dữ liệu hoặc tái sử dụng một giá trị enum có thể khiến hệ thống lỗi âm thầm dù producer mới vẫn chạy bình thường.

Tiến hóa Schema sự kiện cho Backend: Tương thích ngược mà không làm gián đoạn consumer

Một sự kiện đã được publish không còn là chi tiết nội bộ của producer. Nó có thể nằm trong hàng đợi, được lưu nhiều ngày để replay, đi vào data lake hoặc được xử lý bởi những consumer mà đội phát triển producer không nhìn thấy. Vì vậy, đổi tên một field, thay kiểu dữ liệu hoặc tái sử dụng một giá trị enum có thể khiến hệ thống lỗi âm thầm dù producer mới vẫn chạy bình thường.

Bài viết này trình bày cách tiến hóa schema sự kiện trong hệ thống event-driven mà không buộc mọi dịch vụ phải deploy cùng lúc. Trọng tâm là contract rõ ràng, quy tắc tương thích, chiến lược rollout theo thứ tự, kiểm tra tự động và khả năng xử lý dữ liệu cũ khi replay. Ví dụ dùng JSON để dễ đọc, nhưng các nguyên tắc cũng áp dụng cho Avro, Protobuf và những định dạng có schema khác.

1. Schema sự kiện là một hợp đồng lâu dài

API đồng bộ thường có request và response tồn tại trong vài giây. Sự kiện có vòng đời dài hơn nhiều: broker có thể giữ message trong nhiều ngày, kho lưu trữ có thể giữ nhiều năm và một consumer mới có thể đọc lại toàn bộ lịch sử. Contract vì thế không chỉ mô tả payload hiện tại; nó còn phải giải thích danh tính sự kiện, ý nghĩa nghiệp vụ, quy tắc của từng field và cách các phiên bản liên hệ với nhau.

Một envelope thực dụng nên tách metadata chung khỏi dữ liệu domain. Các field như event_id, event_type, occurred_at, schema_version, producer, correlation_iddata giúp consumer kiểm tra, quan sát và định tuyến mà không đoán cấu trúc. event_id nhận diện lần phát cụ thể; nó không nên bị thay bằng aggregate ID hay correlation ID vì ba khái niệm có phạm vi khác nhau.

{
  "event_id": "01J...",
  "event_type": "order.confirmed",
  "schema_version": 2,
  "occurred_at": "2026-09-21T14:10:00Z",
  "producer": "order-service",
  "correlation_id": "req-...",
  "data": {
    "order_id": "ord_123",
    "currency": "VND",
    "total_amount": 1250000
  }
}
Schema kiểm tra hình dạng dữ liệu; tài liệu contract phải kiểm tra cả ý nghĩa. Một field đúng kiểu nhưng đổi đơn vị, múi giờ hoặc định nghĩa nghiệp vụ vẫn là breaking change.

2. Phân biệt backward, forward và full compatibility

Thuật ngữ compatibility chỉ có ý nghĩa khi nói rõ ai đọc dữ liệu của ai. Backward compatibility thường có nghĩa consumer dùng schema mới vẫn đọc được dữ liệu do schema cũ tạo ra. Forward compatibility nghĩa consumer cũ vẫn đọc được dữ liệu do schema mới tạo ra. Full compatibility yêu cầu cả hai hướng. Một số registry còn có chế độ transitive, kiểm tra schema mới với toàn bộ lịch sử thay vì chỉ phiên bản liền trước.

Mục tiêuTình huống cần bảo vệVí dụ
BackwardConsumer mới replay message cũField mới có default hoặc là optional
ForwardConsumer cũ gặp message mớiConsumer bỏ qua field chưa biết
FullProducer và consumer được nâng cấp độc lập theo cả hai thứ tựThay đổi vừa đọc được dữ liệu cũ vừa không phá reader cũ
TransitiveDữ liệu rất cũ vẫn còn được replaySo schema mới với mọi version được giữ lại

Không nên chọn chế độ registry chỉ vì tên nghe an toàn. Hãy bắt đầu từ chính sách retention và rollout. Nếu topic được giữ vô hạn để rebuild projection, consumer hiện tại phải hiểu toàn bộ lịch sử liên quan. Nếu consumer cũ có thể chạy nhiều tháng ở thiết bị biên, producer phải duy trì forward compatibility lâu hơn một hệ thống deploy tập trung.

3. Phân loại thay đổi trước khi sửa code

Thêm field optional thường an toàn hơn xóa hoặc đổi field, nhưng không tự động an toàn. Consumer có thể dùng deserializer nghiêm ngặt và từ chối property lạ. Một field mới không có default có thể khiến reader mới không đọc được message cũ. Ngược lại, đổi field từ integer sang string có thể hợp lệ trong một số JSON parser nhưng làm hỏng validation, truy vấn và code sinh kiểu tĩnh.

Thay đổiRủi ro chínhCách xử lý ưu tiên
Thêm field optionalReader cũ từ chối field lạQuy định rõ unknown-field policy và kiểm thử
Thêm field bắt buộcMessage lịch sử thiếu dữ liệuThêm optional/default trước, bắt buộc ở bước sau nếu thật sự cần
Đổi tên fieldConsumer cũ không tìm thấy fieldExpand bằng field mới, ghi song song, migrate rồi contract field cũ
Đổi kiểu hoặc đơn vịDữ liệu bị từ chối hoặc hiểu saiTạo field mới có tên và semantics rõ ràng
Xóa giá trị enumReplay dữ liệu cũ thất bạiGiữ khả năng đọc giá trị cũ; map sang trạng thái legacy
Đổi ý nghĩa eventPayload hợp lệ nhưng quyết định nghiệp vụ saiTạo event type mới thay vì tái sử dụng tên cũ

Breaking change nguy hiểm nhất thường là thay đổi semantic không được schema phát hiện: amount từng là đơn vị tiền chính rồi trở thành đơn vị nhỏ nhất; timestamp từng biểu diễn lúc giao dịch xảy ra rồi chuyển thành lúc record được ghi; danh sách từng đầy đủ rồi trở thành delta. Những thay đổi này cần field hoặc event mới, tài liệu migration và test nghiệp vụ, không chỉ validation cú pháp.

4. Dùng chiến lược expand–migrate–contract

Giả sử customer_name cần tách thành given_namefamily_name. Xóa field cũ ngay sẽ phá consumer chưa nâng cấp. Rollout an toàn gồm ba pha. Ở pha expand, producer thêm field mới nhưng vẫn phát field cũ. Consumer được cập nhật để ưu tiên field mới và fallback field cũ. Ở pha migrate, theo dõi adoption, replay hoặc backfill nếu cần và dừng tạo dependency mới lên field cũ. Chỉ ở pha contract, sau khi đã có bằng chứng không còn reader cần field cũ, producer mới ngừng phát nó.

function readCustomerName(data):
  if data.given_name exists or data.family_name exists:
    return join(data.given_name, data.family_name)
  return data.customer_name

Ghi song song có chi phí: payload lớn hơn và hai biểu diễn có thể mâu thuẫn. Producer phải có một nguồn sự thật và sinh cả hai field từ cùng dữ liệu trong một transaction. Contract cần nói rõ field nào ưu tiên khi cả hai xuất hiện. Khoảng thời gian dual-write phải có deadline và owner; nếu không, compatibility shim sẽ tồn tại vĩnh viễn.

5. Version event type hay version schema?

Không phải thay đổi nào cũng cần tạo OrderConfirmedV2. Nếu một field optional được thêm mà vẫn giữ nguyên ý nghĩa sự kiện, schema version mới dưới cùng event type thường đủ. Nếu intent, thời điểm phát, cardinality hoặc semantics thay đổi đáng kể, event type mới rõ ràng hơn. Ví dụ chuyển từ snapshot toàn bộ đơn hàng sang delta cập nhật không nên âm thầm giữ tên cũ.

  • Dùng schema version để mô tả sự tiến hóa tương thích của cùng một sự thật nghiệp vụ.
  • Dùng event type mới khi consumer cần quyết định xử lý khác hoặc ý nghĩa cũ không còn đúng.
  • Không đặt version chỉ trong topic name nếu message được lưu chung hoặc chuyển qua nhiều hệ thống.
  • Không tạo một topic mới cho mọi thay đổi nhỏ; việc đó phân mảnh ordering, quyền truy cập và vận hành.

schema_version giúp chẩn đoán và chọn adapter, nhưng không thay cho schema identifier bất biến trong registry. Với định dạng nhị phân, message thường mang schema ID để deserializer lấy đúng writer schema. Với JSON, có thể mang schema ID trong header hoặc envelope. Dù dùng cách nào, dữ liệu đã phát phải luôn trỏ tới định nghĩa không bị sửa tại chỗ.

6. Producer và consumer phải cùng tuân thủ contract

Producer nên validate message trước khi publish, nhưng validation không được biến thành lời hứa “exactly once”. Việc ghi state và ý định phát event vẫn cần cơ chế như Transactional Outbox. Schema registry giải quyết tính hợp lệ và compatibility của contract; nó không giải quyết dual write, duplicate, ordering hay quyền sở hữu nghiệp vụ.

Consumer nên coi payload là dữ liệu không tin cậy: xác thực schema, giới hạn kích thước, xử lý field chưa biết theo policy và phân biệt lỗi tạm thời với lỗi vĩnh viễn. Khi gặp schema không hỗ trợ, consumer không nên retry vô hạn cùng một message. Hãy đưa nó vào quarantine hoặc dead-letter flow có schema ID, event ID và nguyên nhân, đồng thời cảnh báo đội sở hữu contract.

  • Không dùng default để che thiếu dữ liệu quan trọng; default phải có ý nghĩa nghiệp vụ rõ.
  • Không ánh xạ enum lạ thành một giá trị hiện có nếu điều đó tạo quyết định sai; dùng UNKNOWN hoặc nhánh kiểm soát.
  • Không phụ thuộc vào thứ tự property của JSON.
  • Không cho phép consumer dùng field nội bộ chưa được công bố chỉ vì nó đang xuất hiện trong payload.

7. Đưa compatibility check vào CI

Review bằng mắt không đủ cho hàng chục event type. Mỗi thay đổi schema nên chạy ba lớp kiểm tra. Lớp đầu validate cú pháp schema và ví dụ. Lớp hai gọi compatibility checker với subject và policy thật. Lớp ba chạy contract test của những consumer quan trọng trên fixture cũ, fixture mới và dữ liệu biên. Pull request phải hiển thị diff dễ đọc: field thêm, xóa, đổi kiểu, đổi required, enum và default.

pipeline:
  lint schemas
  validate examples against writer schema
  check compatibility against registry history
  run producer serialization tests
  run consumer tests with old and new fixtures
  publish immutable schema artifact
  deploy consumer before producer when required

Đừng để CI tự đăng ký schema production từ một nhánh chưa merge. Build nên kiểm tra bằng API read-only hoặc môi trường registry riêng, sau đó pipeline release mới đăng ký artifact đã được duyệt. Quyền thay đổi policy compatibility phải hạn chế và có audit; nếu bất kỳ service nào cũng có thể chuyển subject sang “none”, quality gate chỉ còn mang tính trang trí.

8. Chọn thứ tự triển khai theo hướng tương thích

Với thay đổi thêm field, cách phổ biến là deploy consumer tolerant trước, sau đó deploy producer phát field mới. Với việc ngừng field cũ, deploy consumer không còn phụ thuộc trước, quan sát đủ lâu, rồi mới dừng producer. Nếu phải đổi semantics, phát event type mới song song, chuyển từng consumer và chỉ retire luồng cũ sau khi lag, lưu lượng và replay requirement đều cho phép.

  1. Đăng ký schema mới và xác nhận policy cho đúng subject.
  2. Deploy reader có thể đọc cả dữ liệu cũ và mới.
  3. Quan sát tỷ lệ version, lỗi deserialization và consumer lag.
  4. Bật producer mới theo canary hoặc feature flag.
  5. Chuyển toàn bộ producer sau khi canary ổn định.
  6. Chỉ contract field cũ khi inventory consumer và thời hạn retention chứng minh an toàn.

Rollback cũng phải được thiết kế. Nếu producer mới đã phát message version mới, rollback binary không làm các message đó biến mất. Consumer cũ được khôi phục phải vẫn đọc được chúng, hoặc rollout cần dừng producer, giữ consumer tương thích và xử lý backlog có kiểm soát. Đây là lý do forward compatibility quan trọng trong môi trường deploy độc lập.

9. Replay là bài kiểm tra thật của schema evolution

Nhiều thiết kế chỉ kiểm tra message “đang bay” và thất bại khi rebuild projection từ dữ liệu nhiều năm. Trước release, hãy chạy consumer mới trên một tập dữ liệu lịch sử đại diện. Kiểm tra mọi schema ID còn trong retention, các field từng có default khác nhau, timezone, decimal, enum đã ngừng dùng và event bị phát trùng.

Có hai cách đọc lịch sử. Consumer có thể chứa adapter cho từng version rồi chuyển về một model nội bộ chuẩn hóa. Hoặc một tiến trình upcaster có thể nâng payload cũ từng bước trước khi business handler chạy. Upcaster phải thuần, xác định và được version-control; không nên gọi database hiện tại để “đoán” dữ liệu quá khứ vì kết quả replay sẽ thay đổi theo thời gian.

v1 payload -> upcastV1ToV2 -> upcastV2ToV3 -> CurrentEvent

rules:
  same input produces same output
  no network calls
  preserve original event identity
  record source and target schema versions

10. Governance vừa đủ để không chặn tốc độ

Mỗi event type cần một owner, subject naming convention, compatibility policy, retention expectation và danh sách consumer có thể tra cứu. Catalog không cần trở thành cổng phê duyệt nặng nề, nhưng phải giúp trả lời: ai được thay contract, dữ liệu có nhạy cảm không, consumer nào đang dùng, schema nào còn tồn tại và khi nào một field deprecated có thể bị xóa.

Không đưa bí mật, token hoặc dữ liệu cá nhân dư thừa vào event với hy vọng xóa sau. Event đã được sao chép sang broker, log và kho phân tích rất khó thu hồi. Schema review nên bao gồm data classification, giới hạn payload và chính sách redaction. Field chứa dữ liệu nhạy cảm cần lý do nghiệp vụ, quyền truy cập và thời hạn lưu phù hợp.

11. Quan sát và xử lý sự cố contract

Dashboard nên theo dõi số message theo event type và schema version, lỗi serialize/deserialize, số message bị quarantine, tuổi consumer lag và tỷ lệ unknown enum. Log lỗi cần có event ID, event type, schema ID/version, topic/partition/offset và tên consumer, nhưng không ghi toàn bộ payload nhạy cảm.

Khi xảy ra sự cố, ưu tiên ngăn producer phát thêm dữ liệu không tương thích, giữ nguyên message lỗi để điều tra và xác định blast radius theo schema version. Không chỉnh sửa schema đã đăng ký để “làm cho nó pass”; thao tác đó phá tính bất biến và khiến cùng một schema ID mang hai ý nghĩa. Hãy đăng ký version mới, sửa producer hoặc thêm adapter có kiểm thử, rồi replay từ offset được kiểm soát.

Checklist triển khai production

  • Event type, owner, semantics và envelope đã được tài liệu hóa.
  • Compatibility policy phản ánh retention và thứ tự deploy thực tế.
  • Thay đổi được phân loại cả về cấu trúc lẫn ý nghĩa nghiệp vụ.
  • Schema artifact bất biến; schema ID/version đi cùng message hoặc header.
  • CI kiểm tra schema diff, compatibility, fixture cũ/mới và contract consumer.
  • Consumer tolerant được deploy trước khi producer phát biểu diễn mới.
  • Rollback đã tính đến message mới còn nằm trong broker.
  • Replay lịch sử chạy được qua adapter hoặc upcaster xác định.
  • Metrics, quarantine, cảnh báo và runbook xử lý contract violation đã sẵn sàng.
  • Field deprecated có owner, số liệu sử dụng và ngày loại bỏ dự kiến.

Tiến hóa schema an toàn không đến từ việc gắn hậu tố v2 cho mọi sự kiện. Nó đến từ một hợp đồng có semantics rõ, policy tương thích phù hợp với vòng đời dữ liệu, CI chặn thay đổi nguy hiểm và rollout cho phép producer cùng consumer nâng cấp độc lập. Khi replay, rollback và quan sát được xem là một phần của thiết kế, event-driven architecture mới giữ được tính linh hoạt mà không biến mỗi release thành một cuộc deploy đồng loạt.

Nguồn tham khảo

Thảo luận

Bình luận 0

Đăng nhập để bình luận

Bạn cần có tài khoản để tham gia thảo luận và trả lời độc giả khác.

Đăng nhậpĐăng ký

Chưa có bình luận. Hãy là người đầu tiên chia sẻ ý kiến.