DDIA · Martin Kleppmann · Chương 4DDIA · Martin Kleppmann · Chương 4DDIA · Martin Kleppmann · Chapter 4
DDIA · Chương 4 · Mã hóa & tiến hóaDDIA · Chương 4 · Mã hóa & tiến hóaDDIA · Chapter 4 · Encoding and Evolution

Dữ liệu sống lâu hơn codeDữ liệu ở lại lâu hơn chương trìnhData outlives code

Ứng dụng luôn thay đổi: thêm trường, đổi tên, bỏ bớt. Nhưng khi bạn triển khai phiên bản mới lên một hệ nhiều máy, bạn gần như không bao giờ đổi hết cùng một lúc — bạn rolling upgrade, nâng từng nút một, và trong suốt quãng thời gian ấy code cũ và code mới chạy song song, đọc chung một kho dữ liệu1. Chương 4 hỏi một câu rất thực dụng: ta chọn định dạng mã hóa nào để hai phiên bản ấy còn hiểu được nhau?

Khi nâng cấp một hệ lớn, người ta không đổi hết mọi máy cùng lúc, mà đổi lần lượt từng máy. Trong lúc đó, máy chạy bản cũ và máy chạy bản mới cùng đọc chung một kho dữ liệu. Chương này hỏi: viết dữ liệu ra sao để cả bản cũ lẫn bản mới đều đọc được? Nhớ nhé: nâng cấp là chuyện diễn ra dần dần, không phải một cái bấm nút.

Applications change constantly: fields get added, renamed, dropped. But when you deploy a new version across many machines, you almost never switch them all at once — you do a rolling upgrade, one node at a time, and throughout that window old and new code run side by side over the same data1. Chapter 4 asks a very practical question: which encoding do we choose so those two versions can still understand each other?

∴
Luận đề của chương Điều quan trọng nhất The thesis of the chapter

Vì các phiên bản code luôn có lúc cùng tồn tại và vì dữ liệu nằm lại lâu hơn code đã sinh ra nó, mọi định dạng mã hóa phải được đánh giá bằng đúng hai chiều tương thích: backward — code mới đọc được dữ liệu cũ, và forward — code cũ đọc được dữ liệu mới1. Chiều thứ hai khó hơn nhiều, vì nó buộc một chương trình phải giữ nguyên vẹn thứ nó không hiểu. Ba nơi hai chiều ấy va vào nhau là cơ sở dữ liệu, lời gọi dịch vụ, và hàng đợi thông điệp.

Có hai kiểu "hiểu nhau". Một: bản mới đọc được dữ liệu bản cũ viết ra. Hai: bản cũ đọc được dữ liệu bản mới viết ra. Kiểu thứ hai khó hơn, vì bản cũ gặp thứ nó chưa từng biết. Nhớ nhé: hệ tốt là hệ mà cả cũ lẫn mới đều không làm hỏng dữ liệu của nhau.

Because versions of code inevitably coexist, and because data lives longer than the code that produced it, every encoding must be judged on exactly two directions of compatibility: backward — new code can read old data, and forward — old code can read new data1. The second is far harder, because it forces a program to preserve intact what it does not understand. The three places these two directions collide are databases, service calls and message queues.

Trục 1 · CompatibilityAxis 1 · Compatibility

Hai chiều tương thích, và vì sao chúng không đối xứngTwo directions of compatibility, and why they aren't symmetric

Mọi thứ trong bộ nhớ — đối tượng, mảng, bảng băm — đều được tối ưu cho CPU truy cập qua con trỏ. Muốn ghi xuống đĩa hay gửi qua mạng, ta phải dịch chúng thành một chuỗi byte tự chứa: đó là encoding (còn gọi là serialization hay marshalling), và chiều ngược lại là decoding1. Chương 4 lấy phép dịch ấy làm tâm, rồi hỏi điều mà các chương trước chưa hỏi: khi lược đồ đổi, chuỗi byte cũ và chuỗi byte mới có còn sống chung được không?

Trong máy, dữ liệu nằm dưới dạng tiện cho máy dùng. Muốn cất vào ổ đĩa hay gửi đi xa, phải "gói" nó thành một dãy byte. Gói lại gọi là mã hóa, mở ra gọi là giải mã. Câu hỏi ở đây là: nếu hôm nay gói kiểu này, mai gói kiểu khác, thì có mở được của nhau không? Nhớ nhé: gói thế nào cũng được, miễn là mở ra vẫn đúng.

Everything in memory — objects, arrays, hash maps — is optimized for a CPU following pointers. To write it to disk or send it over the network, it must be translated into a self-contained byte sequence: that is encoding (also called serialization or marshalling), and the reverse is decoding1. Chapter 4 puts that translation at the centre and asks what earlier chapters did not: when the schema changes, can the old bytes and the new bytes still live together?

Ý lõi 01 · Rolling upgradeIdea 01 · Rolling upgrade

Không có khoảnh khắc "tất cả cùng mới"There's no moment when all is new

Với ứng dụng phía máy chủ, cách triển khai thường thấy là rolling upgrade: đưa bản mới lên vài nút, quan sát, rồi lan dần ra cả cụm — nhờ đó không phải dừng dịch vụ1. Với ứng dụng phía khách, bạn thậm chí phụ thuộc vào việc người dùng có chịu cập nhật hay không. Hệ quả trong cả hai trường hợp là như nhau: luôn có một quãng, có khi rất dài, mà nhiều phiên bản cùng đọc và ghi chung một kho dữ liệu.

Muốn nâng cấp một dàn máy lớn, người ta đổi lần lượt vài máy một, xem có ổn không rồi mới đổi tiếp — như vậy dịch vụ không phải ngừng. Nghĩa là trong một khoảng thời gian, máy cũ và máy mới cùng làm việc. Nhớ nhé: cũ và mới luôn có lúc phải sống chung.

For server-side applications the usual deployment is a rolling upgrade: push the new version to a few nodes, watch, then spread it across the cluster — so service never stops1. For client-side applications you depend on users actually installing the update. Either way the consequence is the same: there is always a window, sometimes a long one, in which several versions read and write the same data.

Nhiều phiên bản luôn cùng tồn tại.
Nhớ nhé: cũ mới sống chung.
Versions always coexist.
Ý lõi 02 · Backward vs forwardIdea 02 · Backward vs forward

Hai chiều, không phải mộtTwo directions, not one

Tương thích ngược (backward compatibility): code mới đọc được dữ liệu do code cũ viết. Tương thích xuôi (forward compatibility): code cũ đọc được dữ liệu do code mới viết1. Cùng một cặp khái niệm được các sổ đăng ký lược đồ phát biểu theo phía người tiêu thụ: BACKWARD nghĩa là "consumers using new schema can read data written with old schema", FORWARD nghĩa là "consumers using old schema can read data written with new schema"11.

Hai chiều khác nhau. Chiều một: bản mới đọc đồ cũ — cái này dễ, vì bản mới biết cả luật cũ lẫn luật mới. Chiều hai: bản cũ đọc đồ mới — cái này khó, vì bản cũ gặp thứ nó chưa từng nghe tên. Nhớ nhé: đừng gộp hai chiều làm một.

Backward compatibility: new code can read data written by old code. Forward compatibility: old code can read data written by new code1. Schema registries state the same pair from the consumer's side: BACKWARD means "consumers using new schema can read data written with old schema", FORWARD means "consumers using old schema can read data written with new schema"11.

Ngược dễ, xuôi khó.
Nhớ nhé: chiều xuôi khó hơn.
Backward is easy; forward is hard.
Ý lõi 03 · Data outlives codeIdea 03 · Data outlives code

Kho dữ liệu là một bảo tàng phiên bảnYour database is a museum of versions

