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 |
| Phạm vi | Xuyên suốt - datasource @nx/core, mọi service, triển khai |
| 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
- Quy hoạch triển khai & 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 ngay 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 ứng dụng; không RLS, không schema-per-tenant |
| Phân giải tenant | JWT claim (organizers[], merchants[]) → lọc theo request context |
| Định tuyến | Theo token; không theo subdomain/host |
| ID | Snowflake (toàn cục duy nhất) qua IdGenerator |
| Triển khai | K8s trên VNPAY Cloud (Kustomize, cluster staging/prod tách biệt); gateway Traefik |
Quyết định
Áp dụng mô hình lai (hybrid), phân bậc 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 tới code nghiệp vụ:
- Tenant Registry - bảng
orgId → { isolationTier, datasourceRef }. Mọi Org mặc định làPOOL. - 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 dựng 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 (chuẩn hoá quy ước
APP_ENV_NODE_IDsẵn có, xem payment ADR-0002).
Hướng di chuyển
| Chiều | Độ khó | Cơ chế |
|---|---|---|
| POOL → SILO (tách) | Dễ | Sao chép có lọc theo organizerId (logical replication / pg_dump --where), cutover, chuyển registry |
| SILO → POOL (gộp) | Khó nhưng khả thi | Nhập dữ liệu giữ nguyên Snowflake ID, kiểm tra không có FK mồ côi, chuyển registry, khoá silo cũ |
Vì sao gộp an toàn ở đây: Snowflake ID toàn cục duy nhất nên khi nhập các dòng của silo vào bảng chung sẽ không trùng PK - đúng đặc tính khiến việc gộp 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ừ dễ đọc cũng không trùng chéo giữa các Org.
Hệ quả
| Lợi | Hại |
|---|---|
| Cô lập trở thành một "núm xoay" theo Org, không phải viết lại | Connection Resolver làm phức tạp thêm hot path của tầng dữ liệu |
| Org mới được tiếp nhận tức thì (POOL mặc định) | Cần cache kết nối theo tenant (vòng đời, thu hồi) |
| Snowflake ID giúp gộp Silo→Pool khả thi | WorkerId phải được quản trị tập trung nếu không việc gộp sẽ vỡ |
| Code nghiệp vụ/repository không đổi giữa các mức | Migration schema phải trải ra N database (Bridge/Silo) |
| Khớp topology K8s/Kustomize + Traefik sẵn có | Việc provision Bridge/Silo cần tự động hoá 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/tuân thủ |
| Chỉ Silo | Cô lập tối đa | Chi phí cao nhất, tiếp nhận chậm, gộp tốn kém | Sai cho POS SMB đại trà |
| Chốt một mức toàn cục ngay | Quyết định đơn giản | Bị khoá cứng; trả "thuế migration" về sau dưới áp lực | Quá sớm; nhu cầu vẫn đang thăm dò |
| Định tuyến tenant theo subdomain/host | Mẫu hình SaaS phổ biến | Phải làm lại URL client + mô hình token | Phân giải theo token đã chạy; không cần |
| Postgres RLS thay cho lọc ứng dụng | Cô lập do DB thực thi | Di chuyển 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 về lai (hybrid), vẫn đang thăm dò.
- Ràng buộc tuân thủ / data-residency / on-prem - chưa xác định; nếu có sẽ khiến SILO + Helm chart di động trở thành bắt buộc.
- BRIDGE là mức cố định hay chỉ là bước trung chuyển sang SILO?
- Dùng RLS hay giữ lọc tầng ứng dụng 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 có hai node trùng nhau).
- [ ] Có runbook cho việc tách (Pool→Silo) và gộp (Silo→Pool).
- [ ] Nâng trạng thái Draft → Accepted sau khi đã trả lời các câu hỏi về thị trường & tuân thủ.
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 - mẫu hình cô lập Pool / Bridge / Silo