PostgreSQL Multi-tenancy: Lựa chọn giữa Schema và RLS cho ứng dụng SaaS của bạn

Database tutorial - IT technology blog
Database tutorial - IT technology blog

Bối cảnh & Lý do: Cơn ác mộng Multi-tenancy

Ngay khi bạn bắt đầu thiết kế kiến trúc cho một nền tảng Software-as-a-Service (SaaS), một câu hỏi sẽ khiến bạn mất ngủ: Làm thế nào để ngăn Tenant A xem dữ liệu của Tenant B? Nếu bạn đang quản lý hơn 500 khách hàng, bạn không thể chỉ đổ mọi thứ vào một bảng khổng lồ và cầu nguyện rằng các bộ lọc WHERE tenant_id = ? của mình không bao giờ lỗi. Chỉ cần thiếu một mệnh đề trong logic ứng dụng, bạn có thể làm rò rỉ hóa đơn riêng tư của một CEO cho đối thủ cạnh tranh. Đó là một sự cố có thể chấm dứt sự nghiệp.

Sau khi đã kinh qua MySQL, MongoDB và PostgreSQL trên nhiều dự án có lưu lượng truy cập cao, tôi nhận thấy PostgreSQL là nhà vô địch không thể tranh cãi cho mô hình multi-tenancy. Sự hỗ trợ gốc cho Schemas và Row-Level Security (RLS) của nó mang lại mức độ an toàn mà hầu hết các cơ sở dữ liệu quan hệ khác khó lòng theo kịp. Thông thường, sự lựa chọn nằm ở việc đánh đổi giữa: sự cô lập triệt để và khả năng vận hành ổn định.

Tôi thường phân loại các chiến lược thành ba nhóm:

  • Database-per-tenant: Cô lập hoàn toàn. Tuy nhiên, nó tốn kém và là một cơn ác mộng để quản lý khi bạn đạt tới hơn 100 khách hàng.
  • Schema-per-tenant: Một lựa chọn trung gian vững chắc. Mỗi tenant có một namespace riêng biệt trong cùng một cơ sở dữ liệu.
  • Shared-schema (Row-level): Mọi người dùng chung các bảng. Cơ sở dữ liệu thực thi việc cô lập thông qua các chính sách nội bộ.

Hãy cùng tìm hiểu hai chiến lược thực tế nhất cho các hệ thống backend hiện đại.

Cài đặt: Thiết lập nền tảng

Để thực hành theo, bạn sẽ cần một instance Postgres. Nếu đã cài đặt Docker, bạn có thể khởi tạo một môi trường local chỉ trong khoảng mười giây. Tôi khuyên bạn nên sử dụng Postgres 16 hoặc mới hơn để tận dụng các tối ưu hóa hiệu năng RLS mới nhất.

# Khởi tạo một container Postgres 16
docker run --name saas-db -e POSTGRES_PASSWORD=mysecretpassword -p 5432:5432 -d postgres:16

# Truy cập vào CLI
docker exec -it saas-db psql -U postgres

Khi đã vào trong, hãy tạo một cơ sở dữ liệu mới. Việc rời khỏi cơ sở dữ liệu mặc định postgres ngay lập tức là một thói quen nhỏ nhưng quan trọng để quản lý môi trường tốt hơn.

CREATE DATABASE itfromzero_saas;
\c itfromzero_saas;

Cấu hình: Triển khai các chiến lược cô lập

Chiến lược 1: Multi-tenancy dựa trên Schema

Hãy coi một “Schema” trong Postgres như một thư mục riêng biệt. Mỗi tenant sẽ có thư mục của riêng họ. Cách tiếp cận này rất tuyệt vời cho các ngành yêu cầu tính tuân thủ cao vì nó cho phép bạn sao lưu hoặc khôi phục dữ liệu của một khách hàng duy nhất mà không ảnh hưởng đến hồ sơ của bất kỳ ai khác.

Khi một công ty mới đăng ký, backend của bạn nên kích hoạt một script để cấp phát không gian làm việc cho họ.

-- Cấp phát schema cho hai khách hàng mới
CREATE SCHEMA tenant_apple;
CREATE SCHEMA tenant_banana;

-- Triển khai cấu trúc bảng giống hệt nhau
CREATE TABLE tenant_apple.users (id SERIAL PRIMARY KEY, name TEXT);
CREATE TABLE tenant_banana.users (id SERIAL PRIMARY KEY, name TEXT);

-- Chèn dữ liệu đặc thù cho từng tenant
INSERT INTO tenant_apple.users (name) VALUES ('Steve');
INSERT INTO tenant_banana.users (name) VALUES ('Bob');