Code của một dịch vụ có thể được thay hoàn toàn trong vài tháng; những hàng dữ liệu nó ghi ra thì nằm đó hàng năm. Vì thế một cơ sở dữ liệu đang chạy thường chứa đồng thời bản ghi của nhiều thế hệ lược đồ — Kleppmann gọi đây là chuyện "dữ liệu sống lâu hơn code"1. Việc di trú toàn bộ sang lược đồ mới thì tốn kém, nên trong thực tế phần lớn hệ chọn cách rẻ hơn: để dữ liệu cũ nguyên chỗ và bắt code phải đọc được mọi thế hệ.

Chương trình có thể được viết lại sau vài tháng, nhưng dữ liệu nó đã ghi thì nằm lại nhiều năm. Vậy nên trong kho luôn có đủ loại dữ liệu cũ mới lẫn lộn. Nhớ nhé: code đi qua, dữ liệu ở lại.

A service's code may be entirely replaced within months; the rows it wrote sit there for years. So a running database usually holds records from several generations of schema at once — Kleppmann's point that "data outlives code"1. Migrating everything to the new schema is expensive, so in practice most systems take the cheaper route: leave old data where it is and make the code read every generation.

Code thay được; dữ liệu ở lại.
Nhớ nhé: dữ liệu ở lại lâu hơn.
Code gets replaced; data stays.
Forward compatibility = preserving what you don't understand

Tương thích ngược chỉ đòi code mới biết thêm luật cũ. Tương thích xuôi đòi một điều lạ hơn nhiều: code cũ phải đi ngang qua những trường nó chưa từng nghe tên mà không hoảng, và lý tưởng nhất là giữ nguyên chúng khi ghi lại.

Bản mới đọc đồ cũ thì chỉ cần nhớ luật cũ. Còn bản cũ đọc đồ mới thì phải biết bỏ qua thứ lạ mà không làm hỏng nó. Nhớ nhé: biết bỏ qua đúng cách cũng là một kỹ năng.

Backward compatibility only asks new code to also know the old rules. Forward compatibility asks something stranger: old code must walk past fields it has never heard of without panicking — and ideally keep them intact when it writes back.

Đây là lý do các định dạng ở nửa sau chương được thiết kế quanh một ý duy nhất: mỗi trường phải tự khai báo đủ để người đọc lạ có thể nhảy qua nó. Protocol Buffers gói số hiệu trường và kiểu wire vào một tag varint theo công thức (field_number << 3) | wire_type, và chính cấu trúc ấy "allows old parsers to skip over new fields they don't understand"5. Thrift đặt trước mỗi trường một byte kiểu và một số hiệu 16 bit7. Avro đi đường khác hẳn: không đánh số gì cả, mà bắt người đọc luôn có trong tay lược đồ của người viết10.

Backward Code mới đọc dữ liệu cũ. Thường dễ: người viết code mới vẫn còn biết định dạng cũ.
Forward Code cũ đọc dữ liệu mới. Khó: code cũ phải bỏ qua cái nó không biết mà không làm hỏng.
Rolling upgrade Vì nâng cấp diễn ra dần, hai phiên bản luôn có lúc cùng đọc–ghi một kho.
Chiều ngược Bản mới đọc đồ cũ — dễ, vì bản mới biết cả luật cũ.
Chiều xuôi Bản cũ đọc đồ mới — khó, vì gặp thứ chưa từng biết.
Nâng dần Đổi từng máy một nên cũ mới luôn sống chung một lúc.
Backward New code reads old data. Usually easy: whoever writes the new code still knows the old format.
Forward Old code reads new data. Hard: it must skip what it doesn't know without damaging it.
Rolling upgrade Because upgrades are gradual, two versions always end up sharing one store.
Trục 2 · Language-specific & textualAxis 2 · Language-specific & textual

Hai họ định dạng dễ với, và cái giá của chúngTwo convenient families, and what they cost

Trước khi bàn định dạng nhị phân, Kleppmann dọn đường bằng cách loại bỏ hai lựa chọn mặc định mà lập trình viên hay với tay lấy đầu tiên: cơ chế tuần tự hóa gắn liền với một ngôn ngữ, và các định dạng văn bản như JSON, XML, CSV. Cả hai đều tiện, và cả hai đều trả giá ở đúng chỗ chương này quan tâm — khả năng để hai phiên bản code hiểu nhau qua thời gian.

Trước khi nói tới cách gói dữ liệu "chuyên nghiệp", chương sách dẹp bớt hai cách dễ mà nhiều người chọn trước: dùng công cụ có sẵn của chính ngôn ngữ mình viết, hoặc dùng chữ như JSON. Cả hai đều tiện nhưng đều có chỗ hụt. Nhớ nhé: cái dễ nhất lúc bắt đầu ít khi là cái tốt nhất về lâu dài.

