Dân Rails nào cũng có chung một vết sẹo: bạn chạy một migration trong lúc deploy, nó giành lấy một cái lock, và app tối thui suốt chín mươi giây trong khi một ràng buộc NOT NULL đi validate bốn triệu dòng dữ liệu. Cách chữa không phải là một công cụ bạn cài vào. Đó là một bộ nhỏ các nước đi mà bạn nhập tâm cho đến khi những thao tác nguy hiểm tự thấy gợn tay. Bài này là cái rubric tôi vẫn với tới — bốn nước đi xử lý gọn khoảng chín mươi phần trăm số migration mà một app Rails đang chạy thật sự cần, cộng thêm vài ca mà chẳng nước đi nào cứu được bạn. Không cần gem phép thuật nào, dù tôi có nhắc tới một cái. Phần lớn câu chuyện là hiểu thao tác nào Postgres làm được trong khi chỉ giữ một cái lock rẻ tiền, và thao tác nào âm thầm rewrite cả một cái bảng.

”Zero-downtime” ở đây thực ra nghĩa là gì

Migration zero-downtime nghĩa là việc đổi schema chạy trong khi ứng dụng vẫn tiếp tục phục vụ request, không cần cửa sổ bảo trì và không có một hàng đợi query nào dồn ứ sau một cái lock đang bị giữ. Về bản chất đây là một bài toán locking của Postgres khoác áo một bài toán Rails.

Phạm vi tôi quan tâm: một database Postgres primary duy nhất, một app Rails deploy liên tục, và những cái bảng đủ lớn để một lần rewrite toàn bộ hay một cái lock ACCESS EXCLUSIVE kéo dài sẽ khiến người dùng cảm nhận được. Nếu cái bảng to nhất của bạn chỉ có mười nghìn dòng, phần lớn những gì sau đây chỉ là chuyện sách vở — cứ chạy migration rồi đi tiếp.

Điều cần giữ trong đầu là cái tôn ti của lock. Postgres lấy các mức lock mạnh yếu khác nhau cho từng loại DDL. ADD COLUMN với default là hằng số lấy một lock ACCESS EXCLUSIVE ngắn ngủi nhưng không rewrite bảng (kể từ Postgres 11). CREATE INDEX chặn ghi suốt thời gian build. VALIDATE CONSTRAINT lấy một lock yếu hơn là SHARE UPDATE EXCLUSIVE, cho phép cả đọc lẫn ghi. Toàn bộ trò chơi nằm ở chỗ giữ các lock mạnh thật ngắn và đẩy việc chậm xuống dưới các lock yếu.

Bốn nước đi

Đây là rubric. Gần như mọi migration an toàn đều là một trong số này, hoặc một chuỗi của chúng.

Nước điKhi nàoKỷ luật
Chỉ thêm (additive)Cột mới, bảng mới, index mớiĐừng bao giờ gộp việc thêm cột với việc backfill cho nó trong cùng một migration
Index concurrentBất kỳ index nào trên một bảng không tầm thườngCREATE INDEX CONCURRENTLY ngoài transaction
Backfill theo batchĐổ dữ liệu vào một cột mớiCập nhật theo từng chunk, tách khỏi đường deploy
Expand/contractĐổi tên, xóa, đổi kiểu, NOT NULLChẻ thay đổi ra nhiều lần deploy

Chỉ thêm. Thêm một cột hay một bảng là an toàn vì nó động vào catalog chứ không động vào dữ liệu — với điều kiện default là hằng số. Thêm một cột kèm một default được backfill vào các dòng đang có trước đây từng rewrite cả bảng; Postgres đời mới lưu default hằng số như metadata, nhưng một default: -> { "gen_random_uuid()" } là volatile và vẫn rewrite. Cứ giữ cho các lần thêm thật nhàm chán.

Index concurrent. Một CREATE INDEX thường sẽ khóa bảng không cho ghi cho tới khi build xong. CONCURRENTLY build mà không chặn ghi, đánh đổi bằng hai lần quét bảng và không có lớp transaction bọc ngoài:

class AddIndexToOrdersOnCustomerId < ActiveRecord::Migration[7.1]
  disable_ddl_transaction!

  def change
    add_index :orders, :customer_id, algorithm: :concurrently
  end
end

Quên disable_ddl_transaction! thì Rails sẽ từ chối — hoặc tệ hơn, chạy nó bên trong một transaction, nơi CONCURRENTLY là bất hợp pháp.

Backfill theo batch. Đừng bao giờ UPDATE một bảng lớn trong một câu lệnh duy nhất bên trong migration; bạn sẽ giữ row lock và làm phình WAL. Hãy lặp theo từng batch, lý tưởng là trong một migration riêng hoặc một task chạy một lần, để việc đổi schema và việc đổi dữ liệu có thể thất bại độc lập với nhau.

Expand/contract. Nước đi tổng. Mọi thay đổi mang tính phá hủy hay biến đổi đều trở thành một chuỗi: trước hết mở rộng (expand) schema sao cho cả code cũ lẫn code mới đều chạy được, deploy, di chuyển dữ liệu, rồi thu hẹp (contract) bằng cách bỏ đi hình dạng cũ. Tôi sẽ đi qua một ví dụ đầy đủ ở dưới.

Cạm bẫy và anti-pattern

Kẻ giết người kinh điển là add_column :users, :status, :string, null: false, default: "active" rồi ngay trong cùng file đó là code đọc users.status. Bản thân migration thì ổn. Cái thứ tự deploy mới là vấn đề — code mới có thể lên một server trước khi migration chạy xong, hoặc migration có thể xong trước khi code cũ rút hết, và một trong hai sẽ thấy một cái cột không khớp với kỳ vọng của nó.

Đổi tên một cột là cái bẫy ai cũng sập một lần. rename_column là một DDL nhanh gọn duy nhất, nên trông có vẻ an toàn. Nhưng app đang chạy vẫn tham chiếu tới cái tên cũ. Khoảnh khắc lệnh rename được commit, mọi request đang dang dở dùng tên cũ đều ném lỗi. Đổi tên không bao giờ là nguyên tử ở tầng ứng dụng, nó chỉ nguyên tử ở tầng database.

Xóa một cột có một phiên bản tinh vi hơn của cùng con bug đó. Rails cache danh sách cột lúc khởi động. Nếu bạn drop một cột mà các tiến trình cũ đang chạy vẫn nghĩ là còn tồn tại, các câu INSERT của chúng sẽ vỡ. Cách chữa là ignored_columns:

class User < ApplicationRecord
  self.ignored_columns += ["legacy_token"]
end

Deploy cái đó trước, rồi mới drop cột ở một lần deploy sau.

Những cái bẫy đáng tin khác: thêm một ràng buộc NOT NULL đi validate cả bảng dưới một lock mạnh; thêm một foreign key mà không có validate: false; bọc một index CONCURRENTLY trong transaction mặc định. Gem strong_migrations bắt được phần lớn những lỗi này ngay lúc migration và đáng để thêm vào ngay từ ngày đầu — nó biến kiến thức truyền miệng thành một bài test fail.

Một ví dụ thực chiến: đổi tên cột một cách an toàn

Giả sử bạn muốn đổi tên users.name thành users.full_name. Đây là dáng của pattern expand/contract, trải ra ba lần deploy. Tôi đang mô tả hình dạng của chuỗi, không phải một dự án cụ thể — cấu trúc thì lần nào cũng như nhau.

Deploy 1 — expand. Thêm cột mới, thuần thêm và an toàn. Ship code ghi vào cả hai cột và đọc từ cột cũ. Schema giờ có cả hai hình dạng; app vẫn hành xử như trước.

