Pagination (P2/2): Index multi-tenant; query và system performance

11 phút đọcSeries: MongoDB: từ gốc đến internals

Ở 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.

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)
TenantIndexkeysdocsmedian (15 lần)
t0000 (250.000 đơn){ createdAt: -1 }78783,25 ms
{ createdAt: -1, tenantId: 1 }78781,10 ms
{ tenantId: 1, createdAt: -1, _id: -1 }20200,67 ms
t0042 (1.520 đơn){ createdAt: -1 }8.5908.59017,75 ms
{ createdAt: -1, tenantId: 1 }8.5908.59020,17 ms
{ tenantId: 1, createdAt: -1, _id: -1 }20201,03 ms
t0499 (ngừng dùng){ createdAt: -1 }823.117823.1171.426 ms
{ createdAt: -1, tenantId: 1 }823.117823.1171.537 ms
{ tenantId: 1, createdAt: -1, _id: -1 }20200,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ách t0499 có đơ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 ở sau createdAt không cứu được. [quan sát] Ở bản này, khi hint index { createdAt: -1, tenantId: 1 }, bounds của tenantId là [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ủa t0499 nằ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 } cho t0499 (trial run mà bài Query Planner mô tả sẽ loại các plan đi xa). Bảng trên dùng hint để 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:

ClientKhông index đúng: query/sp50p95Có index đúng: query/sp50p95
118,351,5 ms74,6 ms2.8920,2 ms0,7 ms
234,452,8 ms85,1 ms5.5360,3 ms0,7 ms
824,8294 ms546 ms1.9870,8 ms5,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ệcMongoDBPostgreSQL
Phân trang offsetskip(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ùngkhông đảm bảo, phải thêm field duy nhất (_id) vào sortcũ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ộtphả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 skip trê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. $lt sót đơn (3.026 đơn trong lab), $lte lặp vô hạn. Luôn thêm _id và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?

  1. Tenant lớn nhất, vì nó có nhiều đơn nhất phải đi qua

    Ngược lại: t0000 (250.000 đơn) chỉ tốn 78 key với index này, vì đơn của nó dày đặc trên trục thời gian nên gom đủ 20 rất nhanh. Xem mục "Đo: ba tenant, ba index".

  2. Không ai: thời gian mỗi trang như nhau vì cùng limit(20)

    limit(20) chỉ dừng khi gom đủ 20 đơn của đúng tenant. Với index bắt đầu bằng createdAt, số key phải đi qua phụ thuộc tenant: 78, 8.590 hay 823.117. Xem mục "Đọc bảng".

  3. Chỉ khi nhiều request đến cùng lúc thì mới có người thấy chậm

    Không cần tải đồng thời: một request của t0499 chạy một mình đã đọc 823.117 key và 823.117 document, median 1.426 ms. Xem mục "Đo: ba tenant, ba index".

  4. Tenant nhỏ và tenant đã ngừng dùng: t0499 đi qua 823.117 key

    Đúng. Index theo thời gian buộc server đi qua đơn của mọi tenant khác cho tới khi gom đủ 20 đơn của tenant cần tìm; t0499 có đơn mới nhất từ hơn 300 ngày trước nên đi qua 82% collection. Với tenantId đứng đầu index, mọi tenant đều tốn 20 key. Xem mục "Đọc bảng".

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ì?

  1. Tăng connection pool của app để server nhận thêm request song song

    Server đã dùng 191% CPU, tức cả 2 CPU. Thêm connection chỉ thêm người xếp hàng; latency tăng theo số request đang chờ. Xem mục "Bài học" trong "Query performance và system performance".

  2. Giảm việc mỗi query bằng index { tenantId, userId, createdAt }

    Đúng. Chi phí của query quyết định trần throughput. Index đúng đưa query từ 38.974 document xuống 20 (0,89 ms), và với 8 client throughput lên gần 2.000 query/s trong lab, không cần nâng phần cứng. Xem mục "Một minh hoạ đo được".

  3. Thêm instance app để chia tải ra nhiều máy, mỗi máy ít request hơn

    Nút thắt nằm ở CPU của mongod, không ở tầng app. Thêm instance app chỉ đưa thêm request vào cùng một hàng đợi. Xem mục "Bài học".

  4. Không cần làm gì: 52 ms một query là đủ nhanh cho người dùng

    52 ms một mình nghe ổn, nhưng mỗi query giữ một CPU bận 52 ms; với 2 CPU, trần chỉ khoảng 25–35 query/s, và 8 client đã đẩy p50 lên 294 ms. Xem mục "Query performance và system performance".

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?

  1. Query ổn vì planner đã dùng index, không phải COLLSCAN

    Thấy IXSCAN chưa đủ để yên tâm. Tỉ lệ 38.974 : 38.974 : 20 là một IXSCAN tệ: đọc gần 2.000 document cho mỗi kết quả. Xem mục "Những lỗi thường gặp".

  2. Tăng limit lên để mỗi request trả nhiều hơn, chia đều chi phí

    Chi phí đến từ việc lọc userId sau khi FETCH, không phải từ kích thước trang. Trang lớn hơn chỉ làm mỗi request đọc nhiều document hơn. Xem mục "Một minh hoạ đo được".

  3. userId không có trong index nên phải FETCH từng đơn để lọc

    Đúng. Theo ESR, tenantId và userId là equality, createdAt là sort. Với index đó query còn 20 key, 20 document, median 0,89 ms thay vì 61 ms. Xem mục "Một minh hoạ đo được".

  4. Planner đã chọn sai index; thêm hint() vào index _id_ sẽ nhanh hơn

    Không có index nào tốt hơn đang bị bỏ qua: index hiện có không chứa userId. hint() chỉ ép chọn giữa các index đã tồn tại, không thay được index còn thiếu. Xem mục "Một minh hoạ đo được".

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.

Tài liệu tham khảo