Schema evolution — đổi schema không downtime
Dữ liệu sống lâu hơn code. Schema phải đổi mà không vỡ data cũ hay client cũ — backward/forward compatibility và expand-contract migration, agnostic.
TL;DR: Code thay đổi liên tục, nhưng dữ liệu sống lâu hơn code rất nhiều — bản ghi ghi hôm nay có thể được đọc bởi code của hai năm sau, và code mới triển khai hôm nay phải đọc được dữ liệu cũ. Schema vì thế phải tiến hoá mà không vỡ. Hai chiều tương thích: backward compatibility (code mới đọc được data cũ) và forward compatibility (code cũ đọc được data mới). Trong rolling upgrade — khi phiên bản cũ và mới chạy song song — bạn cần cả hai. Quy tắc an toàn: chỉ thêm field optional, không đổi nghĩa field cũ, không bỏ field bắt buộc; và dùng expand-contract để đổi cấu trúc qua nhiều bước thay vì một cú nhảy. Hiểu điều này để đổi schema mà không gây downtime.
Đội của bạn triển khai phiên bản mới của một service: thêm field phone vào User. Deploy không phải tắt-bật toàn bộ một lúc — cluster có 20 instance, hệ thống cuốn chiếu (rolling) từng cái một để không gián đoạn. Trong vài phút đó, một nửa instance chạy code mới (biết phone), nửa kia chạy code cũ (không biết phone). Một request đi qua instance mới ghi bản ghi có phone; ngay sau đó một instance cũ đọc bản ghi đó. Nếu code cũ sập khi gặp field lạ, bạn vừa gây lỗi cho người dùng — chỉ vì thêm một field.
Đây là vấn đề schema evolution: dữ liệu và code không đổi cùng lúc, nên các phiên bản phải đọc được dữ liệu của nhau. Bài này giải thích hai chiều tương thích, vì sao rolling upgrade cần cả hai, và quy tắc đổi schema an toàn — nối tiếp ý "field tag" từ bài 02.
1. Analogy — Biểu mẫu giấy qua các năm
Một cơ quan dùng tờ khai giấy. Năm nay họ in mẫu mới: thêm ô "email", bỏ ô "số fax". Nhưng trong ngăn tủ vẫn còn hàng nghìn tờ khai mẫu cũ đã điền, và nhiều nhân viên vẫn cầm mẫu cũ chưa kịp đổi.
- Nhân viên mới (code mới) cầm mẫu mới, nhưng phải xử lý được cả tờ khai cũ trong tủ (data cũ) — họ chấp nhận "tờ này không có ô email" thay vì vứt nó đi. Đó là backward compatibility.
- Nhân viên cũ (code cũ) gặp một tờ khai mẫu mới (data mới có ô email): họ phải bỏ qua ô lạ và vẫn xử lý phần mình hiểu, thay vì xé tờ giấy. Đó là forward compatibility.
Bí quyết để chuyển mẫu êm thấm: thêm ô mới thì để trống được (optional), đừng đổi ý nghĩa ô cũ (ô "ngày" vẫn là ngày, không bỗng thành "tháng"), và đừng đột ngột bỏ ô bắt buộc mà ai đó còn dựa vào.
| Biểu mẫu giấy | Schema evolution |
|---|---|
| Tờ khai đã điền nằm trong tủ | Dữ liệu cũ đã ghi |
| Nhân viên mới đọc tờ khai cũ | Code mới đọc data cũ = backward compat |
| Nhân viên cũ gặp tờ mẫu mới | Code cũ đọc data mới = forward compat |
| Thêm ô để trống được | Thêm field optional |
| Bỏ qua ô lạ, không xé giấy | Bỏ qua field chưa biết |
| Đừng đổi nghĩa ô cũ | Không đổi semantics field cũ |
Backward = code MỚI đọc data CŨ (nhìn về sau lưng). Forward = code CŨ đọc data MỚI (data từ tương lai gửi về). Rolling upgrade cần cả hai vì cũ và mới chạy cùng lúc.
2. Vì sao dữ liệu sống lâu hơn code
Một bản deploy thay code trong vài phút. Nhưng dữ liệu thì tích luỹ và tồn tại:
- Bản ghi ghi từ ba năm trước vẫn nằm trong database, vẫn được đọc.
- Message trong một queue có thể chờ giờ-tới-ngày trước khi được xử lý — bởi một consumer có thể đã lên phiên bản khác.
- Dữ liệu ở tầng cold (xem bài 01) sống nhiều năm; khi cần lấy lại, code đọc nó đã đổi nhiều thế hệ.
Truc thoi gian:
code v1 --- deploy v2 --- deploy v3 --- deploy v4 -->
data: ghi v1 ...... van con ...... van duoc doc boi v4
=> code v4 PHAI doc duoc data ghi tu thoi v1 (backward compat)
Hệ quả: bạn không thể giả định "dữ liệu luôn khớp code hiện tại". Code mới phải đọc được mọi định dạng cũ còn tồn tại, và — trong lúc deploy — code cũ phải sống sót khi gặp định dạng mới.
3. Hai chiều tương thích
flowchart TB
subgraph BACK["Backward compatibility"]
NEWC["Code MOI"] -->|"doc duoc"| OLDD[("Data CU")]
end
subgraph FWD["Forward compatibility"]
OLDC["Code CU"] -->|"doc duoc<br/>(bo qua field la)"| NEWD[("Data MOI")]
endBackward compatibility — code mới đọc data cũ. Đây là chiều dễ hơn và gần như luôn cần: khi nâng cấp, code mới phải xử lý được mọi dữ liệu đã ghi bởi các phiên bản trước. Code mới biết các định dạng cũ trông thế nào (vì nó là hậu duệ), nên chỉ cần xử lý "field này có thể vắng trong bản ghi cũ".
Forward compatibility — code cũ đọc data mới. Đây là chiều khó hơn: code cũ phải xử lý dữ liệu được ghi bởi phiên bản tương lai mà nó chưa từng biết. Mấu chốt là code cũ phải bỏ qua một cách lịch sự những gì nó không hiểu — gặp field lạ thì lờ đi, không sập. Field tag số (từ bài 02) giúp việc này: format binary cho code cũ "nhảy qua" tag chưa biết mà vẫn đọc được phần còn lại.
Khi deploy cuốn chiếu, code cũ và code mới chạy đồng thời trong cùng cluster, đọc/ghi cùng kho dữ liệu. Instance mới ghi data mới → instance cũ phải đọc được (forward). Instance mới cũng phải đọc data do instance cũ vừa ghi và data tồn từ trước (backward). Thiếu một chiều là một nửa cluster sập trong cửa sổ vài phút deploy. Đây là lý do schema evolution không phải chuyện "đổi từ từ" mà là yêu cầu kỹ thuật cứng.
4. Quy tắc đổi schema an toàn
Ba quy tắc giữ cả hai chiều tương thích:
4.1 Thêm field thì phải optional (có default)
Thêm field mới không được làm bắt buộc. Nếu bắt buộc, code mới đọc bản ghi cũ (không có field đó) sẽ thiếu giá trị và lỗi — vỡ backward compat. Field mới phải có giá trị mặc định hoặc cho phép vắng.
OK : them field `phone` optional, default rong
- code moi doc data cu: phone vang -> dung default (OK backward)
- code cu doc data moi: gap tag la -> bo qua (OK forward)
VO : them field `phone` BAT BUOC
- code moi doc data cu thieu phone -> loi (vo backward)
4.2 Không đổi nghĩa field cũ
Một field đã tồn tại phải giữ nguyên ý nghĩa và kiểu. Đừng tái dùng field status (đang là chuỗi "active"/"inactive") thành số 0/1 — code cũ và code mới sẽ diễn giải cùng byte theo hai cách, ra kết quả sai mà không báo lỗi. Cần nghĩa mới thì thêm field mới, đừng đổi field cũ.
4.3 Không bỏ field bắt buộc (mà bên kia còn dựa vào)
Bỏ một field mà code cũ (hoặc consumer khác) vẫn đọc sẽ làm chúng thiếu dữ liệu → vỡ forward compat. Chỉ bỏ field khi chắc không còn ai đọc nó — và cách an toàn để đạt điều đó là expand-contract dưới đây.
5. Expand-contract — đổi cấu trúc qua nhiều bước
Khi cần một thay đổi không tương thích trực tiếp (đổi tên field, đổi kiểu, tách/gộp field), đừng làm trong một bước. Dùng expand-contract (còn gọi parallel change): mở rộng trước, dọn sau, qua nhiều lần deploy để luôn có một trạng thái mọi phiên bản đọc được.
Ví dụ: đổi name (một chuỗi) thành first_name + last_name.
EXPAND (deploy 1): them first_name, last_name (optional).
Code GHI ca name (cu) lan first/last (moi). Code DOC uu tien
truong moi, fallback name. -> moi phien ban deu du data.
MIGRATE (nen): backfill du lieu cu -> dien first/last tu name.
CONTRACT (deploy 2): khi chac khong con ai doc `name`, ngung ghi
va bo `name`. Chi lam khi MOI client da len phien ban moi.
flowchart LR E["EXPAND<br/>them field moi,<br/>ghi ca hai"] --> M["MIGRATE<br/>backfill data cu"] M --> C["CONTRACT<br/>bo field cu<br/>khi het ai doc"]
Ý cốt lõi: ở mọi thời điểm giữa các bước, dữ liệu có cả định dạng cũ lẫn mới, nên cả code cũ lẫn mới đều tìm được thứ chúng cần. Không có khoảnh khắc nào "data chỉ ở dạng một phiên bản không đọc được". Đây cũng chính là tinh thần migration ở Module 5 — schema migration: đổi cấu trúc database mà không khoá bảng hay gây downtime.
6. Pitfall — đổi kiểu hoặc bỏ cột một cú
Cám dỗ: "chỉ là đổi field age từ chuỗi sang số thôi mà, sửa một phát cho xong". Trong rolling upgrade, một phát đó vỡ:
- Đổi kiểu một bước: instance cũ ghi
agechuỗi, instance mới ghiagesố, cùng lúc. Bên đọc gặp loạn kiểu → parse fail hoặc dữ liệu rác. Không có cú "đổi đồng thời mọi instance". - Bỏ cột bắt buộc một bước: vừa bỏ cột
email, code cũ (chưa kịp deploy) đọc bản ghi thiếuemail→ lỗi. Consumer khác (job, report) còn dựa vào cũng vỡ.
WRONG (mot cu):
deploy doi `age` chuoi -> so -> cu va moi ghi/doc lech kieu -> vo
RIGHT (expand-contract):
1. them `age_num` optional, ghi ca `age` (chuoi) lan `age_num`
2. backfill `age_num` tu `age`
3. khi moi client doc `age_num`, ngung ghi + bo `age`
Quy tắc vàng: mọi thay đổi không-tương-thích phải tách thành nhiều bước tương thích. Nếu một bước đơn lẻ khiến code cũ hoặc data cũ không đọc được, đó là dấu hiệu phải dùng expand-contract thay vì "sửa một phát".
7. 📚 Deep Dive
- Designing Data-Intensive Applications (Kleppmann) — Chương 4 "Encoding and Evolution" — nguồn nền tảng định nghĩa backward/forward compatibility và phân tích vì sao mỗi format (JSON, Thrift, Protobuf, Avro) hỗ trợ tiến hoá schema ở mức khác nhau.
- Protocol Buffers — tài liệu chính thức (Google) — quy tắc dùng field number (tag): không tái dùng tag cũ, field mới thêm tag mới, code đọc bỏ qua tag lạ.
- Apache Avro — tài liệu chính thức — Avro khớp schema-ghi với schema-đọc, nền cho khả năng tiến hoá schema linh hoạt.
Ghi chú: Backward/forward compatibility là khái niệm agnostic — đúng cho bất kỳ format và bất kỳ kênh nào dữ liệu đi qua (database, message queue, file). DDIA Chương 4 chỉ rõ vì sao field tag số (Protobuf/Thrift) và schema-resolution (Avro) khiến tiến hoá schema khả thi, còn JSON/CSV thì phải tự lo bằng tay.
8. Liên hệ các bài khác
- Bài 01 — Vòng đời dữ liệu: dữ liệu ở tầng cold sống nhiều năm — chính nó buộc code mới phải backward-compatible với định dạng đã ghi từ lâu.
- Bài 02 — Encoding & serialization: field tag số của binary format là cơ chế kỹ thuật cho phép code cũ bỏ qua field lạ (forward) và code mới hiểu field vắng (backward).
- Module 5 — Schema migration: expand-contract áp dụng trực tiếp cho việc đổi cấu trúc bảng database (thêm/bỏ cột, đổi kiểu) mà không downtime.
- Module 10 — OLTP vs OLAP: pipeline ETL phải xử lý dữ liệu nguồn có nhiều thế hệ schema khác nhau — schema evolution là điều kiện để ETL không vỡ khi nguồn đổi.
9. Tóm tắt
- Dữ liệu sống lâu hơn code: bản ghi cũ vẫn được code mới đọc; code cũ trong lúc deploy vẫn gặp data mới.
- Backward compatibility = code mới đọc data cũ (chiều dễ, gần như luôn cần). Forward compatibility = code cũ đọc data mới (chiều khó, cần code cũ bỏ qua field lạ).
- Rolling upgrade cần cả hai chiều vì code cũ và mới chạy đồng thời, đọc/ghi cùng kho — thiếu một chiều là nửa cluster sập trong cửa sổ deploy.
- Ba quy tắc an toàn: thêm field phải optional có default; không đổi nghĩa/kiểu field cũ; không bỏ field bắt buộc mà bên kia còn dựa vào.
- Field tag số (binary format) là nền kỹ thuật: code cũ nhảy qua tag chưa biết, code mới dùng default cho tag vắng.
- Thay đổi không-tương-thích (đổi tên/kiểu, tách/gộp) phải làm bằng expand-contract: mở rộng (ghi cả hai dạng) → backfill → thu hẹp (bỏ dạng cũ khi hết ai đọc) — luôn có trạng thái mọi phiên bản đọc được.
10. Tự kiểm tra
Q1Phân biệt backward compatibility và forward compatibility. Cái nào thường khó đạt hơn, vì sao?▸
Backward compatibility = code mới đọc được data cũ. Forward compatibility = code cũ đọc được data mới.
Forward khó hơn. Code mới đọc data cũ thì dễ: code mới là hậu duệ, nó biết các định dạng cũ trông thế nào nên xử lý được. Nhưng code cũ phải đọc data ghi bởi một phiên bản tương lai mà nó chưa từng biết — nó chỉ có thể sống sót nếu được thiết kế để bỏ qua một cách lịch sự những field/tag nó không hiểu, thay vì sập. Đó là lý do field tag số và khả năng "skip unknown" của binary format lại quan trọng.
Q2Vì sao một rolling upgrade (deploy cuốn chiếu) lại cần CẢ backward lẫn forward compatibility, không chỉ một?▸
Vì trong cửa sổ deploy, code cũ và code mới chạy đồng thời trên cùng cluster, đọc/ghi cùng kho dữ liệu.
Instance mới ghi data mới, rồi một instance cũ đọc ngay data đó → cần forward (code cũ đọc data mới). Đồng thời instance mới phải đọc data do instance cũ vừa ghi và data tồn từ trước → cần backward (code mới đọc data cũ). Nếu thiếu một chiều, một nửa cluster sẽ sập đúng trong vài phút cả hai phiên bản cùng sống. Vì vậy schema evolution là yêu cầu cứng, không phải "đổi từ từ rồi đâu vào đó".
Q3Vì sao thêm một field mới BẮT BUỘC (không default) lại phá vỡ backward compatibility?▸
Vì code mới sẽ đọc cả những bản ghi cũ được ghi trước khi field đó tồn tại — và những bản ghi đó không hề có field này.
Nếu field là bắt buộc, code mới gặp bản ghi cũ thiếu nó sẽ coi là dữ liệu không hợp lệ và lỗi. Để giữ backward compat, field mới phải optional hoặc có giá trị mặc định: khi vắng, code mới dùng default thay vì sập. Tương tự ở chiều forward, code cũ gặp field mới (mà nó không biết) chỉ cần bỏ qua. Optional + default là điều kiện để cả hai chiều cùng sống.
Q4Cần đổi field 'name' (một chuỗi) thành 'first_name' + 'last_name'. Vì sao không sửa một phát, và expand-contract làm thế nào?▸
Không sửa một phát vì trong rolling upgrade, bỏ name ngay sẽ làm code cũ (chưa kịp deploy) và mọi consumer còn đọc name bị thiếu dữ liệu → vỡ. Cũng không có khoảnh khắc "mọi instance đổi đồng thời".
Expand-contract chia ba bước: (1) Expand — thêm first_name, last_name optional; code ghi cả name lẫn cặp mới, code đọc ưu tiên cặp mới và fallback name. (2) Migrate — backfill, điền cặp mới từ name cho data cũ. (3) Contract — khi chắc không client nào còn đọc name, ngừng ghi và bỏ nó. Ở mọi thời điểm giữa các bước, dữ liệu có đủ cả hai dạng nên mọi phiên bản đều đọc được.
Q5Vì sao 'tái dùng' một field cũ cho nghĩa mới (ví dụ field 'status' từ chuỗi 'active' đổi thành số 0/1) lại nguy hiểm hơn cả việc thêm field mới?▸
Vì nó tạo ra lỗi im lặng, không báo. Khi bạn đổi nghĩa/kiểu một field đang tồn tại, code cũ và code mới cùng đọc cùng một field nhưng diễn giải theo hai cách khác nhau — chuỗi "active" với code này, số với code kia.
Khác với thêm field mới (code cũ chỉ việc bỏ qua tag lạ — an toàn), việc tái dùng field cũ khiến cả hai phiên bản đều "đọc thành công" nhưng ra giá trị sai, không có exception để báo động. Quy tắc: cần nghĩa mới thì thêm field mới (tag mới), tuyệt đối không đổi nghĩa hay kiểu của field cũ đang được dùng.
Bài tiếp theo: Module 10 — OLTP vs OLAP: hai workload đối lập
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên