Chuyển tới nội dung chính

12.9 — 8. Database Migration Strategy

Tóm tắt

Quy tắc nền tảng của migration không downtime: schema mới phải chạy được với code cũ, và schema cũ phải chạy được với code mới — vì trong lúc triển khai, cả hai phiên bản cùng chạy. Mẫu giải bài toán đó là expand-contract: mở rộng, chuyển dữ liệu, rồi mới thu hẹp — và ba bước đó nằm ở ba lần deploy khác nhau, cách nhau hàng tuần. Hai chi tiết kỹ thuật quyết định migration có gây sự cố hay không: ALTER TABLE có thể khoá cả bảng trong nhiều phút (thêm cột NOT NULL không có default, thêm khoá ngoại), và UPDATE toàn bảng giữ khoá tới khi xong — nên backfill phải chia lô.

Mục tiêu bài học​

Sau bài này bạn có thể:

  • Viết migration tương thích ngược cả hai chiều.
  • Áp dụng expand-contract cho đổi tên và đổi kiểu cột.
  • Nhận ra thao tác DDL nào khoá bảng.
  • Backfill dữ liệu lớn theo lô.
  • Chọn thời điểm và cách chạy migration khi triển khai.

Nội dung bài học​

12.9.1 — Migration là mã nguồn​

migrations/
├── V001__initial_schema.sql
├── V002__add_lead_metadata.sql
├── V003__create_notifications.sql
└── V004__add_index_leads_status.sql

Ba nguyên tắc không thương lượng:

1. Không bao giờ sửa database production bằng tay. Một thay đổi thủ công không có trong migration nghĩa là môi trường staging và production khác nhau — và bạn chỉ phát hiện khi deploy tiếp theo thất bại.

2. Migration đã chạy thì không sửa nữa. Sửa V003 sau khi nó đã chạy ở production tạo ra hai thực tế khác nhau: môi trường mới chạy bản đã sửa, môi trường cũ giữ bản gốc. Cần thay đổi thì viết V004.

3. Migration phải chạy được nhiều lần hoặc có cơ chế theo dõi đã chạy. Công cụ nào cũng có bảng lịch sử, nhưng script vẫn nên phòng thủ:

IF NOT EXISTS (SELECT 1 FROM sys.columns
WHERE object_id = OBJECT_ID('Customers') AND name = 'Tier')
BEGIN
ALTER TABLE Customers ADD Tier NVARCHAR(50) NOT NULL DEFAULT 'Standard';
END
Công cụHợp với
EF Core MigrationsỨng dụng .NET sở hữu database, code-first
FlywaySQL thuần, nhiều ngôn ngữ, DBA quản lý
LiquibaseDoanh nghiệp, cần mô tả độc lập hệ quản trị
DbUp.NET, SQL thuần, rất nhẹ

EF Core Migrations được học kỹ ở Module 13. Với database dùng chung nhiều ứng dụng, Flyway hoặc DbUp thường hợp hơn — chúng không giả định một ứng dụng sở hữu schema.

12.9.2 — Tương thích ngược cả hai chiều​

Trong một lần triển khai rolling, có khoảng thời gian code cũ và code mới cùng chạy trên cùng một database:

t=0   3 pod code cu
t=1 2 pod code cũ + 1 pod code mới <- CÙNG database
t=2 1 pod code cu + 2 pod code moi
t=3 3 pod code moi

Nên migration phải thoả:

  • Schema mới chạy được với code cũ — nếu không, pod cũ lỗi trong lúc triển khai.
  • Schema cũ chạy được với code mới — nếu không, bạn không rollback được.
Thay đổiAn toàn?
Thêm cột nullableCó
Thêm cột NOT NULL có defaultCó (xem mục sau về khoá)
Thêm bảng mớiCó
Thêm indexCó (dùng ONLINE)
Thêm cột NOT NULL không defaultKhông — insert của code cũ sẽ lỗi
Xoá cộtKhông — code cũ còn dùng
Đổi tên cộtKhông — phá cả hai chiều
Siết kiểu dữ liệuKhông
Thêm ràng buộc CHECK chặt hơnKhông — dữ liệu cũ có thể vi phạm

Mọi dòng "Không" đều giải được bằng expand-contract.

12.9.3 — Expand-contract​

Đổi tên cột Email thành ContactEmail, không downtime:

