Bỏ qua để đến nội dung
Search & RAG

7.17 Multitenancy

Bài toán: sản phẩm của bạn phục vụ nhiều khách hàng, mỗi khách hàng có corpus riêng, và tuyệt đối không được để dữ liệu của khách này lọt sang khách kia.

Cách làm mà gần như ai cũng nghĩ ra đầu tiên — “mỗi khách hàng một collection” — là cách sai, và Qdrant nói thẳng điều đó trong checklist production của họ.

Vì sao “mỗi khách một collection” hỏng

Phần tiêu đề “Vì sao “mỗi khách một collection” hỏng”

Nhớ lại 7.15: mỗi collection có shard riêng, và 7.4: mỗi shard có segment riêng, mỗi segment có index riêng.

1.000 khách hàng × 1 collection × (ít nhất 1 shard) × (nhiều segment)
= hàng nghìn shard, hàng chục nghìn segment
= hàng chục nghìn cấu trúc index nhỏ xíu, mỗi cái có chi phí cố định

Hậu quả cụ thể:

Vấn đềVì sao
Tốn RAM khủng khiếpMỗi segment có chi phí cố định, không phụ thuộc số điểm trong nó
Index vô dụngHNSW trên 200 điểm không giúp gì — Qdrant sẽ quét thẳng (7.7)
Metadata cụm phìnhMọi collection đi qua Raft (7.15)
Thêm khách hàng = thao tác DDLKhông phải một dòng INSERT

Cách đúng: một collection, partition theo payload

Phần tiêu đề “Cách đúng: một collection, partition theo payload”
Một collection duy nhất
└── mọi point mang payload: { "tenant_id": "kh_00123", … }
└── payload index trên "tenant_id" với is_tenant: true
└── mọi truy vấn LUÔN kèm filter: tenant_id = <khách hiện tại>

Điều làm cách này hoạt động tốt chính là cơ chế ở 7.8: filter chạy bên trong quá trình đi đồ thị, không phải lọc sau. Bạn không lấy 10.000 điểm rồi vứt 9.900.

Và có một điều phản trực giác đáng biết: truy vấn có filter tenant có thể nhanh hơn truy vấn không filter — vì query planner thu hẹp không gian tìm kiếm ngay từ đầu, và nếu tenant đủ nhỏ, nó bỏ HNSW để quét thẳng với recall 1.0.

không làm filter đúng hơn. Nó sắp lại dữ liệu trên đĩa: các điểm cùng tenant_id được gom về gần nhau, nên một truy vấn của tenant đó đọc ít trang đĩa hơn. Với hệ nhiều tenant chạy trên đĩa (memory: cold), đây là khác biệt lớn.

Cấu hình mạnh hơn: bỏ hẳn đồ thị toàn cục

Phần tiêu đề “Cấu hình mạnh hơn: bỏ hẳn đồ thị toàn cục”

Nếu 100% truy vấn của bạn đều có filter tenant, thì đồ thị HNSW toàn cục là RAM vứt đi — không ai bao giờ tìm trên toàn bộ corpus. Qdrant cho phép nói điều đó:

hnsw_config:
m: 0 ← KHÔNG xây đồ thị toàn cục
payload_m: 16 ← chỉ xây đồ thị theo từng giá trị payload

Kết quả: bộ nhớ giảm đáng kể, hiệu năng cho tải nhiều-tenant tốt hơn, và không phải đổi một dòng nào ở phía truy vấn. Đây là một trong những tối ưu có tỷ lệ lợi ích/công sức cao nhất trong cả Phần 7 — với điều kiện tiền đề “mọi truy vấn đều có filter tenant” là đúng.

⚠️ Nếu tiền đề đó không đúng — nếu có dù chỉ một đường truy vấn quét toàn cục — thì m: 0 sẽ khiến đường đó quét tuần tự toàn bộ collection. Kiểm tra kỹ trước khi đặt.

Partition theo payload rất tốt khi bạn có nhiều tenant nhỏ. Nó đuối khi một tenant lớn hơn phần còn lại hàng bậc:

1.000 tenant × 500 điểm = 500.000 điểm
+ 1 tenant × 50.000.000 điểm
─────────────────────────────────────────────
⇒ "hàng xóm ồn ào": để phục vụ một khách, bạn phải nâng cấp hạ tầng cho tất cả

Từ v1.16, Qdrant có tiered multitenancy — kết hợp cả hai cách:

Tenant nhỏTenant lớn
Nằm ởshard dùng chung, partition theo payloadshard riêng
Chi phí cố địnhgần như 0có, nhưng đáng

Có một cơ chế định tuyến dự phòng (fallback) tự đưa request tới shard riêng hoặc shard chung một cách trong suốt với ứng dụng. Và quan trọng nhất là thăng hạng tenant: khi một khách hàng nhỏ lớn dần, họ được chuyển sang shard riêng mà không phải sửa code ứng dụng.

Khuyến nghị của Qdrant, và tôi thấy nó đúng: bắt đầu bằng partition theo payload chuẩn. Nó đơn giản, rẻ, và hiệu quả cho phần lớn trường hợp. Chỉ chuyển sang tiered khi bạn đã nhìn thấy vấn đề cá voi trong dữ liệu thật.

sharding_method: "custom" với shard_key = tenant ID (7.15) cho cách ly vật lý thật sự. Ba lý do chính đáng để dùng nó:

  1. Yêu cầu tuân thủ dữ liệu — dữ liệu khách hàng EU phải nằm trên máy ở EU.
  2. Vài tenant rất lớn, biết trước, không quá nhiều.
  3. Cần xoá gọn — xoá một tenant = xoá một shard key, không phải xoá hàng triệu điểm rồi chờ vacuum (7.6).

Nhưng nhớ: mỗi shard key là shard vật lý, nên đừng dùng cho hàng nghìn tenant nhỏ — bạn sẽ tái tạo đúng vấn đề của “mỗi khách một collection”.

An toàn: đừng để filter tenant phụ thuộc vào code ứng dụng

Phần tiêu đề “An toàn: đừng để filter tenant phụ thuộc vào code ứng dụng”

Đây là phần quan trọng nhất của mục này về mặt rủi ro, và nó không phải chuyện hiệu năng.

Nếu cách ly giữa các khách hàng chỉ được đảm bảo bằng việc lập trình viên nhớ thêm filter: tenant_id = X vào mọi truy vấn, thì rò rỉ dữ liệu chỉ còn cách bạn một lần quên. Và một truy vấn thiếu filter không báo lỗi — nó chỉ trả về kết quả của người khác.

Cách chặn ở tầng hạ tầng: JWT RBAC với claim value_exists, gắn token của mỗi khách hàng vào đúng giá trị payload của họ. Khi đó Qdrant tự áp ràng buộc, không phụ thuộc code ứng dụng nhớ hay quên. Chi tiết ở 7.20.

Nguyên tắc: trong hệ nhiều khách hàng, cách ly phải được thực thi bởi hệ thống, không phải thoả thuận bởi quy ước.

Tình huốngCách làm
Nhiều tenant nhỏ, kích thước tương đươngPayload partitioning + is_tenant: true
Như trên, và mọi truy vấn đều có filter tenantThêm m: 0 + payload_m: 16
Nhiều tenant nhỏ + vài tenant cá voiTiered multitenancy (v1.16+)
Vài tenant lớn, cần cách ly vật lý / tuân thủCustom sharding theo shard_key
Cần xoá trọn một tenant thường xuyênCustom sharding
Mỗi tenant một collectionĐừng
Phần 7 — Qdrant