Chương 4 — Encoding and Evolution
Phần I — Foundations of Data Systems

Chương 4 — Encoding and Evolution

16 phút đọc DDIA · Martin Kleppmann

🎯 Mục tiêu chương: Hiểu cách dữ liệu được chuyển từ cấu trúc in-memory thành chuỗi byte (encoding) và các format phổ biến (JSON/XML, Thrift, Protocol Buffers, Avro) xử lý schema evolution ra sao. Nắm khái niệm backward/forward compatibility và cách chúng áp dụng cho ba kiểu dataflow: qua database, qua service (REST/RPC), và qua message passing — nền tảng để làm rolling upgrade không downtime.

Vì sao encoding và evolution quan trọng

Ứng dụng luôn thay đổi: thêm feature, hiểu rõ hơn yêu cầu, business đổi. Thay đổi feature thường kéo theo thay đổi dữ liệu (thêm field, thêm loại record, trình bày khác). Chương 1 gọi đây là evolvability.

  • Relational DB: tại mỗi thời điểm chỉ có một schema (đổi bằng migration ALTER).
  • Schema-on-read (schemaless): DB chứa lẫn dữ liệu format cũ và mới.

Nhưng code không đổi ngay lập tức được:

  • Server-side: dùng rolling upgrade (staged rollout) — deploy bản mới lên vài node một lúc, kiểm tra ổn rồi mới lan dần → không downtime, deploy thường xuyên hơn.
  • Client-side: phụ thuộc người dùng, họ có thể không cập nhật app rất lâu.

→ Code cũ, code mới, dữ liệu cũ, dữ liệu mới cùng tồn tại. Cần tương thích hai chiều:

Khái niệmĐịnh nghĩaĐộ khó
Backward compatibilityCode mới đọc được dữ liệu do code cũ ghiThường dễ: người viết code mới biết format cũ, có thể xử lý tường minh
Forward compatibilityCode cũ đọc được dữ liệu do code mới ghiKhó hơn: code cũ phải biết bỏ qua những gì phiên bản mới thêm vào

Formats for Encoding Data

Chương trình làm việc với dữ liệu ở (ít nhất) hai dạng:

  1. In-memory: object, struct, list, array, hash table, tree... tối ưu cho CPU truy cập, dùng pointer.
  2. Chuỗi byte tự chứa (self-contained) khi ghi file hoặc gửi qua mạng (ví dụ JSON) — pointer vô nghĩa với process khác.

Chuyển in-memory → byte gọi là encoding (serialization, marshalling); ngược lại là decoding (parsing, deserialization, unmarshalling). Sách dùng từ encoding vì serialization trùng nghĩa với serializable isolation trong transaction (Chương 7). Encoding cũng không liên quan tới encryption.

Language-Specific Formats

Ví dụ: Java java.io.Serializable, Ruby Marshal, Python pickle, thư viện bên thứ ba như Kryo (Java). Tiện vì lưu/khôi phục object với rất ít code, nhưng có vấn đề sâu:

  • Gắn chặt với một ngôn ngữ: đọc từ ngôn ngữ khác rất khó → bị khoá vào ngôn ngữ hiện tại lâu dài và khó tích hợp với tổ chức khác.
  • Bảo mật: để khôi phục đúng kiểu object, decoder phải có khả năng khởi tạo class tuỳ ý → attacker gửi byte sequence độc hại có thể dẫn tới remote code execution.
  • Versioning bị xem nhẹ: forward/backward compatibility thường bị bỏ qua.
  • Hiệu năng bị xem nhẹ: Java serialization nổi tiếng chậm và encoding phình to.

→ Chỉ dùng cho mục đích rất tạm thời.

JSON, XML, and Binary Variants

JSON, XML (và CSV) là các format chuẩn, đa ngôn ngữ, textual nên người đọc được. XML bị chê dài dòng và phức tạp; JSON phổ biến nhờ trình duyệt hỗ trợ sẵn (subset của JavaScript) và đơn giản hơn XML. Các vấn đề tinh tế:

  • Mơ hồ về số: XML và CSV không phân biệt số với chuỗi toàn chữ số (trừ khi có schema ngoài). JSON phân biệt string/number nhưng không phân biệt integer và float, không quy định precision. Số nguyên lớn hơn 2^53 không biểu diễn chính xác bằng IEEE 754 double → sai khi parse trong JavaScript. Ví dụ Twitter: tweet ID là số 64-bit, nên API trả ID hai lần — một lần dạng number, một lần dạng string (id và id_str).
  • Không hỗ trợ binary string: phải encode binary bằng Base64 — hacky và tăng kích thước 33%.
  • Schema là tuỳ chọn: XML Schema và JSON Schema mạnh nhưng phức tạp. Nhiều tool JSON không dùng schema → phải hardcode logic encode/decode (ví dụ biết field nào là Base64).
  • CSV không có schema: ứng dụng tự định nghĩa ý nghĩa cột; thêm cột phải xử lý thủ công; escaping (giá trị chứa dấu phẩy, xuống dòng) có spec nhưng không phải parser nào cũng làm đúng.

Dù vậy chúng vẫn "đủ tốt" và sẽ còn phổ biến, nhất là làm data interchange format giữa các tổ chức — ở đó việc thống nhất được bất cứ thứ gì khó hơn nhiều so với chuyện đẹp hay hiệu quả.

Binary encoding

Dữ liệu dùng nội bộ thì có thể chọn format gọn hơn, parse nhanh hơn. Dataset nhỏ thì lợi ích không đáng kể, nhưng ở mức terabyte thì format ảnh hưởng lớn.

  • Có hàng loạt binary encoding cho JSON (MessagePack, BSON, BJSON, UBJSON, BISON, Smile) và XML (WBXML, Fast Infoset), nhưng không cái nào phổ biến như bản text.
  • Chúng giữ nguyên data model JSON/XML (có thể thêm kiểu như int vs float, binary string), và vì không có schema nên vẫn phải nhúng tên field vào dữ liệu.

Record ví dụ xuyên suốt chương:

{
  "userName": "Martin",
  "favoriteNumber": 1337,
  "interests": ["daydreaming", "hacking"]
}

Với MessagePack:

  • Byte đầu 0x83: object (4 bit cao = 0x80) có 3 field (4 bit thấp = 0x03). Hơn 15 field thì dùng type indicator khác và số field chiếm 2 hoặc 4 byte.
  • Byte 0xa8: string (4 bit cao 0xa0) dài 8 byte (0x08), tiếp theo là 8 byte ASCII userName — nhờ length prefix nên không cần ký tự kết thúc hay escaping.
  • 0xa6 + 6 byte Martin, v.v.

Kết quả: 66 byte, so với 81 byte của JSON text (bỏ whitespace). Giảm ít như vậy liệu có đáng đánh đổi khả năng đọc bằng mắt? Các format có schema dưới đây làm được 32 byte.

Thrift and Protocol Buffers

Apache Thrift (gốc từ Facebook) và Protocol Buffers/protobuf (gốc từ Google) cùng nguyên lý, open source khoảng 2007–08. Cả hai bắt buộc có schema, định nghĩa bằng IDL:

// Thrift IDL
struct Person {
  1: required string       userName,
  2: optional i64          favoriteNumber,
  3: optional list<string> interests
}

// Protocol Buffers
message Person {
  required string user_name       = 1;
  optional int64  favorite_number = 2;
  repeated string interests       = 3;
}

Mỗi bên có code generation tool sinh class từ schema cho nhiều ngôn ngữ; ứng dụng gọi code sinh ra để encode/decode.

Thrift có hai binary format chính (thực ra ba, thêm DenseProtocol chỉ có ở C++, cộng hai format dạng JSON):

FormatKích thước record ví dụCách làm gọn
JSON text (bỏ whitespace)81 byteKhông có — lưu cả tên field
MessagePack66 byteBinary nhưng vẫn lưu tên field
Thrift BinaryProtocol59 byteThay tên field bằng field tag (1, 2, 3) + type annotation
Thrift CompactProtocol34 byteGộp field type + tag vào 1 byte, dùng variable-length integer
Protocol Buffers33 byteTương tự CompactProtocol, bit packing hơi khác
Avro32 byteKhông có tag, không có type — chỉ nối value theo thứ tự schema

Điểm khác cốt lõi so với MessagePack: dữ liệu encode không chứa tên field mà chứa field tag — con số trong schema, như alias ngắn gọn cho field.

Variable-length integer (varint): thay vì 8 byte cho số 1337, chỉ dùng 2 byte; bit cao nhất của mỗi byte báo còn byte tiếp theo hay không. Số từ –64 đến 63 chiếm 1 byte, –8192 đến 8191 chiếm 2 byte, số lớn hơn thì nhiều byte hơn.

Minh hoạ (khái niệm) một field trong protobuf:

field 1 (user_name, string):  [tag=1 | type=length-delimited] [len=6] 'M' 'a' 'r' 't' 'i' 'n'
field 2 (favorite_number):    [tag=2 | type=varint]           [0xb9 0x0a]   ← 1337 dạng varint 2 byte
field 3 (interests, repeated):[tag=3 | type=length-delimited] [len=11] "daydreaming"
                              [tag=3 | type=length-delimited] [len=7]  "hacking"

required/optional không ảnh hưởng cách encode — không có gì trong binary cho biết field là required. required chỉ bật runtime check báo lỗi nếu field chưa set, giúp bắt bug.

Field tags and schema evolution