Before reaching binary formats, Kleppmann clears the ground by ruling out the two defaults programmers reach for first: serialization built into a single language, and textual formats such as JSON, XML and CSV. Both are convenient, and both pay a price exactly where this chapter looks — at whether two versions of code can understand each other over time.

  1. Language-specific formats

    Định dạng ngôn ngữ-riêng — tiện một lần, kẹt mãi mãiLanguage-specific formats — handy once, stuck forever

    Nhiều ngôn ngữ có sẵn cách tuần tự hóa đối tượng của riêng mình, và Kleppmann nêu đúng nhóm ấy trong danh mục tham chiếu của chương: Java Object Serialization, Ruby Marshal, Python pickle3. Chúng gọi một dòng là xong, nhưng buộc mọi bên đọc phải cùng một ngôn ngữ; việc giải mã thường kéo theo khởi tạo lớp tùy ý, mà giải tuần tự hóa dữ liệu không đáng tin là một lớp lỗ hổng đã được lập danh mục riêng — CWE-50215; và chuyện tương thích giữa các phiên bản thường bị coi là việc phụ. Kết luận của ông gọn: nói chung nên tránh, trừ những dùng rất tạm thời1.

    Nhiều ngôn ngữ lập trình có nút "gói đối tượng lại" sẵn, bấm một cái là xong. Nhưng gói kiểu đó chỉ ngôn ngữ ấy mở được, lại dễ bị kẻ xấu lợi dụng khi mở gói lạ, và ít ai nghĩ tới chuyện bản cũ bản mới. Nhớ nhé: tiện nhất thời thường là kẹt lâu dài.

    Many languages ship their own object serialization, and Kleppmann's chapter reference list names exactly that group: Java Object Serialization, Ruby Marshal, Python pickle3. One line of code and you're done — but every reader must then speak the same language; decoding typically instantiates arbitrary classes, and deserialization of untrusted data is a catalogued class of vulnerability — CWE-50215; and cross-version compatibility is usually an afterthought. His verdict is blunt: generally avoid them, except for very transient uses1.

    Nhận ra ở đâuBất cứ chỗ nào bạn thấy một blob nhị phân trong cache, trong hàng đợi hay trong cột BLOB mà chỉ một dịch vụ viết bằng đúng một ngôn ngữ mới mở được — đó chính là cái bẫy này.
    Thấy ở đâuKhi một cục dữ liệu chỉ đúng một chương trình mở được, còn ai khác nhìn vào cũng chịu — là đang dính kiểu này.
    Where you see itAny binary blob in a cache, a queue or a BLOB column that only one service, in one language, can open — that is this trap.

    Vì sao rủi ro bảo mật là điểm chí tử

    CWE-502 mô tả đúng kịch bản: ứng dụng giải tuần tự hóa dữ liệu không đáng tin mà không kiểm tra xem dữ liệu ấy có hợp lệ hay không, mở đường cho kẻ tấn công điều khiển luồng khởi tạo đối tượng15. Với một định dạng có lược đồ tường minh, thứ duy nhất người đọc dựng lên là các trường đã khai báo trước — bề mặt tấn công hẹp hơn hẳn.

    Hiệu năng và tính khả chuyển

    Ngoài khóa ngôn ngữ, các cơ chế này thường không nhanh và không gọn như các định dạng chuyên dụng; danh mục tham chiếu của chương còn dẫn thẳng tới jvm-serializers, một bộ so sánh công khai giữa các thư viện tuần tự hóa trên JVM3.

  2. JSON, XML, CSV

    JSON, XML, CSV — đọc được bằng mắt, mơ hồ với máyJSON, XML, CSV — readable to the eye, vague to the machine

    Ba định dạng văn bản này thắng nhờ một điều thật: chúng liên thông giữa mọi ngôn ngữ và người đọc được. Nhưng chúng mờ ở chỗ quan trọng. Số liệu là ví dụ rõ nhất: đặc tả JSON ghi rằng chỉ những số nguyên nằm trong khoảng [-(2**53)+1, (2**53)-1] mới interoperable giữa các bộ cài đặt12 — nghĩa là một số nguyên 64 bit hoàn toàn có thể mất chính xác khi đi qua một bộ phân tích JSON. Không có kiểu chuỗi nhị phân, nên người ta nhét dữ liệu nhị phân qua Base64, mà phép mã hóa ấy biến mỗi 24 bit đầu vào thành 4 ký tự14 — tức phình khoảng một phần ba. Còn CSV thì không có lược đồ nào cả, và chính RFC của nó thừa nhận "there is no formal specification in existence"13.

    JSON, XML, CSV có cái hay: ai cũng đọc được, ngôn ngữ nào cũng mở được. Nhưng chúng hay nhập nhèm. Một con số rất lớn có thể bị làm tròn sai. Muốn gửi ảnh hay file thì phải đổi sang chữ, và đổi xong thì phình ra khoảng một phần ba. Còn CSV thì chẳng có bản mô tả nào, mỗi nơi hiểu một kiểu. Nhớ nhé: dễ đọc bằng mắt không có nghĩa là rõ ràng với máy.

    These three textual formats win on something real: they cross every language and a human can read them. But they blur exactly where it matters. Numbers are the clearest case: the JSON specification states that only integers in the range [-(2**53)+1, (2**53)-1] are interoperable across implementations12 — so a 64-bit integer can lose precision passing through a JSON parser. There is no binary string type, so binary data is smuggled through Base64, an encoding that turns every 24 input bits into 4 characters14 — roughly a third larger. And CSV has no schema at all; its own RFC concedes "there is no formal specification in existence"13.

    Khi nào vẫn nên dùngKleppmann không cấm JSON: với API công khai giữa các tổ chức khác nhau, nơi cái giá của việc thỏa thuận trước một lược đồ cao hơn cái giá của sự mơ hồ, JSON vẫn là lựa chọn hợp lý1. Vấn đề chỉ bắt đầu khi bạn dùng nó làm định dạng lưu trữ nội bộ cho hàng tỉ bản ghi.
    Vẫn dùng được khi nàoKhi hai bên xa lạ cần trao đổi nhanh mà không kịp thống nhất bản mô tả, JSON vẫn tốt. Chỉ đừng lấy nó làm chỗ cất hàng tỉ bản ghi.
    When it still fitsKleppmann doesn't forbid JSON: for public APIs across organizational boundaries, where agreeing a schema up front costs more than the ambiguity does, JSON remains reasonable1. The trouble starts when it becomes the internal storage format for billions of records.

    Lược đồ là tùy chọn, nên thường không có

    Cả XML lẫn JSON đều có hệ lược đồ riêng — XML Schema là một W3C Recommendation24 còn JSON Schema có đặc tả riêng25, và cả hai đều nằm trong danh mục tham chiếu của chương3 — nhưng chúng là tùy chọn, khá phức tạp, và rất nhiều công cụ dựa trên JSON không dùng đến. Khi lược đồ vắng mặt, luật về nội dung dữ liệu chuyển ngược vào code ứng dụng, nơi không ai kiểm chứng được nó.

    Vì sao "một phần ba" là con số có thật

    Base64 "represents 24-bit groups of input bits as output strings of 4 encoded characters"14: 3 byte vào, 4 byte ra. Tỉ lệ 4/3 là hệ quả số học trực tiếp, chưa kể phần đệm và phí tổn của việc nhúng chuỗi ấy vào JSON. Đây là ví dụ điển hình của một quy ước lách luật trở thành chi phí thường trực.

Human-readable is not the same as machine-unambiguous

Điểm chung của hai họ định dạng vừa rồi là chúng giải quyết bài toán "gửi được dữ liệu đi" mà bỏ ngỏ bài toán "hai phiên bản hiểu nhau". Chính khoảng trống ấy là chỗ các định dạng nhị phân có lược đồ bước vào.

Hai cách trên lo được việc gửi dữ liệu đi, nhưng chưa lo được việc bản cũ bản mới hiểu nhau. Nhớ nhé: đó là lý do người ta cần cách gói có kèm bản mô tả.

What the two families share is that they solve "get the data across" while leaving "two versions understand each other" open. That gap is where schema-driven binary formats step in.

Có một nhóm trung gian đáng nhắc: các định dạng nhị phân không lược đồ như MessagePack, vốn là "JSON nén lại" — vẫn mang theo tên trường trong từng bản ghi3. Chúng tiết kiệm được ít byte nhưng không đổi bản chất: vì mọi tên trường vẫn nằm trong dữ liệu, khoản lãi nhỏ hiếm khi biện minh nổi cho việc mất tính đọc-được-bằng-mắt. Bước nhảy thật sự chỉ đến khi tên trường được thay bằng số hiệu — hoặc bị bỏ hẳn.

Số JSON không phân biệt nguyên với thực; chỉ khoảng ±2⁵³ được bảo đảm liên thông12.
Nhị phân Không có kiểu chuỗi byte; phải Base64, phình khoảng một phần ba14.
Lược đồ XML Schema / JSON Schema là tùy chọn; rất nhiều công cụ không dùng.
CSV Không có lược đồ, và không có cả một đặc tả hình thức nào.
Số lớn Số rất lớn có thể bị làm tròn sai khi đi qua JSON.
File, ảnh Phải đổi sang chữ, xong thì phình thêm khoảng một phần ba.
Bản mô tả Có thì tốt, nhưng thường chẳng ai kèm theo.
CSV Mỗi chỗ hiểu một kiểu, vì chẳng có luật chung.
Numbers JSON can't tell integers from floats; only about ±2⁵³ is guaranteed interoperable12.
Binary No byte-string type; Base64 is the workaround, at roughly a third more size14.
Schema XML Schema / JSON Schema are optional; plenty of tooling ignores them.
CSV No schema — and no formal specification either.
Trục 3 · Schema-driven binaryAxis 3 · Schema-driven binary

Ba cách bỏ tên trường ra khỏi dữ liệuThree ways to take field names out of the data

Thrift, Protocol Buffers và Avro đều bắt đầu từ một schema viết bằng ngôn ngữ mô tả giao diện riêng, rồi sinh mã cho nhiều ngôn ngữ lập trình. Điểm khác nhau nằm ở chỗ chúng thay tên trường bằng cái gì: Thrift và Protocol Buffers thay bằng số hiệu trường do con người gán trong lược đồ, còn Avro thì không thay bằng gì cả — dữ liệu chỉ còn giá trị, và người đọc buộc phải có lược đồ của người viết trong tay.

Ba cách gói dữ liệu này đều bắt đầu bằng một bản mô tả: có những trường nào, kiểu gì. Rồi máy tự sinh ra mã đọc–ghi. Khác nhau ở chỗ: hai cách đầu đánh số cho từng trường, còn cách thứ ba bỏ luôn cả tên lẫn số, chỉ giữ giá trị. Nhớ nhé: bỏ được nhãn đi thì gói nhỏ lại, nhưng phải có cách khác để biết chỗ nào là chỗ nào.