-- === DEPLOY 1: EXPAND ===
ALTER TABLE Leads ADD ContactEmail NVARCHAR(256) NULL;
// Code deploy 1: GHI cả hai, ĐỌC cột cũ
lead.Email = value;
lead.ContactEmail = value;
var email = lead.Email;
-- === DEPLOY 2: BACKFILL (theo lo) ===
-- xem muc 12.9.5
// Code deploy 2: GHI cả hai, ĐỌC cột MỚI
lead.Email = value;
lead.ContactEmail = value;
var email = lead.ContactEmail;
// Code deploy 3: chỉ ghi và đọc cột mới
lead.ContactEmail = value;
var email = lead.ContactEmail;
-- === DEPLOY 4: CONTRACT — sau khi chắc chắn không ai dùng cột cũ ===
ALTER TABLE Leads DROP COLUMN Email;

Bốn lần deploy, mỗi lần đều rollback được độc lập. Đó là cái giá của không downtime, và nó đúng đắn — nhưng cũng là lý do đổi tên cột hiếm khi đáng làm. Tên cột không hoàn hảo rẻ hơn nhiều so với bốn chu kỳ triển khai.

Giữa deploy 3 và 4 nên chờ ít nhất một chu kỳ phát hành — để chắc chắn không còn code nào, không còn báo cáo nào, không còn tích hợp nào đọc cột cũ. Kiểm tra bằng cách grep toàn bộ hệ thống, không chỉ repo chính.

Đổi kiểu cột cũng theo mẫu này: thêm cột kiểu mới, ghi cả hai, backfill, chuyển đọc, xoá cột cũ.

12.9.4 — DDL nào khoá bảng​

-- NHANH — chi doi metadata (SQL Server 2012+, PostgreSQL 11+)
ALTER TABLE Leads ADD Notes NVARCHAR(MAX) NULL;
ALTER TABLE Leads ADD Tier NVARCHAR(50) NOT NULL DEFAULT 'Standard';

-- CHẬM — ghi lại TOÀN BỘ bảng, khoá suốt thời gian đó
ALTER TABLE Leads ALTER COLUMN CompanyName NVARCHAR(500) NOT NULL;
ALTER TABLE Leads ADD CONSTRAINT FK_... FOREIGN KEY ...; -- kiểm tra mọi dòng
ALTER TABLE Leads ADD CONSTRAINT CK_... CHECK ...; -- kiểm tra mọi dòng

Thêm cột NOT NULL có default là thao tác metadata từ SQL Server 2012 và PostgreSQL 11 — gần như tức thì bất kể bảng lớn cỡ nào. Trước các phiên bản đó, nó ghi lại toàn bộ bảng.

Thêm khoá ngoại hoặc CHECK phải kiểm tra mọi dòng hiện có. Với bảng 50 triệu dòng, đó là vài phút khoá — đủ để mọi request timeout.

-- SQL Server: thêm mà KHÔNG kiểm tra dữ liệu cũ
ALTER TABLE Leads WITH NOCHECK
ADD CONSTRAINT FK_Leads_Users FOREIGN KEY (AssignedToUserId) REFERENCES Users(UserId);

-- Kiểm tra sau, ngoài giờ cao điểm
ALTER TABLE Leads WITH CHECK CHECK CONSTRAINT FK_Leads_Users;
-- PostgreSQL: NOT VALID roi VALIDATE sau
ALTER TABLE leads ADD CONSTRAINT fk_leads_users
FOREIGN KEY (assigned_to_user_id) REFERENCES users(user_id) NOT VALID;

ALTER TABLE leads VALIDATE CONSTRAINT fk_leads_users; -- khoá nhẹ hơn nhiều

NOT VALID trong PostgreSQL đặc biệt hữu ích: ràng buộc áp dụng ngay cho dữ liệu mới, còn dữ liệu cũ được kiểm tra sau bằng một lệnh khoá nhẹ hơn nhiều.

Tạo index cũng khoá:

-- SQL Server (Enterprise): không chặn đọc/ghi
CREATE INDEX IX_Leads_Status ON Leads(Status) WITH (ONLINE = ON);

-- PostgreSQL: không chặn ghi
CREATE INDEX CONCURRENTLY idx_leads_status ON leads(status);

CONCURRENTLY chậm hơn (quét bảng hai lần), không chạy được trong transaction, và có thể để lại index không hợp lệ nếu thất bại — phải kiểm tra và dọn:

SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid;

12.9.5 — Backfill theo lô​

