Backward/Forward Compatibility trong Thiết kế Schema: Nguyên tắc và Thực tiễn

30 tháng 5, 2021
7 phút đọc
Backward/Forward Compatibility trong Thiết kế Schema: Nguyên tắc và Thực tiễn

Trong quá trình phát triển hệ thống, việc thay đổi cấu trúc dữ liệu (schema evolution) là điều không thể tránh khỏi. Giống như thiết kế kiến trúc phần mềm, việc định nghĩa schema cần được tính toán kỹ lưỡng để đảm bảo khả năng tương thích giữa các phiên bản cũ và mới — hay còn gọi là backward compatibilityforward compatibility.


Backward Compatibility và Forward Compatibility là gì?#

  • Backward Compatibility (Tương thích ngược): Schema mới có thể đọc dữ liệu được tạo bởi schema cũ. Điều này đảm bảo rằng khi bạn nâng cấp ứng dụng, nó vẫn có thể xử lý được dữ liệu cũ đã lưu trữ trước đó.

  • Forward Compatibility (Tương thích xuôi): Schema cũ có thể đọc dữ liệu được tạo bởi schema mới. Tính năng này quan trọng khi hệ thống có nhiều phiên bản cùng hoạt động, giúp phiên bản cũ không bị lỗi khi gặp dữ liệu mới.

Ví dụ thực tế: Hãy tưởng tượng một hệ thống event-driven với microservices.

  • Service A (v1) gửi event UserCreated với field user_id.

  • Service B (v1) đăng ký nhận event này.

  • Khi Service A nâng cấp lên v2, thêm field email vào event.

  • Nếu schema forward compatible, Service B (v1) vẫn có thể đọc event từ Service A (v2), bỏ qua field mới và tiếp tục chạy êm. Đây chính là sức mạnh của forward compatibility.

Loại Tương thíchConsumer Mới (Schema mới)Consumer Cũ (Schema cũ)Tác động đến Hệ thống
Backward Compatible✅ Có thể đọc dữ liệu Nâng cấp consumer an toàn. Dữ liệu cũ vẫn hữu dụng.
Forward Compatible✅ Có thể đọc dữ liệu mớiRollback an toàn. Các service cũ không crash với dữ liệu mới.
Fully Compatible✅ Đọc được dữ liệu cũ✅ Đọc được dữ liệu mớiLý tưởng nhất. Cho phép nâng cấp không đồng bộ và linh hoạt.

Khái niệm Fully Compatibility là mục tiêu tối thượng của mọi chiến lược schema evolution.


Nguyên tắc thiết kế schema để đảm bảo tương thích#

1. Luôn cung cấp giá trị mặc định (default value) cho các field mới#

Khi thêm một field mới vào schema, hãy luôn đặt giá trị mặc định cho nó. Điều này giúp thỏa mãn forward compatibility khi mình consume message từ version cũ. Ngược lại, nếu bạn cần xóa một field, việc đã khai báo giá trị mặc định trước đó giúp đảm bảo backward compatibility, vì consumer mới vẫn có thể xử lý dữ liệu cũ thiếu field đó.

Ví dụ với Avro:

{
  "type": "record",
  "name": "User",
  "fields": [
    {"name": "id", "type": "int"},
    {"name": "name", "type": "string"},
    {"name": "email", "type": ["null", "string"], "default": null}
  ]
}

Khi thêm field email, ta đặt default là null. Consumer cũ không biết đến email sẽ bỏ qua, consumer mới có thể đọc dữ liệu cũ mà không có email và gán giá trị mặc định.

2. Không thay đổi kiểu dữ liệu (data type) của field đã có#

Thay đổi kiểu dữ liệu (ví dụ từ int sang string) sẽ phá vỡ cả backward và forward compatibility. Nếu cần thay đổi kiểu dữ liệu, hãy thêm một field mới với kiểu mong muốn và giữ nguyên field cũ (có thể đánh dấu deprecated). Consumer có thể dần dần chuyển sang field mới.

Ví dụ: Thay vì đổi age từ int sang string, ta thêm age_str và giữ age:

{"name": "age", "type": "int"},
{"name": "age_str", "type": ["null", "string"], "default": null}

3. Cẩn trọng khi thay đổi Enum#

Thêm giá trị vào Enum được coi là thay đổi schema, nhưng vẫn có thể thực hiện nếu consumer cũ có cơ chế xử lý giá trị mặc định khi gặp giá trị mới. Tuy nhiên, xóa hoặc thay đổi giá trị Enum có thể gây lỗi. Luôn đặt giá trị mặc định cho field kiểu Enum để phòng trường hợp consumer cũ gặp giá trị không xác định.

4. Không đổi tên field, sử dụng alias nếu cần#

Thay đổi tên field sẽ phá vỡ compatibility. Với Avro, bạn có thể dùng alias để ánh xạ tên cũ và tên mới. Các định dạng khác như Protobuf cũng hỗ trợ tương tự thông qua tag number.

Ví dụ Avro alias:

{"name": "new_field_name", "type": "string", "aliases": ["old_field_name"]}

5. Xóa field phải có lý do và đảm bảo đã có giá trị mặc định#

Nếu một field không còn dùng nữa, bạn có thể xóa nó khỏi schema, nhưng chỉ khi field đó đã có giá trị mặc định. Consumer mới sẽ không mong đợi field đó, trong khi producer cũ vẫn tạo dữ liệu có field đó (và được bỏ qua bởi consumer mới).


Case Study: Chiến lược Schema Evolution trong Apache Kafka với Avro#

Giả sử chúng ta có schema Order ban đầu:

{
  "type": "record",
  "name": "Order",
  "fields": [
    {"name": "id", "type": "string"},
    {"name": "amount", "type": "double"},
    {"name": "created_at", "type": "long"}
  ]
}

Tình huống 1: Cần thêm field currency.#

Giải pháp tương thích: Thêm field với default.

{"name": "currency", "type": "string", "default": "USD"}

Schema Registry sẽ xác nhận thay đổi này là BACKWARDFORWARD compatible. Mọi consumer cũ và mới đều hoạt động.

Tình huống 2: Cần đổi kiểu amount từ double sang string để tránh lỗi làm tròn.#

Cách làm sai: Sửa trực tiếp amount.
Chiến lược đúng: Sử dụng Schema Migration với Field Mới.

  1. Thêm field mới amount_decimal (kiểu string).

  2. Cập nhật producer để ghi cả hai field (amountamount_decimal).

  3. Cập nhật consumer mới để đọc amount_decimal.

  4. Chạy song song, chuyển đổi logic dần dần.

  5. Khi tất cả consumer đã chuyển sang mới, có thể đánh dấu amount là deprecated và ngừng ghi.

Bài học#

  • Sự thay đổi được quản lý bằng một quy trình (process), không phải một thao tác (operation) đơn lẻ.

  • Quản lý schema bằng tay là con đường dẫn đến thảm họa. Nếu được, hãy sử dụng các công cụ hỗ trợ Schema Registry (Confluent, AWS Glue): Bạn có thể cấu hình quy tắc tương thích (compatibility rules): BACKWARD, FORWARD, FULL, NONE, lưu trữ lịch sử thay đổi và cung cấp schema cho producer/consumer. Nên bắt đầu với FULL cho các topic quan trọng.


Cơ chế hoạt động của các định dạng#

Mỗi định dạng có cách tiếp cận riêng để hỗ trợ tương thích:

  • Apache Avro: Dựa vào writer's schemareader's schema. Khi đọc dữ liệu, nó khớp tên field, cho phép bỏ qua field thừa và dùng giá trị mặc định cho field thiếu. Alias là vũ khí mạnh để đổi tên.

  • Protocol Buffers (Protobuf): Sức mạnh nằm ở tag numbers. Mỗi field có một tag duy nhất. Consumer cũ sẽ bỏ qua các tag không biết (forward compatibility), và consumer mới có thể đọc tag cũ (backward compatibility). Kiểu dữ liệu mới có thể được thêm vào cho cùng một tag nếu tương thích (ví dụ: int32 -> int64).

  • Apache Thrift: Tương tự Protobuf với field IDs. Hỗ trợ các annotation như required, optional - cần cực kỳ thận trọng khi thay đổi chúng.

  • JSON Schema: Linh hoạt nhưng không có cơ chế built-in cho versioning. Tương thích phụ thuộc hoàn toàn vào logic xử lý của ứng dụng. Cần tuân thủ các nguyên tắc một cách nghiêm ngặt.


Kết luận#

Thiết kế schema tương thích không phải là một mẹo kỹ thuật, mà là một tư duy kiến trúc. Nó đòi hỏi sự kết hợp giữa:

  • Hiểu biết kỹ thuật về các định dạng và nguyên tắc.

  • Quy trình hợp tác giữa các team (backend, data engineering, analytics).

  • Công cụ tự động hóa để thực thi các quy tắc.

Bằng cách áp dụng các nguyên tắc và chiến lược trong bài viết này, bạn không chỉ xây dựng được hệ thống mạnh mẽ trước thay đổi, mà còn tạo ra một nền tảng cho phép đổi mới nhanh chóng và an toàn. Hãy bắt đầu bằng việc thiết lập một Schema Registry, thống nhất bộ quy tắc trong team, và biến schema evolution từ nỗi sợ hãi thành một lợi thế cạnh tranh.

Tài liệu tham khảo & Đọc thêm#

Khám phá thêm