Thrift, Protocol Buffers and Avro all start from a schema written in their own interface definition language, then generate code for many programming languages. They differ in what replaces the field names: Thrift and Protocol Buffers use field numbers assigned by hand in the schema, while Avro uses nothing at all — the data is just values, and the reader must hold the writer's schema.

  1. Apache Thrift

    Thrift — một lược đồ, hai cách đóng góiThrift — one schema, two packings

    Thrift ra đời ở Facebook và được công bố năm 200723. Điều đáng nhớ nhất về nó là cùng một lược đồ có thể được ghi ra bằng nhiều giao thức khác nhau. Trong BinaryProtocol, mỗi trường mở đầu bằng một byte kiểu rồi một số hiệu 16 bit dạng big endian, và cấu trúc kết thúc bằng một "stop field" là byte 07. Trong CompactProtocol, khi khoảng cách số hiệu so với trường trước nằm trong 1–15, cả độ lệch số hiệu lẫn kiểu được nhét chung vào một byte theo dạng ddddtttt8. Trên bản ghi ví dụ trong bài viết của chính Kleppmann, hai cách ấy cho 59 byte so với 34 byte4.

    Thrift do Facebook làm ra. Hay ở chỗ: cùng một bản mô tả, bạn chọn được cách gói "thoáng" hoặc cách gói "chật". Cách chật nhét số thứ tự và kiểu dữ liệu chung vào một byte, nên gói nhỏ hơn hẳn. Nhớ nhé: cùng một nội dung vẫn có thể gói to hay gói nhỏ tùy cách.

    Thrift came out of Facebook and was published in 200723. Its most memorable trait is that one schema can be written out by several protocols. In BinaryProtocol each field starts with a type byte and a 16-bit big-endian field id, and a struct ends with a "stop field", the zero byte7. In CompactProtocol, when the field-id delta from the previous field is 1–15, both the id delta and the type are packed into a single byte as ddddtttt8. On the example record in Kleppmann's own write-up, the two come to 59 bytes versus 344.

    Quy tắc tiến hóaLược đồ Thrift đánh dấu mỗi trường là required hay optional: trường bắt buộc "are always written and are expected to be set", còn trường tùy chọn "are only written when they are set" và được theo dõi bằng cờ isset9. Kleppmann cảnh báo rằng đổi qua lại giữa hai nhãn ấy là chỗ dễ sinh lỗi lúc chạy4.
    Luật khi sửaMỗi trường được đánh dấu "bắt buộc" hoặc "có cũng được". Đổi qua đổi lại hai nhãn này là chỗ hay sinh lỗi.
    Evolution ruleA Thrift schema marks each field required or optional: required fields "are always written and are expected to be set", optional ones "are only written when they are set" and are tracked with isset flags9. Kleppmann warns that flipping between those labels is where runtime errors come from4.

    Vì sao CompactProtocol gọn hơn

    Hai thủ thuật cộng lại. Thứ nhất, thay vì một số hiệu 16 bit cố định, nó ghi độ lệch so với trường trước, và độ lệch nhỏ thì chỉ tốn 4 bit; vượt khoảng 1–15 thì rơi về dạng dài 0000tttt kèm số hiệu mã hóa zigzag varint8. Thứ hai, số nguyên không còn chiếm đủ 32 hay 64 bit mà dùng varint: "each 7 bits are encoded as 8 bits with the top bit set if it is not the last byte"8.

    Đừng đọc 59→34 như một tỉ lệ chung

    Cặp số ấy là kết quả đo trên một bản ghi ví dụ cụ thể trong bài blog năm 2012 của Kleppmann4, không phải hệ số nén phổ quát. Bản ghi nhiều số nhỏ và nhiều trường liền số sẽ lợi hơn hẳn; bản ghi toàn chuỗi dài thì gần như không khác. Khi ai đó trích con số này như tỉ lệ cố định, họ đã bỏ mất ngữ cảnh của phép đo.

  2. Protocol Buffers

    Protocol Buffers — số hiệu trường là bản hợp đồngProtocol Buffers — the field number is the contract

    Protobuf gói số hiệu trường và kiểu wire vào một tag duy nhất dạng varint: "the 'tag' of a record is encoded as a varint formed from the field number and the wire type via the formula (field_number << 3) | wire_type"5. Chỉ có một nhúm kiểu wire — VARINT (0), I64 (1), LEN (2), I32 (5) — và LEN mang ngay sau tag một varint độ dài5. Riêng sint32/sint64 dùng zigzag thay vì bù hai, theo công thức (n << 1) ^ (n >> 31) với sint32, để số âm nhỏ không tốn tối đa số byte5.

    Protobuf đánh cho mỗi trường một con số. Trong dữ liệu, nó ghi con số ấy kèm một mã nhỏ cho biết "cái tiếp theo dài bao nhiêu". Nhờ vậy chương trình cũ gặp con số lạ thì biết đường nhảy qua mà không vấp. Nhớ nhé: có nhãn số thì ai cũng biết chỗ nào là chỗ nào.

    Protobuf packs the field number and the wire type into a single varint tag: "the 'tag' of a record is encoded as a varint formed from the field number and the wire type via the formula (field_number << 3) | wire_type"5. There are only a handful of wire types — VARINT (0), I64 (1), LEN (2), I32 (5) — and LEN carries a varint length right after the tag5. sint32/sint64 use ZigZag rather than two's complement, via (n << 1) ^ (n >> 31) for sint32, so small negative numbers don't cost the maximum number of bytes5.

    Quy tắc tiến hóaTài liệu chính thức nói thẳng: "changing field numbers for any existing field is not safe"; thêm trường mới thì an toàn vì "old binaries simply ignore the new field when parsing"; và số hiệu của trường đã xóa phải được đánh dấu reserved để không ai lỡ tay dùng lại6.
    Luật khi sửaSố đã gán cho một trường thì đừng bao giờ đổi. Thêm trường mới thì được, vì bản cũ sẽ lờ nó đi. Xóa trường thì phải giữ chỗ, đừng cho ai dùng lại số đó.
    Evolution ruleThe official guide is blunt: "changing field numbers for any existing field is not safe"; adding new fields is safe because "old binaries simply ignore the new field when parsing"; and a deleted field's number must be marked reserved so nobody reuses it by accident6.

    Đổi kiểu: tương thích nhưng có bẫy cắt cụt

    Hướng dẫn ghi rằng int32, uint32, int64, uint64 và bool đều tương thích với nhau trên dây — nhưng kèm ngay cảnh báo: nếu một số 64 bit được đọc như int32 thì "it will be truncated to 32 bits"6. Nói cách khác, việc nới rộng kiểu thì an toàn, còn thu hẹp thì âm thầm làm hỏng dữ liệu chứ không báo lỗi.

    Nhãn required đã biến mất trong proto3

    Hướng dẫn ngôn ngữ proto3 hiện thời chỉ liệt kê các dạng singular (optional hoặc implicit), repeated và map — không còn nhãn required nào6. Đây là lời đáp muộn cho đúng nỗi lo mà Kleppmann nêu năm 2012 về việc đổi qua lại giữa required và optional4: cách chắc chắn nhất để không gãy khi tiến hóa là không có trường bắt buộc nào ngay từ đầu.

  3. Apache Avro

    Avro — hai lược đồ, khớp theo tênAvro — two schemas, matched by name

    Avro đi một đường khác hẳn: trong dữ liệu không có số hiệu trường, cũng không có tên trường. Đặc tả nói rõ "binary encoded Avro data does not include type information or field names"10 — một bản ghi chỉ còn là các giá trị nối đuôi nhau, với int và long viết bằng zigzag độ dài thay đổi10. Muốn đọc, bạn cần hai thứ: lược đồ của người viết (writer's schema) và lược đồ mà ứng dụng của bạn mong đợi (reader's schema) — hai bản này không bắt buộc phải giống nhau, chỉ cần tương thích10.

    Avro còn mạnh tay hơn: nó bỏ luôn cả tên lẫn số, chỉ ghi giá trị. Muốn đọc được thì phải kèm theo tờ giấy ghi "gói này đóng theo mẫu nào". Người đọc so tờ giấy ấy với mẫu mà mình cần, rồi ghép các trường theo tên. Nhớ nhé: bỏ được nhãn vì luôn có tờ hướng dẫn đi kèm.

    Avro takes a different route: the data carries neither field numbers nor field names. The spec is explicit — "binary encoded Avro data does not include type information or field names"10 — a record is just values in sequence, with int and long written in variable-length zig-zag coding10. To read it you need two things: the writer's schema and the reader's schema your application expects — and those two need not be identical, only compatible10.

    Quy tắc tiến hóaPhân giải lược đồ của Avro khớp các trường theo tên, nên thứ tự khai báo đổi được thoải mái. Nếu reader có một trường mà writer không có, reader "should use the default value"; nếu writer có một trường mà reader không có, "the writer's value for that field is ignored"10. Hệ quả thực dụng: muốn thêm hay bớt trường an toàn thì trường đó phải có giá trị mặc định4.
    Luật khi sửaTrường được ghép theo tên, nên đổi thứ tự không sao. Người đọc cần trường mà gói không có thì lấy giá trị mặc định; gói có trường lạ thì người đọc bỏ qua. Vậy nên thêm hay bớt trường đều phải có giá trị mặc định.
    Evolution ruleAvro's schema resolution matches fields by name, so declaration order can change freely. If the reader has a field the writer lacks, the reader "should use the default value"; if the writer has a field the reader lacks, "the writer's value for that field is ignored"10. The practical consequence: to add or remove a field safely, it must have a default value4.

    Lược đồ của người viết đi kèm dữ liệu bằng cách nào

    Với tệp lớn, Avro dùng object container file: phần đầu tệp gồm bốn byte nhận dạng Obj\x01, một bản đồ siêu dữ liệu trong đó khóa avro.schema "contains the schema of objects stored in the file, as JSON data (required)", và một sync marker 16 byte10. Nghĩa là lược đồ được ghi một lần cho cả triệu bản ghi, chứ không lặp lại ở từng bản ghi. Đặc tả cũng đưa ra nguyên tắc chung: hệ thống lưu dữ liệu Avro "should always include the writer's schema for that data"10.

    Vì sao Avro hợp với lược đồ sinh động

    Vì không cần con người gán tag, bạn có thể sinh lược đồ Avro tự động từ lược đồ cơ sở dữ liệu mỗi khi nó đổi, rồi dùng lược đồ mới để kết xuất — người đọc vẫn hiểu nhờ phân giải theo tên. Với Thrift hay Protobuf, mỗi lần lược đồ nguồn đổi bạn lại phải trông chừng việc gán tag bằng tay, và một cột bị đổi tên rất dễ thành một tag bị dùng lại. Kleppmann lập luận rằng chính cơ chế này khiến Avro hợp với những nơi lược đồ thay đổi liên tục hoặc được sinh ra tự động4.

A schema is documentation that cannot go stale

Cái lợi ít được nói của định dạng có lược đồ không nằm ở vài byte tiết kiệm được, mà ở chỗ lược đồ là tài liệu buộc phải đúng: nó được dùng để sinh mã và để giải mã, nên không thể lệch khỏi thực tế như một trang wiki bị bỏ quên.

Điều hay nhất không phải là gói nhỏ hơn, mà là bản mô tả ấy luôn đúng — vì chính máy dùng nó để đọc và ghi. Nhớ nhé: tài liệu mà máy phải dùng thì không bao giờ lỗi thời.

The underrated benefit of a schema-driven format isn't the bytes saved but that the schema is documentation that cannot go stale: it is used to generate code and to decode, so it can't drift from reality the way a forgotten wiki page does.

Cần nói rõ một chỗ dễ nhầm về thuật ngữ. Kleppmann phát biểu hai chiều tương thích theo code (code mới/cũ đọc dữ liệu cũ/mới)1, còn các sổ đăng ký lược đồ phát biểu theo consumer và schema11. Hai cách nói trùng nhau về nội dung, nhưng chủ ngữ bị đảo, nên khi đọc tài liệu vận hành rất dễ hiểu ngược. Đáng chú ý hơn: chế độ mặc định của Confluent Schema Registry là BACKWARD chứ không phải cả hai chiều11 — tức là bật một công cụ quản lý lược đồ lên không tự động cho bạn tương thích xuôi; đó vẫn là một lựa chọn phải cấu hình.

Thrift Số hiệu 16 bit (BinaryProtocol)7 hoặc độ lệch 4 bit gói cùng kiểu (CompactProtocol)8.
Protobuf Tag varint (field_number << 3) | wire_type5; số hiệu là bất biến vĩnh viễn6.
Avro Không số hiệu, không tên; khớp theo tên giữa writer's schema và reader's schema.
Thrift Đánh số cho từng trường, có kiểu gói thoáng và kiểu gói chật.
Protobuf Cũng đánh số, và con số đó thì đừng bao giờ đổi.
Avro Không đánh số, chỉ ghi giá trị, kèm tờ mô tả để ghép theo tên.
Thrift A 16-bit id (BinaryProtocol)7 or a 4-bit delta packed with the type (CompactProtocol)8.
Protobuf A varint tag, (field_number << 3) | wire_type5; the number is permanent6.
Avro No ids, no names; matched by name between writer's and reader's schema.
Trục 4 · Modes of dataflowAxis 4 · Modes of dataflow

Ba con đường dữ liệu rời khỏi một tiến trìnhThree routes data takes out of a process

Nửa sau chương chuyển từ "gói thế nào" sang "gửi đi đâu". Kleppmann liệt kê ba dòng chảy phổ biến nhất giữa các tiến trình: qua cơ sở dữ liệu, qua lời gọi dịch vụ (REST và RPC), và qua truyền thông điệp bất đồng bộ1. Mỗi con đường có một đặc tính thời gian riêng, và chính đặc tính ấy quyết định chiều tương thích nào trở nên cấp bách.

Phần sau của chương chuyển câu hỏi: không phải "gói ra sao" nữa mà là "gửi đi đường nào". Có ba đường: cất vào kho dữ liệu, gọi thẳng sang chương trình khác, hoặc gửi thư qua một cái hộp trung gian. Mỗi đường có kiểu trục trặc riêng. Nhớ nhé: dữ liệu đi đường nào thì hỏng theo kiểu của đường đó.

