Cô lập tenant, chung DB/schema, chống rò rỉ dữ liệuTenant isolation, shared DB/schema, preventing leaks
Gateway xác định tenant từ subdomain, đối chiếu với claim tid trong JWT, tra Tenant Registry để biết gói và nơi chứa dữ liệu, rồi áp rate limit theo tenant. Order Service đặt tenant vào context; Hibernate thêm điều kiện tenant_id, PostgreSQL RLS chặn lớp thứ hai. Chọn “Quên filter” để xem RLS cứu một truy vấn lỗi, chọn “Noisy neighbour” để xem giới hạn theo tenant.The gateway resolves the tenant from the subdomain, checks it against the tid claim in the JWT, asks the Tenant Registry for the plan and data location, then applies a per-tenant rate limit. The Order Service puts the tenant in its context; Hibernate adds the tenant_id condition and PostgreSQL RLS blocks as a second layer. Pick “Filter forgotten” to see RLS save a buggy query, pick “Noisy neighbour” to see the per-tenant limit.
1Người dùng của acme mở danh sách đơn hàng.A user of acme opens the order list.
Không dựa vào việc dev nhớ thêm WHERE. Lớp 1: repository decorator hoặc @TenantId của Hibernate 6 tự thêm điều kiện. Lớp 2: PostgreSQL RLS với ENABLE và FORCE ROW LEVEL SECURITY, app dùng role không có BYPASSRLS.Do not rely on developers remembering the WHERE. Layer 1: a repository decorator or Hibernate 6's @TenantId adds the condition. Layer 2: PostgreSQL RLS with ENABLE and FORCE ROW LEVEL SECURITY, the app using a role without BYPASSRLS.
Xác định một lần ở cửa vào (subdomain đối chiếu claim tid đã ký), lưu vào TenantContext. Lưu ý ThreadLocal không tự sang @Async, CompletableFuture hay virtual thread mới; phải truyền tường minh hoặc dùng TaskDecorator. Trong Go, tenant đi trong context.Context.Resolve it once at the edge (subdomain checked against the signed tid claim) and store it in a TenantContext. ThreadLocal does not follow @Async, CompletableFuture or new virtual threads on its own; pass it explicitly or use a TaskDecorator. In Go the tenant travels in context.Context.
Key cache phải có tenant (t:{tid}:order:{id}); message Kafka mang header tenant_id và consumer đặt lại context trước khi xử lý; OpenSearch dùng routing theo tenant và filter alias, không bao giờ tìm trên toàn index.Cache keys carry the tenant (t:{tid}:order:{id}); Kafka messages carry a tenant_id header and consumers restore the context before handling them; OpenSearch uses tenant routing and filtered aliases, never a search over the whole index.
Rate limit theo tenant và theo gói ở gateway (token bucket trên Redis), bulkhead connection theo tenant trong service, hàng đợi job nền chia theo tenant với fair scheduling. Tenant lớn liên tục thì chuyển sang DB riêng.Rate limits per tenant and plan at the gateway (a token bucket on Redis), a per-tenant connection bulkhead in the service, background job queues split by tenant with fair scheduling. A tenant that stays heavy moves to its own database.
Shared schema: một migration cho tất cả, phải tương thích ngược (expand → migrate → contract). Schema hoặc DB riêng: chạy Flyway/Liquibase lần lượt theo danh sách trong registry, ghi phiên bản từng tenant, chạy song song có giới hạn và tiếp tục được khi lỗi.Shared schema: one migration for everyone, backward compatible (expand → migrate → contract). Schema or database per tenant: run Flyway/Liquibase tenant by tenant from the registry, record each tenant's version, run with bounded parallelism and resume after failures.
Đa số tenant ở shared schema cho rẻ; enterprise cần cô lập hợp đồng thì DB riêng. Một DataSource router chọn đích theo registry, code nghiệp vụ không biết tenant đang ở mô hình nào.Most tenants live in the shared schema because it is cheap; enterprise contracts that require isolation get a dedicated database. A DataSource router picks the target from the registry, so business code never knows which model a tenant uses.
| BảngTable | Cột chínhKey columns | Ghi chúNotes |
|---|---|---|
tenant | id, slug, status, plan, db_target, schema_version, rate_limit_rps, created_at | Tenant Registry; status ACTIVE · SUSPENDED · DELETING; db_target = shared-01 hoặc dsn DB riêngThe Tenant Registry; status ACTIVE · SUSPENDED · DELETING; db_target = shared-01 or a dedicated DSN |
orders | id, tenant_id, customer_id, status, total, created_at | tenant_id NOT NULL; index (tenant_id, created_at); RLS tenant_isolationtenant_id NOT NULL; index (tenant_id, created_at); RLS tenant_isolation |
unique keys | unique(tenant_id, order_no) · unique(tenant_id, email) | Ràng buộc unique luôn có tenant_id, nếu không tenant này chặn tenant kia dùng cùng emailUnique constraints always include tenant_id, otherwise one tenant blocks another from using the same email |
Redis keys | rl:{tid}:{epoch_sec} · t:{tid}:order:{id} · t:{tid}:orders:{query}:p{n} | Mọi key bắt đầu bằng tenant; xoá tenant thì SCAN theo tiền tốEvery key starts with the tenant; deleting a tenant scans by prefix |
Tenant được xác định một lần, đặt vào context, rồi đi theo connection tới PostgreSQL để RLS làm lớp chặn cuối. Hibernate @TenantId là lớp đầu.The tenant is resolved once, stored in the context, then carried on the connection to PostgreSQL so RLS is the last line of defence. Hibernate's @TenantId is the first.
@Component
@RequiredArgsConstructor
public class TenantResolverFilter extends OncePerRequestFilter {
private final TenantRegistry registry;
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
String fromHost = req.getServerName().split("\\.")[0]; // acme.app.com
String fromToken = SecurityContextHolder.getContext().getAuthentication()
instanceof JwtAuthenticationToken jwt ? jwt.getToken().getClaimAsString("tid") : null;
if (fromToken == null || !fromToken.equals(fromHost) || !registry.isActive(fromToken)) {
res.sendError(HttpServletResponse.SC_FORBIDDEN);
return;
}
try (var scope = TenantContext.open(fromToken)) { // ThreadLocal
chain.doFilter(req, res);
}
}
}
// Hibernate 6: entities mark the column with @TenantId, Hibernate adds the WHERE
@Component
class TenantIdResolver implements CurrentTenantIdentifierResolver<String> {
@Override public String resolveCurrentTenantIdentifier() {
return TenantContext.require(); // throws if missing
}
@Override public boolean validateExistingCurrentSessions() { return true; }
}
// Every connection taken from the pool carries the tenant for RLS
class TenantAwareDataSource extends DelegatingDataSource {
TenantAwareDataSource(DataSource target) { super(target); }
@Override
public Connection getConnection() throws SQLException {
Connection c = super.getConnection();
try (var st = c.prepareStatement("select set_config('app.tenant_id', ?, false)")) {
st.setString(1, TenantContext.current().orElse("")); // empty sees nothing
st.execute();
}
return c;
}
}type tenantKey struct{}
func TenantFrom(ctx context.Context) (string, bool) {
t, ok := ctx.Value(tenantKey{}).(string)
return t, ok && t != ""
}
// ResolveTenant runs after the JWT middleware.
func ResolveTenant(reg TenantRegistry) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host, _, _ := strings.Cut(r.Host, ".")
claims, ok := ClaimsFrom(r.Context())
if !ok || claims.TenantID != host || !reg.Active(r.Context(), host) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
ctx := context.WithValue(r.Context(), tenantKey{}, host)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// InTenantTx runs fn in a transaction where RLS sees only the caller's tenant.
func InTenantTx(ctx context.Context, pool *pgxpool.Pool, fn func(pgx.Tx) error) error {
tid, ok := TenantFrom(ctx)
if !ok {
return ErrNoTenant
}
return pgx.BeginFunc(ctx, pool, func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, "SELECT set_config('app.tenant_id', $1, true)", tid)
if err != nil {
return err
}
return fn(tx)
})
}
// CacheKey refuses to build a key without a tenant.
func CacheKey(ctx context.Context, parts ...string) string {
tid, ok := TenantFrom(ctx)
if !ok {
panic("cache key without tenant")
}
return "t:" + tid + ":" + strings.Join(parts, ":")
}| Shared schema, cột tenant_idShared schema, tenant_id column | Database riêng mỗi tenantDatabase per tenant | |
|---|---|---|
| Mức cô lậpIsolation | Logic: dựa vào filter và RLS, một lỗi cấu hình có thể lộ dữ liệuLogical: relies on filters and RLS; one misconfiguration can leak data | Vật lý: khác connection string, khó lộ chéoPhysical: different connection strings, hard to leak across |
| Chi phí, vận hànhCost and operations | Rẻ, một cụm cho hàng nghìn tenant, một migrationCheap, one cluster for thousands of tenants, one migration | Tốn tiền, nhiều pool connection, migration và backup theo từng tenantExpensive, many connection pools, migrations and backups per tenant |
| Noisy neighbourNoisy neighbours | Dùng chung CPU, IO; cần rate limit và bulkheadShared CPU and IO; needs rate limits and bulkheads | Tự nhiên tách biệt, scale và restore riêngNaturally separate, scaled and restored on its own |
| Chọn khiPick it when | Nhiều tenant nhỏ, gói self-serviceMany small tenants, self-service plans | Enterprise, yêu cầu tuân thủ hoặc lưu trữ theo vùngEnterprise, compliance or data residency requirements |
Mỗi pattern mở bài học tương ứng trong menu Pattern.Each one opens its lesson in the Patterns menu.
Shared schema rẻ và dễ migration nhưng cô lập chỉ ở mức logic. Schema per tenant cô lập tốt hơn, nhưng vài nghìn schema làm catalog PostgreSQL phình to và migration chậm. Database per tenant cô lập vật lý, tốn chi phí và vận hành. Thực tế hay dùng mô hình lai: đa số shared, enterprise tách DB, chọn qua registry.Shared schema is cheap and easy to migrate but isolation is only logical. Schema per tenant isolates better, but thousands of schemas bloat the PostgreSQL catalog and slow migrations. Database per tenant isolates physically at a real cost in money and operations. In practice a hybrid is common: most tenants shared, enterprises on their own database, chosen through the registry.
ORM chỉ bảo vệ truy vấn đi qua nó. Native query, script báo cáo, job batch hay dev mới đều có thể bỏ qua. RLS nằm trong DB nên là lớp chặn cuối, với điều kiện app không chạy bằng owner hay role có BYPASSRLS và có FORCE ROW LEVEL SECURITY.The ORM protects only queries that go through it. Native queries, report scripts, batch jobs or a new developer can all skip it. RLS lives in the database, so it is the last line of defence, provided the app does not run as the owner or a BYPASSRLS role and FORCE ROW LEVEL SECURITY is on.
Biến session như app.tenant_id còn lại trên connection sau khi trả về pool, request sau có thể dùng nhầm tenant cũ. Dùng SET LOCAL trong transaction, hoặc ghi đè mỗi lần mượn connection và đặt rỗng khi không có tenant. PgBouncer chế độ transaction pooling thì chỉ SET LOCAL là an toàn.A session variable such as app.tenant_id stays on the connection after it returns to the pool, so the next request may inherit the old tenant. Use SET LOCAL inside the transaction, or overwrite it on every checkout and set it empty when there is no tenant. With PgBouncer in transaction pooling mode, only SET LOCAL is safe.
Job lặp qua danh sách tenant trong registry, mở TenantContext cho từng tenant và xử lý tách biệt; lỗi của một tenant không làm dừng tenant khác. Message trong Kafka mang header tenant_id để consumer đặt lại context.The job iterates over the tenants in the registry, opens a TenantContext for each and processes them separately; one tenant's failure does not stop the others. Kafka messages carry a tenant_id header so consumers restore the context.
Phím 1–4 để chọn đáp án, Enter để kiểm tra.Press 1–4 to choose an answer, Enter to check.
Ôn tập ngắt quãng (spaced repetition): thẻ được lặp lại sau 1 → 3 → 7 → 14 → 30 ngày để củng cố trí nhớ dài hạn.Spaced repetition: cards are repeated after 1 → 3 → 7 → 14 → 30 days to build long-term memory.