Migration trong EF Core nghe thì đơn giản — chạy Add-Migration, rồi Update-Database là xong. Nhưng ai đã từng làm dự án có nhiều người cùng code, database production đang chạy, hay phải rollback một migration lỗi lúc 11 giờ đêm thì sẽ hiểu: migration là một trong những thứ dễ học nhưng rất dễ gây họa nếu không hiểu rõ cơ chế bên dưới.
Bài viết này mình chia sẻ từ kinh nghiệm làm việc với EF Core + PostgreSQL trong hệ thống multi-tenant — bao gồm cả những tình huống mà documentation chính thức không đề cập.
Migration là gì và tại sao cần nó?
Nói đơn giản, migration là cách EF Core đồng bộ thay đổi từ C# model xuống database schema. Thay vì viết SQL tay mỗi khi thêm cột, đổi kiểu dữ liệu hay tạo bảng mới, bạn thay đổi code rồi để EF Core sinh ra SQL tương ứng.
Nhưng migration không chỉ là "sinh SQL". Nó là một hệ thống version control cho database — tương tự Git cho code. Mỗi migration là một "commit" ghi lại thay đổi schema tại một thời điểm, có thể apply lên hoặc rollback về.
Điều này quan trọng vì database không giống code — bạn không thể git checkout để quay về schema cũ. Một khi cột đã bị xóa, dữ liệu trong đó mất luôn. Migration giúp kiểm soát quá trình này.
Cấu trúc một migration
Trước khi đi vào thao tác, cần hiểu migration gồm những gì. Khi bạn chạy Add-Migration, EF Core tạo ra 3 file:
Migrations/
├── 20260223_AddInvoiceTable.cs // Migration chính
├── 20260223_AddInvoiceTable.Designer.cs // Snapshot tại thời điểm này
└── AppDbContextModelSnapshot.cs // Snapshot mới nhất (cập nhật liên tục)File migration chính chứa hai method:
public partial class AddInvoiceTable : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
// Thay đổi khi apply migration
}
protected override void Down(MigrationBuilder migrationBuilder)
{
// Thay đổi khi rollback migration
}
}Up() chạy khi apply migration, Down() chạy khi rollback. Nghe đơn giản nhưng có một điểm nhiều người bỏ qua: EF Core sinh method Down() tự động, nhưng không phải lúc nào nó cũng đúng. Đặc biệt với các migration phức tạp (data migration, rename column...), bạn nên kiểm tra và sửa Down() thủ công.
File ModelSnapshot là file quan trọng nhất mà ít người để ý. EF Core so sánh model hiện tại trong code với snapshot này để biết cần tạo migration gì. Nếu file này bị conflict hoặc bị sửa tay sai, mọi migration sau đó đều có thể sai theo.
Tạo migration — Không chỉ là Add-Migration
Cách cơ bản
Với .NET CLI (mình dùng cách này vì không phụ thuộc IDE):
# Tạo migration
dotnet ef migrations add AddInvoiceTable
# Hoặc chỉ định project cụ thể (khi solution có nhiều project)
dotnet ef migrations add AddInvoiceTable \
--project src/Infrastructure \
--startup-project src/WebApiVới Package Manager Console trong Visual Studio:
Add-Migration AddInvoiceTableĐặt tên migration cho đúng
Tên migration quan trọng hơn bạn nghĩ. Sau vài tháng, bạn sẽ có hàng chục migration và cần biết mỗi cái làm gì chỉ từ tên.
Quy tắc mình dùng trong team:
# Tốt — rõ ràng, biết ngay migration làm gì
AddInvoiceTable
AddStatusColumnToOrder
RemoveLegacyCustomerFields
ChangeAmountDecimalPrecision
# Tệ — không ai biết cái này làm gì sau 2 tuần
Update1
FixBug
Changes
DatabaseUpdateKiểm tra SQL trước khi apply
Đây là thói quen mà mình bắt buộc trong team: luôn xem SQL được sinh ra trước khi chạy lên database, đặc biệt với staging và production.
# Xem SQL mà migration sẽ chạy
dotnet ef migrations script --idempotent
# Xem SQL của một migration cụ thể
dotnet ef migrations script PreviousMigration CurrentMigrationFlag --idempotent rất quan trọng — nó sinh ra script có kiểm tra migration đã chạy chưa, an toàn để chạy nhiều lần mà không bị lỗi duplicate.
Với PostgreSQL, đôi khi EF Core sinh ra SQL không tối ưu. Ví dụ khi thêm cột có default value vào bảng lớn, EF Core có thể sinh lệnh khiến PostgreSQL lock toàn bộ bảng. Xem trước SQL giúp bạn phát hiện vấn đề này sớm.
Apply migration
Trong development
dotnet ef database updateĐơn giản, trực tiếp. Nhưng mình khuyên không bao giờ dùng dotnet ef database update cho production. Lý do:
Nó chạy trực tiếp từ máy dev đến production database — nếu connection string sai, nếu migration có bug, bạn không có cơ hội review. Thay vào đó, hãy sinh SQL script rồi chạy qua pipeline.
Trong production — dùng SQL script
# Sinh idempotent script cho toàn bộ migration chưa apply
dotnet ef migrations script --idempotent -o migrate.sqlSau đó chạy script này qua CI/CD pipeline hoặc chạy tay trên production qua công cụ quản lý database (pgAdmin, DBeaver...). Cách này cho bạn cơ hội review SQL, backup database trước khi chạy, và rollback nếu có vấn đề.
Apply migration lúc app khởi động
Một cách khác mà một số team dùng:
// Program.cs
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.Migrate();
}Cách này tiện cho development và staging, nhưng cực kỳ nguy hiểm cho production. Lý do: nếu deploy nhiều instance cùng lúc (load balancer), nhiều instance chạy migration đồng thời sẽ gây race condition. PostgreSQL có advisory lock nên thường không bị lỗi nghiêm trọng, nhưng SQL Server thì khác — mình đã thấy deadlock xảy ra với cách này.
Rollback migration
Rollback về migration cụ thể
# Quay về migration trước đó
dotnet ef database update PreviousMigrationName
# Quay về trạng thái ban đầu (xóa hết)
dotnet ef database update 0Khi bạn chạy lệnh này, EF Core thực thi method Down() của tất cả migration từ hiện tại ngược về migration bạn chỉ định.
Xóa migration cuối cùng (chưa apply)
dotnet ef migrations removeLệnh này chỉ hoạt động khi migration chưa được apply lên database. Nếu đã apply rồi, bạn phải rollback trước rồi mới remove.
Rollback trong thực tế — Không đơn giản như documentation nói
Documentation nói bạn chỉ cần chạy database update TênMigrationCũ là xong. Thực tế phức tạp hơn nhiều.
Tình huống 1: Migration có data migration
protected override void Up(MigrationBuilder migrationBuilder)
{
// Thêm cột mới
migrationBuilder.AddColumn<string>(
name: "FullName",
table: "Users",
nullable: true);
// Copy dữ liệu từ FirstName + LastName sang FullName
migrationBuilder.Sql(
"UPDATE \"Users\" SET \"FullName\" = \"FirstName\" || ' ' || \"LastName\"");
// Xóa cột cũ
migrationBuilder.DropColumn(name: "FirstName", table: "Users");
migrationBuilder.DropColumn(name: "LastName", table: "Users");
}Nếu rollback migration này, method Down() sẽ tạo lại cột FirstName và LastName — nhưng dữ liệu trong đó đã mất. Bạn cần viết Down() thủ công để tách FullName ngược lại, hoặc chấp nhận mất dữ liệu.
Bài học: với bất kỳ migration nào xóa cột hoặc thay đổi dữ liệu, hãy viết Down() cẩn thận và test nó trước khi apply lên production.
Tình huống 2: Migration đã deploy lên production và có data mới
Giả sử bạn thêm bảng Invoices, deploy lên production, user đã tạo 500 invoices. Giờ bạn muốn rollback migration đó — 500 invoices sẽ bị xóa. Trong trường hợp này, rollback không phải lựa chọn đúng. Thay vào đó, bạn nên tạo migration mới để sửa vấn đề mà vẫn giữ nguyên dữ liệu.
Nguyên tắc thực tế: rollback chỉ an toàn khi migration chưa lên production, hoặc khi bạn chắc chắn không mất dữ liệu quan trọng.
Xử lý conflict migration — Phần đau đầu nhất
Đây là vấn đề mà gần như ai làm team cũng gặp, nhưng documentation lại viết rất sơ sài.
Tại sao conflict xảy ra?
Khi hai developer cùng tạo migration từ cùng một base, file ModelSnapshot sẽ bị conflict khi merge. Ví dụ:
main: Migration A → Snapshot v1
dev-1: Migration A → Migration B → Snapshot v2 (thêm bảng Invoice)
dev-2: Migration A → Migration C → Snapshot v2 (thêm bảng Payment)Khi merge cả hai vào main, Git sẽ báo conflict ở file AppDbContextModelSnapshot.cs vì cả hai branch đều sửa file này từ cùng một version.
Cách xử lý đúng
Bước 1: Merge code bình thường, resolve conflict trong model và DbContext
Đây là phần dễ — resolve conflict trong các file C# như bạn vẫn làm với bất kỳ merge conflict nào.
Bước 2: Xóa migration bị conflict và tạo lại
Đây là phần quan trọng. Thay vì cố resolve conflict trong file Snapshot (rất dễ sai), hãy:
# Xóa migration của bạn (giữ lại migration của người kia đã merge)
dotnet ef migrations remove
# Tạo lại migration mới — EF Core sẽ tự so sánh với snapshot mới nhất
dotnet ef migrations add AddPaymentTableEF Core sẽ tự tính toán diff giữa model hiện tại (đã có cả Invoice lẫn Payment) và snapshot mới nhất (đã có Invoice từ migration của người kia). Migration mới sẽ chỉ chứa thay đổi của bạn (Payment).
Bước 3: Verify bằng cách xem SQL
dotnet ef migrations script LastAppliedMigration --idempotentKiểm tra SQL có đúng chỉ tạo bảng Payment mà không đụng đến Invoice không.
Quy trình team mình áp dụng để giảm conflict
Sau nhiều lần bị conflict migration, team mình đã áp dụng quy tắc sau:
Một là, communicate trước khi tạo migration. Nghe cũ nhưng hiệu quả nhất. Nhắn trong group chat kiểu "Mình đang tạo migration thêm bảng Payment, ai đang tạo migration thì sync trước nhé." Điều này giúp tránh 90% conflict.
Hai là, merge main vào branch trước khi tạo migration. Luôn pull và merge main mới nhất trước khi chạy Add-Migration. Như vậy snapshot của bạn đã bao gồm migration của người khác.
Ba là, migration nhỏ, merge sớm. Thay vì tạo một migration khổng lồ thay đổi 10 bảng, tách thành nhiều migration nhỏ và merge vào main sớm nhất có thể. Migration càng nhỏ, conflict càng dễ resolve.
Bốn là, không bao giờ sửa file ModelSnapshot bằng tay. Nếu snapshot bị lỗi sau merge, xóa migration cuối và tạo lại. Sửa tay file snapshot là cách nhanh nhất để tạo ra bug mà bạn sẽ chỉ phát hiện khi deploy production.
Migration nâng cao — Những thứ bạn sẽ cần trong production
Thêm cột NOT NULL vào bảng đã có dữ liệu
Đây là tình huống cực kỳ phổ biến. Bạn thêm property mới vào model:
public class Order
{
// ... các property cũ
public string TrackingCode { get; set; } = string.Empty; // Cột mới, NOT NULL
}EF Core sẽ sinh migration thêm cột TrackingCode NOT NULL. Nhưng bảng Orders đã có 10,000 records — giá trị TrackingCode cho các record cũ là gì?
Nếu chạy thẳng, PostgreSQL sẽ báo lỗi vì không thể thêm cột NOT NULL mà không có default value cho dữ liệu hiện có.
Cách xử lý đúng — sửa migration thủ công:
protected override void Up(MigrationBuilder migrationBuilder)
{
// Bước 1: Thêm cột nullable trước
migrationBuilder.AddColumn<string>(
name: "TrackingCode",
table: "Orders",
nullable: true);
// Bước 2: Fill dữ liệu cho records cũ
migrationBuilder.Sql(
"UPDATE \"Orders\" SET \"TrackingCode\" = 'LEGACY-' || \"Id\" WHERE \"TrackingCode\" IS NULL");
// Bước 3: Đổi thành NOT NULL
migrationBuilder.AlterColumn<string>(
name: "TrackingCode",
table: "Orders",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldNullable: true);
}Ba bước: thêm nullable → fill data → chuyển NOT NULL. An toàn và không mất dữ liệu.
Đổi tên cột mà không mất dữ liệu
EF Core đôi khi hiểu sai khi bạn rename property — nó nghĩ bạn xóa cột cũ và tạo cột mới, dẫn đến mất dữ liệu.
Kiểm tra SQL sinh ra. Nếu thấy DROP COLUMN + ADD COLUMN thay vì RENAME COLUMN, hãy sửa migration thủ công:
protected override void Up(MigrationBuilder migrationBuilder)
{
// EF Core có thể sinh DROP + ADD — thay bằng RENAME
migrationBuilder.RenameColumn(
name: "CustomerName",
table: "Orders",
newName: "ClientName");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "ClientName",
table: "Orders",
newName: "CustomerName");
}Thêm index cho bảng lớn với PostgreSQL
Khi thêm index vào bảng có hàng triệu records, lệnh CREATE INDEX mặc định sẽ lock bảng trong suốt quá trình tạo index. Với bảng lớn, điều này có thể mất vài phút — trong thời gian đó, mọi query đến bảng bị block.
PostgreSQL hỗ trợ CREATE INDEX CONCURRENTLY để tạo index mà không lock bảng. Nhưng EF Core không hỗ trợ cú pháp này, nên bạn phải dùng raw SQL:
protected override void Up(MigrationBuilder migrationBuilder)
{
// KHÔNG dùng migrationBuilder.CreateIndex() cho bảng lớn
// Dùng CONCURRENTLY để tránh lock bảng
migrationBuilder.Sql(
"CREATE INDEX CONCURRENTLY \"IX_Orders_CreatedDate\" ON \"Orders\" (\"CreatedDate\")");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex("IX_Orders_CreatedDate", "Orders");
}Lưu ý quan trọng: CREATE INDEX CONCURRENTLY không chạy được trong transaction. Nếu migration chạy trong transaction (mặc định với PostgreSQL), bạn cần tắt:
[DbContext(typeof(AppDbContext))]
[Migration("20260223_AddOrderCreatedDateIndex")]
public partial class AddOrderCreatedDateIndex : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"CREATE INDEX CONCURRENTLY \"IX_Orders_CreatedDate\" ON \"Orders\" (\"CreatedDate\")",
suppressTransaction: true);
}
}Checklist migration cho production
Qua nhiều lần deploy, team mình đúc kết được checklist này. Mỗi migration trước khi lên production đều phải qua:
Review SQL sinh ra — chạy dotnet ef migrations script --idempotent và đọc kỹ SQL. Tìm các lệnh nguy hiểm: DROP TABLE, DROP COLUMN, ALTER COLUMN thay đổi kiểu dữ liệu.
Test rollback — chạy thử Down() trên database test để đảm bảo rollback hoạt động. Nhiều lần method Down() được sinh tự động nhưng không chạy được.
Backup trước khi chạy — không bao giờ chạy migration trên production mà chưa backup. Với PostgreSQL, dùng pg_dump là nhanh nhất.
Chạy ngoài giờ cao điểm — migration có thể lock bảng, đặc biệt với ALTER TABLE trên bảng lớn. Chạy lúc traffic thấp để giảm impact.
Kiểm tra dung lượng disk — một số migration (thêm index, thay đổi kiểu cột) cần disk space tạm. PostgreSQL có thể fail giữa chừng nếu hết disk, và đó là tình huống rất khó recover.
Không chạy nhiều migration cùng lúc — apply từng migration một, verify từng bước. Nếu một migration fail giữa chừng, bạn biết chính xác vấn đề ở đâu.
Kết luận
Migration trong EF Core là công cụ mạnh nhưng cần được sử dụng có kỷ luật. Phần tạo migration thì đơn giản, phần rollback và xử lý conflict mới là nơi kinh nghiệm thực tế tạo ra sự khác biệt.
Nếu bạn chỉ nhớ được ba điều từ bài viết này: một là luôn xem SQL sinh ra trước khi apply, hai là không sửa file ModelSnapshot bằng tay, và ba là migration chỉ nên rollback khi chưa lên production — có data rồi thì tạo migration mới để fix thay vì rollback.
Leave a comment
Your email address will not be published. Required fields are marked *