The second half of the chapter moves from "how to pack" to "where it goes". Kleppmann lists the three most common flows between processes: via databases, via service calls (REST and RPC), and via asynchronous message passing1. Each route has its own relationship with time, and that is what decides which direction of compatibility becomes urgent.

  1. Dataflow through databases

    Qua cơ sở dữ liệu — gửi thư cho chính mình ở tương laiThrough a database — a message to your future self

    Ghi vào cơ sở dữ liệu là gửi một thông điệp cho chính bạn ở thời điểm nào đó về sau — có thể là vài mili giây, có thể là năm năm1. Vì trong một cụm đang rolling upgrade, tiến trình cũ và mới cùng ghi vào một kho, nên cả hai chiều tương thích đều bắt buộc cùng lúc: bản mới phải đọc nổi bản ghi cũ, và bản cũ phải đọc nổi bản ghi mới. Đây là con đường mà quãng thời gian giữa lúc ghi và lúc đọc dài nhất, nên cũng là nơi lỗi tiến hóa nằm im lâu nhất trước khi lộ ra.

    Ghi dữ liệu vào kho giống như viết thư cho chính mình đọc sau này — có khi đọc ngay, có khi năm năm nữa mới mở. Trong lúc nâng cấp, chương trình cũ và mới cùng viết vào một kho, nên cả hai phải đọc được thư của nhau. Nhớ nhé: thư viết hôm nay, người đọc có thể là bản cũ hoặc bản mới.

    Writing to a database is sending a message to yourself at some later moment — maybe milliseconds away, maybe five years1. Because during a rolling upgrade old and new processes write into the same store, both directions of compatibility are required at once: new code must read old rows, and old code must read new rows. This is the route with the longest gap between write and read, so it is also where an evolution bug lies dormant longest.

    Cái bẫy đắt nhất của chươngMột ứng dụng bản cũ đọc một bản ghi đã có trường mới, giải mã vào đối tượng của nó, sửa một chỗ, rồi ghi cả đối tượng trở lại. Trường mới — thứ nó không hề biết — biến mất. Bản ghi vẫn hợp lệ, không ai báo lỗi, và dữ liệu thì đã mất1. Tương thích xuôi ở đây không chỉ là "đọc được mà không sập", mà là "giữ nguyên vẹn thứ mình không hiểu khi ghi lại".
    Cái bẫy đáng nhớ nhấtBản cũ mở một bản ghi có trường lạ, sửa một chỗ rồi cất lại — và trường lạ kia biến mất luôn. Không ai báo lỗi cả. Nhớ nhé: đọc được thôi chưa đủ, phải giữ lại được cái mình không hiểu.
    The chapter's costliest trapAn old version of the application reads a record that already has a new field, decodes it into its own object, changes one thing, and writes the whole object back. The new field — which it never knew about — disappears. The record stays valid, nothing raises an error, and the data is gone1. Forward compatibility here isn't just "read without crashing"; it's "preserve what you don't understand when writing back".

    Vì sao di trú toàn bộ hiếm khi là câu trả lời

    Về nguyên tắc bạn có thể viết lại toàn bộ dữ liệu sang lược đồ mới, nhưng trên một tập dữ liệu lớn đó là thao tác tốn kém và rủi ro. Vì vậy các hệ quản trị thường chọn cách rẻ hơn: thêm cột với giá trị mặc định null mà không đụng tới dữ liệu cũ, để mọi bản ghi cũ được đọc như thể chúng luôn có trường ấy1. Bản ghi trên đĩa giữ nguyên; chỉ cách diễn giải đổi.

    Ảnh chụp lược đồ và kho phân tích

    Khi kết xuất dữ liệu ra kho phân tích hay bản sao lưu, dữ liệu được ghi một lần rồi bất biến, nên có thể mã hóa bằng một lược đồ duy nhất cho cả ảnh chụp — đúng mô hình tệp chứa của Avro, nơi avro.schema nằm trong phần đầu tệp10. Chính vì kịch bản này mà việc sinh lược đồ tự động từ lược đồ cơ sở dữ liệu trở nên tự nhiên4.

  2. Dataflow through services: REST and RPC

    Qua dịch vụ — vì sao RPC không phải lời gọi hàmThrough services — why RPC isn't a function call

    Con đường thứ hai là gọi qua mạng. REST không phải một giao thức mà là một phong cách kiến trúc dựng trên các nguyên tắc của HTTP, được Roy Fielding trình bày trong luận án năm 200018. RPC thì già hơn nhiều — ý tưởng gọi thủ tục từ xa đã được Birrell và Nelson hiện thực và phân tích từ năm 198417 — và tham vọng của nó là làm cho lời gọi qua mạng trông giống lời gọi trong cùng tiến trình. Ngay trang giới thiệu gRPC hôm nay vẫn hứa đúng điều đó: máy khách "can directly call a method on a server application on a different machine as if it were a local object"19.

    Cách thứ hai là gọi thẳng sang máy khác qua mạng. Có kiểu REST — dùng đúng cách của web. Có kiểu RPC — cố làm cho việc gọi qua mạng trông y hệt gọi một hàm ngay trong máy mình. Nhưng "trông giống" không có nghĩa là "giống". Nhớ nhé: gọi qua mạng luôn khác gọi trong nhà.

    The second route is the call across the network. REST is not a protocol but an architectural style built on the principles of HTTP, set out by Roy Fielding in his 2000 dissertation18. RPC is much older — remote procedure calls were implemented and analysed by Birrell and Nelson back in 198417 — and its ambition is to make a network call look like a call inside the same process. gRPC's own introduction still promises exactly that today: a client "can directly call a method on a server application on a different machine as if it were a local object"19.

    Vì sao phép ẩn dụ ấy rò rỉLập luận kinh điển mà Kleppmann dựa vào là bài "A Note on Distributed Computing" của Waldo và cộng sự (1994): đối tượng trong hệ phân tán về bản chất khác đối tượng trong một không gian địa chỉ, vì lập trình viên buộc phải để ý tới độ trễ, một mô hình truy cập bộ nhớ khác, và lỗi bộ phận cùng tính đồng thời16. Một lời gọi cục bộ hoặc chạy hoặc không; một lời gọi qua mạng có thể hết giờ mà bạn không biết phía kia đã thực thi hay chưa.
    Vì sao ví von đó hỏngGọi hàm trong máy thì hoặc chạy hoặc không. Gọi qua mạng thì có thể chờ mãi không thấy trả lời, mà bên kia có khi đã làm rồi. Hai chuyện đó khác hẳn nhau.
    Why that metaphor leaksThe classic argument Kleppmann leans on is Waldo et al.'s 1994 "A Note on Distributed Computing": objects in a distributed system are intrinsically unlike objects in a single address space, because programmers must reckon with latency, a different model of memory access, and partial failure and concurrency16. A local call either runs or doesn't; a network call can time out while you have no idea whether the other side executed.

    Chiều tương thích nào cho dịch vụ

    Với dịch vụ, máy chủ và máy khách thường không được nâng cấp cùng lúc, và trong thực tế máy chủ thường đi trước. Nghĩa là yêu cầu cần tương thích ngược (máy chủ mới phục vụ được máy khách cũ) còn phản hồi cần tương thích xuôi (máy khách cũ đọc được phản hồi mới)1. Với API công khai, bạn thậm chí không kiểm soát được lúc nào máy khách cập nhật, nên phiên bản cũ có thể phải sống nhiều năm.

    Thử lại: chỗ RPC khác nhất

    Một lời gọi thất bại có thể được thử lại tự động, nhưng nếu yêu cầu đã tới đích và chỉ có phản hồi bị mất, việc thử lại sẽ khiến hành động xảy ra hai lần — trừ khi giao thức có cơ chế khử trùng lặp (idempotence)1. Đây chính là "lỗi bộ phận" mà Waldo nói tới: thứ không tồn tại trong lời gọi hàm cục bộ nên cũng không có trong trực giác của lập trình viên16.

  3. Message-passing dataflow

    Qua thông điệp — người trung gian không hỏi bên trong có gìThrough messages — the middleman never opens the envelope

    Con đường thứ ba nằm giữa hai con đường trên: thông điệp được gửi qua một message broker — Kleppmann nêu nhóm công cụ như RabbitMQ, ActiveMQ, Apache Kafka13. Cái lợi: broker làm bộ đệm khi bên nhận quá tải, tự gửi lại khi tiến trình chết, bên gửi không cần biết địa chỉ IP của bên nhận, và một thông điệp có thể tới nhiều người nhận. Cái giá: giao tiếp trở thành một chiều, bất đồng bộ — bên gửi thường không chờ trả lời.

    Cách thứ ba là gửi thư qua một cái hộp chung: người gửi bỏ thư vào hộp rồi đi làm việc khác, người nhận lúc nào rảnh thì lấy ra. Hộp giữ thư hộ khi người nhận bận, và gửi lại nếu chẳng may hỏng giữa chừng. Nhớ nhé: gửi qua hộp thì không ai phải đứng đợi ai.

    The third route sits between the other two: messages go through a message broker — Kleppmann names tools such as RabbitMQ, ActiveMQ and Apache Kafka13. The upside: the broker buffers when the recipient is overloaded, redelivers when a process dies, the sender needn't know the recipient's IP, and one message can reach many consumers. The cost: communication becomes one-way and asynchronous — the sender usually doesn't wait for a reply.

    Vì sao điều này quan trọng với tiến hóaBroker thường chỉ coi thông điệp là một dãy byte kèm chút siêu dữ liệu, hoàn toàn không quan tâm mô hình dữ liệu bên trong1. Sự vô tư ấy là lợi thế — bạn đổi định dạng mà không phải đổi hạ tầng — nhưng cũng có nghĩa là không có ai kiểm tra tương thích giúp bạn. Trách nhiệm rơi trọn vào bên gửi và bên nhận, mà hai bên thì được triển khai độc lập.
    Vì sao đáng chú ýCái hộp trung gian không mở thư ra xem, nó chỉ chuyển. Tiện, nhưng cũng nghĩa là chẳng ai kiểm giúp xem người nhận có hiểu nổi thư hay không.
    Why it matters for evolutionA broker usually treats a message as a byte sequence with some metadata and is entirely agnostic about the data model inside1. That indifference is an advantage — you change format without touching infrastructure — but it also means nobody checks compatibility for you. The burden falls on sender and receiver, which are deployed independently.

    Mô hình actor phân tán

    Mô hình actor là một cách lập trình đồng thời trong đó logic được đóng trong các actor chỉ giao tiếp bằng thông điệp bất đồng bộ, và mỗi actor xử lý một thông điệp tại một thời điểm nên không cần khóa: tài liệu Akka nói thẳng rằng "no concurrency guards such as synchronized or AtomicInteger are needed since an actor instance processes one message at a time"21. Khi mô hình ấy trải ra nhiều nút — Akka, hay Microsoft Orleans với các virtual actor22 — thông điệp phải đi qua mạng, và bài toán mã hóa cùng hai chiều tương thích quay lại nguyên vẹn1.

    Vì sao nâng cấp cuốn chiếu vẫn là phép thử

    Với actor phân tán, việc nâng cấp cuốn chiếu chỉ an toàn khi thông điệp giữ được cả tương thích ngược lẫn xuôi — danh mục tham chiếu của chương dẫn tới đúng một thảo luận cộng đồng về việc nâng cấp trong một cụm Akka, cho thấy đây là vấn đề vận hành có thật chứ không phải chuyện lý thuyết3. Còn với Kafka, thông điệp được giữ lại theo chính sách lưu trữ của chủ đề20, nên một thông điệp viết hôm nay vẫn có thể được đọc lại bởi một phiên bản khác rất lâu sau đó.

