Xin chào, lại là mình - Đức Phúc, anh chàng hơn 6 năm trong nghề vẫn nghèo technical nhưng thích viết Blog để chia sẻ kiến thức bản thân học được trong quá trình “cơm áo gạo tiền” đây. Các bạn có thể theo dõi mình thêm qua một số nền tảng bên dưới nhé:
- Linkedin: https://www.linkedin.com/in/phuc-ngo-728433346
- Viblo: https://viblo.asia/u/NHDPhucIT
- Patreon: https://www.patreon.com/felix_ngo
Ở bài viết trước, chúng ta đã tìm hiểu về sai lầm trong việc lưu trữ dữ liệu ngày tháng theo dạng Dynamic Field, và cách mà chúng ta đã khắc phục nó rồi đúng không nào? Nếu bạn nào chưa đọc bài viết đó, các bạn có thể xem bài viết đó tại đây nha:
MongoDB - Dynamic Fields: Câu truy vấn chậm đi hơn 200% vì một sai lầm - Bạn biết chưa?
Sau khi đọc xong, bạn đã sẵn sàng để đọc tiếp bài này rồi đấy, bắt đầu thôi
1. Bài toán
Trước tiên, cùng mình nhìn lại schema cuối cùng mà chúng ta đã có sau khi kết thúc bài trước ở bảng analyst_optimized
nhé:
{ "_id": { "$oid": "6827eff09b43f525fcf9c3b8" }, "userId": 2, "date": "2024-01-01", "value": 81
}
Bây giờ, bài toán mới của chúng ta là:
Viết API (Query) hỗ trợ Admin có thể truy vấn thông tin theo ngày/tháng/năm tùy chỉnh. Lưu ý rằng, Admin có thể chỉ chọn 1 hoặc nhiều điều kiện trong 3 loại:
- Ngày
- Tháng
- Năm
để truy vấn. Ví dụ:- Truy vấn tất cả những bản ghi trong ngày
20
và tháng01
. Không quan tâm đến năm- Truy vấn tất cả những bản ghi trong tháng
02
. Không quan tâm đến ngày và năm
Okay, nhìn vào yêu cầu bài toán, chúng ta sẽ biết được mình sẽ cần truy vấn dựa trên field nào rồi đúng không nào? Đó chính là field date
2. Hiệu chỉnh dữ liệu
Như các bạn thấy, với schema ở bài trước, ta đang định nghĩa trường date
có kiểu dữ liệu là String
. Tuy nhiên, thực tế, để tiện cho quá trình truy vấn mà không phải dùng đến những phép toán phức tạp, ta nên lưu chúng dưới dạng Date
. Từ giờ các bạn có thể hiểu rằng, trường date
của chúng ta sẽ có dữ liệu dạng Date
nhé
Với những ai quan tâm đến phần thực nghiệm ở bài trước, các bạn có thể xem phần Thực nghiệm của bài này ở phía dưới, mình sẽ cung cấp code để các bạn thực hiện việc chuyển đổi nha
3. Thử truy vấn và nhìn nhận vấn đề
Ở bài trước, chúng ta đã tạo 1 INDEX mới là: userId_1_date_1
vì truy vấn của chúng ta dựa trên 2 trường userId
và date
. Tuy nhiên, bây giờ ta chỉ cần truy vấn trên trường date
mà thôi. Do đó, mình sẽ tạo thêm 1 INDEX cho trường date
này nhé:
Ví dụ, để tìm những documents trong tháng
02
, năm2024
, truy vấn của chúng ta sẽ là:
{ $expr: { $and: [ { $eq: [{ $year: "$date" }, 2024] }, { $eq: [{ $month: "$date" }, 2] } ] }
}
Thử chạy truy vấn cùng EXPLAIN
xem kết quả ra sao nha:
Phân tích chút nào:
- Số documents thỏa mãn điều kiện:
2_064_465
(chiếm khoảng 15% tổng số documents (13_750_254
)) - INDEX được sử dụng:
0
, dẫn đến câu truy vấn trên đã phải quét qua toàn bộ13_750_254
documents của bảng - Thời gian thực thi: Khoảng
11.8s
Lạ nhỉ, rõ ràng chúng ta đã đánh INDEX cho trường date
rồi mà? Truy vấn cũng không hể có bất kì điều kiện nào khác.
Vâng, lý do là vì MongoDB phải tính toán giá trị như
$year
và$month
trong câu truy vấn của chúng ta tại thời điểm thực thi, tức là trên từng document một, vì thế không thể sử dụng INDEX được.
Hmm, thế có cách nào khác không nhỉ? À, mình có thể truy vấn so sánh trực tiếp trên trường date
luôn cho ví dụ này:
{ date: { $gte: ISODate("2024-02-01T00:00:00.000Z"), $lt: ISODate("2024-03-01T00:00:00.000Z") }
}
Thử EXPLAIN
câu truy vấn này xem sao nhé:
Oh NICE!!! Dữ liệu trả về đúng rồi, và điều đặc biệt là chúng ta đã dùng được INDEX trên trường date
, nhờ vậy mà thời gian thực thi chỉ còn khoảng 2s
, số lượng documents được quét qua để kiểm tra cũng giảm hẳn 85% rồi nè
Tuy nhiên, đừng vội mừng, giờ thử với yêu cầu mới như sau nha:
Tìm những document có tháng là
02
. Không quan tâmngày
vànăm
Với trường hợp này, chúng ta không thể dùng truy vấn so sánh ở trên nữa, mà buộc phải dùng toán từ $month
để truy vấn:
{ $expr: { $and: [ { $eq: [{ $month: "$date" }, 2] } ] }
}
Và như đã nói, INDEX trên trường date
của chúng ta vô dụng trong trường hợp này
4. Giải quyết vấn đề
Để giải quyết vấn đề, điều cần làm là suy nghĩ làm thế nào để tạo được INDEX và tận dụng được nó cho những truy vấn dạng filter đặc biệt như này. Và với bài toán kinh điển này, ta có 1 cách giải quyết vô cùng đơn giản nhưng cực kỳ hiệu quả, đó chính là:
Tách riêng ngày/tháng/năm cho mỗi document và tạo INDEX dựa trên các trường đó. Từ đó, khi thực hiện truy vấn, ta có thể tận dụng được INDEX dù cho có bỏ qua 1 hay nhiều điều kiện bên trên
Hãy thử nhé, schema lúc này của chúng ta sẽ trông như thế này:
{ "_id": { "$oid": "6827eff09b43f525fcf9c3f4" }, "userId": 4, "date": { "$date": "2024-02-01T00:00:00.000Z" }, "value": 74, "day": 1, "month": 2, "year": 2024
}
Ta sẽ đánh INDEX trên cả 3 field này nhé:
Rồi, giờ cùng nhìn lại 2 ví dụ ở trên nhé:
Ví dụ 1: Tìm những documents trong tháng
02
, năm2024
{month: 2, year: 2024}
và đây là kết quả:
Ví dụ 2: Tìm những document có tháng là
02
. Không quan tâmngày
vànăm
{month: 2}
Tương tự, xem chiến lược nào:
Các bạn có thể thấy, ta đã tận dụng được INDEX rồi, các bạn cũng có thể thử khi truy vấn với từng điều kiện kết hợp giữa ngày/tháng/năm nhé
5. Thực nghiệm
Trước tiên, ta cần chuyển đổi dữ liệu của trường date
từ String
sang Date
. Các bạn có thể tạo thêm 1 file, mình đặt tên là convert.js
để ta chuyển đổi kiểu dữ liệu cho trường date
và lưu vào 1 bảng mới tên là analyst_date
nhé:
const { MongoClient } = require("mongodb"); const uri = "mongodb://localhost:27017";
const dbName = "demo_dynamic_fields";
const sourceCollectionName = "analyst_optimized";
const targetCollectionName = "analyst_date";
const BATCH_SIZE = 1000; async function convert() { const client = new MongoClient(uri); try { await client.connect(); const db = client.db(dbName); const sourceCollection = db.collection(sourceCollectionName); const targetCollection = db.collection(targetCollectionName); console.log("Connected to MongoDB"); // Optional: clear target collection first await targetCollection.deleteMany({}); console.log("Cleared target collection"); let totalInserted = 0; let batch = []; const cursor = sourceCollection.find({}); for await (const doc of cursor) { const convertedDoc = { ...doc, date: new Date(doc.date), }; batch.push(convertedDoc); if (batch.length >= BATCH_SIZE) { await targetCollection.insertMany(batch); totalInserted += batch.length; console.log(`Inserted ${totalInserted} documents`); batch = []; } } if (batch.length > 0) { await targetCollection.insertMany(batch); totalInserted += batch.length; console.log(`Inserted ${totalInserted} documents`); } console.log("Conversion complete!"); } catch (err) { console.error(err); } finally { await client.close(); }
} convert();
Chạy lệnh node convert.js
để chạy file trên nha.
Tiếp theo, mình tạo thêm 1 file split_date.js
để tách riêng các trường day
, month
, year
từ field date
nhé. File này viết 2 chiều nha, bao gồm cả việc xóa 3 field này khỏi schema nếu các bạn muốn hủy nó
const { MongoClient } = require("mongodb"); const uri = "mongodb://localhost:27017";
const dbName = "demo_dynamic_fields";
const collectionName = "analyst_date";
const BATCH_SIZE = 1000; const option = process.argv[2]; // 'add' or 'remove' if (!option || (option !== "add" && option !== "remove")) { console.error('Please provide an option: "add" or "remove"'); process.exit(1);
} async function splitDate() { const client = new MongoClient(uri); try { await client.connect(); const db = client.db(dbName); const collection = db.collection(collectionName); console.log("Connected to MongoDB"); let totalUpdated = 0; let batch = []; const cursor = collection.find({}); for await (const doc of cursor) { let updateDoc; if (option === "add") { const date = doc.date; updateDoc = { $set: { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate(), }, }; } else { updateDoc = { $unset: { year: "", month: "", day: "", }, }; } batch.push({ updateOne: { filter: { _id: doc._id }, update: updateDoc, }, }); if (batch.length >= BATCH_SIZE) { await collection.bulkWrite(batch); totalUpdated += batch.length; console.log(`Updated ${totalUpdated} documents`); batch = []; } } if (batch.length > 0) { await collection.bulkWrite(batch); totalUpdated += batch.length; console.log(`Updated ${totalUpdated} documents`); } console.log("Operation complete!"); } catch (err) { console.error(err); } finally { await client.close(); }
} splitDate();
Bây giờ, để thêm 3 fields trên vào schema, ta chạy lệnh:
node split_date.js add
Để remove 3 fields đó sau khi test, bạn chạy lệnh:
node split_date.js remove
Được rồi, vậy là xong setup, nhiệm vụ tiếp theo là đánh INDEX cho 3 fields day
, month
, year
trong bảng analyst_date
và thực nghiệm nội dung bài này thôi nhé
6. Cái giá phải trả
À thì, như bài trước, chúng ta sẽ phải hy sinh 1 chút về memory cho phương án này nhé. Còn đánh đổi hay không thì mình đã nêu lý do ở bài trước rồi nha.
7. After-credit
Bí mật về INDEX trong MongoDB chưa hết đâu, đừng vội "rời rạp" nhé. Hẹn gặp lại các bạn ở bài viết tiếp theo
Trong thời gian đó, đừng quên upvote/bookmark hoặc để lại ý kiến nếu bài này hữu ích với các bạn nhé. Nếu muốn connect với mình, bạn có thể connect qua:
- Linkedin: https://www.linkedin.com/in/phuc-ngo-728433346
- Viblo: https://viblo.asia/u/NHDPhucIT
- Patreon: https://www.patreon.com/felix_ngo