Record encode chỉ là chuỗi nối các field đã encode, mỗi field nhận diện bằng tag number + datatype; field không set thì bỏ qua. Do đó tag là thứ quyết định ý nghĩa dữ liệu:

  • Đổi tên field: OK — dữ liệu encode không bao giờ chứa tên.
  • Đổi tag của field: KHÔNG — làm mọi dữ liệu cũ vô nghĩa.
  • Thêm field mới với tag mới:
    • Forward compat: code cũ gặp tag lạ thì bỏ qua; type annotation cho parser biết cần skip bao nhiêu byte.
    • Backward compat: code mới đọc dữ liệu cũ vẫn ổn vì tag cũ giữ nguyên nghĩa. Nhưng field mới không được là required (dữ liệu cũ không có field đó → check fail). Mọi field thêm sau lần deploy đầu phải optional hoặc có default.
  • Xoá field: như thêm field nhưng đảo chiều compat. Chỉ xoá được field optional (không bao giờ xoá required), và không bao giờ dùng lại tag number đó (còn dữ liệu cũ đâu đó mang tag này; code mới phải bỏ qua nó).

Ví dụ tiến hoá an toàn:

message Person {
  required string user_name       = 1;
  optional int64  favorite_number = 2;
  repeated string interests       = 3;
  optional string email           = 4;   // thêm mới: optional, tag mới
  // tag 5 từng là "nickname" đã xoá → reserved, không tái sử dụng
}

Datatypes and schema evolution

  • Đổi datatype có thể được nhưng rủi ro mất precision/bị cắt. Ví dụ int32 → int64: code mới đọc dữ liệu cũ ổn (điền 0 vào bit thiếu); nhưng code cũ đọc dữ liệu mới vẫn dùng biến 32-bit → giá trị không vừa sẽ bị truncate.
  • Protobuf không có kiểu list/array mà có marker repeated: cùng tag xuất hiện nhiều lần. Hệ quả hay: đổi optional (một giá trị) thành repeated (nhiều giá trị) là an toàn — code mới đọc dữ liệu cũ thấy list 0 hoặc 1 phần tử; code cũ đọc dữ liệu mới chỉ thấy phần tử cuối cùng.
  • Thrift có kiểu list riêng tham số hoá theo kiểu phần tử → không tiến hoá single → multi như protobuf được, nhưng hỗ trợ nested list.

Avro

Apache Avro bắt đầu năm 2009, là subproject của Hadoop, vì Thrift không hợp với use case của Hadoop. Cũng dùng schema, có hai ngôn ngữ schema: Avro IDL (cho người viết) và dạng JSON (cho máy đọc):

record Person {
  string                userName;
  union { null, long }  favoriteNumber = null;
  array<string>         interests;
}
{
  "type": "record", "name": "Person",
  "fields": [
    {"name": "userName",       "type": "string"},
    {"name": "favoriteNumber", "type": ["null", "long"], "default": null},
    {"name": "interests",      "type": {"type": "array", "items": "string"}}
  ]
}
  • Không có tag number trong schema. Encode chỉ 32 byte — gọn nhất.
  • Byte sequence không có gì nhận diện field hay kiểu: chỉ là các value nối nhau. String = length prefix + UTF-8; integer dùng varint như Thrift CompactProtocol.
  • Decode bằng cách đi qua các field theo thứ tự trong schema và dùng schema để biết kiểu → chỉ decode đúng nếu dùng đúng schema lúc ghi. Vậy Avro tiến hoá schema thế nào?

The writer's schema and the reader's schema

  • Writer's schema: schema mà ứng dụng dùng khi encode (ví dụ compile sẵn trong app).
  • Reader's schema: schema mà code đọc đang kỳ vọng (ví dụ code generate lúc build).
  • Ý tưởng then chốt: hai schema không cần giống nhau, chỉ cần tương thích. Khi decode, thư viện Avro đặt hai schema cạnh nhau và dịch dữ liệu từ writer's schema sang reader's schema (schema resolution, quy định rõ trong Avro spec):
    • Field khác thứ tự: không sao — ghép theo tên field.
    • Field có trong writer nhưng không có trong reader: bỏ qua.
    • Field reader cần nhưng writer không có: điền default value khai báo trong reader's schema.

Ví dụ resolution:

Writer's schema (v1):  userName, favoriteNumber, interests
Reader's schema (v2):  userName, interests, photoUrl (default: null)

→ Đọc: userName ← khớp tên; interests ← khớp tên (khác thứ tự vẫn OK)
       favoriteNumber ← writer có, reader không → bỏ qua
       photoUrl ← writer không có → điền default null

Schema evolution rules

  • Forward compat: schema mới là writer, schema cũ là reader. Backward compat: schema mới là reader, schema cũ là writer.
  • Chỉ được thêm hoặc xoá field có default value (ví dụ favoriteNumber default null):
    • Thêm field không default → reader mới không đọc được dữ liệu writer cũ → phá backward compat.
    • Xoá field không default → reader cũ không đọc được dữ liệu writer mới → phá forward compat.
  • null không phải default hợp lệ cho mọi kiểu: muốn cho phép null phải dùng union type, ví dụ union { null, long, string } field;. null chỉ làm default được nếu là một nhánh của union (chính xác: default phải cùng kiểu với nhánh đầu tiên). Dài dòng hơn nhưng giúp tránh bug nhờ tường minh cái gì được null.
  • Vì vậy Avro không có optional/required như protobuf/Thrift, mà dùng union + default.
  • Đổi datatype: được nếu Avro convert được kiểu.
  • Đổi tên field: reader's schema khai báo alias để khớp tên cũ → backward compatible nhưng không forward compatible. Tương tự thêm nhánh vào union cũng chỉ backward compatible.