-- SAI — một UPDATE cho 50 triệu dòng
UPDATE Customers
SET Tier = CASE WHEN AnnualRevenue >= 1000000 THEN 'Enterprise' ELSE 'Standard' END;

Một UPDATE như vậy: giữ khoá tới khi toàn bộ xong, làm transaction log phình khổng lồ, và nếu thất bại ở phút thứ 40 thì rollback mất thêm 40 phút nữa.

-- DUNG — chia lo, moi lo mot transaction
DECLARE @BatchSize INT = 5000, @Rows INT = 1;

WHILE @Rows > 0
BEGIN
UPDATE TOP (@BatchSize) Customers
SET Tier = CASE
WHEN AnnualRevenue >= 1000000 THEN 'Enterprise'
WHEN AnnualRevenue >= 100000 THEN 'Business'
ELSE 'Standard'
END
WHERE Tier IS NULL; -- điều kiện để LẦN SAU bỏ qua dòng đã xong

SET @Rows = @@ROWCOUNT;
WAITFOR DELAY '00:00:00.100'; -- nhuong cho giao dich khac
END

Bốn yếu tố làm nó an toàn:

  1. Lô nhỏ — khoá giữ trong mili giây, không phải phút.
  2. Điều kiện WHERE loại dòng đã xử lý — script dừng giữa chừng rồi chạy lại được.
  3. Nghỉ giữa các lô — giao dịch của người dùng có chỗ chen vào.
  4. Mỗi lô là một transaction — transaction log được cắt bình thường.

Điểm 2 quan trọng nhất: backfill 50 triệu dòng sẽ bị gián đoạn ít nhất một lần. Script phải tiếp tục được từ chỗ dừng, không phải chạy lại từ đầu.

Với bảng rất lớn, tách backfill thành một job nền chạy vài ngày thay vì một script chạy trong cửa sổ bảo trì. Nó chậm hơn nhưng không cần downtime nào.

12.9.6 — Chạy migration khi nào​

CáchƯuNhược
Lúc ứng dụng khởi độngĐơn giản, tự độngNhiều pod cùng chạy; migration chậm chặn khởi động
Bước riêng trong pipelineKiểm soát, chạy một lần, thấy rõ lỗiThêm một bước
Init container / Kubernetes JobTách bạch, chạy trước khi pod lênCấu hình phức tạp hơn
Thủ côngKiểm soát tối đaNgười quên, môi trường lệch nhau
// Chấp nhận được cho dự án nhỏ — KHÔNG cho production nhiều instance
using var scope = app.Services.CreateScope();
await scope.ServiceProvider.GetRequiredService<AppDbContext>()
.Database.MigrateAsync();

Với nhiều instance, ba pod cùng khởi động sẽ cùng chạy migration. EF Core và Flyway đều có khoá, nên thường không hỏng — nhưng hai pod còn lại chờ suốt thời gian đó, và nếu migration mất 10 phút thì health check đã báo hỏng từ lâu (bài 8.10).

Cách được khuyến nghị: bước riêng trong pipeline triển khai, trước khi pod mới lên.

- name: Apply database migrations
run: dotnet ef database update --connection "$CONN"

- name: Deploy application
run: kubectl rollout restart deployment/crm-api

Thứ tự này chỉ đúng vì migration tương thích ngược — schema mới chạy được với code cũ đang phục vụ. Đó là lý do mục 12.9.2 là nền tảng của cả bài.

Và luôn sao lưu trước migration phá huỷ. Không phải vì bạn nghĩ nó sẽ hỏng, mà vì DROP COLUMN không có undo.

12.9.7 — Rà lại code của bạn​

Danh sách rà soát migration

  • •Không ai sửa schema production bằng tay.
  • •Migration đã chạy không bao giờ bị sửa; thay đổi thì viết migration mới.
  • •Mọi migration tương thích ngược với code đang chạy.
  • •Đổi tên và đổi kiểu cột đi theo expand-contract nhiều lần deploy.
  • •Biết thao tác DDL nào khoá bảng trên phiên bản đang dùng.
  • •Thêm khoá ngoại và CHECK dùng NOCHECK hoặc NOT VALID rồi kiểm tra sau.
  • •Tạo index trên bảng lớn dùng ONLINE hoặc CONCURRENTLY.
  • •Backfill chia lô, có nghỉ, và tiếp tục được từ chỗ dừng.
  • •Migration chạy ở bước riêng trong pipeline, không lúc ứng dụng khởi động.
  • •Có sao lưu trước mọi migration phá huỷ.
  • •Migration được kiểm thử trên dữ liệu có kích thước như production.

