Menu

Migration trong SQLite: quản lý phiên bản schema

Cách thay đổi schema SQLite an toàn theo thời gian bằng PRAGMA user_version, các script migration có thứ tự và transaction để có thể hoàn tác.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

Schema sẽ thay đổi. Hãy chuẩn bị cho điều đó.

Phiên bản schema đầu tiên không bao giờ là phiên bản cuối cùng. Cột được thêm vào, bảng bị tách ra, chỉ mục được thiết kế lại. Câu hỏi không phải là schema có thay đổi hay không, mà là thay đổi đó có được áp dụng gọn gàng trên mọi laptop, máy chủ và thiết bị người dùng đang giữ một bản cơ sở dữ liệu cũ hơn hay không.

Đó là việc của migration: một chuỗi script nhỏ, có thứ tự, đưa cơ sở dữ liệu từ phiên bản N lên phiên bản N+1. Chạy chúng theo thứ tự, mọi cơ sở dữ liệu sẽ được cập nhật tới bản hiện tại. Bỏ qua kỷ luật này, bạn sẽ gặp những bug kiểu "trên máy tôi vẫn chạy" mất cả buổi chiều để truy ra.

SQLite cung cấp đúng một công cụ có sẵn cho việc này: PRAGMA user_version. Đó là một số nguyên 32 bit mà cơ sở dữ liệu lưu giúp bạn, bản thân SQLite không bao giờ đụng tới. Bạn tự quyết định ý nghĩa của nó.

Một cơ sở dữ liệu mới bắt đầu ở 0. Hãy đặt nó bằng số của migration bạn vừa áp dụng. Đọc nó khi khởi động để biết mình đang ở đâu.

Một vòng lặp migration tối giản

Cách hình dung: mỗi migration là một script SQL được đánh số. Ứng dụng đọc user_version hiện tại, chạy lần lượt mọi script có số lớn hơn, và cập nhật user_version sau mỗi script.

Đây là migration 1, tạo schema ban đầu:

Có hai điều cần chú ý. Toàn bộ được bọc trong BEGIN; ... COMMIT; nên có tính nguyên tử: nếu CREATE TABLE thất bại, user_version không bị tăng và bạn có thể sửa rồi chạy lại. Và PRAGMA user_version = 1 là câu lệnh cuối trước khi commit, nên phiên bản chỉ đổi khi mọi thứ khác đã thành công.

Giờ giả sử bạn cần thêm cột created_at. Đó là migration 2:

Cơ sở dữ liệu ở phiên bản 0 sẽ chạy cả hai. Cơ sở dữ liệu ở phiên bản 1 chỉ chạy script thứ hai. Cơ sở dữ liệu ở phiên bản 2 không chạy gì. Thứ tự chính là cam kết.

ALTER TABLE làm được gì và không làm được gì

ALTER TABLE của SQLite được thiết kế có chủ đích là rất hẹp. Nó hỗ trợ:

  • ADD COLUMN: thêm một cột mới vào cuối, có thể kèm giá trị mặc định.
  • DROP COLUMN: xóa một cột (từ bản 3.35).
  • RENAME COLUMN: đổi tên một cột (từ bản 3.25).
  • RENAME TO: đổi tên chính bảng đó.

Chỉ vậy thôi. Bạn không thể đổi kiểu dữ liệu của cột, thay đổi NOT NULL, sửa ràng buộc CHECK, hay thêm FOREIGN KEY cho một cột đã có.

-- Not supported:
ALTER TABLE users ALTER COLUMN email TYPE VARCHAR(255);
ALTER TABLE users ADD CONSTRAINT users_email_check CHECK (email LIKE '%@%');

Khi cần một thay đổi mà SQLite không làm trực tiếp được, công thức chính thức là "dựng lại bảng". Cách này dài dòng hơn nhưng hoàn toàn đáng tin cậy.

Dựng lại bảng cho các thay đổi lớn hơn

Quy trình là: tạo bảng mới với cấu trúc bạn muốn, sao chép dữ liệu sang, xóa bảng cũ, đổi tên bảng mới thế vào chỗ. Tất cả nằm trong một transaction.

Tài liệu đầy đủ của SQLite gọi đây là quy trình 12 bước và lưu ý thêm vài điều về trigger, view và tham chiếu khóa ngoại: nên đọc qua một lần trước khi làm trên schema production. Trong đa số trường hợp, phiên bản bốn bước ở trên là đủ.

Lưu ý: nếu có khóa ngoại trỏ tới bảng bạn đang dựng lại, hãy chạy PRAGMA foreign_keys = OFF trước migration và PRAGMA foreign_keys = ON sau đó. Nếu không, DROP TABLE có thể phá vỡ tính toàn vẹn tham chiếu giữa chừng.

Chạy migration từ ứng dụng

Phần ghi chép đủ đơn giản để bạn tự viết. Trong Python với thư viện chuẩn:

Những nguyên tắc bất biến chính:

  • Migration được đánh số liên tiếp bắt đầu từ 1. Không bỏ trống số, không đổi thứ tự.
  • Mỗi migration được bọc trong một transaction cùng với lệnh tăng PRAGMA user_version = N.
  • Khi một migration đã được commit và phát hành, bạn không bao giờ sửa nó nữa. Thay đổi mới nằm trong migration mới.

Quy tắc cuối cùng là quy tắc các nhóm hay vi phạm nhất. Nếu bạn sửa migration 3 sau khi cơ sở dữ liệu của một đồng nghiệp đã áp dụng nó, cơ sở dữ liệu của họ sẽ lặng lẽ lệch khỏi của bạn mãi mãi.