But what is the writer's schema?

Không thể nhúng cả schema vào mỗi record (schema có khi lớn hơn dữ liệu, mất hết lợi ích nén). Tuỳ ngữ cảnh:

Ngữ cảnhCách reader biết writer's schema
File lớn chứa hàng triệu record cùng schema (Hadoop)Ghi writer's schema một lần ở đầu file — Avro object container file
Database với record ghi riêng lẻ ở các thời điểm khác nhauMỗi record có version number ở đầu; DB lưu danh sách schema theo version; reader tra version → lấy schema. Ví dụ Espresso (LinkedIn)
Gửi record qua kết nối mạng hai chiềuThương lượng schema version khi thiết lập kết nối, dùng suốt connection — Avro RPC protocol

Dù thế nào thì một database các schema version (schema registry) cũng rất đáng có: vừa là tài liệu, vừa cho phép kiểm tra compatibility trước khi deploy. Version có thể là số tăng dần hoặc hash của schema.

Dynamically generated schemas

Ưu điểm lớn của việc không có tag number: Avro thân thiện với schema sinh động (dynamically generated).

  • Ví dụ: dump nội dung một relational DB ra file binary. Với Avro, sinh schema tự động từ relational schema: mỗi bảng → một record schema, mỗi cột → một field, tên cột = tên field.
  • DB schema đổi (thêm một cột, bỏ một cột) → chỉ cần sinh lại Avro schema và export; không cần quan tâm tới thay đổi. Reader vẫn ghép được vì field nhận diện bằng tên.
  • Với Thrift/protobuf, tag thường phải gán thủ công; mỗi lần DB schema đổi, admin phải cập nhật mapping cột → tag, và cẩn thận không dùng lại tag cũ. Schema sinh động vốn không phải mục tiêu thiết kế của chúng.

Code generation and dynamically typed languages

  • Thrift/protobuf dựa vào code generation — hữu ích cho ngôn ngữ static type (Java, C++, C#): cấu trúc in-memory hiệu quả, type check, autocomplete trong IDE.
  • Với ngôn ngữ dynamic (JavaScript, Ruby, Python) thì code generation ít ý nghĩa, thậm chí bị ghét vì thêm bước compile; với schema sinh động nó còn là vật cản.
  • Avro có code generation tuỳ chọn, dùng tốt không cần nó. Object container file nhúng writer's schema nên self-describing: mở bằng thư viện Avro và xem như file JSON. Rất hợp với ngôn ngữ xử lý dữ liệu dynamic như Apache Pig: mở file Avro, phân tích, ghi output Avro mà không phải nghĩ tới schema.

The Merits of Schemas

  • Schema của protobuf/Thrift/Avro đơn giản hơn nhiều so với XML Schema/JSON Schema (vốn hỗ trợ validation chi tiết như regex, khoảng giá trị) → dễ implement, hỗ trợ nhiều ngôn ngữ.
  • Ý tưởng không mới: ASN.1 (chuẩn hoá 1984) dùng tag number để tiến hoá schema; encoding DER của nó vẫn dùng cho chứng chỉ SSL (X.509). Nhưng ASN.1 quá phức tạp, tài liệu kém → không nên dùng cho ứng dụng mới.
  • Nhiều hệ thống có binary encoding độc quyền, ví dụ network protocol của relational DB; vendor cung cấp driver (ODBC/JDBC) để decode.

Ưu điểm của binary encoding có schema:

  • Gọn hơn nhiều so với các "binary JSON" vì bỏ được tên field.
  • Schema là tài liệu luôn đúng: vì bắt buộc cần để decode nên không thể lệch với thực tế (khác tài liệu viết tay).
  • Schema registry cho phép kiểm tra forward/backward compat trước khi deploy.
  • Code generation cho ngôn ngữ static type → type check lúc compile.

Tóm lại, schema evolution cho sự linh hoạt như schemaless/schema-on-read JSON, đồng thời đảm bảo tốt hơn về dữ liệu và tooling tốt hơn.

Tiêu chíLanguage-specificJSON/XML/CSVThrift / ProtobufAvro
Đa ngôn ngữKhôngCóCó (code gen)Có
SchemaNgầm theo classTuỳ chọn, phức tạpBắt buộc, IDLBắt buộc, IDL hoặc JSON
Nhận diện field—Tên field trong dữ liệuTag number trong dữ liệuTên field trong writer's schema
Kích thướcThường phình toLớn nhấtNhỏ (33–34 byte)Nhỏ nhất (32 byte)
Người đọc đượcKhôngCóKhôngKhông
CompatThường không cóTuỳ cách dùngRõ ràng qua tag rulesRõ ràng qua schema resolution + default
Schema sinh động——Khó (gán tag thủ công)Rất tốt

Modes of Dataflow

Compatibility là quan hệ giữa process encode và process decode. Ba cách dữ liệu chảy giữa các process:

Dataflow Through Databases

  • Process ghi encode, process đọc decode. Nếu chỉ một process thì đọc là phiên bản tương lai của chính nó → ghi DB giống "gửi message cho chính mình trong tương lai" → backward compat hiển nhiên cần.
  • Thường nhiều process (nhiều service, hoặc nhiều instance cùng service) truy cập cùng lúc; trong rolling upgrade, một số chạy code mới, một số còn code cũ → dữ liệu do code mới ghi có thể bị code cũ đọc → cần cả forward compat.
  • Cạm bẫy mất field lạ: code mới thêm field và ghi vào DB; code cũ (không biết field) đọc record, sửa, rồi ghi lại. Mong muốn: code cũ giữ nguyên field lạ. Format encoding hỗ trợ việc này, nhưng nếu ứng dụng decode vào model object rồi re-encode thì field lạ có thể bị rơi mất trong quá trình chuyển đổi. Không khó giải quyết, chỉ cần ý thức được.

Different values written at different times

  • Trong một DB có giá trị ghi 5 ms trước và giá trị ghi 5 năm trước. Deploy code mới mất vài phút, nhưng dữ liệu 5 năm tuổi vẫn nằm đó với encoding gốc — "data outlives code".
  • Migration (rewrite dữ liệu sang schema mới) có thể làm nhưng đắt với dataset lớn, nên DB tránh. Hầu hết RDBMS cho phép thay đổi đơn giản như thêm cột default null mà không rewrite: khi đọc row cũ, DB tự điền null cho cột thiếu (ngoại lệ: MySQL thường rewrite cả bảng dù không cần). Espresso của LinkedIn dùng Avro để lưu, tận dụng luật schema evolution của Avro.
  • Nhờ schema evolution, cả DB trông như encode bằng một schema duy nhất dù bên dưới là nhiều phiên bản lịch sử.

Archival storage

  • Snapshot DB để backup hoặc load vào data warehouse: thường encode bằng schema mới nhất cho nhất quán, dù nguồn là hỗn hợp nhiều version.
  • Dump ghi một lần rồi immutable → hợp với Avro object container file, hoặc format columnar cho analytics như Parquet.

Dataflow Through Services: REST and RPC

  • Mô hình phổ biến: client và server; server expose API qua mạng, gọi là service.
  • Web: browser gửi GET/POST, dùng chuẩn chung HTTP, URL, SSL/TLS, HTML. Client khác: app mobile/desktop, JavaScript app dùng XMLHttpRequest (Ajax) — response thường là JSON cho code xử lý, API là đặc thù ứng dụng.
  • Server có thể là client của service khác (app server → DB). Chia ứng dụng lớn thành service nhỏ theo chức năng: service-oriented architecture (SOA), gần đây là microservices.
  • Service giống DB ở chỗ cho submit/query dữ liệu, nhưng chỉ cho input/output định trước bởi business logic → encapsulation, kiểm soát chi tiết client được làm gì.
  • Mục tiêu then chốt của microservices: mỗi service do một team sở hữu, deploy và tiến hoá độc lập không cần phối hợp → phải chấp nhận server và client cũ/mới chạy cùng lúc → encoding phải tương thích qua các version API.

Web services

Khi dùng HTTP làm giao thức nền gọi là web service, dùng trong nhiều bối cảnh:

  1. App trên thiết bị người dùng gọi service qua internet công cộng.
  2. Service gọi service khác cùng tổ chức, thường cùng datacenter (middleware).
  3. Service gọi service của tổ chức khác qua internet (public API, xử lý thẻ tín dụng, OAuth).
Tiêu chíRESTSOAP
Bản chấtTriết lý thiết kế dựa trên nguyên tắc HTTP, không phải protocolProtocol dựa trên XML
Quan hệ với HTTPTận dụng HTTP: URL định danh resource, cache control, auth, content negotiationMuốn độc lập với HTTP, tránh dùng hầu hết tính năng HTTP
Mô tả APIOpenAPI/Swagger (tuỳ chọn)WSDL (XML, không dành cho người đọc)
ToolingĐơn giản, ít code generationPhụ thuộc nặng vào tool, code gen, IDE; hệ chuẩn WS-* phức tạp
InteroperabilityTốt, mọi ngôn ngữHay lỗi giữa các vendor; khó với ngôn ngữ không được hỗ trợ
Xu hướngPhổ biến, gắn với microservices, tích hợp giữa tổ chứcCòn ở enterprise lớn, không được ưa ở công ty nhỏ

Lưu ý: SOAP không phải điều kiện của SOA — SOAP là công nghệ cụ thể, SOA là cách tiếp cận kiến trúc. Ngay trong phe REST cũng tranh cãi, ví dụ HATEOAS.

The problems with remote procedure calls (RPCs)

Lịch sử công nghệ gọi API qua mạng nhiều hype nhưng nhiều vấn đề: EJB và Java RMI (chỉ Java), DCOM (chỉ Microsoft), CORBA (quá phức tạp, không có backward/forward compat). Tất cả dựa trên RPC (từ những năm 1970): làm cho gọi service từ xa trông như gọi hàm local — gọi là location transparency. Ý tưởng này sai về căn bản vì network request khác local call:

  • Không dự đoán được: request/response có thể mất, máy xa chậm hoặc chết — ngoài tầm kiểm soát → phải tính tới retry.
  • Thêm một kết cục: timeout — không có response thì không biết request đã tới hay chưa.
  • Retry có thể thực thi nhiều lần: có khi request tới nơi, chỉ response bị mất → cần idempotence/deduplication trong protocol.
  • Latency biến động dữ dội: dưới 1 ms lúc tốt, nhiều giây khi mạng nghẽn/service quá tải; local call thì gần như cố định.
  • Không truyền được pointer: mọi tham số phải encode thành byte — ổn với số/chuỗi, rắc rối với object lớn.
  • Khác ngôn ngữ: framework phải dịch kiểu dữ liệu giữa các ngôn ngữ, ví dụ vấn đề số > 2^53 của JavaScript.

→ Đừng cố làm remote service giống local object. Một phần sức hút của REST là không che giấu việc nó là network protocol.

Current directions for RPC

  • RPC không biến mất. Framework dựa trên các encoding của chương: Thrift và Avro có sẵn RPC; gRPC dùng Protocol Buffers; Finagle dùng Thrift; Rest.li dùng JSON over HTTP.
  • Thế hệ mới thừa nhận remote call khác local call: Finagle, Rest.li dùng futures/promises cho hành động async có thể fail, dễ gọi song song nhiều service rồi gộp kết quả; gRPC hỗ trợ streams (chuỗi request/response theo thời gian). Một số có service discovery (tìm IP/port của service).
  • RPC binary tuỳ biến hiệu năng tốt hơn JSON over REST. Nhưng REST có lợi thế lớn: dễ thử nghiệm, debug (browser, curl, không cần code gen), mọi ngôn ngữ đều hỗ trợ, hệ sinh thái tool khổng lồ (cache, load balancer, proxy, firewall, monitoring, testing).
  • Kết luận: REST thống trị public API; RPC chủ yếu cho giao tiếp giữa service cùng tổ chức, cùng datacenter.

Data encoding and evolution for RPC

  • Giả định đơn giản hoá hợp lý: server được update trước, client sau → chỉ cần backward compat cho request (server mới đọc request của client cũ) và forward compat cho response (client cũ đọc response của server mới).
  • Compat của RPC thừa hưởng từ encoding: Thrift, gRPC (protobuf), Avro RPC theo luật của format tương ứng; SOAP dùng XML schema — tiến hoá được nhưng có cạm bẫy tinh tế; REST thường JSON không schema chính thức — thêm optional request parameter và thêm field vào response thường được coi là thay đổi tương thích.
  • Khó khăn: RPC hay dùng xuyên tổ chức, provider không ép được client nâng cấp → phải giữ compat rất lâu, có khi vô thời hạn; thay đổi phá compat thì thường phải chạy song song nhiều version API.
  • Chưa có chuẩn chung về API versioning. Với REST: version trong URL (/v1/...) hoặc trong HTTP Accept header. Với service dùng API key: lưu version client chọn ở phía server, cho đổi qua giao diện quản trị riêng.

Message-Passing Dataflow

Asynchronous message passing nằm giữa RPC và database: giống RPC vì message tới process khác với latency thấp; giống DB vì message không đi thẳng mà qua trung gian message broker (message queue, message-oriented middleware) lưu tạm.

Lợi ích so với RPC trực tiếp:

  • Làm buffer khi bên nhận không sẵn sàng hoặc quá tải → tăng reliability.
  • Tự redeliver message cho process đã crash → không mất message.
  • Bên gửi không cần biết IP/port bên nhận (hữu ích trên cloud, VM đến rồi đi).
  • Gửi một message cho nhiều người nhận.
  • Tách rời logic sender và recipient (sender publish, không quan tâm ai consume).

Khác RPC: thường một chiều — sender không chờ reply (reply nếu có thì qua kênh riêng). Pattern asynchronous: gửi xong là quên.

Message brokers

  • Trước đây thống trị bởi phần mềm thương mại: TIBCO, IBM WebSphere, webMethods. Nay open source phổ biến: RabbitMQ, ActiveMQ, HornetQ, NATS, Apache Kafka (so sánh chi tiết ở Chương 11).
  • Cách dùng chung: process gửi message tới queue hoặc topic có tên; broker đảm bảo giao tới một hoặc nhiều consumer/subscriber. Nhiều producer và consumer trên cùng topic.
  • Topic chỉ một chiều, nhưng consumer có thể publish sang topic khác (chuỗi xử lý) hoặc tới reply queue mà sender gốc đọc (request/response kiểu RPC).
  • Broker không áp data model: message chỉ là byte + metadata → dùng encoding nào cũng được. Encoding backward + forward compatible → đổi và deploy publisher/consumer độc lập, theo thứ tự bất kỳ.
  • Consumer republish sang topic khác thì cũng phải giữ field lạ, giống cạm bẫy ở phần database.

Distributed actor frameworks

  • Actor model: mô hình concurrency trong một process. Thay vì thao tác thread (race condition, lock, deadlock), logic đóng gói trong actor — mỗi actor đại diện một client/entity, có state local không chia sẻ, giao tiếp bằng message bất đồng bộ. Message không đảm bảo tới nơi. Mỗi actor xử lý một message một lúc → không lo thread.
  • Distributed actor framework: dùng cùng mô hình để scale qua nhiều node; message giữa node khác nhau được encode, gửi qua mạng, decode trong suốt.
  • Location transparency hợp với actor hơn RPC vì actor model vốn đã giả định message có thể mất ngay cả trong một process → ít "lệch pha" giữa local và remote.
  • Về bản chất là tích hợp message broker + actor model. Rolling upgrade vẫn phải lo forward/backward compat vì node mới và cũ gửi message cho nhau.
FrameworkEncoding mặc địnhRolling upgrade
AkkaJava built-in serialization (không có compat)Được nếu thay bằng Protocol Buffers
OrleansFormat tuỳ biến riêng, không hỗ trợ rolling upgradePhải dựng cluster mới, chuyển traffic, tắt cluster cũ; hoặc dùng serialization plug-in
Erlang OTPRecord schema khó thay đổiCó thể nhưng phải lên kế hoạch kỹ; kiểu maps (Erlang R17, 2014) có thể giúp
DataflowAi encode / decodeCompat cần cóLưu ý
DatabaseProcess ghi encode, process đọc decode (có thể nhiều năm sau)Backward (bắt buộc) + forward (khi rolling upgrade)Data outlives code; giữ field lạ khi update
REST / RPCClient encode request, server decode + encode response, client decodeBackward cho request, forward cho response (giả định server update trước)API public phải giữ compat rất lâu, versioning
Message passingSender encode, recipient decode qua brokerCả hai chiều để deploy theo thứ tự bất kỳAsync, một chiều; giữ field lạ khi republish

Summary

  • Chi tiết encoding không chỉ ảnh hưởng hiệu năng mà còn ảnh hưởng kiến trúc và khả năng deploy. Rolling upgrade giúp release không downtime, deploy nhỏ và thường xuyên, phát hiện/rollback bản lỗi trước khi ảnh hưởng nhiều user → tốt cho evolvability.
  • Vì các node chạy version khác nhau, mọi dữ liệu lưu chuyển phải có backward compat (code mới đọc dữ liệu cũ) và forward compat (code cũ đọc dữ liệu mới).
  • Language-specific encoding: gói gọn trong một ngôn ngữ, thường không có compat. JSON/XML/CSV: phổ biến, compat tuỳ cách dùng, mơ hồ về kiểu số và binary. Thrift/Protobuf/Avro: gọn, hiệu quả, compat rõ ràng, schema làm tài liệu và code gen, nhưng phải decode mới đọc được.
  • Ba mode dataflow: database, RPC/REST, message passing (broker hoặc actor). Cẩn thận một chút thì compat và rolling upgrade hoàn toàn làm được.

⚠️ Hiểu lầm & cạm bẫy thường gặp

  • Dùng Java Serializable / Python pickle để lưu trữ lâu dài hoặc nhận dữ liệu từ ngoài — khoá ngôn ngữ, không compat, và có thể dẫn tới remote code execution.
  • Tin rằng JSON giữ chính xác số nguyên 64-bit — JavaScript mất chính xác với số > 2^53; hãy trả ID dạng string (như Twitter).
  • Đổi tag number hoặc tái sử dụng tag đã xoá trong protobuf/Thrift — làm hỏng dữ liệu cũ âm thầm. Hãy đánh dấu tag đã xoá là reserved.
  • Thêm field required mới — phá backward compat vì dữ liệu cũ không có field đó.
  • Nghĩ required/optional ảnh hưởng binary encoding — không; chỉ là runtime check.
  • Đổi int32 → int64 và nghĩ an toàn hai chiều — code cũ đọc giá trị lớn sẽ bị truncate.
  • Avro: thêm/xoá field không có default — phá backward hoặc forward compat. Và null không tự động là default hợp lệ, phải dùng union với null là nhánh đầu.
  • Đổi tên field trong Avro bằng alias chỉ backward compatible, không forward.
  • Mất field lạ khi decode vào model object rồi ghi lại — code cũ xoá mất dữ liệu do code mới thêm. Áp dụng cho cả DB lẫn consumer republish message.
  • Coi RPC như local function call — quên timeout, retry gây thực thi trùng (cần idempotency key), latency biến động.
  • Nghĩ migration DB là miễn phí — dữ liệu cũ sống rất lâu; rewrite dataset lớn rất đắt.
  • Akka với serialization mặc định không rolling upgrade được — phải đổi sang protobuf hoặc tương tự.

💼 Áp dụng thực tế & phỏng vấn

  • Zero-downtime deployment: khi được hỏi "làm sao thêm một field/đổi schema mà không downtime", trả lời theo bước: (1) thêm field optional/có default, (2) deploy reader hiểu field mới, (3) deploy writer ghi field mới, (4) backfill nếu cần, (5) chỉ xoá field cũ khi không còn reader nào dùng. Đây là pattern expand–contract (parallel change).
  • Schema registry với Kafka: Confluent Schema Registry lưu các version Avro/Protobuf schema, message chỉ mang schema ID; registry kiểm tra compat (BACKWARD/FORWARD/FULL) trước khi producer đăng ký schema mới — đúng ý tưởng "database of schema versions" của chương.
  • Chọn giao thức giữa service: public API → REST + JSON (dễ debug, tooling phong phú, versioning qua URL/header). Nội bộ, hiệu năng cao, nhiều ngôn ngữ → gRPC + protobuf (streaming, code gen, deadline). Pipeline dữ liệu/Hadoop/data lake → Avro hoặc Parquet.
  • Idempotency trong API thanh toán/đặt hàng: vì retry qua mạng có thể thực thi hai lần, dùng idempotency key (như Stripe). Đây là câu hỏi hay gặp trong phỏng vấn.
  • Async vs sync: dùng message queue (Kafka, RabbitMQ, SQS) để buffer spike, decouple service, fan-out tới nhiều consumer; đổi lại không có response ngay và phải xử lý redelivery/duplicate.
  • Mobile client: client cũ có thể sống nhiều năm → API phải forward-compatible trong response (client cũ bỏ qua field lạ) và server phải chấp nhận request kiểu cũ; cân nhắc giữ nhiều version API song song.
  • Con số đáng nhớ: record ví dụ JSON 81 byte → MessagePack 66 → Thrift Binary 59 → Thrift Compact 34 → Protobuf 33 → Avro 32; Base64 tăng 33%; số > 2^53 mất chính xác trong JS.

❓ Câu hỏi ôn tập

Bấm vào câu hỏi để xem đáp án
1Phân biệt backward và forward compatibility. Cái nào thường khó hơn và vì sao?
Backward: code mới đọc dữ liệu cũ; forward: code cũ đọc dữ liệu mới. Forward khó hơn vì code cũ phải biết bỏ qua những phần do phiên bản mới thêm mà nó không hiểu.
2Nêu các vấn đề của language-specific encoding như Java Serializable hay pickle.
Gắn chặt một ngôn ngữ, lỗ hổng bảo mật do khởi tạo class tuỳ ý (RCE), không quan tâm versioning/compat, hiệu năng và kích thước kém.
3Protobuf/Thrift đạt được forward và backward compat thế nào khi thêm field?
Field nhận diện bằng tag number; code cũ gặp tag lạ thì dùng type annotation để skip (forward). Field mới phải optional hoặc có default để code mới đọc dữ liệu cũ không lỗi (backward).
4Vì sao không được tái sử dụng tag number của field đã xoá?
Dữ liệu cũ vẫn có thể mang tag đó; nếu gán cho field mới, code mới sẽ hiểu sai dữ liệu cũ thành field mới.
5Avro decode dữ liệu thế nào khi không có tag hay type trong byte stream?
Dùng writer's schema để biết thứ tự và kiểu field, rồi schema resolution ghép với reader's schema theo tên: field thừa bỏ qua, field thiếu điền default của reader.
6Trong Avro, làm sao reader biết writer's schema?
File lớn: nhúng schema một lần ở đầu object container file. DB: mỗi record có version number, tra schema trong registry. Kết nối mạng: thương lượng schema khi mở connection.
7Vì sao Avro phù hợp hơn protobuf cho schema sinh động (ví dụ dump DB)?
Avro khớp field bằng tên nên có thể sinh schema tự động từ tên cột mỗi lần export; protobuf cần gán tag thủ công và phải tránh dùng lại tag cũ.
8Tại sao "RPC trông như gọi hàm local" là ý tưởng có vấn đề? Với RPC cần compat chiều nào?
Network không dự đoán được: timeout không biết kết quả, retry có thể thực thi trùng (cần idempotence), latency biến động, phải encode tham số, khác kiểu dữ liệu giữa ngôn ngữ. Giả định server update trước: cần backward compat cho request và forward compat cho response.

Đây là bản tóm tắt và ghi chú, không thay thế sách gốc. Hãy ủng hộ tác giả bằng cách đọc bản gốc.