def change
  add_column :users, :full_name, :string
end

Backfill. Ở một bước riêng, chép dữ liệu đang có theo từng batch:

User.unscoped.in_batches(of: 5_000) do |batch|
  batch.update_all("full_name = name")
end

Cái này chạy ngoài đường deploy. Nếu nó chết giữa chừng, bạn chạy lại; không có gì hướng tới người dùng bị vỡ cả, vì app vẫn còn đọc name.

Deploy 2 — chuyển đọc. Giờ full_name đã được đổ đầy và được giữ đồng bộ, ship code đọc từ full_name. Vẫn ghi vào cả hai. Tại điểm này name là gánh nặng chết nhưng vô hại.

Deploy 3 — contract. Thêm name vào ignored_columns, deploy, rồi ở một migration kế tiếp drop cái cột đi. Dừng việc ghi kép.

Những lần đổi tên đau đớn là những lần tôi cố làm trong một lần deploy vì “có gì đâu, đổi tên thôi mà.” Chưa bao giờ chỉ là đổi tên cả.

— Self note

Bốn lần deploy cho một lần đổi tên nghe thật vô lý, cho tới lần đầu tiên nó cứu bạn khỏi một sự cố lúc 2 giờ sáng. Cái giá phải trả là thời gian trên lịch và sự kỷ luật, không phải độ khó kỹ thuật. (Nếu bạn đang cân xem một dự án cụ thể đáng bỏ ra bao nhiêu sự chặt chẽ kiểu này, thì đó thực ra là một câu hỏi về scope — các MVP giai đoạn đầu thường có thể bỏ qua nó.)

Làm xong thì trông như thế nào

Bạn làm đúng khi lần deploy chẳng có gì đáng nói. Cụ thể:

Phép thử thành thật là khả năng quay ngược. Ở mỗi bước, bạn có thể rollback lần deploy code mà không cần rollback schema, và ngược lại, được không? Nếu được, thì thay đổi đó thật sự đã được tách rời. Nếu một lần rollback sẽ khiến app mắc kẹt với một schema không tương thích, thì bạn đã bỏ sót một bước.

Khi nào điều này không áp dụng

Toàn bộ bộ máy này là chi phí phụ trội, và chi phí phụ trội mà bạn không cần thì chỉ là phí. Trước khi ra mắt, khi chưa có traffic production và chưa có người dùng nào để làm phiền, cứ chạy bất kỳ migration nào bạn thích và reset database nếu nó hỏng — điệu nhảy expand/contract ở đó thuần túy chỉ là lễ nghi.

Nó cũng đổ vỡ ở quy mô cực lớn, nơi mà ngay cả một thay đổi DDL chỉ động vào metadata cũng có thể kẹt lại sau autovacuum hay một transaction chạy dài, và bạn cần thêm lock timeout cùng logic retry chồng lên những nước đi này. Và một số thay đổi — partition một cái bảng đang nóng, đổi kiểu lớn trên một cột khổng lồ — thật sự xứng đáng có một cửa sổ bảo trì được lên kế hoạch. Biết mình đang ở ca nào mới là kỹ năng thật sự.

Cái khẳng định

Đây là một điều có thể chứng-sai để mang về: nếu một migration Rails giữ một lock ACCESS EXCLUSIVE lâu hơn cái thời gian Postgres cần để cập nhật vài dòng catalog, thì hoặc là nó đang rewrite một cái bảng, hoặc là nó đang chờ một cái bảng khác rewrite — và bạn sẽ thấy chính xác là cái nào trong pg_stat_activity, chứ không phải trong code review. Mọi migration an toàn tôi từng viết đều giữ cái cửa sổ lock đó xuống tới mức mili-giây. Nếu của bạn thì không, bạn không tìm ra một ngoại lệ khôn ngoan nào đâu; bạn vừa tìm ra cái lần rewrite mà bạn không biết là nó vẫn ở đó.