Bài tập áp dụng​

Bài 1 — UPDATE toàn bảng và transaction log​

Trên bảng 2 triệu dòng, chạy một UPDATE toàn bộ và đo thời gian khoá cùng kích thước transaction log. Chia lô 5.000 và đo lại.

Tiêu chí hoàn thành: bạn nêu được hai vấn đề của UPDATE một lần, và vì sao chia lô giải quyết được cả hai.

Gợi ý và lời giải — Bài 1

Gợi ý. Một transaction giữ khoá cho tới khi commit, và ghi log cho tới khi commit. Hai hệ quả khác nhau từ cùng một nguyên nhân.

Lời giải — UPDATE một lần:

SELECT name, size * 8 / 1024 AS size_mb FROM sys.database_files WHERE type_desc = 'LOG';
-- CrmDb_log 512

SET STATISTICS TIME ON;
UPDATE Leads SET Tier = N'Standard' WHERE Tier IS NULL; -- 2 triệu dòng
CPU time = 41.2 s,  elapsed time = 78.4 s

-- log sau khi chạy
CrmDb_log 9216 -- phình từ 512 MB lên 9 GB

Trong lúc chạy, xem trạng thái chặn:

SELECT session_id, wait_type, wait_time / 1000 AS wait_sec, blocking_session_id
FROM sys.dm_exec_requests WHERE blocking_session_id <> 0;
session_id  wait_type  wait_sec  blocking_session_id
61 LCK_M_S 74 58
62 LCK_M_X 71 58

Hai vấn đề:

1. Khoá kéo dài suốt 78 giây. SQL Server nâng cấp khoá dòng lên khoá bảng khi số dòng bị khoá vượt khoảng 5.000 — gọi là lock escalation. Từ đó, mọi truy vấn khác trên bảng đều chờ. Với một bảng nghiệp vụ chính, đó là 78 giây ngừng dịch vụ.

2. Transaction log phình 18 lần. Log không thể bị cắt bớt (truncate) cho tới khi transaction commit — vì nếu có rollback thì SQL Server cần toàn bộ thông tin để hoàn tác. Với 2 triệu dòng trong một transaction, log phải giữ đủ dữ liệu hoàn tác cho cả 2 triệu dòng.

Và nếu log đạt giới hạn dung lượng giữa chừng, transaction bị rollback — bạn mất 78 giây và không thay đổi được gì.

Chia lô 5.000:

DECLARE @rows INT = 1;

WHILE @rows > 0
BEGIN
UPDATE TOP (5000) Leads
SET Tier = N'Standard'
WHERE Tier IS NULL; -- điều kiện để LẦN SAU bỏ qua dòng đã xong

SET @rows = @@ROWCOUNT;

CHECKPOINT; -- cho phép cắt log (recovery model SIMPLE)
WAITFOR DELAY '00:00:00.100'; -- nhường chỗ cho giao dịch khác
END
Tổng thời gian: ~95 giây (chậm hơn một chút)
Khoá lâu nhất một lần: ~180 ms
Log lớn nhất: 640 MB

Vì sao chia lô giải quyết được cả hai:

  • Khoá: mỗi lô là một transaction riêng, commit xong là nhả khoá. 5.000 dòng nằm dưới ngưỡng lock escalation, nên vẫn là khoá dòng chứ không phải khoá bảng. Giao dịch khác chỉ chờ tối đa 180 mili-giây thay vì 78 giây.
  • Log: commit sau mỗi lô cho phép log được cắt bớt và tái sử dụng, nên nó không phình lên.

Tổng thời gian tăng khoảng 20% — đó là cái giá, và gần như luôn đáng trả: hệ thống vẫn phục vụ được trong suốt quá trình.

Ba chi tiết làm vòng lặp này đúng:

  1. Điều kiện WHERE phải tự loại dòng đã xử lý. Tier IS NULL đúng vì sau khi cập nhật, dòng đó không còn khớp. Nếu điều kiện không tự thu hẹp, vòng lặp chạy mãi.

  2. Cần index cho điều kiện lọc, nếu không mỗi lô phải quét lại từ đầu và tổng thời gian tăng theo bình phương:

    CREATE INDEX IX_Leads_Tier_Null ON Leads(Tier) WHERE Tier IS NULL;
  3. WAITFOR DELAY không thừa. Không có nó, vòng lặp chiếm khoá liên tục và giao dịch khác vẫn bị đói — chỉ là bị đói thành nhiều lần ngắn thay vì một lần dài.

