Khi nói về deployment, nhiều người thường nghĩ theo một flow khá đơn giản:
“`text
Build
→ Deploy
→ Done
“`
Nhưng với các hệ thống đang chạy production, đặc biệt là mobile app hoặc hệ thống có nhiều service phụ thuộc lẫn nhau, một thay đổi database có thể không hoàn thành chỉ trong vài phút.
Có những migration kéo dài hàng giờ hoặc vài ngày.
Trong trường hợp đó, vấn đề quan trọng không còn là:
> Làm sao deploy version mới?
Mà là:
> Làm sao để version cũ, version mới và dữ liệu đang được migrate có thể cùng tồn tại mà không làm hệ thống bị lỗi?
Đây là lúc các khái niệm như **additive migration, backward compatibility, bridge version, backfill, feature flag và expand-contract** trở nên quan trọng.
—
## 1. Vấn đề của breaking database migration
Giả sử hệ thống hiện tại có:
“`text
users
– id
– install_id
“`
Bây giờ vì security requirement mới, chúng ta muốn lưu thêm:
“`text
install_id_hash
“`
Và giả sử bảng có khoảng:
“`text
10,000,000 users
“`
Nếu chúng ta làm ngay:
“`sql
ALTER TABLE users
ADD COLUMN install_id_hash TEXT NOT NULL;
“`
thì có một số vấn đề.
Thứ nhất, dữ liệu cũ chưa có hash.
Thứ hai, application version cũ hoàn toàn không biết đến field mới.
Thứ ba, quá trình hash 10 triệu record có thể mất hàng giờ hoặc vài ngày.
Thứ tư, trong thời gian backfill đang chạy, production vẫn tiếp tục nhận dữ liệu mới.
Điều này có nghĩa migration không thể được xem là một transaction deployment đơn giản.
—
# 2. Expand trước, Contract sau
Một chiến lược phổ biến là:
“`text
EXPAND
↓
MIGRATE
↓
SWITCH
↓
CONTRACT
“`
Nguyên tắc rất đơn giản:
> Đừng phá cái cũ trước khi cái mới đã hoạt động hoàn toàn.
Thay vì đổi schema trực tiếp, bước đầu tiên chỉ nên là một additive migration.
Ví dụ:
“`sql
ALTER TABLE users
ADD COLUMN install_id_hash TEXT NULL;
“`
Schema lúc này:
“`text
users
– id
– install_id
– install_id_hash nullable
“`
Application cũ vẫn hoạt động bình thường vì `install_id` chưa bị xóa.
Application mới bắt đầu có khả năng sử dụng field mới.
—
# 3. Bridge Version là gì?
Bridge Version không nhất thiết phải là API `/v1.5`.
Nó có thể chỉ là một phiên bản backend tạm thời có khả năng hiểu cả cấu trúc cũ và mới.
Ví dụ:
“`text
V1
Old schema / old behavior
↓
Bridge Version
Support old + new
↓
V2
New schema / new behavior only
“`
Nhiệm vụ chính của bridge là tạo một khoảng thời gian để:
* client cũ vẫn hoạt động
* client mới bắt đầu dùng behavior mới
* dữ liệu cũ được migrate
* dữ liệu mới được lưu đúng format mới
* hệ thống vẫn có khả năng rollback
Bridge chỉ nên tồn tại trong thời gian migration.
Nó không nên trở thành kiến trúc vĩnh viễn.
—
# 4. Ví dụ thực tế với cột hash
Giả sử V1 hiện tại ghi:
“`text
install_id = “ABC123”
“`
V2 muốn sử dụng:
“`text
install_id_hash = HASH(“ABC123”)
“`
Flow migration hợp lý có thể là:
“`text
Phase 1
Add install_id_hash nullable
↓
Phase 2
Deploy bridge version
↓
Phase 3
Backfill historical data
↓
Phase 4
Switch reads to hash
↓
Phase 5
Remove old compatibility
“`
Điểm quan trọng nằm ở Phase 2.
Bridge version phải đảm bảo mọi dữ liệu mới sau thời điểm deployment đều được ghi theo cấu trúc mới.
Ví dụ:
“`text
Old client
↓
install_id
↓
Backend Bridge
├── save install_id
└── calculate + save install_id_hash
“`
Tức là trong giai đoạn migration, backend có thể tạm thời dual-write:
“`text
install_id
+
install_id_hash
“`
—
# 5. Tại sao không chỉ backfill data cũ?
Giả sử worker bắt đầu hash:
“`text
Row 1
Row 2
…
Row 8,000,000
“`
Trong khi đó production vẫn tạo dữ liệu:
“`text
Row 10,000,001
Row 10,000,002
Row 10,000,003
“`
Nếu application mới không populate `install_id_hash`, worker sẽ liên tục phải đuổi theo dữ liệu mới.
Migration sẽ không thực sự có một điểm kết thúc rõ ràng.
Vì vậy phải tách:
“`text
Historical data
→ Backfill Worker xử lý
New data
→ Application ghi theo format mới ngay
“`
Đây là một trong những nhiệm vụ quan trọng nhất của bridge version.
—
# 6. Backfill nên chạy riêng khỏi deployment
Một lỗi khá phổ biến là đưa toàn bộ migration vào application startup.
Ví dụ:
“`text
Deploy
↓
Application starts
↓
UPDATE 10 million rows
↓
Application ready
“`
Đây là một ý tưởng nguy hiểm.
Migration lớn có thể:
* giữ transaction quá lâu
* tạo lock
* tăng WAL
* gây replication lag
* tăng CPU/IO database
* làm startup deployment timeout
* gây khó rollback
Thay vào đó nên chạy backfill riêng theo batch.
Ví dụ:
“`text
10,000 rows
↓
commit
10,000 rows
↓
commit
10,000 rows
↓
commit
“`
Worker có thể xử lý:
“`sql
SELECT id, install_id
FROM users
WHERE install_id_hash IS NULL
LIMIT 10000;
“`
Sau đó update theo từng batch.
Điều này giúp kiểm soát:
“`text
Database load
CPU
IO
Transaction size
Retry
Progress
“`
—
# 7. Trong thời gian migration, read như thế nào?
Khi dữ liệu chưa migrate hết, application không thể mặc định mọi row đều có field mới.
Có thể dùng fallback logic:
“`text
install_id_hash exists?
│
├── Yes → use new value
│
└── No → use legacy representation
“`
Đây có thể coi là một dạng dual-read tạm thời.
Ví dụ:
“`csharp
var hash = user.InstallIdHash;
if (string.IsNullOrEmpty(hash))
{
hash = Hash(user.InstallId);
}
“`
Logic này chỉ tồn tại trong migration window.
Sau khi toàn bộ dữ liệu đã được backfill, fallback nên được xóa.
—
# 8. Feature Flag giúp gì?
Deploy code mới không đồng nghĩa phải bật behavior mới ngay.
Có thể:
“`text
Code V2 deployed
FeatureFlag = OFF
“`
Application mới đã có code xử lý `install_id_hash`, nhưng user vẫn đang chạy behavior cũ.
Sau khi:
“`text
backfill complete
tests passed
metrics stable
“`
mới bật:
“`text
FeatureFlag = ON
“`
Flow:
“`text
Deploy new code
↓
Backfill
↓
Verify
↓
Enable feature flag
“`
Nếu phát hiện vấn đề:
“`text
FeatureFlag = OFF
“`
mà không nhất thiết rollback toàn bộ deployment.
—
# 9. Mobile làm migration khó hơn Web
Web application thường dễ chuyển version hơn.
Sau deployment:
“`text
User refresh
→ nhận frontend mới
“`
Nhưng mobile khác.
Có thể tồn tại:
“`text
Mobile V1
Mobile V1.5
Mobile V2
“`
cùng lúc trong nhiều tuần hoặc nhiều tháng.
Nếu application chưa có force-update hoặc minimum supported version, backend không thể giả định tất cả user đã upgrade.
Backend phải tiếp tục hiểu V1.
Architecture có thể là:
“`text
Mobile V1
↓
V1 API Adapter
↓
Canonical Domain
Mobile V2
↓
V2 API Adapter
↓
Canonical Domain
“`
Điều quan trọng là versioning nên được xử lý ở boundary.
Không nên để:
“`text
if v1
if v1.5
if v2
“`
lan sâu khắp domain logic.
—
# 10. Một backend vẫn có thể support V1 và V2
Không nhất thiết phải deploy hai backend riêng:
“`text
Backend A = V1
Backend B = V2
“`
Một deployment hoàn toàn có thể có:
“`text
Backend
├── /v1
└── /v2
“`
Ví dụ:
“`text
V1 Request
{
“full_name”: “John Smith”
}
“`
V2:
“`text
{
“first_name”: “John”,
“last_name”: “Smith”
}
“`
Backend normalize:
“`text
V1 DTO ──┐
↓
Canonical Model
↑
V2 DTO ──┘
“`
Domain chỉ cần biết representation chuẩn.
Đây chính là compatibility layer.
—
# 11. Khi nào Bridge Version thực sự cần thiết?
Không phải migration nào cũng cần bridge.
Bridge đặc biệt hữu ích khi có một trong các tình huống:
“`text
Old mobile clients còn tồn tại
Data migration mất nhiều giờ/ngày
Old + new application instances cùng chạy
External integration chưa migrate
Breaking API contract
Breaking database schema
Need rollback support
Event/message consumers chưa migrate hết
“`
Nếu deployment rất đơn giản:
“`text
Internal web application
No old clients
Small dataset
No external dependencies
“`
thì có thể migration trực tiếp mà không cần một bridge phase dài.
—
# 12. Khi nào được xóa dữ liệu/schema cũ?
Không nên xóa chỉ vì V2 đã deploy.
Chỉ nên contract khi đã xác nhận:
“`text
Backfill = 100%
New writes populate new field = 100%
New behavior stable
Old API usage = 0
Old mobile version = 0 hoặc unsupported
No downstream dependency uses old field
Rollback no longer needs old schema
“`
Sau đó mới:
“`text
Remove fallback
Remove dual-write
Remove old API contract
Set NOT NULL
Drop old column
Remove feature flag
“`
Ví dụ:
“`sql
ALTER TABLE users
ALTER COLUMN install_id_hash SET NOT NULL;
“`
và sau một release khác:
“`sql
ALTER TABLE users
DROP COLUMN install_id;
“`
—
# 13. Vì sao destructive migration nên làm ở release sau?
Giả sử V2 vừa deploy nhưng có bug.
Nếu schema vẫn là:
“`text
install_id
install_id_hash
“`
thì rollback:
“`text
V2 → V1
“`
vẫn có khả năng chạy.
Nhưng nếu migration đã:
“`sql
DROP COLUMN install_id;
“`
thì rollback application V1 cũng không giúp được nữa.
V1 cần column đó nhưng DB không còn.
Vì vậy:
> Database compatibility thường nên tồn tại lâu hơn application deployment.
—
# 14. Migration không phải một deployment, mà là một quá trình
Một migration production lớn nên được nhìn như:
“`text
Day 1
Add new schema
↓
Day 1
Deploy compatibility version
↓
Day 1–3
Backfill 10 million rows
↓
Day 3
Validate
↓
Day 3
Enable new behavior
↓
Day 3–10
Monitor old clients
↓
Later release
Remove compatibility
↓
Later release
Drop old schema
“`
Không có gì sai nếu migration kéo dài vài ngày.
Thực tế, cố ép toàn bộ quá trình vào một deployment ngắn có thể còn nguy hiểm hơn.
—
# 15. Mental Model dễ nhớ
Có thể nhớ bằng 6 bước:
“`text
ADD
↓
SUPPORT BOTH
↓
BACKFILL
↓
SWITCH
↓
MONITOR
↓
REMOVE OLD
“`
Hay theo thuật ngữ:
“`text
Expand
→ Bridge
→ Migrate
→ Cutover
→ Observe
→ Contract
“`
—
# Kết luận
Zero-downtime migration không có nghĩa migration phải hoàn thành ngay lập tức.
Ngược lại, một migration an toàn thường cố tình tạo ra một giai đoạn mà old và new cùng tồn tại.
Bridge version tồn tại để quản lý chính giai đoạn này.
Nó cho phép:
* schema cũ và mới cùng hoạt động
* client cũ và mới cùng tồn tại
* dữ liệu được backfill dần
* new writes không tạo thêm technical debt
* feature mới được bật có kiểm soát
* rollback vẫn an toàn
Nguyên tắc quan trọng nhất là:
> **Never make the destructive change first.**
Thay vì:
“`text
Remove old
→ Deploy new
“`
hãy nghĩ:
“`text
Add new
→ Support both
→ Migrate
→ Switch
→ Verify
→ Remove old
“`
Đó là cách biến một breaking database change thành một migration có thể kiểm soát và gần với zero downtime hơn.