Vấn đề nằm ở khoá bảng: ALTER TABLE ... SET NOT NULL phải quét toàn bảng để xác minh không có NULL, và giữ ACCESS EXCLUSIVE lock suốt quá trình quét — mọi đọc/ghi đều bị chặn. Migration chạy một phát của Django (AddField(null=False, default=...)) rơi đúng vào bẫy này.
Tách thành nhiều lần deploy (expand → migrate → contract):
1. Deploy 1 — thêm cột nullable. AddField(null=True). Thêm cột nullable không cần quét bảng nên gần như tức thời. Code mới phải đọc/ghi được khi cột còn NULL.
2. Deploy 2 — backfill theo lô. Data migration cập nhật theo chunk vài nghìn dòng, mỗi lô một transaction, có nghỉ giữa các lô. Đặt atomic = False trong Migration để không bọc tất cả vào một transaction khổng lồ.
3. Deploy 3 — siết ràng buộc. Với PostgreSQL: thêm CHECK (col IS NOT NULL) NOT VALID (không quét), rồi VALIDATE CONSTRAINT (chỉ lấy khoá yếu), sau đó mới SET NOT NULL — bước này nhận diện được check hợp lệ nên không quét lại.
class Migration(migrations.Migration):
atomic = False # each batch commits on its own
operations = [migrations.RunPython(backfill_in_batches, migrations.RunPython.noop)]Nguyên tắc chung của mọi thay đổi schema có downtime: code và schema phải tương thích hai chiều trong suốt giai đoạn chuyển tiếp, vì trong lúc deploy luôn có cả pod cũ lẫn pod mới cùng chạy. Đổi tên cột cũng theo mạch đó: thêm cột mới → ghi cả hai → backfill → chuyển đọc sang cột mới → xoá cột cũ ở deploy sau.
Ghi chú: Django 5.0 có db_default cho phép đặt default ở tầng database, giảm bớt một số bước cho trường hợp đơn giản. Team lớn thường dùng thêm django-pg-zero-downtime-migrations để chặn sẵn các operation gây khoá dài.