Sản phẩm SaaS đang chạy ổn với 10 khách hàng. Mỗi khách hàng một bộ deploy riêng, Kubernetes namespace riêng, database instance riêng, config riêng. Team DevOps hai người xử lý tốt. Rồi sales ký thêm 50 hợp đồng trong quý, roadmap nhắm 500 khách hàng cuối năm. Đột nhiên “mỗi khách hàng một bộ deploy” trở thành cơn ác mộng vận hành: 500 database instance, 500 bộ migration, 500 pipeline CI/CD, 500 thứ cần monitor. Hai người DevOps không thể quản lý 500 bộ hạ tầng giống hệt nhau chỉ khác mỗi data bên trong.
Đây là lúc multi-tenancy trở thành bài toán kiến trúc bắt buộc, không phải vì kỹ thuật thú vị, mà vì chi phí vận hành single-tenant tăng tuyến tính theo số khách hàng sẽ giết chết margin của sản phẩm SaaS trước khi nó kịp profitable.
Multi-tenancy nghĩa là nhiều khách hàng (tenant) chia sẻ cùng hạ tầng, cùng application server, cùng database, hoặc ít nhất cùng cluster. Mức độ chia sẻ và cách cô lập dữ liệu giữa các tenant chính là quyết định kiến trúc quan trọng nhất, ảnh hưởng đến chi phí, bảo mật, hiệu năng, và khả năng vận hành trong nhiều năm. Bài này phân tích ba mô hình phổ biến, trade-off thực tế của từng mô hình, và cách chọn phù hợp với giai đoạn sản phẩm.
Ba mô hình và bức tranh tổng thể
Trước khi đi sâu từng mô hình, cần hình dung chúng nằm ở đâu trên phổ isolation–cost.
Từ trái sang phải: chi phí tăng, isolation tăng, complexity vận hành thay đổi theo hướng khác nhau. Shared DB rẻ nhất nhưng cô lập yếu nhất. DB-per-tenant cô lập mạnh nhất nhưng đắt nhất. Schema-per-tenant nằm giữa, và đôi khi là sweet spot cho nhiều sản phẩm SaaS giai đoạn tăng trưởng.
Không có mô hình “tốt nhất”, chỉ có mô hình phù hợp nhất với ràng buộc hiện tại của sản phẩm: số tenant, yêu cầu compliance, ngân sách hạ tầng, và năng lực team vận hành.
Shared DB, shared schema, đơn giản nhất, rẻ nhất
Hiểu nôm na thì shared schema giống như chung cư, tất cả ở chung một toà nhà, chung hành lang, chỉ khác nhau số phòng. Rẻ và tiện, nhưng hàng xóm ồn ào thì cả tầng khổ.
Đây là mô hình mà hầu hết SaaS nên bắt đầu, trừ khi có ràng buộc compliance bắt buộc isolation từ ngày đầu. Ý tưởng cực kỳ đơn giản: tất cả tenant dùng chung một database, chung bảng, phân biệt bằng cột tenant_id trên mọi bảng.
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id UUID NOT NULL,
customer_id BIGINT NOT NULL,
total NUMERIC(12,2) NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
CONSTRAINT fk_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id)
);
CREATE INDEX idx_orders_tenant ON orders (tenant_id, created_at);
Mọi bảng chứa data tenant đều có tenant_id. Mọi query đều phải có WHERE tenant_id = ?. Mọi index quan trọng đều bắt đầu bằng tenant_id. Nghe đơn giản, và đúng là đơn giản, cho đến khi ai đó quên WHERE tenant_id.
Row-Level Security, lưới an toàn cho truy vấn
PostgreSQL cung cấp Row-Level Security (RLS) như một lớp bảo vệ ở cấp database engine, không phụ thuộc vào application code có nhớ filter hay không.
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.current_tenant')::UUID);
Khi application mở connection, set session variable app.current_tenant bằng tenant ID của request hiện tại. Từ đó, mọi SELECT, UPDATE, DELETE trên bảng orders tự động chỉ thấy row của tenant đó, kể cả khi developer viết SELECT * FROM orders không có WHERE. RLS hoạt động ở mức query planner, transparent với application code.
Tuy nhiên RLS không phải silver bullet. Thứ nhất, nó chỉ hoạt động khi session variable được set đúng, nếu middleware quên set, hoặc connection pool tái sử dụng connection mà không reset session variable, tenant A có thể thấy data tenant B. Middleware phải set current_tenant ở đầu mỗi request và clear ở cuối, không có ngoại lệ. Thứ hai, RLS tạo overhead nhỏ cho mỗi query vì planner phải thêm filter condition. Với hệ thống QPS cao, overhead này cần benchmark cụ thể, thường không đáng kể nhưng nên đo thay vì giả định. Thứ ba, super user và table owner mặc định bypass RLS, cần tạo role riêng cho application connection, không dùng role owner.
Rủi ro data leak
Rủi ro lớn nhất của shared schema là cross-tenant data leak, tenant A nhìn thấy data của tenant B. Nguyên nhân phổ biến nhất không phải bug phức tạp mà là developer quên WHERE tenant_id = ? trong một query mới. Trong hệ thống có vài trăm query, chỉ cần sót một cái là đủ tạo security incident nghiêm trọng.
RLS giảm rủi ro này đáng kể nhưng không triệt tiêu, vì RLS phụ thuộc vào session variable được set đúng. Defence in depth nghĩa là áp dụng nhiều lớp: RLS ở database, middleware inject tenant_id vào mọi query ở application layer (ORM scope, query builder default filter), và integration test verify rằng mọi endpoint chỉ trả data của đúng tenant.
Một pattern hiệu quả là tenant context middleware: mọi request đi qua middleware đọc tenant từ JWT/session, set vào request context, và ORM/repository layer tự động filter theo context đó. Developer không bao giờ phải tự thêm WHERE tenant_id, framework làm sẵn. Django có django-tenants, Rails có acts_as_tenant, NestJS có thể implement qua request-scoped provider. Nếu framework không có sẵn, đây là một trong những thứ đầu tiên cần xây khi chọn shared schema.
Noisy neighbor, kẻ phá phách trong chung cư
Shared schema nghĩa là tất cả tenant chia sẻ cùng database resource: CPU, memory, IO, connection pool. Khi một tenant chạy report nặng quét hàng triệu row, mọi tenant khác chậm theo vì database đang bận phục vụ query đó.
Noisy neighbor là vấn đề không thể “fix” hoàn toàn trong shared schema, chỉ có thể mitigate. Một số cách:
statement_timeout per tenant: set timeout query ngắn hơn cho tenant free tier, dài hơn cho enterprise. Thực hiện qua session variable khi mở connection. Tenant nào chạy query quá lâu thì bị cancel, không ảnh hưởng người khác.
Rate limit per tenant ở application layer: giới hạn số request/giây hoặc số query/giây cho mỗi tenant. Tenant vượt quota nhận 429 thay vì tiếp tục gửi query vào database.
Resource quota ở database level phức tạp hơn, PostgreSQL không có native per-user resource quota mạnh mẽ. Có thể dùng pg_stat_statements để monitor query cost per tenant và alert khi tenant nào tiêu thụ bất thường. Citus (extension cho Postgres) hỗ trợ per-tenant resource isolation tốt hơn khi dùng distributed table.
Nếu noisy neighbor trở thành vấn đề thường xuyên dù đã mitigate, đó là tín hiệu sản phẩm đã outgrow shared schema, cần cân nhắc chuyển tenant lớn sang dedicated resource hoặc chuyển mô hình.
Ưu và nhược điểm thực tế
Shared schema tốt nhất khi số tenant lớn (hàng trăm đến hàng nghìn), data mỗi tenant nhỏ đến vừa, và team cần ship feature nhanh mà không muốn phức tạp hoá hạ tầng. Schema migration chạy một lần cho tất cả tenant, không phải lặp 500 lần. Connection pool dùng chung, không phình theo số tenant. Backup và restore đơn giản vì chỉ có một database.
Nhược điểm rõ ràng nhất: isolation yếu (data leak risk, noisy neighbor), không thể backup/restore riêng một tenant dễ dàng (phải export/import data theo tenant_id), và tuỳ chỉnh schema per tenant không khả thi. Nếu khách hàng enterprise yêu cầu “data phải nằm ở region riêng” hoặc “cần restore data của riêng chúng tôi”, shared schema không đáp ứng được mà không có effort đáng kể.
Schema-per-tenant, cô lập logic trong cùng instance
Mô hình này tạo một schema riêng trong cùng database instance cho mỗi tenant. Trong PostgreSQL, schema là namespace cho bảng, tenant_a.orders và tenant_b.orders là hai bảng khác nhau, cùng cấu trúc nhưng data hoàn toàn tách biệt.
CREATE SCHEMA tenant_acme;
CREATE TABLE tenant_acme.orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id BIGINT NOT NULL,
total NUMERIC(12,2) NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE SCHEMA tenant_globex;
CREATE TABLE tenant_globex.orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id BIGINT NOT NULL,
total NUMERIC(12,2) NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
Chú ý: bảng orders không cần cột tenant_id vì schema đã đóng vai trò phân biệt. Application chỉ cần set search_path đúng schema khi mở connection:
SET search_path TO tenant_acme, public;
Từ đó, mọi query SELECT * FROM orders tự động truy vấn tenant_acme.orders. Nếu có bảng dùng chung (lookup table, config), đặt trong schema public.
Isolation tốt hơn, backup per tenant khả thi
Vì data nằm riêng schema, cross-tenant data leak do quên WHERE gần như không thể xảy ra, query trên schema A không bao giờ thấy data schema B (trừ khi viết cross-schema query có chủ đích, điều dễ phát hiện trong code review). Đây là ưu điểm bảo mật lớn nhất so với shared schema.
Backup per tenant khả thi bằng pg_dump với flag --schema:
pg_dump -d mydb --schema=tenant_acme > tenant_acme_backup.sql
Restore cũng tương tự, chỉ restore schema cần thiết mà không ảnh hưởng tenant khác. Khi khách hàng enterprise yêu cầu “export toàn bộ data”, bạn dump schema của họ ra, đơn giản và sạch sẽ hơn nhiều so với SELECT * FROM orders WHERE tenant_id = ? rồi export từng bảng.
Migration, nỗi đau tăng tuyến tính
Đây là nhược điểm lớn nhất của schema-per-tenant. Mỗi lần thêm cột, thêm index, đổi constraint, phải chạy trên mọi schema. 500 tenant = 500 lần ALTER TABLE.
for schema in $(psql -t -c "SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'tenant_%'"); do
psql -c "ALTER TABLE ${schema}.orders ADD COLUMN discount NUMERIC(5,2) DEFAULT 0;" mydb
done
Script đơn giản này ẩn chứa nhiều vấn đề. Nếu ALTER fail ở schema thứ 247 thì sao? 246 schema đã được migrate, 254 schema chưa, hệ thống ở trạng thái không nhất quán. Application code mới expect cột discount tồn tại, nhưng một số schema chưa có. Cần migration tooling xử lý partial failure: ghi lại schema nào đã chạy, retry schema thất bại, và rollback nếu cần.
Thời gian migration cũng tăng tuyến tính. ALTER TABLE trên một schema mất 2 giây, 500 schema mất ~17 phút, nếu chạy tuần tự. Parallelise được nhưng cần cẩn thận không gây quá tải database. Migration zero-downtime (online DDL) vốn đã phức tạp trong single-schema, nhân lên cho hàng trăm schema thì complexity tăng đáng kể.
Một số team giải quyết bằng migration queue: mỗi khi deploy version mới, background worker duyệt từng schema, chạy pending migration, ghi kết quả vào bảng tracking. Application code phải handle cả schema cũ lẫn mới trong giai đoạn chuyển tiếp, thêm nhánh code tương tự feature flag cho schema version.
Connection pooling, con số bất ngờ
PostgreSQL search_path là session-level setting. Khi dùng connection pool (PgBouncer, pgpool), mỗi connection trả về pool có thể mang search_path của tenant trước đó. Nếu không reset search_path khi lấy connection từ pool, tenant A có thể vô tình query trên schema tenant B.
Giải pháp chuẩn là set search_path mỗi lần lấy connection và reset khi trả về. PgBouncer ở transaction mode hỗ trợ server_reset_query để chạy DISCARD ALL khi connection trả về pool. Nhưng DISCARD ALL có overhead, nó reset mọi session state, kể cả prepared statement. Cân nhắc SET search_path ở đầu mỗi transaction thay vì dùng session-level.
Với số tenant lớn (hàng nghìn), nếu mỗi tenant cần ít nhất một connection sẵn sàng trong pool, tổng connection cần thiết vượt quá max_connections của PostgreSQL. Đây là giới hạn thực tế mà nhiều team phát hiện muộn. PostgreSQL mặc định max_connections=100, tăng lên được nhưng mỗi connection tốn ~5-10 MB RAM. 1000 connection = 5-10 GB RAM chỉ cho connection overhead.
Khi nào chọn schema-per-tenant
Schema-per-tenant phù hợp nhất khi số tenant ở mức vài chục đến vài trăm, mỗi tenant có data đáng kể (không quá nhỏ để shared schema quá dư thừa overhead, không quá lớn để cần instance riêng), và có yêu cầu backup/restore per tenant. Nhiều sản phẩm SaaS B2B ở giai đoạn Series A/B, đã có product-market fit, khách hàng bắt đầu yêu cầu data isolation nhưng chưa cần compliance mức enterprise, sẽ thấy schema-per-tenant là sweet spot.
DB-per-tenant, isolation mạnh nhất
Mỗi tenant có database instance riêng, có thể là RDS instance riêng, Cloud SQL instance riêng, hoặc database riêng trong cùng cluster (tuỳ mức isolation cần thiết). Application cần biết tenant nào kết nối database nào, thường qua tenant routing layer.
Tenant A request → App → Tenant Router → DB Instance A
Tenant B request → App → Tenant Router → DB Instance B
Cô lập hoàn toàn
Không có cách nào tenant A truy cập data tenant B, hai database instance khác nhau, credentials khác nhau, network có thể khác nhau. Đây là isolation mạnh nhất, và là yêu cầu bắt buộc cho một số ngành có compliance nghiêm ngặt: banking, healthcare (HIPAA), government.
Backup, restore, scale, tất cả per tenant. Tenant enterprise chạy chậm? Tăng instance size cho riêng họ. Tenant muốn restore data về thứ Hai tuần trước? Restore database riêng mà không ảnh hưởng ai. Tenant yêu cầu data phải ở EU (data residency)? Deploy instance ở eu-west-1. Mỗi yêu cầu đều có giải pháp straightforward vì không chia sẻ gì với tenant khác.
Noisy neighbor về cơ bản biến mất, mỗi tenant có resource riêng, query nặng chỉ ảnh hưởng chính họ.
Chi phí vận hành, trade-off thực sự
Đổi lại isolation hoàn toàn là chi phí vận hành tăng phi tuyến. 500 database instance nghĩa là 500 thứ cần monitor (disk space, connection count, replication lag), 500 thứ cần backup (cron job, retention policy), 500 thứ cần patch (security update, version upgrade), và 500 thứ có thể fail lúc 3 giờ sáng.
Managed database (RDS, Cloud SQL, Azure SQL) giảm bớt gánh nặng vận hành nhưng tăng chi phí tiền bạc. RDS instance nhỏ nhất (db.t3.micro) khoảng $15/tháng. 500 tenant = $7,500/tháng chỉ cho database, chưa tính storage, backup, network. So với shared schema dùng một instance db.r6g.xlarge khoảng $400/tháng cho 500 tenant, chênh lệch gần 20 lần.
Migration vẫn phải chạy trên mọi instance nhưng khó hơn schema-per-tenant vì phải kết nối từng database qua network. Pipeline migration cần retry, timeout, rollback per instance, và dashboard tracking trạng thái migration từng tenant. Đây thường là bài toán mà team phải xây tooling riêng, không có off-the-shelf migration tool nào design cho hàng trăm database instance.
Tenant routing
Application cần bảng mapping tenant_id → connection_string. Routing layer đọc tenant từ request (subdomain, JWT claim, header), lookup connection string, và mở connection đến đúng database.
# Pseudocode tenant routing
def get_connection(tenant_id: str):
config = tenant_registry.get(tenant_id)
if not config:
raise TenantNotFound(tenant_id)
return connection_pool.get_or_create(
host=config.db_host,
port=config.db_port,
database=config.db_name,
credentials=config.credentials
)
Tenant registry có thể là bảng trong database quản trị riêng, config file, hoặc service discovery. Cache registry cẩn thận, khi tenant mới được provision hoặc tenant migrate sang host khác, cache phải invalidate. TTL ngắn (vài phút) cho registry cache là hợp lý.
Connection pool per tenant cũng cần quản lý. Nếu mỗi tenant cần pool 5 connection, 500 tenant cần 2500 connection từ application. Dùng lazy pool, chỉ tạo connection khi tenant có request, thu hồi sau thời gian idle. Connection pool manager cần eviction policy cho tenant ít dùng.
Khi nào chọn DB-per-tenant
DB-per-tenant phù hợp khi có yêu cầu compliance bắt buộc (data residency, audit isolation, ngành regulated), số tenant ít đến vừa nhưng mỗi tenant có giá trị cao (enterprise SaaS, contract lớn cover được chi phí instance riêng), hoặc tenant có workload cực kỳ khác nhau mà shared resource gây noisy neighbor không chấp nhận được.
Nếu sản phẩm có 5000 tenant SMB mỗi tenant trả $29/tháng, DB-per-tenant là cách chắc chắn để lỗ. Nhưng nếu có 50 tenant enterprise mỗi tenant trả $10,000/tháng, chi phí $15/tenant/tháng cho database riêng là không đáng kể và isolation mang lại giá trị sales rõ ràng.
Hybrid, kết hợp thực dụng
Thực tế, nhiều SaaS thành công không chọn duy nhất một mô hình mà kết hợp: shared schema cho free tier và SMB (chi phí thấp, số lượng lớn), schema-per-tenant hoặc DB-per-tenant cho enterprise (isolation cao, chi phí cover bởi contract lớn).
tenant_id filter)] ROUTER -->|Enterprise| DEDICATED[(Dedicated DB
per tenant)] ROUTER -->|Growth| SCHEMA[(Schema-per-tenant
cùng instance)]
Hybrid approach thêm complexity ở routing layer nhưng cho phép tối ưu chi phí theo đúng giá trị mỗi segment khách hàng. Tenant nào upgrade từ SMB lên enterprise thì migrate data từ shared sang dedicated, bài toán data migration sẽ nói ở phần sau.
Một variant phổ biến khác là pool-based: thay vì mỗi tenant một instance, nhóm tenant vào “pool”. Mỗi pool là một database instance chứa vài chục tenant (shared schema hoặc schema-per-tenant). Khi pool quá tải, tạo pool mới và route tenant mới vào đó. Khi một tenant trong pool quá lớn, tách ra instance riêng. Đây là mô hình mà nhiều SaaS lớn (Salesforce, Slack thời đầu) sử dụng, cân bằng giữa chi phí và khả năng scale.
Noisy neighbor, phát hiện và xử lý
Noisy neighbor không chỉ là vấn đề của shared schema, ngay cả schema-per-tenant trên cùng instance cũng bị, vì chia sẻ CPU, IO, buffer pool. Chỉ DB-per-tenant mới thoát hẳn.
Phát hiện
Mọi metric hệ thống cần dimension tenant_id, không chỉ error rate hay latency, mà cả database query time, cache hit rate, queue depth. Khi latency P99 tăng, cần drill down theo tenant để biết tenant nào gây ra.
-- Postgres: query nặng nhất đang chạy, với tenant context
SELECT pid, usename, query, now() - query_start AS duration,
current_setting('app.current_tenant', true) AS tenant
FROM pg_stat_activity
WHERE state = 'active' AND query_start < now() - interval '5 seconds'
ORDER BY duration DESC;
pg_stat_statements cho aggregate: query nào tốn nhiều total time nhất, gọi bao nhiêu lần. Kết hợp với tenant context trong application log, có thể xác định tenant nào đang chạy query nào với tần suất bất thường.
Dashboard noisy neighbor nên hiển thị top 5 tenant theo resource consumption (query time, row scanned, connection count) trong 15 phút gần nhất. Khi một tenant chiếm > 30% resource trên shared instance, đó là tín hiệu cần hành động.
Xử lý
Tầng đầu tiên là rate limit per tenant ở application layer. Token bucket hoặc sliding window per tenant, giới hạn số request/giây. Tenant vượt quota nhận 429 Too Many Requests với Retry-After header. Rate limit cần áp dụng trước khi request đến database, không có ích gì rate limit sau khi query đã chạy.
Tầng thứ hai là query timeout per tenant. Set statement_timeout theo tenant tier khi mở connection. Free tier 5 giây, paid tier 30 giây, enterprise 120 giây. Query vượt timeout bị cancel, tenant biết query của mình bị giới hạn, cần tối ưu hoặc upgrade.
Tầng thứ ba là resource quota, khó hơn nhưng cần thiết cho hệ thống lớn. Citus (PostgreSQL extension) hỗ trợ tenant isolation trên distributed table. Nếu không dùng extension, có thể implement soft quota: monitor resource consumption per tenant, khi vượt ngưỡng thì throttle hoặc queue request của tenant đó thay vì reject.
Khi mitigate không đủ, tenant quá lớn hoặc workload quá khác biệt, giải pháp cuối cùng là tenant migration: chuyển tenant ra instance riêng. Đây là quyết định kiến trúc cần plan trước, không phải chữa cháy lúc 2 giờ sáng.
Tenant-aware caching
Cache là lớp quan trọng trong mọi hệ thống, và multi-tenant thêm một chiều complexity: cache key phải bao gồm tenant_id, nếu không tenant A có thể nhận data cached của tenant B.
Cache key namespace
Mọi cache key phải có prefix hoặc namespace chứa tenant identifier:
cache:tenant_acme:user:42:profile
cache:tenant_globex:user:42:profile
User ID 42 trong tenant Acme và user ID 42 trong tenant Globex là hai user hoàn toàn khác nhau. Nếu cache key chỉ là user:42:profile, tenant đăng nhập sau sẽ nhận profile của tenant đăng nhập trước, đây là cross-tenant data leak qua cache, phổ biến hơn nhiều người nghĩ.
Framework cache wrapper nên enforce tenant namespace, không cho phép tạo cache key không có tenant prefix. Tương tự tenant context middleware cho database, cache layer cần tenant-aware wrapper mà developer không thể bypass.
Eviction per tenant
Khi tenant update data, cần invalidate cache của đúng tenant đó mà không ảnh hưởng cache tenant khác. Với namespace rõ ràng, có thể dùng wildcard delete: DEL cache:tenant_acme:* khi cần flush toàn bộ cache của một tenant (ví dụ khi migrate data hoặc tenant thay đổi plan).
Redis hỗ trợ SCAN với pattern matching, dùng SCAN 0 MATCH cache:tenant_acme:* để tìm và xoá key theo tenant. Nhưng với số key lớn, SCAN tốn thời gian. Cách tốt hơn là dùng cache version per tenant: thay vì delete key, tăng version number, cache:tenant_acme:v3:user:42:profile. Key cũ (v2) tự expire theo TTL, key mới (v3) được tạo khi cache miss. Không cần scan và delete, đơn giản và nhanh.
Cache size per tenant
Tenant lớn có thể chiếm hết cache nếu không giới hạn. Khi Redis memory đầy, eviction policy (allkeys-lru) có thể evict key của tenant nhỏ để nhường chỗ cho tenant lớn, tenant nhỏ bị cache miss liên tục, performance tệ dù data ít.
Giải pháp đơn giản nhất: Redis instance riêng cho tenant lớn (nếu dùng hybrid model). Phức tạp hơn: implement application-level quota, track số key và total size per tenant, từ chối cache write khi tenant vượt quota. Tenant vượt quota vẫn hoạt động nhưng mọi request đều cache miss, chậm hơn, incentive tự nhiên để upgrade plan.
Data migration giữa các mô hình
Chuyển tenant từ shared schema sang dedicated database (hoặc ngược lại) là bài toán không tránh được trong hybrid model. Tenant SMB upgrade lên enterprise cần migrate ra instance riêng. Tenant enterprise downgrade (hoặc churn) cần consolidate về shared để tiết kiệm.
Migrate từ shared sang dedicated
Quy trình cơ bản: tạo database mới, export data của tenant từ shared (với tenant_id filter), transform (bỏ cột tenant_id vì dedicated không cần), import vào database mới, cập nhật tenant routing, verify data, xoá data cũ khỏi shared.
Mỗi bước đều có vấn đề cần xử lý. Export data của tenant đang active nghĩa là data có thể thay đổi giữa lúc export và lúc cutover. Hai cách tiếp cận: offline migration (đặt tenant vào maintenance mode, downtime vài phút đến vài giờ tuỳ data size) hoặc online migration (export snapshot, replay changes từ WAL/CDC, cutover khi lag gần zero, phức tạp hơn nhiều nhưng downtime gần zero).
Verify data là bước hay bị bỏ qua vì “export import có gì sai được”. Có thể sai: encoding issue, constraint conflict, sequence không sync, trigger fire trong quá trình import tạo data thừa. Checksum row count từng bảng, spot check data quan trọng (orders, payments), và chạy integration test trên database mới trước khi cutover.
Migrate từ schema-per-tenant sang DB-per-tenant
Dễ hơn shared sang dedicated vì data đã tách biệt theo schema. pg_dump --schema=tenant_acme rồi pg_restore vào database mới. Schema name có thể đổi thành public trong quá trình restore vì dedicated database không cần schema prefix.
Automation là bắt buộc
Migration thủ công qua pgAdmin hoặc script chạy tay có thể chấp nhận cho 5 tenant. Khi có 50 tenant cần migrate trong một quý, cần pipeline tự động: trigger migration từ admin dashboard, pipeline xử lý export → transform → import → verify → cutover → cleanup, alert khi bất kỳ bước nào fail, và rollback procedure rõ ràng.
Mình từng thấy team không có automation cho tenant migration, mỗi lần enterprise customer ký hợp đồng, engineer phải dành 2 ngày chạy migration thủ công, verify bằng tay, rồi prayer-driven cutover lúc nửa đêm. Sau lần thứ 5 thì team quyết định đầu tư 3 sprint xây migration pipeline tự động, ROI rõ ràng khi số tenant cần migrate tăng.
Compliance và data residency
Multi-tenancy trong SaaS thường kéo theo yêu cầu compliance, đặc biệt khi bán cho enterprise hoặc khách hàng ở EU, healthcare, finance.
Data residency
GDPR yêu cầu data của EU citizen phải được xử lý theo quy định, và nhiều enterprise đòi data phải nằm ở region cụ thể. Shared schema trên một database ở US East không đáp ứng được yêu cầu “data must stay in EU”, vì data EU tenant nằm chung bảng với data US tenant trên instance US.
Schema-per-tenant cho phép logical separation nhưng physical data vẫn ở cùng instance cùng region. DB-per-tenant cho phép deploy instance ở bất kỳ region nào, giải pháp rõ ràng nhất cho data residency.
Hybrid approach xử lý data residency bằng cách route tenant EU sang instance ở EU region, tenant US sang instance US. Tenant routing layer cần hiểu region requirement, thêm field data_region vào tenant registry.
Per-tenant data deletion (GDPR right to be forgotten)
Khi tenant xoá tài khoản hoặc user yêu cầu xoá data theo GDPR Article 17, cần xoá mọi data liên quan đến tenant đó, database, cache, log, backup, analytics.
Shared schema: phải DELETE WHERE tenant_id = ? trên mọi bảng, clear cache namespace, purge log (nếu log chứa PII). Đây là operation phức tạp và dễ sót bảng, cần inventory đầy đủ mọi bảng chứa tenant data.
Schema-per-tenant: DROP SCHEMA tenant_acme CASCADE, đơn giản, sạch sẽ, không sót.
DB-per-tenant: DROP DATABASE hoặc xoá instance, đơn giản nhất, clean nhất.
Backup cũng cần xử lý, backup chứa data tenant đã xoá cần được xoá hoặc retain theo policy riêng. Đây là nơi DB-per-tenant có lợi thế rõ ràng: xoá backup riêng của tenant mà không ảnh hưởng backup tenant khác. Shared schema thì backup chứa data mọi tenant, không thể xoá data một tenant khỏi backup mà không restore-modify-rebackup.
Testing multi-tenant
Single-tenant testing, tạo một user, thực hiện action, verify kết quả, không đủ cho multi-tenant. Cần test verify rằng tenant A không bao giờ thấy data tenant B, và ngược lại.
Integration test cross-tenant isolation
Mỗi endpoint API cần test case: tạo data cho tenant A, request với context tenant B, verify response không chứa data tenant A. Nghe hiển nhiên nhưng nếu không có test tự động cho pattern này, regression sẽ xảy ra, developer thêm endpoint mới, quên tenant filter, code review không catch, data leak lên production.
def test_orders_tenant_isolation():
# Setup: create order for tenant A
order_a = create_order(tenant_id="tenant_a", total=100)
# Act: query orders as tenant B
orders = get_orders(tenant_id="tenant_b")
# Assert: tenant B sees no orders
assert len(orders) == 0
# Act: query orders as tenant A
orders = get_orders(tenant_id="tenant_a")
# Assert: tenant A sees their order
assert len(orders) == 1
assert orders[0].id == order_a.id
Test này cần chạy cho mọi endpoint trả data tenant-scoped. Mỗi model/resource mới cần test tương tự. Tốt nhất là tạo test helper hoặc decorator tự động generate cross-tenant isolation test cho mọi endpoint.
Load test per tenant
Noisy neighbor chỉ lộ khi có load. Load test nên simulate nhiều tenant đồng thời, với một tenant chạy workload nặng trong khi tenant khác chạy bình thường. Verify rằng latency tenant bình thường không bị ảnh hưởng quá ngưỡng chấp nhận được.
Migration test
Nếu hệ thống hỗ trợ migrate tenant giữa các mô hình, test migration end-to-end: tạo tenant trên shared, insert data đa dạng (orders, users, relations), migrate sang dedicated, verify data integrity, verify application hoạt động đúng trên dedicated.
Anti-pattern thường gặp
Những sai lầm phổ biến nhất khi xây hệ thống multi-tenant, phần lớn từ việc developer nghĩ đơn giản hoá hoặc quên rằng hệ thống có nhiều hơn một tenant.
Quên tenant_id trong query. Đây là nguyên nhân số một gây cross-tenant data leak trong shared schema. Có thể phòng bằng RLS, ORM default scope, middleware inject, và integration test. Nhiều lớp bảo vệ vì hậu quả quá lớn, data leak giữa tenant có thể kết thúc hợp đồng và gây kiện tụng.
Cache không có tenant namespace. Cache key user:42:profile thay vì tenant_acme:user:42:profile. Tenant B đọc cache, nhận profile của user 42 thuộc tenant A. Cross-tenant leak qua cache khó debug hơn qua database vì cache thường không có audit log.
Không monitor per tenant. Dashboard chỉ hiện aggregate metric, latency P99 cả hệ thống 200ms, trông tốt. Nhưng tenant X latency P99 2 giây vì bị noisy neighbor, không ai biết cho đến khi tenant X complain. Mọi metric quan trọng cần dimension tenant_id, hoặc ít nhất top-N tenant breakdown.
Shared background job queue không phân biệt tenant. Queue có 10,000 job, 9,500 từ một tenant chạy report nặng. 500 job từ 499 tenant khác phải chờ. Job queue cần fair scheduling per tenant, round-robin hoặc weighted fair queue, không phải FIFO đơn thuần.
Hardcode single-tenant assumption. Global variable, singleton database connection, static config, tất cả đều break trong multi-tenant. Mọi state phải scoped theo request context hoặc tenant context. Audit codebase cho pattern getConnection() không nhận tenant parameter, đó là bug tiềm ẩn.
Schema migration không idempotent. Migration phải chạy trên nhiều schema/database, có thể fail và retry. Nếu migration không idempotent (chạy lần hai gây lỗi), retry sẽ thất bại và hệ thống ở trạng thái inconsistent. Luôn viết migration idempotent: CREATE TABLE IF NOT EXISTS, ALTER TABLE ADD COLUMN IF NOT EXISTS.
Chọn mô hình theo giai đoạn sản phẩm
Quyết định mô hình multi-tenant không phải quyết định một lần, nó evolve theo giai đoạn sản phẩm.
Giai đoạn early startup (0-50 tenant): shared schema là lựa chọn mặc định. Chi phí thấp nhất, phát triển nhanh nhất, team nhỏ vận hành được. Đầu tư vào tenant context middleware và RLS từ đầu, cost thấp nhưng giá trị bảo vệ lớn. Đừng over-engineer cho scale chưa có.
Giai đoạn growth (50-500 tenant): nếu bắt đầu có enterprise customer yêu cầu isolation, chuyển sang hybrid, shared cho SMB, schema-per-tenant hoặc DB-per-tenant cho enterprise. Đầu tư vào tenant migration tooling vì sẽ cần di chuyển tenant thường xuyên. Noisy neighbor monitoring là bắt buộc.
Giai đoạn scale (500+ tenant): cần platform engineering cho multi-tenant, SDK chuẩn hoá, migration pipeline tự động, provisioning tenant tự động (self-service), monitoring per tenant, và cost allocation per tenant. Ở giai đoạn này, multi-tenancy không còn là feature mà là platform capability.
Một sai lầm phổ biến là chọn DB-per-tenant từ ngày đầu “cho chắc” (lý do hay gặp: đọc blog của Notion, Figma, hoặc Salesforce rồi quên rằng họ có cả team platform engineering riêng cho việc này), rồi nhận ra chi phí vận hành đè chết startup trước khi có đủ revenue. Ngược lại, chọn shared schema mãi mà không plan cho isolation upgrade cũng sai, khi enterprise customer hỏi “data có tách biệt không?” mà câu trả lời là “cùng bảng, filter bằng WHERE” thì deal có thể mất.
Thiết kế application với tenant abstraction layer từ đầu, dù bên dưới là shared schema, để khi cần chuyển mô hình, thay đổi nằm ở infrastructure layer, không phải rewrite application logic.
Lựa chọn mô hình multi-tenant không phải quyết định kỹ thuật đơn thuần, nó quyết định ai là khách hàng bạn có thể bán cho và margin bạn có thể giữ được. Shared schema cho phép onboard nghìn tenant với chi phí vận hành không đổi; DB-per-tenant cho phép ký hợp đồng enterprise với yêu cầu compliance. Thiết kế tenant abstraction layer đúng từ đầu để khi cần chuyển mô hình, thay đổi nằm ở infrastructure chứ không phải rewrite toàn bộ application.