Trên PostgreSQL cùng nguyên tắc, cú pháp khác:

WITH batch AS (
SELECT id FROM leads WHERE tier IS NULL LIMIT 5000 FOR UPDATE SKIP LOCKED
)
UPDATE leads SET tier = 'Standard' WHERE id IN (SELECT id FROM batch);

SKIP LOCKED còn cho phép chạy nhiều tiến trình song song mà không tranh nhau cùng một lô.


Bài 2 — Thêm khoá ngoại vào bảng lớn​

Thêm khoá ngoại vào bảng lớn và đo thời gian truy vấn khác bị chặn. Dùng WITH NOCHECK (hoặc NOT VALID) và so sánh.

Tiêu chí hoàn thành: bạn nêu được NOCHECK đánh đổi cái gì, và bước nào bắt buộc phải làm sau đó.

Gợi ý và lời giải — Bài 2

Gợi ý. Thêm một ràng buộc nghĩa là tuyên bố "mọi dòng đều thoả". Database phải kiểm tra tuyên bố đó — trừ khi bạn bảo nó đừng kiểm.

Lời giải — thêm khoá ngoại thông thường:

ALTER TABLE Leads
ADD CONSTRAINT FK_Leads_Users FOREIGN KEY (OwnerId) REFERENCES Users(Id);
elapsed time = 52.3 s

Trong lúc đó:

SELECT session_id, wait_type, wait_time / 1000 AS wait_sec
FROM sys.dm_exec_requests WHERE blocking_session_id <> 0;
session_id  wait_type  wait_sec
61 LCK_M_S 48

Bảng bị khoá 52 giây — SQL Server phải đọc mọi dòng để xác minh rằng OwnerId nào cũng tồn tại trong Users.

Với WITH NOCHECK:

-- SQL Server: thêm mà KHÔNG kiểm tra dữ liệu cũ
ALTER TABLE Leads WITH NOCHECK
ADD CONSTRAINT FK_Leads_Users FOREIGN KEY (OwnerId) REFERENCES Users(Id);
elapsed time = 0.04 s

Gần như tức thì, và không chặn gì.

NOCHECK đánh đổi cái gì. Ràng buộc có hiệu lực cho dữ liệu mới ngay lập tức: mọi INSERT và UPDATE từ giờ đều bị kiểm. Nhưng dữ liệu đã có không được xác minh, nên ràng buộc rơi vào trạng thái not trusted.

Hậu quả không chỉ là "có thể còn dòng sai". Optimizer không dùng được ràng buộc không đáng tin để tối ưu:

SELECT name, is_not_trusted FROM sys.foreign_keys WHERE name = 'FK_Leads_Users';
-- FK_Leads_Users 1

Ví dụ, với một khoá ngoại đáng tin, optimizer có thể loại bỏ hẳn một JOIN không cần thiết vì nó biết chắc mọi dòng đều có cặp. Với ràng buộc không đáng tin, nó phải giữ JOIN đó.

Bước bắt buộc sau đó — xác minh ngoài giờ cao điểm:

-- Kiểm tra sau, ngoài giờ cao điểm
ALTER TABLE Leads WITH CHECK CHECK CONSTRAINT FK_Leads_Users;

Lệnh này vẫn quét toàn bảng, nhưng bạn chọn được thời điểm — và nó dùng khoá nhẹ hơn nhiều so với lúc thêm ràng buộc.

-- Kiểm chứng
SELECT name, is_not_trusted FROM sys.foreign_keys WHERE name = 'FK_Leads_Users';
-- FK_Leads_Users 0

Và trước khi xác minh, hãy tìm dòng vi phạm:

SELECT l.Id, l.OwnerId
FROM Leads l
LEFT JOIN Users u ON u.Id = l.OwnerId
WHERE l.OwnerId IS NOT NULL AND u.Id IS NULL;

Nếu có kết quả, bạn phải xử lý chúng trước — gán lại chủ sở hữu, hoặc đặt NULL, tuỳ nghiệp vụ.

PostgreSQL có cơ chế tương đương:

ALTER TABLE leads ADD CONSTRAINT fk_leads_users
FOREIGN KEY (owner_id) REFERENCES users(id) NOT VALID;

-- Sau đó, khoá nhẹ hơn nhiều
ALTER TABLE leads VALIDATE CONSTRAINT fk_leads_users;

Cùng nguyên tắc áp cho CHECK constraint và cột NOT NULL:

Thao tácChậm vìCách làm nhanh
Thêm FOREIGN KEYKiểm mọi dòngWITH NOCHECK / NOT VALID
Thêm CHECKKiểm mọi dòngWITH NOCHECK / NOT VALID
Thêm cột NOT NULL có giá trị mặc địnhGhi lại toàn bảng (bản cũ)Thêm nullable, backfill theo lô, rồi siết
Thêm cột nullableChỉ đổi metadataTức thì

Dòng cuối là lý do bước "expand" trong expand–contract luôn bắt đầu bằng một cột nullable: nó gần như miễn phí.


Bài 3 — Đổi tên cột bằng expand–contract, không downtime​

Thực hiện đầy đủ bốn bước đổi tên một cột trên môi trường staging đang có traffic, và xác nhận không có request nào lỗi ở mỗi bước.

Tiêu chí hoàn thành: bạn giải thích được vì sao không thể gộp bốn bước thành hai, dù nhìn có vẻ thừa.

Gợi ý và lời giải — Bài 3

Gợi ý. Trong lúc rollout, code cũ và code mới cùng chạy trên cùng một database. Hãy vẽ ra thời điểm đó.

Lời giải — bốn bước đổi PhoneNumber thành Phone:

Giai đoạn 1 — MỞ RỘNG (tương thích ngược):
1. Thêm cột Phone (nullable)
2. Deploy code GHI cả hai cột, ĐỌC cột cũ
3. Chạy script chép dữ liệu: UPDATE Customers SET Phone = PhoneNumber

Giai đoạn 2 — CHUYỂN:
4. Deploy code GHI cả hai, ĐỌC cột MỚI
5. Theo dõi vài ngày, xác nhận không có vấn đề

Giai đoạn 3 — THU HẸP:
6. Deploy code chỉ ghi và đọc cột mới
7. Xoá cột PhoneNumber
// Code deploy 1: GHI cả hai, ĐỌC cột cũ
customer.PhoneNumber = input.Phone;
customer.Phone = input.Phone;
return new CustomerDto(Phone: customer.PhoneNumber);
// Code deploy 2: GHI cả hai, ĐỌC cột MỚI
customer.PhoneNumber = input.Phone;
customer.Phone = input.Phone;
return new CustomerDto(Phone: customer.Phone);
// Code deploy 3: chỉ ghi và đọc cột mới
customer.Phone = input.Phone;
return new CustomerDto(Phone: customer.Phone);
-- === DEPLOY 4: CONTRACT — sau khi chắc chắn không ai dùng cột cũ ===
ALTER TABLE Customers DROP COLUMN PhoneNumber;

Vì sao không gộp được — vẽ ra thời điểm rollout. Trong mọi lần rollout, có một khoảng thời gian hai phiên bản cùng chạy:

t=0   3 pod code cũ
t=1 2 pod code cũ + 1 pod code mới <- CÙNG database
t=2 1 pod code cũ + 2 pod code mới
t=3 3 pod code mới

Thử gộp bước 1 và 2 — deploy code chỉ dùng cột mới, đồng thời thêm cột:

t=1   Pod MỚI ghi vào Phone
Pod CŨ ghi vào PhoneNumber, và ĐỌC PhoneNumber
-> người dùng sửa số qua pod mới, rồi xem qua pod cũ -> thấy số CŨ

Dữ liệu phân mảnh giữa hai cột, và người dùng thấy giá trị khác nhau tuỳ vào pod nào phục vụ.

Thử gộp bước 3 và 4 — xoá cột ngay sau khi chuyển sang đọc cột mới:

t=1   Pod CŨ vẫn ghi vào PhoneNumber -> cột không còn -> LỖI 500

Giai đoạn "ghi cả hai" là thứ giữ cho hai phiên bản cùng đúng, và nó phải kéo dài qua trọn vẹn một lần rollout ở mỗi đầu. Đó là lý do có bốn bước chứ không phải hai.

Bước 5 — theo dõi — cũng không bỏ được. Nó bảo vệ bạn khỏi những đường ghi mà bạn không biết: một job nền của đội khác, một script tích hợp, một báo cáo. Cách kiểm chắc chắn nhất là để PhoneNumber lại vài ngày rồi kiểm xem còn ai ghi vào nó không:

