· postgresql / multi-tenancy / rls
Multi-tenant Postgres 2026: schema, RLS hay database riêng?
Schema-per-tenant xuống cấp sau 500 tenant trong Postgres thuần. RLS có năm CVE đã biết và bẫy hiệu năng 20×. Đây là bản đồ quyết định cho năm 2026.
Bởi Ethan · Cập nhật 24 tháng 5, 2026
3.452 từ · 18 phút đọc
Chọn model tenancy trước khi bạn ship dòng dữ liệu đầu tiên. Schema-per-tenant là hướng ít bất ngờ nhất cho khoảng 500 tenant trở xuống — sau đó, chính system catalog của Postgres trở thành điểm nghẽn. Row-Level Security scale được xa hơn nhưng có năm CVE đã biết và một cái bẫy hiệu năng khiến bạn tốn gấp 20 lần trên một bảng bận nếu viết policy sai. Database riêng biệt cho isolation mạnh nhất nhưng yêu cầu connection pooler ngay khi bạn có hơn một trăm tenant. Cách tiếp cận hybrid — RLS cho free tenant, schema hoặc database riêng cho tenant trả tiền — là điểm đến của hầu hết sản phẩm SaaS trưởng thành, không phải điểm xuất phát.
Bài này dành cho ai
Các engineer đang chọn kiến trúc tenancy trước khi schema ổn định. Nếu bạn đã có 200.000 tenant trên shared schema, chi phí migration sẽ chi phối mọi quyết định — bài này không đề cập đến các con đường migration.
Ba model
| Model | Đơn vị isolation | Dùng chung |
|---|---|---|
| Schema-per-tenant | Postgres schema (namespace) | Database, WAL, lock manager, buffer pool, pg_class |
| RLS (shared schema) | Row filter qua policy | Mọi thứ — cùng bảng, cùng schema |
| Database riêng | Postgres database hoặc instance | WAL và lock manager (cùng instance); không gì cả (instance/Neon project riêng) |
Một chi tiết đáng chú ý từ bài benchmark của EDBT tháng 3 năm 2026 (Erdelt & Rabl, DOI: 10.48786/edbt.2026.46566): schema-per-tenant và separate-databases-on-the-same-instance cho isolation tương đương ở cấp WAL và buffer pool. Cả hai đều dùng chung lock manager và recovery path. Nếu bạn cần isolation thật sự để giới hạn phạm vi ảnh hưởng khi có sự cố, bạn cần separate instance — hoặc model serverless nơi mỗi project là cơ sở hạ tầng độc lập.
Schema-per-tenant: giới hạn trần
Khi nào nó hoạt động tốt
Schema-per-tenant rõ ràng về mặt khái niệm: mỗi tenant có namespace riêng, migration chạy theo từng schema, và bạn có thể restore schema của một tenant mà không ảnh hưởng đến ai khác. Dưới 100 tenant, nó không gây vấn đề đo lường được.
Bức tường catalog overhead
Vấn đề nằm ở pg_class. Mỗi bảng, index, sequence, và view đều đổ vào system catalog của Postgres. Ở 1.200 schema với khoảng 200 bảng mỗi schema — tương đương 240.000 catalog entry — một câu query information_schema.tables đơn giản mất 383 ms vì nó phải scan tuần tự qua 1,3 triệu dòng. Điều này được đo trên Postgres 9.5.7 bởi Ulf Lohbrügge và đăng lên PostgreSQL mailing list năm 2017.
Flyway dùng information_schema queries nội bộ. Ở 1.200 schema, 383 ms nhân với 10+ query của Flyway cho mỗi tenant đẩy thời gian migration lên hơn hai tiếng trên cùng dataset đó.
Giới hạn thực tế
| Số tenant | Tình trạng schema-per-tenant |
|---|---|
| < 100 | Ổn, không có điểm nghẽn đã biết |
| 100–500 | information_schema chậm; query thẳng vào pg_class |
| 500–1.000 | Catalog overhead đo được; migration tool chậm đáng kể |
| 1.000–10.000 | Cần Citus schema-based sharding hoặc tương đương |
| > 10.000 | Không được hỗ trợ kể cả với Citus |
Citus 12 (Microsoft, tháng 7 năm 2023) hướng đến 1–10.000 tenant mỗi cluster với schema-based sharding, phân biệt với row-based sharding vốn được định vị cho “100–1M+ tenant.”
Bẫy PgBouncer transaction mode
Transaction pool mode của PgBouncer — thường là mặc định được khuyến nghị vì hiệu năng — loại bỏ SET search_path giữa các transaction. Schema-per-tenant dựa vào search_path để định tuyến mỗi kết nối đến đúng tenant schema. Trong transaction mode, việc định tuyến đó sẽ lặng lẽ hỏng mà không có lỗi. Bạn phải chạy session mode, điều này giới hạn connection reuse và tái tạo đúng vấn đề connection count mà pooling sinh ra để giải quyết.
Arkency ghi lại điều này vào tháng 10 năm 2020 sau khi gặp phải trong production: “Bất cứ thứ gì khác ngoài session mode sẽ không cho phép bạn dùng search_path để chuyển tenant.”
Tooling migration trong 2026
Atlas (v1.0, tháng 12 năm 2025) xử lý multi-schema với meta-argument for_each để lặp qua các tenant. Chính sách fail-fast: một lỗi migration ở một tenant dừng toàn bộ quá trình. Flyway khuyến nghị Pattern 2 (một bộ migration, một history table mỗi tenant) và đánh dấu rõ Pattern 3 (shared history) là “không được khuyến nghị.” Docs của Neon khuyên không dùng schema-per-tenant cho SaaS: “không giảm độ phức tạp vận hành hay chi phí so với cách tiếp cận nhiều database, nhưng lại thêm rủi ro.”
RLS: bẫy về tính đúng đắn
Cơ chế hoạt động
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY; -- ngăn owner bypass trong test
-- SAI: function được evaluate mỗi dòng
CREATE POLICY bad_policy ON orders
USING (tenant_id = auth.uid());
-- ĐÚNG: (select ...) được cache mỗi statement qua tối ưu hóa initPlan của Postgres
CREATE POLICY tenant_isolation ON orders
AS PERMISSIVE FOR ALL
TO authenticated
USING (tenant_id = (select auth.uid()))
WITH CHECK (tenant_id = (select auth.uid()));
CREATE INDEX ON orders(tenant_id); -- bắt buộc
Hành vi mặc định: RLS là default-deny. Không có policy nghĩa là không có dòng nào được đọc hay ghi. Superuser và role BYPASSRLS luôn bypass — FORCE ROW LEVEL SECURITY trên table owner đóng kẽ hở đó trong môi trường test. TRUNCATE và foreign-key reference check không thuộc phạm vi RLS (docs Postgres 16/17).
Hồ sơ CVE
Năm CVE nhắm vào RLS từ 2019, nguồn tại postgresql.org/support/security:
| CVE | Đã vá | CVSS | Cơ chế |
|---|---|---|---|
| CVE-2024-10976 | 14 tháng 11, 2024 | 4.2 | CTE/subquery plan reuse sau SET ROLE — PG 12–17 |
| CVE-2024-0985 | 8 tháng 2, 2024 | 8.0 | REFRESH MATERIALIZED VIEW CONCURRENTLY privilege drop |
| CVE-2023-2455 | 11 tháng 5, 2023 | 4.2 | Function inlining + SET ROLE áp dụng policy của role sai |
| CVE-2023-39418 | 10 tháng 8, 2023 | 3.1 | MERGE bỏ qua policy UPDATE/SELECT (chỉ PG 15) |
| CVE-2019-10130 | 9 tháng 5, 2019 | 3.1 | Planner statistics leakage qua leaky operator |
Mẫu hình lặp đi lặp lại: plan reuse sau khi đổi role. Khi một query được plan dưới một role nhưng execute dưới role khác — qua security definer function, SET ROLE, hoặc function inlining — policy của role lúc planning được áp dụng tại thời điểm execution. CVE-2024-10976, vá tháng 11 năm 2024, là trường hợp gần nhất. Mọi code path kết hợp security definer function với connection pooling đều là bề mặt bypass tiềm năng.
Bẫy SECURITY DEFINER view
Trước Postgres 15, view chạy với quyền của view owner (thực chất là SECURITY DEFINER) và lặng lẽ bypass RLS trên các bảng bên dưới. Bản vá đến trong Postgres 15 (tháng 11 năm 2022):
CREATE VIEW tenant_orders WITH (security_invoker = true) AS
SELECT * FROM orders;
-- hoặc retroactively:
ALTER VIEW tenant_orders SET (security_invoker = true);
Bất kỳ ứng dụng nào trên Postgres < 15 có view và RLS nên coi mọi view như một RLS bypass.
Connection pooling và context leakage
PgBouncer ở transaction mode tái sử dụng server connection qua các client khác nhau. Cách dùng current_user không hoạt động vì mọi kết nối từ app đều dùng chung một database role. Cách xử lý chuẩn: set tenant context bên trong transaction dùng SET LOCAL, tự động reset khi commit.
BEGIN;
SELECT set_config('rls.tenant_id', $1, true); -- true = LOCAL, reset khi kết thúc transaction
-- thực hiện tenant queries
COMMIT;
Nếu bỏ LOCAL và một pooled connection tái sử dụng server connection từ client trước, context của tenant trước đó vẫn còn hoạt động. Không có lỗi. Dữ liệu sai được trả về lặng lẽ.
Bẫy hiệu năng
Sự khác biệt giữa auth.uid() và (select auth.uid()) trong mệnh đề USING của policy không phải vấn đề phong cách. Postgres evaluate bare function call cho mỗi dòng. Dạng (select ...) kích hoạt initPlan — kết quả được tính một lần mỗi statement và cache lại.
Đo trên bảng 100.000 dòng (Supabase Discussion #14576, tháng 5 năm 2023):
| Dạng policy | Thời gian thực thi |
|---|---|
auth.uid() = user_id (không có index) | 171 ms |
(select auth.uid()) = user_id (không có index) | 9 ms |
(select auth.uid()) = user_id (có index) | < 0.1 ms |
Trên 1 triệu dòng:
| Pattern | Thời gian thực thi |
|---|---|
=ANY(user_teams()) chuẩn | > 2 phút (timeout) |
=ANY(ARRAY(select user_teams())) có index | 2 ms |
Đánh index cho cột. Luôn dùng (select function()). Đây không phải tối ưu vi mô — đây là ranh giới giữa sản phẩm hoạt động và sản phẩm timeout trên bất kỳ tenant nào có dữ liệu thực.
Kịch bản thất bại thực tế
CVE-2025-48757 (tháng 5 năm 2025, CVSS 8.26): Lovable, một AI-powered app generator, ship các Supabase project với RLS policy không khớp với business logic (mattpalmer.io). Bảng tạo qua SQL có RLS tắt theo mặc định — Table Editor của Supabase bật tự động, nhưng SQL Editor thì không (Supabase RLS docs). Tooling không bảo vệ bạn khỏi việc bỏ sót.
Bảy footgun RLS trong production
- SECURITY DEFINER view bypass RLS — dùng
security_invoker = truetrên Postgres 15+ - Connection pool context leakage — bỏ
SET LOCALlà bạn sẽ trả về dữ liệu sai tenant BYPASSRLSvà superuser bypass — dùngFORCE ROW LEVEL SECURITYtrong môi trường test- Thiếu
WITH CHECK— cho phép INSERT những dòng bạn không thể SELECT lại - Materialized view — background refresh bypass RLS hoàn toàn
- Unique constraint — tiết lộ sự tồn tại của dòng cross-tenant; dùng uniqueness composite
(tenant_id, email) - Leaky operator statistics — lớp CVE-2019-10130; đã vá từ PG 9.5+ nhưng cho thấy bề mặt tấn công
Nguồn: Bytebase RLS footguns, Supabase RLS docs, Postgres official docs.
Database riêng: chi phí vận hành
Vấn đề connection overhead
Postgres dùng một backend process mỗi connection. Mỗi idle connection tốn ~2 MiB khi bật huge_pages — nhưng chi phí lớn hơn nằm ở snapshot subsystem. Benchmark tháng 10 năm 2020 của Citus Data cho thấy TPS giảm chỉ do idle connection (pgbench, direct connections, 2x Xeon Gold 5215, 192 GiB RAM, Debian Sid, kernel 5.8.5):
| Idle connection | TPS (48 active worker) | Giảm |
|---|---|---|
| 0 | 1.032.435 | — |
| 1.000 | 902.109 | −12,6% |
| 5.000 | 702.680 | −31,9% |
| 10.000 | 521.558 | −49,5% |
Nguyên nhân gốc: GetSnapshotData() chiếm ~50% CPU ở 5.000 idle connection. Đây là giới hạn scalability cơ bản của Postgres, không phải vấn đề cấu hình. Trong deployment 1.000 tenant mà mỗi tenant giữ một idle connection, bạn đã mất 13% throughput. Connection pooler không phải tùy chọn.
So sánh các pooler
Kiến trúc khác nhau ở những điểm quan trọng khi scale. pgcat là multi-threaded (Rust/Tokio), trong khi PgBouncer là single-threaded — một giới hạn đã biết khi connection count tăng cao (pgcat docs, PgBouncer features). Supavisor được xây đặc thù cho multi-tenant cluster và thiết kế cho 1M+ connection (Supavisor). Với hầu hết team dưới 500 tenant, PgBouncer đơn giản hơn để vận hành và giới hạn đó chưa bao giờ là vấn đề.
# Cấu hình PgBouncer cho per-tenant database
[databases]
tenant_acme = host=db.internal dbname=tenant_acme pool_size=5 pool_mode=transaction
tenant_globex = host=db.internal dbname=tenant_globex pool_size=5 pool_mode=transaction
* = host=db.internal pool_size=3 ; wildcard cho tenant name động
[pgbouncer]
pool_mode = transaction
default_pool_size = 5
max_db_connections = 10 ; giới hạn mỗi database bất kể số client
max_client_conn = 10000 ; PgBouncer xử lý rẻ (~2 kB/client, không phải 2 MiB)
server_idle_timeout = 60 ; đóng idle tenant connection nhanh
Neon: database-per-tenant ở quy mô serverless
Phản bác truyền thống về database-per-tenant — “bạn sẽ cần một server riêng mỗi tenant” — không còn đúng với Neon. Neon tạo một Postgres project hoàn chỉnh trong vài mili giây, scale idle project về zero chi phí compute, và hỗ trợ lên đến 5.000 project trên Business plan. Giá storage giảm xuống $0.35/GB-tháng sau vụ Databricks mua lại vào năm 2025.
Với team cần point-in-time recovery mỗi tenant, compliance isolation (HIPAA, giảm phạm vi SOC 2), hoặc dedicated compute, database-per-tenant trên Neon là câu trả lời cho năm 2026. Neon khuyến nghị rõ model này thay vì schema-per-tenant cho SaaS. Xem so sánh Neon và Supabase nếu bạn đang cân nhắc giữa hai nền tảng.
Chi phí vận hành nằm ở cross-tenant reporting. Database riêng yêu cầu dblink, postgres_fdw, hoặc ETL pipeline để query cross-tenant. Nếu analytics layer cần aggregate thường xuyên trên tất cả tenant, hãy tính đến điều đó.
Với deployment database riêng tự host, Railway cung cấp managed Postgres và phù hợp với fleet tenant nhỏ hơn. Cả Railway lẫn Fly.io đều không có fleet-provisioning automation — bạn phải tự script per-tenant provisioning qua API của họ. Fly.io giới hạn 1 TB storage mỗi cluster.
Isolation cross-instance
Database riêng trên cùng Postgres instance vẫn dùng chung WAL, lock manager, và buffer pool (theo bài EDBT 2026 ở trên). Một tenant “ồn ào” trên cùng instance có thể tăng latency cho tenant khác do resource contention. Isolation thật sự yêu cầu separate instance — hoặc Neon, nơi mỗi project chạy trên cơ sở hạ tầng độc lập.
Các cách tiếp cận hybrid
Tier-based sharding với Citus 12
Citus 12 cho phép bạn kết hợp các model tenancy theo tier trong một cluster. Free tenant dùng row-based isolation (shared schema, scale đến 100M+ tenant). Paid tenant có schema-per-tenant sharding (đến 10.000 mỗi cluster). Enterprise tenant có dedicated database. Một câu lệnh kích hoạt schema-based sharding: SET citus.enable_schema_based_sharding TO on;. Nếu bạn đã chạy Citus, đây là con đường tự nhiên khi customer base phân tầng theo quy mô.
Virtual tenant database của Nile
Nile có cách tiếp cận khác: một database tương thích Postgres với logical tenant isolation được thực thi ở application layer. Họ xây pg_karnak, một Postgres extension dùng two-phase commit (PREPARE TRANSACTION / COMMIT PREPARED) cho atomic DDL trên các virtual tenant database. Ràng buộc: primary key phải có tenant_id là cột đầu. Đây là managed service với operational surface riêng — thú vị cho team muốn sự đơn giản của shared schema nhưng cần ranh giới logic mạnh hơn.
Ma trận hỗ trợ ORM
| Prisma | Drizzle | TypeORM | |
|---|---|---|---|
| Multiple schema | GA từ v6.13.0 (30 tháng 7, 2025) | Native pgSchema | @Entity({ schema }), chỉ PG và MSSQL |
| Dynamic schema switching | Không hỗ trợ | App-layer routing | Không hỗ trợ; issue #10619 đóng “not planned” |
| RLS trong schema definition | Không (issue #12735, mở từ tháng 4 năm 2022) | First-class từ v0.36.0 (30 tháng 10, 2024) | Không |
| RLS helper | Client extension (README: “not for production”) | pgPolicy(), pgRole(), crudPolicy(), tích hợp Neon + Supabase | Không |
| Migration support cho RLS | Không (raw SQL hoặc Atlas Pro) | Có — drizzle-kit quản lý role + policy | Không |
| TypeScript RLS policy type-safety | Không | Có | Không |
Drizzle có câu chuyện multi-tenancy mạnh nhất trong năm 2026 — xem so sánh Drizzle và Prisma nếu chưa chọn ORM. RLS trở thành first-class từ v0.36.0 (tháng 10 năm 2024), bao gồm pgPolicy(), pgRole(), và tích hợp cho Neon và Supabase. drizzle-kit tạo và quản lý role và policy migration. Một lưu ý: v1.0.0-beta.2 (tháng 2 năm 2025) thay đổi drizzle-kit push/pull để quản lý mặc định tất cả schema trong code của bạn. Những người dùng multi-schema phải kiểm tra cấu hình schemaFilter trước khi upgrade.
import { pgTable, pgRole, pgPolicy } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
const currentTenantId = sql`(select current_setting('rls.tenant_id'))::uuid`;
export const orders = pgTable('orders', {
id: integer().primaryKey(),
tenantId: uuid('tenant_id').notNull(),
}, (table) => [
pgPolicy('tenant_isolation', {
as: 'permissive',
for: 'all',
using: sql`${table.tenantId} = ${currentTenantId}`,
withCheck: sql`${table.tenantId} = ${currentTenantId}`,
}),
]);
Prisma multiSchema là GA từ v6.13.0 (30 tháng 7 năm 2025) cho Postgres, CockroachDB, và SQL Server. RLS vẫn là issue mở #12735 có nhãn “has-stopgap” — giải pháp thay thế chính thức là một client extension mà README nói thẳng là “not intended for production.”
TypeORM 1.0 ra mắt tháng 5 năm 2026. Không có tính năng multi-tenancy hay RLS mới. Vấn đề connection pool — một pool mới mỗi DataSource ngăn tenant-routing mà không cần N pool độc lập — bị đóng với “not planned” trong issue #10619.
Kết luận
Bắt đầu với RLS nếu bạn dùng Drizzle. Nó có tooling first-class, scale mà không tốn catalog overhead, và rủi ro CVE có thể quản lý được nếu bạn chạy Postgres minor version hiện tại, dùng security_invoker view, và luôn SET LOCAL cho context. Viết policy với (select function()) và đánh index cột tenant ngay từ ngày đầu.
Dùng schema-per-tenant nếu bạn đang trên Prisma (multiSchema là GA từ v6.13.0), bạn cần pg_dump restore mỗi tenant, hoặc compliance yêu cầu schema-level separation. Giữ dưới 500 tenant trong Postgres thuần. Dùng Atlas hoặc Flyway Pattern 2 cho migration. Chạy PgBouncer ở session mode.
Dùng database riêng trên Neon nếu:
- Bạn cần PITR mỗi tenant, HIPAA/SOC 2 isolation, hoặc dedicated compute
- Bạn đang xây sản phẩm mà vi phạm của một tenant không được nằm trong phạm vi ảnh hưởng đến tenant khác
- Bạn sẵn sàng chấp nhận chi phí cross-tenant reporting
Chọn hybrid (Citus tier-based sharding) nếu bạn đã chạy Citus và tenant base của bạn có nhiều phân tầng kích thước.
Q1: Bao nhiêu tenant (dự kiến tối đa)?
├─ < 500 → Schema-per-tenant hoặc RLS. Tiếp Q2.
├─ 500–10.000 → Schema cần Citus; RLS scale được tự nhiên. Tiếp Q2.
└─ > 10.000 → Chỉ RLS hoặc Neon database-per-tenant.
Q2: Cần compliance isolation? (HIPAA, phạm vi SOC 2, PITR mỗi tenant)
├─ Có → Database riêng trên Neon. Xong.
└─ Không → Tiếp tục.
Q3: Có cần cross-tenant reporting/analytics thường xuyên không?
├─ Có → RLS hoặc schema-per-tenant (cùng database; admin query bypass RLS).
└─ Không → Tiếp tục.
Q4: ORM?
├─ Drizzle → RLS (first-class; drizzle-kit quản lý migration).
├─ Prisma → Schema-per-tenant (multiSchema GA v6.13.0); RLS chỉ là workaround.
└─ TypeORM → Schema-per-tenant qua @Entity({schema}); không có RLS hay dynamic switching.
Q5: Enterprise tenant cần isolation khỏi free-tier tenant?
└─ Có → Hybrid: RLS cho free tier, schema hoặc DB cho enterprise (Citus 12).
Lưu ý
- Các con số hiệu năng RLS (171 ms vs 9 ms) đến từ benchmark cộng đồng trên hạ tầng Supabase, không phải nghiên cứu có kiểm soát. Chúng đáng tin về mặt hướng đi; phần cứng của bạn sẽ cho kết quả khác.
- Không có benchmark head-to-head có kiểm soát giữa RLS và không RLS trên phần cứng và query giống hệt nhau trong tài liệu học thuật chính thức. Tuyên bố “~30% overhead” trên các diễn đàn cộng đồng là không có cơ sở — đừng đưa vào tài liệu kiến trúc.
- Tính năng sharding của pgcat được đánh dấu “experimental” trong config docs hiện tại.
- Giới hạn Neon project (5.000 trên Business) và giá ($0.35/GB-tháng storage) chính xác tính đến tháng 8 năm 2025; cả hai có thể thay đổi.
- Link Neon và Railway ở trên là affiliate link — toolchew nhận hoa hồng nếu bạn đăng ký qua đó, miễn phí với bạn.
Tham khảo
- CREATE POLICY — Postgres 17 docs
- Row Security Policies — Postgres 17 docs
- CVE-2024-10976
- CVE-2024-0985
- CVE-2023-2455
- CVE-2023-39418
- CVE-2019-10130
- CVE-2025-48757 — mattpalmer.io
- Lohbrügge 1.200-schema benchmark — pgsql-performance mailing list, 2017
- Citus 12 schema-based sharding
- Analyzing connection scalability in Postgres — Citus Data, 2020
- RLS performance optimization — Supabase Discussion #14576
- RLS footguns — Bytebase
- Supabase RLS docs
- Neon multitenancy guide
- Neon database-per-user guide
- Drizzle ORM RLS docs
- Drizzle v0.36.0 release notes
- Prisma multiSchema changelog — v6.13.0
- Prisma multiSchema docs
- TypeORM 1.0 release notes
- Arkency schema-per-tenant surprises, 2020
- EDBT 2026 — Erdelt & Rabl
- Tomas Vondra — JWT context for RLS, 2025
- PgBouncer config reference
- Atlas multi-tenant support
- Nile pg_karnak distributed DDL