Gần như mọi founder Rails tôi từng nói chuyện cuối cùng đều hỏi cùng một câu, chỉ khác cách diễn đạt: “Làm sao để dữ liệu của khách hàng A không lọt sang khách hàng B?” Có khi họ hỏi dưới góc độ bảo mật, có khi dưới góc độ billing, có khi là “Stripe họ làm thế nào?”. Câu trả lời thành thật là: “multi-tenancy” thực ra là ba pattern khác nhau đang dùng chung một cái tên, và chọn sai pattern thì cái giá phải trả là vài tuần làm việc không nằm trong plan, hoặc một sự cố lúc 2h sáng mà tôi đảm bảo bạn không muốn trải qua. Bài này là bộ rubric tôi ước ai đó đã đưa cho tôi lần đầu tiên phải chọn: row-scoped, schema-per-tenant, hay database-per-tenant — mỗi pattern thực sự nghĩa là gì trong codebase Rails, mỗi pattern vỡ trận ở chỗ nào, và làm sao để quyết định app của bạn cần cái nào trước khi viết migration đóng đinh câu trả lời.

Khi người ta nói “multi-tenancy” thì họ đang nói về cái gì

Multi-tenancy trong Rails về bản chất là câu hỏi về isolation: làm sao để row của khách hàng A không bị khách hàng B nhìn thấy, và cái rào chắn đó cần chắc tới mức nào. Ba pattern khác nhau ở chỗ rào chắn nằm ở đâu.

Row-scoping đặt rào chắn bên trong code ứng dụng. Mỗi bảng chứa dữ liệu tenant đều có một cột organization_id (hoặc account_id, tenant_id), và mọi query đều phải filter theo cột đó. Database vẫn là một schema duy nhất, dùng chung. Schema-per-tenant đẩy rào chắn vào sâu trong Postgres: mỗi tenant có schema riêng, với bản sao của tất cả các bảng. Database-per-tenant đi xa hơn — mỗi tenant có database riêng, thường có connection pool riêng, thỉnh thoảng có cả server riêng.

Vị trí của rào chắn quyết định một con bug sẽ phải trả giá thế nào. Thiếu where(organization_id:) trong row-scoping là data leak. Thiếu switch schema trong schema-per-tenant là exception. Thiếu switch connection trong database-per-tenant thì connect không nổi. Các pattern đánh đổi ergonomic của developer lấy việc giảm blast radius, và chính cái đánh đổi đó mới là cốt lõi của vấn đề.

Bộ rubric

Pattern bạn cần phụ thuộc vào bốn đặc tính của app. Tôi suy nghĩ theo thứ tự này.

Câu hỏiRow-scopingSchema-per-tenantDatabase-per-tenant
Số lượng tenant1 → hàng triệu1 → ~hàng nghìn1 → ~hàng trăm
Query cross-tenant (analytics, admin)DễĐau đầuRất đau đầu
Schema drift theo tenantKhông thểCó thểCó thể
Ranh giới compliance / data-residencyYếuTrung bìnhMạnh
Thời gian chạy migrationMột lượtMột lượt mỗi tenantMột lượt mỗi tenant
Áp lực lên connection poolThấpTrung bìnhCao
Failure mode khi “lộ dữ liệu khách hàng”Âm thầmỒn àoỒn ào

Mặc định lựa chọn ra như sau:

Đa số Rails SaaS apps hỏi tôi câu này thực ra đều cần row-scoping mà bản thân họ chưa nhận ra. Những công ty thật sự cần isolation mạnh hơn thường tự biết lý do — hoặc cơ quan quản lý đã nhắc, hoặc form mua hàng của một enterprise customer duy nhất đã ghi rõ ra.

Những cái bẫy

Cái bẫy của row-scoping là cái ai cũng nghe rồi mà vẫn rơi vào: thiếu mệnh đề where. User.find(params[:id]) trông vô hại cho tới lúc bạn nhận ra params[:id] đến từ một URL mà attacker kiểm soát, và app của bạn vừa trả về user của người khác. Cách chữa là kỷ luật current_organization.users.find(params[:id]), ép buộc bằng ActiveRecord scopes, một gem như acts_as_tenant, hoặc — tốt hơn — Postgres row-level security policy fail-closed nếu app quên.

Cái bẫy của schema-per-tenant là migration drift. Bạn chạy rake db:migrate và nó đụng vào schema A. Schema của khách hàng B, tạo từ tuần trước qua snapshot, thiếu cột mới. Bạn phát hiện ra lúc 11h đêm khi dashboard của họ trả 500. Cách chữa là coi schema như một quần thể — mọi migration đều chạy trên mọi schema, trong job, có monitoring, và có pg_dump --schema-only diff để verify chúng khớp nhau.

Cái bẫy của database-per-tenant là áp lực connection pool. Connection của Postgres không phải miễn phí. Một trăm tenant × ba mươi Puma worker × một connection mỗi cái = ba nghìn connection idle, nhiều hơn ngưỡng cho phép của hầu hết Postgres instance. PgBouncer chạy transaction mode sẽ giúp. Chấp nhận pattern này chỉ dành cho số ít tenant lớn, không phải nhiều tenant nhỏ, cũng giúp.

