Khi nói về deployment, nhiều người thường nghĩ theo một flow khá đơn giản:
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ó:
users
- id
- install_id
Bây giờ vì security requirement mới, chúng ta muốn lưu thêm:
install_id_hash
Và giả sử bảng có khoảng:
10,000,000 users
Nếu chúng ta làm ngay:
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à:
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ụ:
ALTER TABLE users
ADD COLUMN install_id_hash TEXT NULL;
Schema lúc này:
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ụ:
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:
install_id = "ABC123"
V2 muốn sử dụng:
install_id_hash = HASH("ABC123")
Flow migration hợp lý có thể là:
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ụ:
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:
install_id
+
install_id_hash
5. Tại sao không chỉ backfill data cũ?
Giả sử worker bắt đầu hash:
Row 1
Row 2
...
Row 8,000,000
Trong khi đó production vẫn tạo dữ liệu:
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:
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ụ:
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ụ:
10,000 rows
↓
commit
10,000 rows
↓
commit
10,000 rows
↓
commit
Worker có thể xử lý:
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:
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:
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ụ:
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ể:
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:
backfill complete
tests passed
metrics stable
mới bật:
FeatureFlag = ON
Flow:
Deploy new code
↓
Backfill
↓
Verify
↓
Enable feature flag
Nếu phát hiện vấn đề:
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:
User refresh
→ nhận frontend mới
Nhưng mobile khác.
Có thể tồn tại:
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à:
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 để:
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:
Backend A = V1
Backend B = V2
Một deployment hoàn toàn có thể có:
Backend
├── /v1
└── /v2
Ví dụ:
V1 Request
{
"full_name": "John Smith"
}
V2:
{
"first_name": "John",
"last_name": "Smith"
}
Backend normalize:
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:
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:
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:
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:
Remove fallback
Remove dual-write
Remove old API contract
Set NOT NULL
Drop old column
Remove feature flag
Ví dụ:
ALTER TABLE users
ALTER COLUMN install_id_hash SET NOT NULL;
và sau một release khác:
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à:
install_id
install_id_hash
thì rollback:
V2 → V1
vẫn có khả năng chạy.
Nhưng nếu migration đã:
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ư:
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:
ADD
↓
SUPPORT BOTH
↓
BACKFILL
↓
SWITCH
↓
MONITOR
↓
REMOVE OLD
Hay theo thuật ngữ:
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ì:
Remove old
→ Deploy new
hãy nghĩ:
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.