MongoClient của Vert.x có hai cách đọc nhiều bản ghi, tên gần giống nhau và chữ ký gần giống nhau. Một cái sẽ làm ứng dụng của bạn chết khi dữ liệu lớn lên, cái kia thì không, và không có gì trong API gợi ý điều đó.

MongoDB 7.0.40 trong Docker, 300 000 bản ghi, khoảng 111 MB.

Đọc một bản ghi

Thông lượng Mỗi lượt
Tìm theo trường chỉ mục, tuần tự 4 878 lượt/giây 205,0 µs
Tìm theo trường chỉ mục, 64 lượt đồng thời 39 430 lượt/giây 25,4 µs
Tìm theo trường không chỉ mục, 64 lượt đồng thời 341 lượt/giây 2 932,7 µs

Thiếu chỉ mục làm mọi thứ chậm 116 lần. Con số này không có gì mới về mặt MongoDB, nhưng nó đáng đặt cạnh hai dòng trên để thấy tỉ lệ: đi từ tuần tự sang 64 lượt đồng thời mua được 8 lần, còn thêm một chỉ mục mua được 116 lần. Chỉnh mức đồng thời là chuyện của tuần này; thiếu chỉ mục là chuyện của cả năm.

Ghi: một lần một bản ghi, hay một lần một nghìn

Thông lượng
insert từng cái, 64 lượt đồng thời 40 019 bản ghi/giây
bulkWrite 1 000 bản ghi mỗi lần 934 989 bản ghi/giây

Gấp 23 lần, cùng lý do với gom lô ở Redis: một vòng đi về mạng cho một nghìn bản ghi thay vì một nghìn vòng.

Có một cái bẫy khi viết đoạn đo này mà tôi dính ngay: tôi dựng danh sách BulkOperation một lần rồi dùng lại cho cả 50 lượt gọi. Kết quả:

!! 50/50 LOI — MongoBulkWriteException: E11000 duplicate key error
   collection: khodb.tam index: _id_ dup key: { _id: "6a8bad564e716d4d590db386" }

Client sửa chính JsonObject của bạn, gắn _id vào sau lần ghi đầu tiên. Lần thứ hai gửi lại đúng những đối tượng đó là trùng khoá. Nếu mã của bạn có thói quen dựng sẵn một mẫu rồi gửi nhiều lần — hoặc thử lại một lô sau khi hỏng — thì đây là chỗ nó sẽ hỏng, và thông báo lỗi không hề gợi ý rằng thủ phạm là việc dùng lại đối tượng.

Hai cách đọc nhiều bản ghi

// cach 1: doc het vao mot List trong bo nho
mg.find("ban_ghi", new JsonObject()).onSuccess(ds -> { /* ds la List<JsonObject> */ });

// cach 2: doc theo lo, tra ve ReadStream
ReadStream<JsonObject> luong = mg.findBatchWithOptions("ban_ghi", new JsonObject(),
        new FindOptions().setBatchSize(1000));
luong.handler(d -> ...).endHandler(v -> ...);

Đo trên máy rảnh rang, heap 3 GB:

Thời gian Heap đỉnh
find — đọc hết 0,33 s 370 MB
findBatchWithOptions — theo luồng 0,41 s 273 MB

Nhìn bảng này thì find nhanh hơn và chênh lệch bộ nhớ có vẻ không đáng kể. Nếu dừng ở đây, kết luận sẽ là "cứ dùng find cho gọn".

Nhưng con số heap đỉnh không phân biệt được bộ nhớ đang sống với rác chưa được dọn. Bản đọc theo luồng sinh ra rất nhiều JsonObject ngắn ngày; bản find thì phải giữ toàn bộ danh sách sống cùng lúc. Cùng một con số, hai ý nghĩa khác hẳn.

Phép thử phân biệt được hai thứ đó là siết heap lại rồi xem cái nào còn sống:

Heap tối đa find findBatch
128 MB OOM OK
256 MB OOM OK
512 MB OK OK
1 024 MB OK OK

Đọc 111 MB dữ liệu, find cần ít nhất 512 MB heap, còn bản theo luồng chạy được với 128 MB và vẫn đọc đủ 300 000 bản ghi trong 0,57 giây.

Tỉ lệ 4,6 lần giữa dữ liệu và heap cần thiết không có gì lạ: mỗi bản ghi thành một JsonObject với HashMap bên trong, mỗi chuỗi thành một String riêng, cộng phần dôi của việc ArrayList phải nhân đôi dung lượng khi lớn dần. Điểm đáng nhớ là find biến kích thước dữ liệu thành yêu cầu về heap, còn đọc theo luồng thì không.

Và nó hỏng theo cách tệ nhất: không phải chậm dần cho bạn kịp thấy, mà chạy tốt suốt cho tới ngày bảng dữ liệu đủ lớn, rồi OutOfMemoryError — thứ hạ cả tiến trình chứ không chỉ hỏng một request. Với ba trăm nghìn bản ghi thì ngưỡng là 512 MB; với sáu trăm nghìn thì là một con số khác mà không ai đo trước.

Luật thực dụng: find chỉ dùng khi bạn biết chắc số bản ghi có trần, ví dụ đã có limit hoặc truy vấn theo khoá. Mọi trường hợp còn lại dùng findBatchWithOptions. Chi phí là 24% thời gian và một chút mã dài dòng hơn.

Đọc theo luồng còn mở ra một thứ nữa: ReadStream nối thẳng được vào HTTP response, và khi đó tốc độ đọc của client sẽ điều tiết ngược lại tốc độ đọc từ CSDL. Đó là chủ đề của phần sau.

Một cái bẫy về phiên bản

vertx-mongo-client 4.5.11 để Maven tự chọn driver MongoDB, và Maven chọn 5.5.1. Kết quả:

NoClassDefFoundError: com/mongodb/connection/StreamFactoryFactory
    at io.vertx.ext.mongo.impl.config.StreamTypeParser.streamFactory

Lớp đó đã bị bỏ ở driver 5.x. Ghim mongodb-driver-reactivestreams, mongodb-driver-corebson về 4.11.5 là chạy. Lỗi này nổ ra ngay khi tạo client nên bạn phát hiện sớm — khác với cái bẫy SCRAM ở phần 37 chỉ nổ lúc kết nối — nhưng nó vẫn đáng nhắc vì thông báo không hề nói rằng nguyên nhân là lệch phiên bản driver.

Thử ba mươi giây

Tìm những chỗ find không có trần trong mã của bạn:

grep -rn "\.find(" --include=*.java src/ | grep -v "findOne\|findBatch\|Limit\|limit"

Mỗi dòng tìm được là một câu hỏi: bộ sưu tập này lớn nhất có thể là bao nhiêu bản ghi? Không trả lời được thì đó là một OutOfMemoryError đang chờ ngày dữ liệu đủ lớn.

Kiểm chứng nhanh bằng cách chạy đúng đường mã đó với -Xmx256m. Sống thì tốt; chết thì bạn vừa biết trước một sự cố thay vì gặp nó lúc nửa đêm.

Phần sau nối ReadStream của cơ sở dữ liệu thẳng vào HTTP response, và đo bộ nhớ khi client đọc chậm hơn cơ sở dữ liệu trả về.