Pattern row-scoped thực sự trông như thế nào

Đây là hình dáng tôi reach for đầu tiên. Không có gì mới — biến thể của nó đã có trong Rails apps suốt mười lăm năm — nhưng cái sự nhàm chán đó chính là điểm mạnh.

current_organization được set theo từng request, thường từ subdomain hoặc giá trị trong session:

class ApplicationController < ActionController::Base
  before_action :set_current_organization

  private

  def set_current_organization
    Current.organization = Organization.find_by!(subdomain: request.subdomain)
  end
end

Mọi model thuộc về tenant đều belongs_to một organization và expose một default scope được enforce ở tầng database qua row-level security:

class Project < ApplicationRecord
  belongs_to :organization
end
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON projects
  USING (organization_id = current_setting('app.current_organization_id')::bigint);

Một setter ở mức connection nối hai phía lại với nhau:

class ApplicationRecord < ActiveRecord::Base
  def self.with_tenant(organization)
    connection.execute("SET app.current_organization_id = #{organization.id.to_i}")
    yield
  ensure
    connection.execute("RESET app.current_organization_id")
  end
end

Kỷ luật là: code app vẫn viết current_organization.projects.find(...) cho gọn, nhưng nếu lỡ tay có developer nào đó viết Project.find(...) trần trụi, Postgres trả về zero row cho tenant sai. Rào chắn có hai lớp, và lớp trong cùng được enforce bởi database — thứ không quên.

Bạn có thể làm điều tương tự mà không cần RLS — acts_as_tenant sẽ set default scope ở phía Ruby — nhưng tôi thích rào chắn ở database hơn vì failure mode là “không có row” thay vì “row sai”, và vì Postgres enforce cả trong rails console, chỗ tôi dễ làm chuyện ngu ngốc nhất.

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

Bạn biết multi-tenancy của mình hoạt động khi bốn thứ sau đều đúng.

Thứ nhất, mọi bảng thuộc tenant đều có cột organization_id rõ ràng hoặc nằm trong một schema/database mà bản thân nó đã tenant-scoped. Không có vùng xám nhập nhằng kiểu row này thuộc “tất cả mọi người” — những row đó nằm trong bảng dùng chung thực sự (countries, currencies, plan tiers) và được đánh dấu rõ trong tài liệu schema.

Thứ hai, bạn nói được blast radius của một scope bị quên trong đúng một câu. Với row-scoping kèm RLS, câu đó là “query trả về zero row”. Với row-scoping không có RLS, câu đó là “query trả về row của tenant khác”. Biết câu trả lời là điều kiện cần để bạn quyết định có thêm tầng RLS hay không.

Thứ ba, mọi background job, mọi Sidekiq retry, mọi scheduled task hoặc là set tenant rõ ràng ngay từ đầu, hoặc là chứng minh được nó tenant-agnostic. Audit này làm cơ học: grep perform, check từng cái một.

Rào chắn chỉ chắc bằng đúng cái job cô đơn nhất đã quên nhắc tới nó.

— Tự note, sau một job như vậy

Thứ tư, bạn có câu trả lời cho bài toán admin/analytics. Hoặc là có một role “platform admin” bypass scoping tại đúng một chỗ rõ ràng, hoặc là bạn duy trì analytics replica riêng với credential riêng. “Tính sau” thì ngày một còn chấp nhận được, đến tháng thứ sáu là chết.

Khi nào không cần đến cái này

Bỏ qua toàn bộ rubric này nếu bạn đang build app single-tenant — một internal tool, một dự án consulting cho đúng một khách hàng, một personal project. Thêm organization_id vào mọi bảng vì “biết đâu sau này dùng” là premature abstraction kinh điển. Bạn luôn có thể retrofit row-scoping về sau; nó khó chịu nhưng không khó, và quá trình chuyển đổi sẽ buộc bạn khám phá ra mô hình tenancy thật sự thay vì đoán mò.

Cũng bỏ qua nếu “tenant” của bạn thật ra chỉ là user có dữ liệu riêng — habit tracker, app ghi chú, công cụ quản lý chi tiêu cá nhân. belongs_to :user là đủ. Gọi cái đó là “multi-tenancy” chỉ làm code nghe có vẻ hoành tráng hơn và mọi cuộc thảo luận trở nên rối rắm hơn.

Một khẳng định có thể bị bác bỏ

Nếu SaaS của bạn có dưới một nghìn tenant, không có hợp đồng data-residency nào đã ký, và bạn có làm reporting cross-tenant ở mức nào đó, thì row-scoping kèm Postgres RLS sẽ là câu trả lời đúng và bạn sẽ không hối hận về nó trong ít nhất ba năm. Pattern này nhàm chán, migration chạy một lượt, và failure mode là “không có row” thay vì “row sai”. Người nào nói khác đi thường là đang bán một multi-tenancy gem, hoặc đang lập luận từ vấn đề họ gặp ở một công ty lớn gấp mười lần công ty bạn.