SELECT COUNT(*) FROM Customers
WHERE PhoneNumber IS NOT NULL AND (Phone IS NULL OR Phone <> PhoneNumber);

Kết quả khác 0 nghĩa là vẫn còn đường ghi chưa được cập nhật.

Script chép dữ liệu ở bước 3 phải chia lô, theo đúng bài 1:

DECLARE @rows INT = 1;
WHILE @rows > 0
BEGIN
UPDATE TOP (5000) Customers
SET Phone = PhoneNumber
WHERE Phone IS NULL AND PhoneNumber IS NOT NULL;
SET @rows = @@ROWCOUNT;
WAITFOR DELAY '00:00:00.100';
END

Kiểm chứng từng bước bằng một bài test tải nhẹ chạy suốt quá trình:

while true; do
code=$(curl -s -o /dev/null -w '%{http_code}' .../api/v1/customers/42)
[ "$code" != "200" ] && echo "$(date +%T) LỖI $code"
sleep 0.2
done

Không có dòng LỖI nào trong toàn bộ bốn bước là tiêu chí hoàn thành.

Cùng khuôn này áp cho mọi thay đổi phá vỡ tương thích: đổi kiểu cột, tách một bảng thành hai, đổi ý nghĩa một giá trị enum. Luôn là: thêm cái mới → ghi cả hai → chuyển đọc → xoá cái cũ.

Tự kiểm tra​

Câu hỏi thường gặp

Vì sao migration phải tương thích ngược cả hai chiều?

Vì trong một lần triển khai rolling, code cũ và code mới cùng chạy trên cùng một database. Schema mới phải chạy được với code cũ để pod cũ không lỗi, và schema cũ phải chạy được với code mới để bạn rollback được.

Expand-contract gồm những bước nào?

Mở rộng bằng cách thêm cấu trúc mới tương thích ngược, ghi cả cũ lẫn mới, backfill dữ liệu, chuyển đọc sang cái mới, ngừng ghi cái cũ, rồi cuối cùng mới xoá. Đó là bốn lần deploy riêng biệt, mỗi lần đều rollback được độc lập, và nên cách nhau ít nhất một chu kỳ phát hành trước bước xoá.

Thao tác DDL nào khoá bảng lâu?

Đổi kiểu cột, thêm khoá ngoại và thêm CHECK, vì chúng phải kiểm tra hoặc ghi lại mọi dòng hiện có. Ngược lại, thêm cột nullable hoặc cột NOT NULL có default là thao tác metadata gần như tức thì từ SQL Server 2012 và PostgreSQL 11.

Làm sao thêm ràng buộc mà không khoá bảng lâu?

Trên SQL Server dùng WITH NOCHECK để thêm mà không kiểm tra dữ liệu cũ, rồi kiểm tra sau ngoài giờ cao điểm. Trên PostgreSQL dùng NOT VALID, khi đó ràng buộc áp dụng ngay cho dữ liệu mới còn dữ liệu cũ được kiểm tra sau bằng VALIDATE CONSTRAINT với khoá nhẹ hơn nhiều.

Vì sao backfill phải chia lô?

Một UPDATE toàn bảng giữ khoá tới khi xong, làm transaction log phình khổng lồ, và nếu thất bại giữa chừng thì rollback mất thêm thời gian bằng đúng phần đã chạy. Chia lô nhỏ với điều kiện WHERE loại dòng đã xử lý còn cho phép script dừng giữa chừng rồi chạy lại.

Nên chạy migration ở đâu trong quy trình triển khai?

Ở một bước riêng trong pipeline, trước khi pod mới lên. Chạy lúc ứng dụng khởi động khiến nhiều pod cùng chạy migration, và dù có khoá thì các pod còn lại vẫn phải chờ, nên migration dài sẽ làm health check báo hỏng.

Kết luận​

Ba điều đáng nhớ nhất:

  1. Cả hai phiên bản code cùng chạy trong lúc triển khai. Migration phải tương thích cả hai chiều.
  2. Thêm khoá ngoại và CHECK khoá bảng. Dùng NOCHECK hoặc NOT VALID.
  3. Backfill chia lô và tiếp tục được từ chỗ dừng — nó sẽ bị gián đoạn.

Tham khảo​

Điều hướng​