ADR-0001. Các mức cô lập multi-tenancy (Pool / Bridge / Silo)
| Trường | Giá trị |
|---|---|
| Trạng thái | Draft (Đề xuất) |
| Ngày | 2026-05-22 |
| Người quyết định | Phat Nguyen, Architecture |
| Phạm vi | Cross-cutting — datasource @nx/core, mọi service, deployment |
| Thay thế | — |
| Bối cảnh sản phẩm | Chiến lược Multi-Tenancy (PRD) |
Bối cảnh
Bài toán
- BANA chạy một database dùng chung (
nx_seller) với một connection pool tĩnh mỗi service (PostgresCoreDataSourcegọinew Pool()một lần). Tenant chỉ được phân biệt bằng lọcmerchantId/organizerIdtrong query repository — tức mô hình Pool. - Ta cần hỗ trợ mức cô lập mạnh hơn (DB riêng, stack riêng) cho một số Org trong khi giữ Pool rẻ cho số đông, và có thể di chuyển Org giữa các mô hình — gồm cả chiều khó: gộp Silo → Pool.
Điều thúc đẩy (Trigger)
- Quy hoạch deployment & vận hành dài hạn. Số tenant và độ đa dạng hợp đồng đang tăng; chốt cứng một mô hình toàn cục bây giờ sẽ rất đắt để gỡ về sau.
Hiện trạng (AS-IS)
| Khía cạnh | Hiện tại |
|---|---|
| Cô lập DB | Một nx_seller chung, một pool tĩnh/service |
| Cột tenant | merchantId (chính), organizerId (cha) trên ~52 bảng |
| Cách thực thi cô lập | Lọc ở tầng app; không RLS, không schema-per-tenant |
| Phân giải tenant | JWT claim (organizers[], merchants[]) → lọc theo request context |
| Routing | Theo token; không theo subdomain/host |
| ID | Snowflake (toàn cục duy nhất) qua IdGenerator |
| Deployment | K8s trên VNPAY Cloud (Kustomize, cluster staging/prod tách biệt); gateway Traefik |
Quyết định
Áp dụng mô hình hybrid, phân mức (tiered) trong đó mức cô lập là thuộc tính theo từng Org, quyết định lúc chạy — không phải hằng số cho cả hệ thống. Đơn vị cô lập là Organizer.
| Mức | Service | Database | Mặc định cho |
|---|---|---|---|
| POOL | Chung | nx_seller chung, lọc organizerId | Mọi Org (mặc định) |
| BRIDGE | Chung | Một DB mỗi Org | Org cần cô lập dữ liệu |
| SILO | Stack riêng | Một DB mỗi Org | Enterprise / on-prem |
Ba cơ chế nền tảng giúp điều này khả thi — tất cả nằm trong @nx/core, không đụng code nghiệp vụ:
- Tenant Registry — bảng
orgId → { isolationTier, datasourceRef }. Mọi Org mặc địnhPOOL. - Connection Resolver — nâng
PostgresCoreDataSourcetừ một pool tĩnh thành bộ phân giải pool theo tenant, đọc registry và cache kết nối. Đây là thay đổi chặn duy nhất; mọi thứ khác xây trên nó. - Bộ cấp phát Snowflake WorkerId — cấp worker/node ID tập trung để các silo chạy độc lập không bao giờ sinh ID trùng (formalize convention
APP_ENV_NODE_IDsẵn có, xem payment ADR-0002).
Tính có hướng của migration
| Chiều | Độ khó | Cơ chế |
|---|---|---|
| POOL → SILO (tách) | Dễ | Copy filtered theo organizerId (logical replication / pg_dump --where), cutover, flip registry |
| SILO → POOL (gộp) | Khó nhưng khả thi | Import giữ nguyên Snowflake ID, verify không orphan FK, flip registry, khóa silo cũ |
Vì sao gộp an toàn ở đây: Snowflake ID toàn cục duy nhất nên import row của silo vào bảng chung không trùng PK — đúng đặc tính khiến merge dựa trên auto-increment gần như bất khả. Voucher sequence theo merchant được scope bởi merchantId nên số chứng từ human-readable cũng không trùng chéo giữa các Org.
Hệ quả
| Lợi | Hại |
|---|---|
| Cô lập thành một "núm xoay" theo Org, không phải viết lại | Connection Resolver thêm phức tạp vào hot path tầng data |
| Org mới onboard tức thì (POOL mặc định) | Cần cache kết nối theo tenant (vòng đời, eviction) |
| Snowflake ID giúp gộp Silo→Pool khả thi | WorkerId phải quản trị tập trung nếu không merge sẽ vỡ |
| Code nghiệp vụ/repository không đổi giữa các mức | Migrate schema phải fan-out qua N database (Bridge/Silo) |
| Khớp topology K8s/Kustomize + Traefik sẵn có | Provision Bridge/Silo cần tự động hóa trước khi scale |
Phương án đã cân nhắc
| Phương án | Lợi | Hại | Vì sao loại |
|---|---|---|---|
| Chỉ Pool | Vận hành đơn giản nhất | Không cô lập được, không lên on-prem | Mất khách enterprise/compliance |
| Chỉ Silo | Cô lập tối đa | Chi phí cao nhất, onboard chậm, merge đắt | Sai cho POS SMB đại trà |
| Chốt một mức toàn cục ngay | Quyết định đơn giản | Lock-in; trả "thuế migration" sau dưới áp lực | Quá sớm; nhu cầu còn đang thăm dò |
| Routing tenant theo subdomain/host | Pattern SaaS phổ biến | Phải làm lại URL client + mô hình token | Token-based đã chạy; không cần |
| Postgres RLS thay lọc app | Cô lập do DB thực thi | Migrate lớn mọi đường query | Ngoài phạm vi quyết định này; ghi nhận là câu hỏi mở |
Câu hỏi mở
- Thị trường mục tiêu (SMB vs enterprise) — nghiêng hybrid, còn thăm dò.
- Ràng buộc compliance / data-residency / on-prem — chưa xác định; nếu có sẽ khiến SILO + Helm chart portable thành bắt buộc.
- BRIDGE là mức cố định hay chỉ trung chuyển sang SILO?
- Dùng RLS hay giữ lọc tầng app cho POOL?
Hoàn thành khi
- [ ] Tenant Registry tồn tại; mọi Org có
isolationTier(mặc địnhPOOL). - [ ]
PostgresCoreDataSourcephân giải kết nối theo tenant context từ registry. - [ ] Snowflake worker ID được cấp phát tập trung (không hai node trùng một).
- [ ] Có runbook cho split (Pool→Silo) và merge (Silo→Pool).
- [ ] Nâng trạng thái Draft → Accepted khi đã trả lời câu hỏi thị trường & compliance.
Tham chiếu
- Chiến lược Multi-Tenancy (PRD)
packages/core/src/datasources/postgres-core.datasource.ts— pool tĩnh cần nâng cấppackages/core/src/utilities/request.utility.ts— trích xuất tenant-context hiện tại- Payment ADR-0002 — tiền lệ phân vùng Snowflake
NODE_ID - AWS SaaS Lens — pattern cô lập Pool / Bridge / Silo