Pagination (P2/2): Index multi-tenant; query và system performance
Ở phần trước: skip đi qua n kết quả rồi bỏ đi, nên càng sâu càng chậm. Keyset dùng (createdAt, _id) làm cursor, và index phải khớp thứ tự sort.
- Cần đọc trước: Phân trang: skip + limit và keyset
- Dẫn tới: Cost-Based Ranker & Cardinality Estimation. Phần này là phần cuối của bài về phân trang.
Thiết kế index cho multi-tenant: vì sao tenantId đứng đầu
Hình dung: kho có nhiều khách thuê
Một kho hàng cho 500 công ty thuê. Cách 1: xếp mọi thùng theo ngày nhập, khách nào cũng lẫn vào nhau. Cách 2: mỗi công ty một dãy kệ riêng, trong dãy thì xếp theo ngày nhập.
Công ty A hỏi "20 thùng mới nhất của tôi". Với cách 1, thủ kho đi từ đầu dãy ngày mới nhất, nhặt thùng nào của A, bỏ qua thùng của 499 công ty khác. Nếu A là khách lớn thì sớm gom đủ 20. Nếu A là khách nhỏ, hoặc A đã ngừng gửi hàng từ năm ngoái, thủ kho đi gần hết kho. Với cách 2, thủ kho đi thẳng tới dãy của A, lấy 20 thùng đầu.
{ createdAt: -1 } { tenantId: 1, createdAt: -1 }
mới ─────────────────────────► cũ t0000: mới ──► cũ
t0000 t0371 t0000 t0042 t0000 ... t0042: mới ──► cũ ← nhảy thẳng vào đây
t0499: mới ──► cũ
khách nhỏ / khách cũ = đi rất xa khách nào cũng = 20 bướcĐo: ba tenant, ba index
Query của màn hình danh sách, dùng hint() để ép từng index (bài Query Planner):
db.orders.find({ tenantId: t }).sort({ createdAt: -1 }).limit(20).hint(idx)| Tenant | Index | keys | docs | median (15 lần) |
|---|---|---|---|---|
t0000 (250.000 đơn) | { createdAt: -1 } | 78 | 78 | 3,25 ms |
{ createdAt: -1, tenantId: 1 } | 78 | 78 | 1,10 ms | |
{ tenantId: 1, createdAt: -1, _id: -1 } | 20 | 20 | 0,67 ms | |
t0042 (1.520 đơn) | { createdAt: -1 } | 8.590 | 8.590 | 17,75 ms |
{ createdAt: -1, tenantId: 1 } | 8.590 | 8.590 | 20,17 ms | |
{ tenantId: 1, createdAt: -1, _id: -1 } | 20 | 20 | 1,03 ms | |
t0499 (ngừng dùng) | { createdAt: -1 } | 823.117 | 823.117 | 1.426 ms |
{ createdAt: -1, tenantId: 1 } | 823.117 | 823.117 | 1.537 ms | |
{ tenantId: 1, createdAt: -1, _id: -1 } | 20 | 20 | 0,90 ms |
Đọc bảng
- Với index bắt đầu bằng
createdAt, chi phí phụ thuộc vào tenant là ai: khách lớn thì 78 key, khách nhỏ 8.590 key, khách đã nghỉ 823.117 key. Đó là chi phí của thủ kho đi dọc dãy "theo ngày" cho đến khi gom đủ 20 thùng của đúng khách. Khácht0499có đơn mới nhất từ hơn 300 ngày trước, nên phải đi qua 82% collection mới gặp đơn đầu tiên. - Với
tenantIdđứng đầu, mọi tenant đều tốn 20 key. Chi phí không phụ thuộc vào kích thước hay hành vi của tenant khác. - Đặt
tenantIdở saucreatedAtkhông cứu được. [quan sát] Ở bản này, khihintindex{ createdAt: -1, tenantId: 1 }, bounds củatenantIdlà[MinKey, MaxKey]và điều kiện tenant được kiểm tra ở FETCH, nên số document đọc bằng số key. Nhưng kể cả khi điều kiện được áp lên key, server vẫn phải đi qua 823.117 key, vì các key củat0499nằm rải rác khắp chiều thời gian.
Đây là ESR của bài Compound Indexes (Equality trước, Sort sau) nhìn từ góc multi-tenant: tenantId là điều kiện bằng có mặt trong mọi query, nên nó đứng đầu. Điều nguy hiểm của index sai không phải là chậm đều, mà là chậm theo tenant: dashboard trung bình trông ổn, còn một nhóm khách nhỏ hoặc khách cũ nhận latency 1,4 giây và không ai thấy trên biểu đồ p50.
Lưu ý: khi cả ba index tồn tại, planner tự chọn
{ tenantId: 1, createdAt: -1, _id: -1 }chot0499(trial run mà bài Query Planner mô tả sẽ loại các plan đi xa). Bảng trên dùnghintđể thấy cái giá của từng index, không phải để mô tả lựa chọn mặc định.
Hệ quả cho thiết kế
Mọi index phục vụ query của tenant
│
├── bắt đầu bằng tenantId (equality, có trong mọi query)
├── tiếp theo là field sort (createdAt)
├── cuối là _id nếu dùng cursor (tie-breaker)
└── ⚠ index không có tenantId ở đầu = ổn với khách lớn, thảm hoạ với khách nhỏ/cũCòn một lý do không liên quan đến tốc độ: tenantId có trong mọi filter cũng là một hàng rào bảo mật. Một query quên tenantId vừa có thể trả dữ liệu của tenant khác, vừa không dùng được index nào ở trên. Nhiều team ép điều này ở tầng repository của app (không có hàm nào nhận filter mà thiếu tenantId). Bài Security sẽ quay lại chuyện cô lập tenant.
Chi phí: mỗi index phục vụ một "màn hình" có thứ tự sort riêng. Index { tenantId, createdAt, _id } ở đây tốn 27,9 MB, lớn hơn cả _id_ (11 MB). Mỗi index như vậy là thêm một lần ghi cho mỗi insert (xem Index Fundamentals). Đừng tạo index cho mọi cách sort mà UI có thể cho phép; hãy tạo cho những cách sort người dùng thật sự dùng, và giới hạn UI theo đó.
Query performance và system performance
Hình dung: quán phở có hai đầu bếp
Quán có 2 đầu bếp (2 CPU). Một tô phở mất 50 giây để nấu. Có 1 khách: khách chờ 50 giây. Có 2 khách: mỗi đầu bếp nấu một tô, cả hai vẫn chờ 50 giây, quán ra gấp đôi số tô mỗi phút. Có 8 khách cùng lúc: vẫn chỉ 2 đầu bếp. Quán không ra nhiều tô hơn mỗi phút, nhưng mỗi khách chờ lâu gấp nhiều lần vì phải xếp hàng. Đổi công thức để mỗi tô chỉ mất 1 giây, cùng 8 khách, hàng đợi gần như biến mất.
2 đầu bếp = 2 CPU
thời gian nấu 1 tô = chi phí của một query (query performance)
số tô mỗi phút = throughput (system performance)
thời gian khách chờ = latency = nấu + xếp hàngĐây chỉ là cách hình dung. mongod không có "một đầu bếp một query"; nhiều thread chia nhau CPU và có thể nhường nhau (yield). Nhưng khi query bị giới hạn bởi CPU như minh hoạ dưới đây, quy luật "hết đầu bếp thì xếp hàng" khớp với số đo.
Một minh hoạ đo được
Màn hình "Đơn của tôi" lấy đơn của một user trong tenant lớn:
db.orders.find({ tenantId: "t0000", userId: "u0042" }).sort({ createdAt: -1 }).limit(20)Nếu chỉ có index của màn hình danh sách, { tenantId: 1, createdAt: -1, _id: -1 }, thì userId không nằm trong index: server đi dọc các đơn của tenant theo thời gian, FETCH từng đơn để kiểm tra userId, và với u0042 phải đọc 38.974 key và 38.974 document để trả 20 (median 61 ms khi chạy một mình). Theo ESR, cả tenantId lẫn userId là equality, createdAt là sort, nên index đúng là { tenantId: 1, userId: 1, createdAt: -1 }: còn 20 key, 20 document, median 0,89 ms.
Rồi cho nhiều client chạy query đó cùng lúc. Mỗi client là một tiến trình mongosh chạy liên tục 10 giây, mọi client bắt đầu cùng một mốc giờ; mỗi mức chạy 3 lượt (không index đúng) hoặc 5 lượt (có index), bảng ghi median:
| Client | Không index đúng: query/s | p50 | p95 | Có index đúng: query/s | p50 | p95 |
|---|---|---|---|---|---|---|
| 1 | 18,3 | 51,5 ms | 74,6 ms | 2.892 | 0,2 ms | 0,7 ms |
| 2 | 34,4 | 52,8 ms | 85,1 ms | 5.536 | 0,3 ms | 0,7 ms |
| 8 | 24,8 | 294 ms | 546 ms | 1.987 | 0,8 ms | 5,2 ms |
[quan sát] Không có index đúng: từ 1 lên 2 client, throughput gần gấp đôi mà latency giữ nguyên (đầu bếp thứ hai vào việc). Từ 2 lên 8 client, throughput không tăng, còn p50 nhảy từ 53 ms lên 294 ms; top trong container cho thấy mongod dùng 191% CPU, tức cả 2 CPU. Server đã bão hoà, thêm client chỉ thêm người xếp hàng. Phép kiểm tra nhanh bằng Little's law (số request đang ở trong hệ thống = throughput × thời gian mỗi request): 8 / 24,8 query/s ≈ 323 ms mỗi request, cùng cỡ với p50 đo được.
Có index đúng, throughput lên hàng nghìn query/s. Con số 8 client thấp hơn 2 client không phải do server: lúc đó mongod chỉ dùng khoảng 50% CPU, phần còn lại là 8 tiến trình mongosh tranh nhau 2 CPU trong cùng container. Bottleneck đã dời sang công cụ đo, nên đây không phải trần thật của server. Số có index cũng dao động rộng giữa các lượt (1.471–2.800 query/s với 8 client) vì máy host còn chạy các lab khác.
Bài học
- Một query 50 ms nghe ổn khi chạy một mình. Nhưng nó giữ một CPU bận 50 ms, và server chỉ có 2 CPU. Chi phí của query quyết định trần throughput của cả hệ thống.
- Khi hệ thống đã bão hoà, latency tăng theo số request đang chờ, không theo query. Tăng connection pool hay thêm instance app lúc này chỉ làm hàng đợi dài hơn; Connection Pool & Capacity đo chuyện đó và chuyện định cỡ hệ thống.
- Giảm việc của query từ 38.974 xuống 20 document tăng throughput với 8 client từ khoảng 25 lên gần 2.000 query/s trong lab này. Không nâng cấp phần cứng nào cho được con số đó.
Bài này chỉ dừng ở khái niệm. Đo tải một cách nghiêm túc (client đặt ở máy khác, p99, nhiều loại query trộn lẫn) và đọc metric của hệ thống thuộc về Phần Production.
Khi query chậm trên production thì tìm từ đâu
Ví dụ "Đơn của tôi" ở trên là loại query bạn sẽ không thấy khi test: nó nhanh với user có nhiều đơn và chậm nhất với user mới (user chỉ có 2 đơn không bao giờ gom đủ 20, nên phải đi hết 250.000 đơn của tenant). Tìm ra nó trên production là việc của slow query log, database profiler, $currentOp và một quy trình đi từ triệu chứng tới nguyên nhân. Tất cả được gom vào bài Observe → Diagnose → Tune, cùng với metric của server, WiredTiger và connection pool. Hai thói quen nên có ngay từ bây giờ: gắn comment cho query từ app (để log cho biết query từ endpoint nào), và luôn thử explain("executionStats") với tham số xấu nhất, không phải tham số đẹp nhất.
So với PostgreSQL
| Việc | MongoDB | PostgreSQL |
|---|---|---|
| Phân trang offset | skip(n).limit(k) | OFFSET n LIMIT k, cùng vấn đề: phải sinh n hàng rồi vứt |
| Thứ tự giữa các giá trị trùng | không đảm bảo, phải thêm field duy nhất (_id) vào sort | cũng không đảm bảo; phải thêm cột duy nhất (id) vào ORDER BY |
| Keyset trên nhiều cột | phải viết $or hai nhánh như ở phần trước (Phân trang: skip + limit và keyset) | có so sánh bộ giá trị (created_at, id) < ($1, $2) và B-tree index (tenant_id, created_at, id) dùng được trực tiếp |
Bài toán và lời giải giống nhau ở cả hai bên: offset tỉ lệ với độ sâu, keyset cần index khớp thứ tự sort và một tie-breaker duy nhất, và multi-tenant cần tenant_id đứng đầu index. Khác biệt chính là cú pháp: PostgreSQL diễn đạt "đứng sau bộ (c, id)" trong một biểu thức, MongoDB cần hai nhánh $or và planner ghép lại bằng SORT_MERGE.
Những lỗi thường gặp
- Phân trang bằng
skiptrên danh sách dài, đang được ghi. Chậm dần theo độ sâu (31 ms ở trang 10.000, 139 ms ở trang 1.000 khi có filter ngoài index), và trùng hoặc sót khi có insert/delete xen giữa. - Cursor chỉ có
createdAt.$ltsót đơn (3.026 đơn trong lab),$ltelặp vô hạn. Luôn thêm_idvào sort, index và cursor. - Sort và index lệch nhau. Index thiếu
_idở cuối, hoặc chiều sort{ createdAt: -1, _id: 1 }không khớp index{ ..., createdAt: -1, _id: -1 }: thứ tự không còn đến từ index, và mỗi trang phải sort lại mọi document của tenant. - Index không có
tenantIdở đầu. Trông ổn với khách lớn, rồi 1,4 giây cho khách nhỏ hoặc khách đã nghỉ. - Thấy IXSCAN là yên tâm. Phải nhìn tỉ lệ
keysExamined : docsExamined : nreturned. 38.974 : 38.974 : 20 là IXSCAN tệ. - Đánh giá query bằng một lần chạy, một client, tham số đẹp. 52 ms một mình thành 294 ms khi có 8 người cùng chờ.
Cột mốc: Bạn đã có thể đặt
tenantIdđứng đầu index multi-tenant, đọc tỉ lệkeysExamined : docsExamined : nreturned, và tách query performance khỏi system performance. Bài phân trang khép lại ở đây.
Hỏi & đáp
Màn hình "đơn mới nhất của tenant" dùng index { createdAt: -1 }. Load test với tenant lớn nhất thấy rất nhanh. Khi lên production, ai phàn nàn đầu tiên?
Query "Đơn của tôi" (52 ms khi chạy một mình) trên server 2 CPU: từ 2 lên 8 client, throughput không tăng (khoảng 25–34 query/s) còn p50 nhảy từ 53 ms lên 294 ms. Nên làm gì?
Explain của "Đơn của tôi" khi chỉ có index { tenantId: 1, createdAt: -1, _id: -1 } cho thấy plan IXSCAN với keysExamined 38.974, docsExamined 38.974, nReturned 20. Kết luận nào đúng?
Nếu phải giải thích bài này mà không dùng thuật ngữ MongoDB nào: muốn đọc tiếp thì kẹp bookmark, đừng đếm lại từ đầu sách, và bookmark phải chỉ đúng một chỗ, nên khi nhiều trang trùng ngày thì ghi thêm số trang. Kho cho nhiều khách thuê thì chia kệ theo khách trước, theo ngày sau. Và một yêu cầu mất một phút thì không sao, nhưng tám người cùng gửi cho hai thủ kho thì người cuối hàng chờ rất lâu.
Bài tiếp theo
Các bài thực hành của Phần Index & Query dừng ở đây. Từ Index Fundamentals đến bài này, mọi thứ xoay quanh một câu hỏi: làm sao để đọc ít nhất có thể. Index, planner, pipeline, keyset pagination đều là cách để server đi thẳng tới đúng document và không đụng vào những thứ còn lại.
Còn một câu hỏi bài Query Planner mới trả lời một nửa: planner dựa vào đâu để biết một plan sẽ đọc bao nhiêu document? Bài Cost-Based Ranker & Cardinality Estimation mở phần bên trong đó: khi nào planner ước lượng chi phí thay vì chạy thử các plan, con số ước lượng đến từ đâu, và ước lượng sai thì plan sai ra sao. Sau đó bài Query Execution Engine khép lại Phần Index & Query bằng cách các stage (kể cả SORT_MERGE của keyset ở trên) thực sự chạy.
Rồi đến Phần Transactions. Hầu hết thí nghiệm của Phần Index & Query chỉ đọc, và các lệnh ghi đều chạm vào một document. Khi một nghiệp vụ phải chạm nhiều document cùng lúc (tạo đơn hàng và trừ tồn kho và ghi sổ cái), Phần Transactions mở đầu bằng bài Transactions.