Ghi lại lịch sử migration

user_version cho biết cơ sở dữ liệu đang ở đâu. Nó không cho biết từng bước chạy khi nào hay đã làm gì. Một bảng ghi chép nhỏ sẽ giải quyết điều đó:

Giờ bạn có một hàng cho mỗi migration kèm tên và thời điểm, rất tiện khi gỡ lỗi câu hỏi "vì sao cơ sở dữ liệu này lại có một cột mà code không hề biết?"

PRAGMA user_version vẫn là nguồn dữ liệu chuẩn cho vòng lặp; bảng kia là để con người đọc.

Rollback: transaction giúp được gì và không giúp được gì

DDL của SQLite có tính transaction. Nếu migration 5 bắt đầu tạo bảng, sao chép dữ liệu và tăng user_version, rồi bước sao chép thất bại giữa chừng, ROLLBACK sẽ hoàn tác tất cả, kể cả CREATE TABLE. Cơ sở dữ liệu trở về đúng trạng thái trước BEGIN.

Điều đó áp dụng cho các migration thất bại. Nó không áp dụng cho những migration đã commit thành công mà giờ bạn lại hối tiếc. Với những trường hợp đó, bạn phải viết một down-migration riêng: một script hoàn tác thay đổi. SQLite không có cơ chế đảo ngược tự động. Nếu migration 7 thêm một cột, bản down sẽ xóa cột đó. Nếu migration 7 xóa một cột, bản down không thể khôi phục dữ liệu; tốt nhất nó chỉ có thể tạo lại cột rỗng.

Trên thực tế, nhiều dự án nhỏ bỏ hẳn down-migration và dựa vào bản sao lưu để "hoàn tác". Đó là một lựa chọn hợp lý, miễn là bạn thật sự sao lưu.

Vài thói quen giúp tránh rắc rối về sau

  • Mỗi migration cho một thay đổi logic. Một migration thêm ba cột không liên quan khó review và khó hoàn tác hơn ba migration riêng.
  • Thử migration trên một bản sao của production. Thay đổi schema có thể chậm trên bảng lớn; phát hiện điều đó ngay trên production thì chẳng vui chút nào.
  • Không bao giờ sửa migration đã phát hành. Hãy thêm migration mới.
  • Sao lưu trước. Một lệnh .backup nhanh trong CLI, hoặc sao chép tệp khi cơ sở dữ liệu đã đóng, là cách bảo hiểm rẻ trước mọi migration không đơn giản.
  • Cẩn thận với PRAGMA foreign_keys. Tắt nó khi dựng lại bảng, bật lại sau đó.

Với dự án lớn hơn, hãy dùng một công cụ chuyên dụng: Alembic với SQLAlchemy, golang-migrate, Knex, Flyway. Chúng lo việc sắp thứ tự, nhiều tiến trình chạy đồng thời và các quy ước làm việc nhóm mà nếu không bạn sẽ phải tự xây lại. Nguyên tắc vẫn giống vòng lặp ở trên; công cụ chỉ bỏ đi phần code lặp lại.

Tiếp theo: chế độ WAL và xử lý đồng thời

Migration thường chạy khi ứng dụng đang ngoại tuyến hoặc đang giữ khóa độc quyền. Thời gian còn lại, cơ sở dữ liệu phục vụ việc đọc và ghi từ nhiều kết nối cùng lúc, và chế độ journal mặc định của SQLite không phải lúc nào cũng phù hợp nhất. Trang tiếp theo nói về chế độ WAL, những gì nó thay đổi và khi nào nên chuyển sang.

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

Làm sao quản lý phiên bản schema SQLite?

Mỗi cơ sở dữ liệu SQLite có sẵn một ô số nguyên 32 bit tên là user_version, truy cập qua PRAGMA user_version. Đọc giá trị này khi khởi động, so sánh với số migration mới nhất mà code của bạn biết, rồi chạy các migration còn thiếu theo thứ tự. Không cần thêm bảng nào, dù nhiều ứng dụng vẫn thêm một bảng để lưu lịch sử.

Có thể rollback một migration SQLite không?

Bọc mỗi migration trong BEGIN; ... COMMIT;. Nếu có lỗi bên trong, ROLLBACK hoàn tác toàn bộ bước đó, cả thay đổi schema lẫn thay đổi dữ liệu, vì DDL của SQLite có tính transaction. Để hoàn tác một migration đã commit, bạn cần một script down riêng do chính bạn viết; SQLite không tự tạo ra nó.

Vì sao ALTER TABLE trong SQLite bị giới hạn?

SQLite hỗ trợ ALTER TABLE ADD COLUMN, RENAME TABLE, RENAME COLUMN và DROP COLUMN, nhưng không hỗ trợ các thay đổi tùy ý như đổi kiểu dữ liệu hay ràng buộc của một cột. Cách xử lý là quy trình 12 bước: tạo bảng mới với cấu trúc mong muốn, INSERT INTO new_table SELECT ... FROM old_table, xóa bảng cũ rồi đổi tên bảng mới.

Nên dùng công cụ migration hay tự viết?

Với ứng dụng nhỏ, một vòng lặp tự viết chạy qua các tệp .sql được đánh số, dựa trên PRAGMA user_version, chỉ khoảng 30 dòng code và chạy tốt. Với dự án lớn hơn, các công cụ như Alembic (Python), golang-migrate (Go) hay Knex (Node) lo việc sắp thứ tự, khóa và quy trình làm việc nhóm mà nếu không bạn sẽ phải tự xây lại.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu