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?

0 0 0

Người đăng: Đức Phúc

Theo Viblo Asia

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é:

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 collection
  • metrics: Trọng tâm chính là field này đây. Nó là 1 Object với key 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ó userId2 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: image.png

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 đổi
  • date: Thay vì làm key 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 INDEX
  • userId: 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ộ đôi userIddate
  • value: Giá trị số lượt click tương ứng với date

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: image.png

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à userIddate 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ả: image.png

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 đó image.png

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_MAXMETRICS_MIN nên số lượng records trong bảng analyst_optimizedcủ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é: image.png

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 image.png

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

image.png

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é

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!!!

Bình luận

Bài viết tương tự

- vừa được xem lúc

Mô hình quan hệ - thực thể (Entity – Relationship Model)

Mô hình quan hệ thực thể (Entity Relationship model - E-R) được CHEN giới thiệu vào năm 1976 là một mô hình được sử dụng rộng rãi trong các bản thiết kế cơ sở dữ liệu ở mức khái niệm, được xây dựng dựa trên việc nhận thức thế giới thực thông qua tập các đối tượng được gọi là các thực thể và các mối

0 0 139

- vừa được xem lúc

[Embulk #1] Công cụ giúp giảm nỗi đau chuyển đổi dữ liệu

Embulk là gì. Embulk là một công cụ open source có chức năng cơ bản là load các record từ database này và import sang database khác.

0 0 69

- vừa được xem lúc

Window Functions trong MySQL, Nâng cao và cực kì hữu dụng (Phần II).

Chào mọi người, lại là mình đây, ở phần trước mình đã giới thiệu với mọi người về Window Functions Phần I. Nếu chưa rõ nó là gì thì mọi người nên đọc lại trước nha, để nắm được định nghĩa và các key words, tránh mắt chữ O mồm chứ A vì phần này mình chủ yếu sẽ thực hành với các Window Functions.

0 0 118

- vừa được xem lúc

Window Functions trong MySQL, Nâng cao và cực kì hữu dụng (Phần I).

Chào mọi người, mình mới tìm hiểu đc topic Window Functions cá nhân mình cảm thấy khá là hay và mình đánh giá nó là phần nâng cao. Vì ít người biết nên Window Functions thấy rất ít khi sử dụng, thay vì đó là những câu subquery dài dằng dặc như tin nhắn nhắn cho crush, và người khác đọc hiểu được câu

0 0 989

- vừa được xem lúc

Disable và Enable trigger trong Oracle

Origin post: https://www.tranthanhdeveloper.com/2020/12/disable-va-enable-trigger-trong-oracle.html.

0 0 51

- vừa được xem lúc

Lưu trữ dữ liệu với Data Store

. Data Store là một trong những componet của bộ thư viện Android JetPack, nó là một sự lựa chọn hoàn hảo để thay thế cho SharedPreferences để lưu trữ dữ liệu đơn giản dưới dạng key-value. Chúng ta cùng làm một so sánh nhỏ để thấy sự tối ưu của Data Store với SharedPreferences nhé.

0 0 78