Every boundary between processes is a version boundary

Ba dòng chảy khác nhau về công nghệ nhưng giống nhau ở bản chất: mỗi lần dữ liệu rời khỏi một tiến trình, nó bước qua một ranh giới nơi hai phiên bản code khác nhau có thể đang đứng ở hai đầu. Thiết kế cho tiến hóa chính là thừa nhận ranh giới ấy thay vì giả vờ nó không có.

Ba đường đi khác nhau nhưng giống nhau một điểm: cứ mỗi lần dữ liệu ra khỏi một chương trình, hai đầu có thể là hai bản khác nhau. Nhớ nhé: chỗ nào dữ liệu đi qua, chỗ đó cũ mới phải hiểu nhau.

The three flows differ in technology but agree in nature: every time data leaves a process it crosses a boundary where two different versions of code may stand on either side. Designing for evolvability means admitting that boundary rather than pretending it isn't there.

Có một sợi chỉ nối chương này về Chương 1. Ở đó Kleppmann đặt evolvability — khả năng thay đổi hệ khi yêu cầu thay đổi — làm một trong ba trụ của maintainability2. Chương 4 là lần đầu khái niệm ấy được đưa xuống tận tầng byte: tính dễ tiến hóa của một hệ dữ liệu không nằm ở chỗ code sạch đến đâu, mà nằm ở chỗ định dạng mã hóa có cho phép hai phiên bản cùng tồn tại hay không. Một lược đồ chọn sai ở tuần đầu có thể khóa chặt khả năng thay đổi của cả hệ trong nhiều năm.

Qua CSDL Khoảng cách ghi–đọc dài nhất; cần cả hai chiều, và cần giữ nguyên trường lạ khi ghi lại.
Qua dịch vụ Máy chủ thường nâng trước: yêu cầu cần tương thích ngược, phản hồi cần tương thích xuôi.
Qua thông điệp Broker không quan tâm nội dung; không ai kiểm tra tương thích thay bạn.
Vào kho Viết hôm nay, có khi năm sau mới đọc — cả cũ lẫn mới đều phải đọc được.
Gọi thẳng Máy chủ thường đổi trước, nên phải chiều được máy khách cũ.
Gửi thư Hộp thư không mở thư ra xem, nên hai đầu tự lo hiểu nhau.
Via database Longest write-to-read gap; both directions needed, plus preserving unknown fields on write-back.
Via services Servers usually upgrade first: requests need backward, responses need forward compatibility.
Via messages The broker is agnostic about content; nobody checks compatibility on your behalf.

Ba ngộ nhận thường gặp về mã hóa và tiến hóaThree common misreadings of encoding and evolution

“Ngược với xuôi là một, chỉ khác cách nói”"Backward and forward are the same thing said twice"

Sai, và đây là nhầm lẫn tốn kém nhất. Tương thích ngược chỉ cần code mới biết thêm luật cũ; tương thích xuôi buộc code cũ phải hành xử đúng trước thứ chưa tồn tại lúc nó được viết. Sổ đăng ký lược đồ tách bạch hai chế độ BACKWARD và FORWARD chính vì chúng cho phép những thay đổi khác nhau11.

Wrong, and it's the costliest confusion here. Backward only needs new code to also know the old rules; forward demands that old code behave correctly in the face of something that did not exist when it was written. Schema registries keep BACKWARD and FORWARD as separate modes precisely because they permit different changes11.

“Dùng Protobuf là tự khắc tiến hóa được”"Using Protobuf makes you evolvable automatically"

Sai: định dạng chỉ cấp cho bạn cơ chế, kỷ luật vẫn là của con người. Đổi số hiệu một trường đang dùng là thao tác không an toàn6; dùng lại số hiệu của trường đã xóa cũng vậy, nên tài liệu khuyên đánh dấu reserved6. Và thu hẹp kiểu số vẫn cắt cụt dữ liệu trong im lặng6.

Wrong: the format gives you the mechanism, the discipline stays human. Changing the number of a live field is unsafe6; reusing a deleted field's number is too, which is why the guide says to mark it reserved6. And narrowing a numeric type still truncates data silently6.

“RPC hiện đại làm mạng trở nên trong suốt”"Modern RPC makes the network transparent"

Sai: giao diện có thể trông giống lời gọi cục bộ19, nhưng độ trễ, mô hình bộ nhớ, lỗi bộ phận và tính đồng thời thì không biến mất chỉ vì cú pháp đã đẹp16. Các khung RPC tốt không che giấu mạng — chúng phơi ra thời hạn chờ, việc thử lại và tính khử trùng lặp để bạn phải quyết định một cách có ý thức.

