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
Cũng đã lâu rồi, mình không viết về chủ đề Optimization. Cũng vô tình, hôm nay mình review 1 dự án cho học viên của mình, thấy được vấn đề về cách mà bạn ấy thiết kế database cho CSDL MongoDB, nên là lên luôn bài viết này cho mọi người nhé!
Đây cũng là sai lầm mà mình thấy rất nhiều người gặp phải, kể cả chính mình khi mới bước vào nghề. Hãy cùng mình xem nó là gì nha
1. Bài toán
Bạn có 1 hệ thống yêu cầu theo dõi, phân tích từng User sẽ click vào 1 tính năng X bao nhiêu lần trong 1 ngày. Như vậy, mỗi ngày, chúng ta sẽ cần truy vấn theo 2 điều kiện:
- User có ID:
U
(Number)- Ngày có giá trị là:
YYYY-MM-DD
2. Cách thiết kế sai lầm
Okay, và đây là cách thiết kế mà rất nhiều người lựa chọn khi làm việc với MongoDB, 1 CSDL NoSQL
{ "_id": { "$oid": "6827ee217d8223b91335a17d" }, "userId": 2, "metrics": { "2024-01-01": 76, "2024-01-02": 12, "2024-01-03": 83, "2024-01-04": 61, "2024-01-05": 23, ... }
}
Bạn có thể thấy, model (collection) lúc này có 3 fields:
_id
: ID của record (document)userId
: ID của User. Với cách thiết kế này, userId là unique field, tức là mỗi User chỉ có duy nhất 1 document trong collectionmetrics
: Trọng tâm chính là field này đây. Nó là 1 Object vớikey
là ngày tháng năm, vàvalue
là số lượt click trong ngày vào tính năng X tương ứng cho ngày đó. Như vậy, rõ ràng,metrics
lúc này chính là Dynamic Field mà mình nhắc đến, vìkey
của nó không hề được cố định trước, mà nó tùy biến theo ngày ghi nhận
Hmm, vậy thì sai lầm của phương án này là đâu nhỉ? Giờ cùng mình thử 1 truy vấn để lấy ra số lượt Click của user có userId
là 2
trong ngày 2024-01-01
nhé! Bảng dữ liệu hiện có mình mô phòng là 500_000 document (user) nha
{"metrics.2024-01-01": {$exists: true}, userId: 2}
Rồi, hãy xem kết quả và phân tích chiến lược thực thi của câu lệnh trên một chút nha:
Như hình trên mình lấy từ Mongo Compass khi thực hiện truy vấn, bạn có thể thấy 1 số điểm như nhau:
- Số document được trả về: 1 (vì bảng này mỗi userId chỉ có 1 document nhé)
- Plan thực thi:
COLLSCAN
: Giá trị này mang ý nghĩa là câu truy vấn trên sẽ quét toàn bộ collection để tìm kiếm data - Số lượng document được kiểm tra có phù hợp với truy vấn hay không: 500K
- Thời gian thực thi:
250ms
- Index: Không có Index nào được sử dụng
Các bạn thấy đấy, lấy duy nhất 1 document, nhưng lúc này MongoDB sẽ phải quét qua toàn bộ document có trong collection (500K). Thật sự là rất không ổn. Phí tài nguyên và thời gian của nó đúng không nào?
Lúc này, chúng ta sẽ nghĩ đến việc đánh INDEX cho bảng này. Nhưng đánh thế nào đây?
Thực tế, khi thực hiện truy vấn này, chúng ta không biết trước được ngày mà chúng ta sẽ truy vấn là ngày nào, có bao nhiêu ngày được lưu trong DB. Ví dụ thay vì truy vấn ngày 2024-01-01
, nó có thể là ngày 2024-02-02
. Hiển nhiê, chúng ta không thể đánh INDEX tràn lan cho tất cả các ngày theo kiểu metrics_2024-01-01: ASC/DESC
được, vì lúc đó số INDEX sẽ cực kì lớn vì mỗi ngày sẽ có 1 INDEX, mà ngày thì lưu sao cho đủ
Có thể, các bạn thấy 250ms là con số nhỏ, sao phải quan tâm. Tuy nhiên, mình thấy con số này không nhỏ, vì việc truy vấn này có thể diễn ra liên tục, đồng thời, hoặc khi số lượng document tăng lên đáng kể, thời gian cũng sẽ tăng theo
3. Giải pháp và hiệu quả nó mang lại
Bây giờ, để có thể tối ưu được, chúng ta cần tìm một cách thiết kế sao cho ta có thể đánh được INDEX. Tức là chuyển từ dynamic field sang statis field (field tĩnh)
Chúng ta cùng xem một số documents trong schema mới lúc này nhé:
{ "_id": { "$oid": "6827eff09b43f525fcf9c3b2" }, "userId": 1, "date": "2024-01-02", "value": 62
},
{ "_id": { "$oid": "6827eff09b43f525fcf9c3b4" }, "userId": 1, "date": "2024-01-04", "value": 22
},
{ "_id": { "$oid": "6827eff09b43f525fcf9c3c1" }, "userId": 2, "date": "2024-01-10", "value": 38
}
...
Nhìn vào đây, ta thấy mỗi document bây giờ ngắn gọn hơn đúng không nào. Và điều cần lưu ý là:
_id
: Field này giữ nguyê, không có gì thay đổidate
: Thay vì làmkey
trong một Object của môtk dynamic field, ta sẽ tách mỗi ngày đó thành 1 document. Chính nhờ điều này, chúng ta đã có 1 cái tên cố định làdate
để có thể đánh INDEXuserId
: Về ý nghĩa, field này cũng không có gì thay đổi. Tuy nhiên, trường này sẽ không còn là unique field nữa. Mà nó có thể nhiều document cùng userId. Unique bây giờ của chúng ta sẽ là bộ đôiuserId
vàdate
value
: Giá trị số lượt click tương ứng vớidate
Bằng cách tách ra như thế này, với ví dụ từ 500_000 documents như cách thiết kế đầu tiên, mình có tổng cộng 1_375_0254 documents. Con số này có thể khác ở các bạn, khi các bạn kiểm nghiệm thực tế bằng code ví dụ ở phần 4 của bài này nhé. Nhưng điều bạn cần nắm là số document tăng lên chính là vì chúng ta tách date
ra thành 1 field cố định từ 1 Object cho mỗi User
Okay, nhìn như này thì số documents lớn quá nhỉ. Nhưng giờ chúng ta có thể đánh INDEX được rồi. Mình sẽ đánh INDEX cho bảng này như sau:
Chúng ta có 2 index:
-_id_
: Đây là INDEX mặc định mà MongoDB tạo cho chúng ta, phần này không cần quan tâm trong bài toán này bạn nhé
userId_1_date_1
: Đây chính là INDEX mà mình tạo. Vì câu truy vấn của chúng ta gồm 2 điều kiện là userId
và date
như đã nói ở trên, do đó chúng ta cần một Compound Index cho 2 trường này. Order (thứ tự) mình chọn là ASC (1) nhé
Rồi, thử làm câu truy vấn nào, vẫn là 2 điều kiện như ở trên, chỉ khác là chúng ta sẽ dùng trường date
thay cho trường metrics
:
{date: "2024-01-10", userId: 2}
Và đây là kết quả:
Wow!!! Chỉ mất đúng 1ms
cho câu truy vấn này. Và các bạn cũng có thể thấy, truy vấn chúng ta đã quét qua INDEX mà chúng ta tạo trước đó
Chính nhờ điều này, mà plan thực thi của chúng ta là IXSCAN
, tốt hơn rất nhiều thay vì quét đầy đủ bảng như COLLSCAN
Tuyệt vời, vậy là đã giảm thời gian truy vấn đi 250% đúng không nào?
4. Bạn chưa tin con số ở trên? Thực nghiệm nhé
Okay, giờ chúng ta sẽ thực nghiệm với Node.js nhé
- Đầu tiên, bạn tạo cho mình 1 file
package.json
để cài đặt MongoDB nhé:
{ "name": "demo-dynamic-fields", "version": "1.0.0", "description": "Seed script for inserting documents into MongoDB", "main": "seed.js", "scripts": { "seed": "node seed.js" }, "dependencies": { "mongodb": "^5.7.0" }, "author": "Duc Phuc", "license": "ISC"
}
Đừng quên chạy lệnh yarn install
để cài đặt package nha
Rồi, bây giờ, tạo thêm 1 field nữa để tạo fake data nha. MÌnh tạo 1 file tên là seed.js
const { MongoClient, ObjectId } = require("mongodb"); const uri = "mongodb://localhost:27017";
const dbName = "demo_dynamic_fields"; // Change as needed
const collectionName = "analysts"; // Change as needed const TOTAL_DOCS = 500_000;
const BATCH_SIZE = 1000;
const METRICS_MIN = 5;
const METRICS_MAX = 50;
const START_DATE = new Date("2024-01-01"); function pad(n) { return n < 10 ? "0" + n : n;
} function formatDate(date) { return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad( date.getDate() )}`;
} function generateMetrics() { const numMetrics = Math.floor(Math.random() * (METRICS_MAX - METRICS_MIN + 1)) + METRICS_MIN; const metrics = {}; let date = new Date(START_DATE); for (let i = 0; i < numMetrics; i++) { const dateStr = formatDate(date); metrics[dateStr] = Math.floor(Math.random() * 100); date.setDate(date.getDate() + 1); } return metrics;
} async function seed() { const client = new MongoClient(uri); try { await client.connect(); const db = client.db(dbName); const collection = db.collection(collectionName); const optimizedCollection = db.collection("analyst_optimized"); console.log("Connected to MongoDB"); // Optional: clear collection first console.log("Clean previous data if it's exists"); await collection.deleteMany({}); await optimizedCollection.deleteMany({}); for (let i = 0; i < TOTAL_DOCS; i += BATCH_SIZE) { const batch = []; const optimizedBatch = []; for (let j = 0; j < BATCH_SIZE && i + j < TOTAL_DOCS; j++) { const userId = i + j + 1; const metrics = generateMetrics(); batch.push({ userId, metrics, }); // Prepare optimized documents for (const [date, value] of Object.entries(metrics)) { optimizedBatch.push({ userId, date, value, }); } } await collection.insertMany(batch); // Insert into analyst_optimized await optimizedCollection.insertMany(optimizedBatch); console.log(`Inserted ${i + batch.length} / ${TOTAL_DOCS}`); } console.log("Seeding complete!"); } catch (err) { console.error(err); } finally { await client.close(); }
} seed();
Code cũng khá dễ hiểu, nó gồm 2 phần:
- Tạo 500__000 documents vào bảng
analyst
theo cách thiết kế sai lầm đầu tiên - Từ dữ liệu của bảng trên, ta sẽ tách ra theo phương án thiết kế tối ưu ở trên vào bảng
analyst_optimized
Lưu ý: Vì số lượng records cho mỗi User được mình random giữa
METRICS_MAX
vàMETRICS_MIN
nên số lượng records trong bảnganalyst_optimized
của mỗi bạn sẽ khác nhau nhé. Nhưng đâu đó cũng sẽ khoàng 1 triệu records
Tiếp theo, bạn chạy lệnh sau để import dữ liệu vào DB nhé:
node seed.js
Sau khi dữ liệu được import vào DB, bạn tạo INDEX cho nó nhé:
Việc cuối cùng là truy vấn ở:
- Bảng
analyst
cho phương án thiết kế đầu tiên - Bảng
analyst_optimized
cho phương án thiết kế tối ưu
Câu truy vấn mình đã đính kèm ở trên cho mỗi phần rồi nhé. Với Mongo Compass, bạn có thể bấm nút Explain thay vì nút Find để xem được chiến lược thực thi cho mỗi câu truy vấn
Nếu bạn không có Mongo Compass, bạn có thể dùng Mongo CLI kèm lệnh explain
Lưu ý quan trọng: Ở lần truy vấn đầu tiên ở bảng
analyst_optimized
, các bạn có thể sẽ thấy không có INDEX nào được sử dụng, thời gian thực thi cũng lâu. Lý do là ở lần đầu tiên, MongoDB lúc này mới kiểm tra data và đánh INDEX vào memory nhé. Bạn chỉ cần chạy lại lần 2 là được nha
5. Sự đánh đổi cho phương án này
Tất nhiên, điều gì cũng có cái giá của nó. Thời gian thực thi của chúng ta đã giảm đi hơn 200%, nhưng thứ chúng ta cần đánh đổi lúc này chính là memory (bộ nhớ) dùng để đánh INDEX. Như hình này, bạn có thể thấy INDEX chúng ta chiếm khoảng 160.3MB
Ngoài ra, việc tăng số documents trong bảng analyst_optimized
bằng cách tách date
làm 1 field, sẽ làm tăng bộ nhớ cần dùng để chứa hơn 1 triệu documents:
- Bảng
analyst
: 116.4MB - Bảng
analyst_optimized
: 243.1MB
Vậy, sự đánh đổi này có đáng không?
Câu trả lời còn tùy thuộc vào bạn. MÌnh đã thấy đâu đó một câu nói thế này:
Không có giải pháp nào tốt nhất cho 1 vấn đề, mà hãy tìm giải pháp phù hợp nhất cho nó
Như vậy, bạn chọn memory hay thời gian thực thi để đánh đổi, nó tùy thuộc vào dự án của bạn nhé!!! Nhưng nếu là mình, mình sẵn sàng đánh đổi. Vì điều này làm tăng hiệu suất cho dự án của chúng ta, và đương nhiên là tăng trải nghiệm cho chính khách hàng của mình
6. Kết bài
Cũng dài quá rồi, mình hy vọng bài viết này sẽ giúp các bạn có thêm góc nhìn về Dynamic Field khi thiết kế, cũng như tận dụng được INDEX cho cách thiết kế của mình, tránh những sai lầm như trên nhé
Một lần nữa, đừng quên connect với mình để cùng trao đổi nhé
- Linkedin: https://www.linkedin.com/in/phuc-ngo-728433346
- Viblo: https://viblo.asia/u/NHDPhucIT
- Patreon: https://www.patreon.com/felix_ngo
Và cũng đừng quên Upvote, Bookmark bài viết này nếu bạn thích nó nhé. Cám ơn các bạn rất nhiều và hẹn gặp lại!!!