Encoding & Schema Evolution — Thrift, Protobuf, Avro và bài toán tương thích dữ liệu (DDIA)

Phong

Mở đầu

Ảnh: Markus Spiske — Pexels

Backend engineer nào cũng từng gặp tình huống này: hôm qua deploy service A lên production, hôm nay service B không đọc được dữ liệu từ A. Hoặc tệ hơn — rollback một version, và dữ liệu cũ không tương thích với code mới. Nghe quen không?

Đây chính là lý do Chapter 4 của DDIA — "Encoding and Evolution" — ra đời. Khi hệ thống của bạn phát triển, dữ liệu cũng cần "tiến hóa" cùng. Và câu chuyện bắt đầu từ thứ cơ bản nhất: chúng ta encode dữ liệu như thế nào trước khi gửi qua mạng hay lưu xuống disk?

Vấn đề với JSON và XML

JSON và XML quá phổ biến rồi — gần như ai cũng dùng. Nhưng khi bạn làm việc ở scale lớn, chúng bộc lộ vài điểm yếu:

  • Số không có phân biệt integer và float — JSON chỉ có một kiểu "number". Con số lớn hơn 2⁵³ bị mất precision khi parse ở nhiều ngôn ngữ.
  • Binary strings không được hỗ trợ tốt — phải base64 encode, làm dữ liệu phình to 33%.
  • Schema không bắt buộc — parser phải tự suy luận kiểu dữ liệu, dễ gặp lỗi runtime.
  • Dung lượng lớn — mỗi field name được lặp lại trong từng record.

Đó là lý do tại sao các binary encoding format ra đời. Thay vì gửi {"user_name": "Alice", "user_id": 123}, họ chỉ gửi mã số field + value — tiết kiệm rất nhiều bytes.

Thrift — BinaryProtocol vs CompactProtocol

Ảnh: Christina Morillo — Pexels

Thrift do Facebook phát triển, là một trong những binary encoding format đầu tiên được dùng rộng rãi. Điểm đặc biệt: bạn định nghĩa schema bằng IDL (Interface Definition Language), và Thrift sinh code cho bạn tự động encode/decode.

Thrift có hai định dạng encoding:

BinaryProtocol: Mỗi field được encode thành 3 phần — field type (1 byte), field tag (2 bytes), và value. Field tag là số nguyên, field name là string — nhưng trong wire format, chỉ gửi tag, không gửi name. Đây là điểm mấu chốt cho schema evolution.

CompactProtocol: Giống BinaryProtocol nhưng nén thêm. Dùng variable-length integers (varint) — số nhỏ tốn ít bytes hơn. Kết quả: kích thước chỉ bằng ~⅓ của BinaryProtocol.

Ví dụ: record {"userName": "Martin", "favoriteNumber": 1337, "interests": ["daydreaming", "hacking"]} tốn 81 bytes dạng JSON, 59 bytes BinaryProtocol, và chỉ 34 bytes CompactProtocol.

Protocol Buffers — Của Google

Protobuf (Protocol Buffers) của Google rất giống Thrift CompactProtocol. Cũng dùng field tags, cũng có IDL. Nhưng có vài khác biệt quan trọng:

  • Optional fields: Protobuf phân biệt field có mặt hay không. Nếu không có giá trị, nó không gửi gì cả — tiết kiệm bandwidth.
  • Wire types: Mỗi field có một wire type (0: varint, 1: 64-bit, 2: length-delimited, 3/4: start/end group, 5: 32-bit). Parser dùng wire type để biết cách parse, không cần schema definition lúc runtime.
  • Oneof: Protobuf hỗ trợ union types — một field chỉ được set 1 giá trị trong danh sách.
  • Backward/forward compatibility: Bạn có thể thêm field mới — old code chỉ việc bỏ qua field có tag nó không biết. Ngược lại, bạn không được xoá field đang dùng (có thể dùng reserved keyword để đánh dấu).

Protobuf rất phổ biến trong microservices — gRPC mặc định dùng Protobuf để serialize dữ liệu. Mỗi service đều maintain một bản copy của .proto file, và gRPC đảm bảo tương thích nếu bạn tuân thủ vài quy tắc đơn giản.

Avro — Của Apache Hadoop

Ảnh: Zayed Hossain — Pexels

Avro khác hoàn toàn Thrift và Protobuf ở một điểm: không có field tags.

Thay vì mỗi field có một số tag riêng, Avro encode dữ liệu dựa vào thứ tự field trong schema. Khi đọc, nó cần schema của writer (schema mà data được ghi) và schema của reader (schema bạn dùng để đọc). Nếu hai schema giống nhau hoặc tương thích, bạn có thể đọc được dữ liệu.