Wrong: the interface may look like a local call19, but latency, the memory model, partial failure and concurrency don't vanish because the syntax got prettier16. Good RPC frameworks don't hide the network — they surface timeouts, retries and idempotence so you have to decide consciously.

Tự đọc mìnhRead yourself

Soi khả năng tiến hóa của chính hệ bạnTest your own system's evolvability

Chương 4 cho một bộ câu hỏi rất dễ áp dụng, vì câu trả lời hoặc có hoặc không — không có chỗ cho cảm tính. Chúng không hỏi hệ của bạn dùng công nghệ gì, mà hỏi điều gì sẽ xảy ra vào đúng giây phút hai phiên bản code của bạn gặp nhau.

Thử soi hệ bạn đang làm bằng mấy câu dưới. Không hỏi bạn dùng công nghệ gì, chỉ hỏi: nếu bản cũ và bản mới gặp nhau thì chuyện gì xảy ra? Nhớ nhé: câu trả lời hoặc là có, hoặc là không — không có chỗ cho cảm tính.

Chapter 4 gives questions that are easy to apply, because the answers are yes or no — there's no room for vibes. They don't ask what technology you use; they ask what happens at the exact moment two versions of your code meet.

Trong lúc triển khai, hai phiên bản code của bạn cùng sống bao lâu?Vài phút hay vài tháng? Với ứng dụng phía khách mà bạn không ép cập nhật được, con số ấy có thể là nhiều năm. Độ dài của quãng đó chính là độ dài của khoảng thời gian bạn phải bảo đảm cả hai chiều tương thích.
Bạn có gọi tên được chiều nào đang thiếu không?Thử nói thành lời: "code mới của tôi đọc được dữ liệu tháng trước" (ngược) và "code tháng trước đọc được dữ liệu code mới ghi" (xuôi). Nếu câu thứ hai khiến bạn ngập ngừng, bạn đã tìm ra chỗ yếu.
Định dạng bạn đang dùng có cho người đọc nhảy qua trường lạ không?Đây là câu hỏi kỹ thuật rất cụ thể. Protobuf làm được vì mỗi trường mang tag chứa kiểu wire nên parser cũ biết cần bỏ qua bao nhiêu byte5. JSON làm được ở tầng cú pháp nhưng chẳng có gì bảo đảm ứng dụng của bạn không ném lỗi khi gặp khóa lạ.
Có chỗ nào trong hệ đọc-sửa-ghi cả bản ghi không?Đây là nơi bẫy mất trường nằm chờ. Hãy tìm mọi đoạn code tải một bản ghi vào đối tượng rồi ghi nguyên đối tượng trở lại. Nếu bản ghi có trường mà đối tượng không có, trường ấy sẽ bốc hơi mà không ai báo1.
Ai đang giữ lược đồ, và nó có được kiểm chứng tự động không?Một lược đồ nằm trong kho mã và được dùng để sinh mã thì không thể lỗi thời. Một "lược đồ" chỉ tồn tại trong đầu vài người thì không phải lược đồ. Nếu bạn dùng sổ đăng ký lược đồ, bạn đã đặt chế độ tương thích nào — và có biết mặc định của nó là gì không11?
Bạn có quy ước nào về việc không dùng lại số hiệu trường không?Với Thrift và Protobuf, số hiệu là bản hợp đồng vĩnh viễn với mọi byte đã ghi ra trong quá khứ. Tài liệu Protobuf khuyên đánh dấu reserved cho số hiệu của trường đã xóa để không ai dùng lại6. Nhóm bạn có làm vậy, hay đang trông vào trí nhớ?
Ở ranh giới dịch vụ, bên nào được nâng cấp trước?Nếu máy chủ đi trước, thì yêu cầu cần tương thích ngược còn phản hồi cần tương thích xuôi1. Nếu bạn không biết bên nào đi trước, bạn đang phải bảo đảm cả hai chiều cho cả hai hướng — hãy nói rõ điều đó ra thay vì hy vọng.
Nếu một lời gọi hết giờ, bạn có biết nó đã thực thi hay chưa?Đây là lỗi bộ phận mà Waldo và cộng sự cảnh báo16. Nếu câu trả lời là "không biết" thì việc thử lại của bạn cần tính khử trùng lặp. Nếu bạn chưa từng hỏi câu này, có nghĩa là khung RPC đã che mạng đi quá tốt.
Khi bạn nâng cấp, bản cũ và bản mới sống chung bao lâu?Càng lâu thì càng phải chắc là hai bên hiểu được nhau.
Bản cũ gặp trường lạ thì làm gì — bỏ qua hay báo lỗi?Bỏ qua được mà không làm hỏng mới là ổn.
Có chỗ nào mở bản ghi ra sửa rồi cất lại nguyên cả cục không?Đó là chỗ trường lạ dễ biến mất nhất.
Bản mô tả dữ liệu của bạn nằm ở đâu — trong mã hay trong đầu ai đó?Nếu chỉ nằm trong đầu người thì nó sẽ sai lúc nào không hay.
During a deploy, how long do two versions of your code coexist?Minutes or months? For client-side apps you can't force-update, it may be years. That window is exactly how long you must guarantee both directions.
Can you name which direction you're missing?Say it out loud: "my new code reads last month's data" (backward) and "last month's code reads what my new code writes" (forward). If the second one makes you hesitate, you've found the weak spot.
Does your format let a reader skip an unknown field?Protobuf can, because each field's tag carries the wire type so an old parser knows how many bytes to skip5. JSON can syntactically, but nothing stops your application from throwing on an unknown key.
Is there anywhere in the system that reads, modifies and writes a whole record?That's where the field-loss trap waits. Find every place that loads a record into an object and writes the object back. Fields the object doesn't know about evaporate silently1.
Who holds the schema, and is it checked automatically?A schema in the repo that generates code cannot go stale. A "schema" that lives in a few people's heads isn't one. If you run a registry, which compatibility mode did you set — and do you know its default11?
Do you have a rule against reusing field numbers?In Thrift and Protobuf the number is a permanent contract with every byte ever written. The Protobuf guide says to mark a deleted field's number reserved6. Does your team do that, or rely on memory?
At a service boundary, which side upgrades first?If servers go first, requests need backward and responses need forward compatibility1. If you don't know which goes first, you owe both directions both ways — say so rather than hope.
When a call times out, do you know whether it executed?This is the partial failure Waldo et al. warned about16. If the answer is "no idea", your retries need idempotence. If you've never asked, your RPC framework hid the network too well.
Kết luận cô đọngThe conclusion, distilled
Mọi định dạng mã hóa cuối cùng đều được chấm bằng một câu hỏi: hai phiên bản code có sống chung được không. Vì nâng cấp luôn diễn ra dần và dữ liệu luôn ở lại lâu hơn code đã sinh ra nó, tương thích ngược và tương thích xuôi không phải tính năng thêm vào mà là điều kiện để thay đổi. Chọn được một định dạng mà người đọc lạ biết cách bước qua thứ nó không hiểu, ấy là bạn đã mua cho hệ của mình quyền được tiến hóa.Gói dữ liệu kiểu nào cũng chỉ hơn thua ở một điểm: bản cũ và bản mới có hiểu nhau không. Vì nâng cấp thì đổi dần, mà dữ liệu thì ở lại rất lâu. Nhớ nhé: chọn cách gói cho phép người đọc bỏ qua thứ lạ mà không làm hỏng — đó là cách giữ cho hệ của bạn còn sửa được về sau.Every encoding is finally graded on one question: can two versions of the code coexist. Because upgrades are always gradual and data always outlives the code that produced it, backward and forward compatibility aren't added features but the precondition for change. Choose a format whose readers know how to step over what they don't understand, and you have bought your system the right to evolve.