Điểm mạnh thực sự ở đây là search_path. Thay vì hardcode tên schema trong SQL, ứng dụng của bạn sẽ thiết lập session context ngay sau khi lấy một kết nối từ pool.

-- Chuyển sang context của Apple
SET search_path TO tenant_apple;
SELECT * FROM users; -- Steve sẽ xuất hiện

-- Chuyển sang context của Banana
SET search_path TO tenant_banana;
SELECT * FROM users; -- Bob sẽ xuất hiện

Chiến lược 2: Row-Level Security (RLS)

Schema rất gọn gàng cho đến khi bạn đạt tới 1.000 tenant. Ở quy mô đó, việc chạy một lệnh ALTER TABLE đơn giản để thêm một cột có thể mất hàng giờ vì cơ sở dữ liệu phải lặp qua 1.000 bảng riêng biệt. Đây là lúc RLS tỏa sáng. Mọi người dùng chung một bảng, nhưng cơ sở dữ liệu đóng vai trò như một người gác cổng vô hình.

Đầu tiên, chúng ta định nghĩa một bảng dùng chung với cột tenant_id bắt buộc.

CREATE TABLE projects (
    id SERIAL PRIMARY KEY,
    tenant_id TEXT NOT NULL,
    name TEXT NOT NULL
);

-- Kích hoạt trình gác cổng
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

Tiếp theo, chúng ta cần một cách để báo cho Postgres biết ai là “tenant hiện tại” cho request đang hoạt động. Tôi sử dụng một biến session tùy chỉnh kết hợp với một chính sách bảo mật.

-- Định nghĩa chính sách: Chỉ hiển thị các hàng khớp với ID tenant của session
CREATE POLICY tenant_isolation_policy ON projects
    USING (tenant_id = current_setting('app.current_tenant'));

-- Tạo một user bị hạn chế cho ứng dụng để ngăn chặn leo thang đặc quyền
CREATE ROLE app_user WITH LOGIN PASSWORD 'password';
GRANT ALL ON projects TO app_user;

Ứng dụng của bạn sau đó thực thi các bước này trong một transaction duy nhất:

BEGIN;
-- Xác định tenant cho web request cụ thể này
SET LOCAL app.current_tenant = 'customer_123';

-- Truy vấn này sẽ KHÔNG BAO GIỜ thấy dữ liệu của customer_456
SELECT * FROM projects;
COMMIT;

Đây là mạng lưới an toàn của bạn. Ngay cả khi lập trình viên quên mệnh đề WHERE trong một phép join phức tạp, cơ sở dữ liệu sẽ đơn giản là không trả về các hàng chưa được cấp quyền. Nó đã cứu tôi những bàn thua trông thấy trong môi trường production không chỉ một lần.

Xác minh & Giám sát: Giữ vững sự ổn định

Cô lập không phải là một tính năng “thiết lập xong rồi quên”. Bạn cần chứng minh nó hoạt động. Tôi luôn viết các bài kiểm tra tích hợp (integration tests) cố tình thử truy cập chéo dữ liệu. Nếu Tenant A có thể truy vấn thành công ID của Tenant B, quá trình build phải thất bại ngay lập tức.

Hiệu năng là nửa kia của vấn đề. RLS thêm một bộ lọc ngầm định vào mọi truy vấn, điều này có thể là một “sát thủ thầm lặng” nếu bạn không cẩn thận. Bạn phải đánh index cho cột tenant_id của mình. Nếu không có nó, cơ sở dữ liệu sẽ thực hiện quét tuần tự (sequential scans) khi bảng của bạn lớn dần, biến một truy vấn 10ms thành một trải nghiệm giật lag 500ms.

-- Index bắt buộc để đảm bảo hiệu năng RLS
CREATE INDEX idx_projects_tenant_id ON projects(tenant_id);

Trong một dự án gần đây, việc chuyển sang RLS đã cho phép chúng tôi quản lý 2.500 tenant trong một bảng duy nhất. Thời gian migration để cập nhật schema của chúng tôi đã giảm từ 45 phút xuống dưới 30 giây. Tuy nhiên, nếu bạn làm việc trong lĩnh vực Fintech hoặc Y tế, sự tách biệt vật lý của mô hình Schema-per-tenant vẫn có thể là cái giá bạn phải trả để vượt qua các đợt kiểm tra bảo mật. Dù bạn chọn con đường nào, hãy đảm bảo logic cô lập của bạn nằm càng gần dữ liệu càng tốt.

Share: