Skip to content
··3 phút đọc

Tôi xây Multi-Tenancy ngày 2. Ngày 67, tôi xây lại

Tôi nghĩ thêm org_id vào mọi bảng nghĩa là multi-tenancy chắc chắn. Rồi audit bảo mật phát hiện agency đang ghi vào bảng SME — không qua bug, mà theo thiết kế.

Ngày 27 tháng 10, 2025, 11:47 PM. Tôi đang chạy audit bảo mật tưởng như thường lệ trên STRAŦUM. Mọi thứ hoạt động tốt nhiều tuần — SME có dữ liệu riêng, agency có riêng, multi-tenancy vững chắc.

Cà phê nguội rồi. Script audit đang xử lý log. Rồi tôi thấy: Agency đang ghi vào bảng SME.

Không qua bug. Không qua lỗ hổng bảo mật. Qua chính kiến trúc.

Tôi ngồi khoảng 20 phút chỉ nhìn schema. Sao tôi bỏ lỡ? Tôi dành nhiều tuần xây kiến trúc multi-tenant, viết 83 RLS policies, test cả tài khoản SME và Agency. Mọi thứ hoạt động. Nhưng "hoạt động" và "đúng" không giống nhau.

Đây là loại bug khiến bạn tự hỏi có nên xây phần mềm không. Vì nó không phải typo. Không phải edge case bỏ lỡ. Đó là sự ngây thơ về kiến trúc.

Vấn đề: Không phải mọi tenant đều giống nhau

Ban đầu tôi dùng bảng chung với org_id filtering và entity_id nullable cho agency. Điều này tạo ra bảng với hai data model khác nhau, query lộn xộn với xử lý NULL phức tạp, và mọi tính năng cần logic "if SME, else Agency".

Tệ hơn, agency ghi entity_id=NULL sẽ ô nhiễm dữ liệu SME. Đây không phải kiến trúc multi-tenant. Đây là một bảng cố phục vụ hai data model khác nhau.

Phát hiện: Tenant khác nhau cần schema khác nhau

SME data model: organization → campaigns → agent_outputs

Agency data model: organization → workspace_entities → campaigns → agent_outputs

Agency có cả lớp (workspace entities) mà SME không có.

Giải pháp: Database schema riêng biệt.

-- Bảng SME (public schema)
public.brand_guidelines
public.campaigns

-- Bảng Agency (tenant_b schema)
tenant_b.workspace_entities
tenant_b.entity_data
tenant_b.campaigns

Giờ SME và Agency có bảng hoàn toàn khác. Không schema chung. Không entity_id nullable. Không logic "if SME, else Agency".

Kiến trúc: Schema Routing

Pattern 1: Router Functions cho ghi

CREATE FUNCTION save_resource_routed(params)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
  SELECT type INTO org_type FROM organizations WHERE id = p_org_id;

  IF org_type = 'TENANT_B' THEN
    INSERT INTO tenant_b.entity_data (...) VALUES (...);
  ELSE
    INSERT INTO public.brand_guidelines (...) VALUES (...);
  END IF;
  RETURN result;
END;
$$;

Không có if/else trong code ứng dụng. Database thực hiện routing.

Pattern 2: Security-Invoker Views cho đọc

CREATE VIEW resources_unified
WITH (security_invoker = on)  -- Tôn trọng RLS policies
AS
  SELECT id, organization_id, NULL AS entity_id, data, 'public' AS source
  FROM public.brand_guidelines
UNION ALL
  SELECT id, organization_id, entity_id, content AS data, 'tenant_b' AS source
  FROM tenant_b.entity_data;

Migration: 33 Migration trong 48 giờ

Bạn biết gì vui không? Viết 33 database migration liên tiếp khi biết nếu DÙ MỘT có typo, bạn sẽ corrupt dữ liệu production. Thực ra "vui" không phải từ đúng. "Kinh hoàng" chính xác hơn. Tôi chạy mỗi migration trên staging ba lần trước khi chạm production.

Tổng nỗ lực: 33 migration, 2 ngày với Claude Code, 100% đáng giá.

Kết quả: Multi-Tenant Isolation thực sự

Trước: Cột nullable entity_id, query phức tạp, logic if/else trong app, rủi ro cross-contamination

Sau: Data model sạch, query đơn giản, database xử lý routing, không thể cross-schema contamination (tách biệt vật lý)

Bài học

  1. org_id cần thiết, không đủ — nếu tenant type khác nhau cần data model khác nhau, cần schema routing
  2. Logic ứng dụng → Logic database — mọi if (tenantType === 'TYPE_B') trong app là code smell
  3. Views + RLS = đọc thống nhất — với security_invoker = on
  4. Security Definer Functions mạnh mẽ — chạy quyền nâng cao trong khi tôn trọng RLS
  5. Sửa sớm, sửa đúng — lỗi kiến trúc đắt nhưng sửa được. 15 alpha user sửa rẻ hơn 100 khách hàng trả tiền.

Bạn đã bao giờ phát hiện lỗi kiến trúc không phải bug mà là sai thiết kế? Bạn xử lý việc xây lại thế nào — sửa dần hay gỡ toàn bộ như tôi?

Thân mến, Chandler

Series kiến trúc STRAŦUM: Đây là phần 2 của hành trình multi-tenancy. Bắt đầu với xây multi-tenancy ngày 2. Sau schema rebuild, tôi phát hiện 31 màn hình trắng và database đúng nhưng chậm gấp 296 lần.

Đọc tiếp

Hành trình
Kết nối
Ngôn ngữ
Tùy chọn