Điều này tạo ra một design rất khác:

  • Schema resolution: Avro có cơ chế so sánh writer schema và reader schema tự động. Nếu writer có field A và reader không có, nó bỏ qua. Nếu reader có field B mà writer không có, nó dùng default value.
  • Không cần field tags — schema tự định nghĩa vị trí. Avro file thường kèm schema ngay trong header, hoặc lưu ở schema registry (Kafka, Confluent).
  • Dễ thay đổi field name — vì không có tag, field name chỉ là metadata. Bạn có thể rename field mà vẫn tương thích.

Avro rất mạnh trong batch processingdata lakes (Hadoop, Spark, Kafka). Khi có hàng triệu records, việc gộp schema vào file header giúp bất kỳ ai cũng có thể đọc được dữ liệu — không cần schema definition riêng.

So sánh — Ai thắng?

Không có format nào "tốt nhất" cả — tuỳ vào bài toán của bạn:

Tiêu chíThriftProtobufAvro
Field tagsKhông
Schema trong dataKhôngKhôngCó (Avro file)
Dễ đổi field nameKhó (có tag)Khó (có tag)Dễ
Kích thướcTrung bìnhNhỏNhỏ nhất
Hỗ trợ RPCCó (riêng)gRPCThường dùng với Kafka
Phổ biến ởHệ thống cũ (FB)MicroservicesBig data / Streaming

Schema Evolution — Khi nào code cũ gặp dữ liệu mới?

Phần quan trọng nhất: bạn deploy service lên production, nhưng dữ liệu cũ (đã lưu trong database hoặc queue) vẫn còn đó. Làm sao để code mới đọc được dữ liệu cũ, và code cũ đọc được dữ liệu mới?

Cả ba format đều hỗ trợ điều này — nhưng cách thực hiện khác nhau:

  • Thêm field mới: Thrift/Protobuf dùng tag mới → code cũ bỏ qua. Avro dùng default value với field mới.
  • Xoá field: Thrift/Protobuf không được xoá — chỉ có thể reserved tag. Avro xoá được nếu reader không cần.
  • Đổi kiểu dữ liệu: Phức tạp. Protobuf có vài chuyển đổi an toàn (int32 → int64), Avro dùng schema resolution tự động.
  • Đổi tên field: Avro dễ (alias), Thrift/Protobuf khó vì dùng tag.

Nguyên tắc chung: backward compatibility (code mới đọc dữ liệu cũ) và forward compatibility (code cũ đọc dữ liệu mới) là hai mặt của vấn đề. Trong hệ thống thực tế, bạn cần cả hai — rollout từ từ, rollback an toàn, và data luôn đọc được ở bất kỳ version nào.

Key Takeaways

  • JSON và XML không đủ tốt cho hệ thống lớn — binary encoding format giải quyết vấn đề kích thước, độ chính xác và schema.
  • Thrift và Protobuf dùng field tags (số nguyên) thay vì field names — giúp schema evolution nhưng khó đổi tên field.
  • Avro không dùng field tags — dựa vào writer schema và reader schema, lý tưởng cho big data và streaming.
  • Schema evolution là kỹ năng bắt buộc khi làm microservices — deploy không làm hỏng dữ liệu cũ, rollback không bị crash.
  • Chọn format dựa vào use case: gRPC → Protobuf, Kafka → Avro, internal tools → Thrift.

Glossary

Thuật ngữÝ nghĩa
Binary EncodingCách encode dữ liệu dạng nhị phân thay vì text, nhỏ gọn hơn JSON/XML
Field TagSố nguyên đánh dấu field trong schema — dùng thay field name khi encode
Schema EvolutionKhả năng thay đổi schema (thêm/xoá/sửa field) mà không làm hỏng dữ liệu cũ
Writer SchemaSchema dùng khi ghi dữ liệu (Avro concept)
Reader SchemaSchema dùng khi đọc dữ liệu — Avro tự động resolve giữa writer và reader schema
VarintVariable-length integer — encode số nguyên với độ dài thay đổi, số nhỏ tốn ít bytes
IDLInterface Definition Language — ngôn ngữ định nghĩa schema cho Thrift/Protobuf
Wire TypeTrong Protobuf — chỉ định cách parse value (varint, 64-bit, length-delimited v.v.)
Schema RegistryCentral repository lưu schema versions — dùng trong Kafka/Avro để đảm bảo tương thích

Kết

Chapter 4 của DDIA là một chapter "thực chiến" — nó trả lời câu hỏi mà bất kỳ backend engineer nào cũng gặp: làm sao để dữ liệu không bị hỏng khi hệ thống thay đổi. Binary encoding formats như Thrift, Protobuf và Avro không chỉ giúp tiết kiệm bandwidth — chúng còn là nền tảng để xây dựng hệ thống có thể tiến hoá theo thời gian.

Phần tiếp theo (4.2) sẽ nói về cách dữ liệu chảy qua các hệ thống — từ database qua REST/RPC tới message queue. Nếu bạn đã deploy microservices, chắc chắn không nên bỏ qua.