Anh em từ SQL bước sang MongoDB thường vấp đúng ba chỗ. Chỗ thứ nhất là gọi tên sự vật: table, row, column giờ thành collection, document, field. Chỗ thứ hai là kiến trúc: cài xong rồi nhưng bên trong nó chạy bằng cái gì thì không biết. Chỗ thứ ba đắt nhất, là hiệu năng: dữ liệu ít thì chạy ngon, dữ liệu nhiều lên thì đứng hình.
Bài này tôi đi trọn cả ba chỗ đó theo đúng thứ tự một người mới nên đi. Toàn bộ số liệu trong bài lấy từ đúng buổi tôi ngồi làm trên máy, giả lập 5 triệu document rồi đo từng bước.
Video gốc dài 1 giờ 17 phút. Bài viết này gói lại phần cốt lõi, bổ sung mốc phiên bản và các giới hạn chính thức mà một video quay cuối 2023 chưa nói tới.
Ai đang muốn hiểu vì sao MongoDB lúc nhanh lúc chậm ở tầng chọn chiến lược thực thi thì đọc riêng bài MongoDB lúc nhanh lúc chậm, nguyên nhân từ kiến trúc ít người để ý. Bài đó mổ tầng query planner. Bài này là bài nhập môn trọn vẹn.
Bốn khái niệm cơ bản nhất ánh xạ một đổi một. Anh em nào đã làm với cơ sở dữ liệu quan hệ thì học MongoDB rất nhanh, vì chỉ đổi tên chứ tư tưởng vẫn thế.
| Cơ sở dữ liệu quan hệ | MongoDB | Khác biệt cần nhớ |
|---|---|---|
| Database | Database | Giống nhau |
| Table | Collection | Không phải khai báo trước |
| Column | Field | Không phải định nghĩa kiểu dữ liệu trước |
| Row | Document | Lưu dạng BSON, hai document trong cùng một collection được phép khác cấu trúc |
| Index | Index | Đánh trên field thay vì trên cột |
Điểm đáng nói nhất nằm ở dòng cuối bảng trên và ở chỗ này: MongoDB cho anh em nhúng dữ liệu.
Trong cơ sở dữ liệu quan hệ, anh em tách bảng user và bảng address, nối với nhau bằng một cột chung, muốn lấy đủ thông tin thì phải join. Sang MongoDB, cả hai cụm thông tin đó nằm gọn trong một document. Lấy ra là lấy trọn, không join gì cả.

Đấy là cái hay đầu tiên anh em gặp. Nhưng nó không miễn phí.
Được: đọc nhanh vì không join, dữ liệu đi cùng nhau thì nằm cùng nhau.
Mất: một document BSON tối đa 16 mebibyte, đây là giới hạn cứng của MongoDB, không cấu hình lên được (MongoDB Manual, Documents). Nhúng một mảng mà mảng đó cứ dài mãi ra theo thời gian thì sớm muộn document sẽ đụng trần. Chính hãng xếp cái này vào nhóm mẫu thiết kế sai, gọi tên là mảng không có biên (MongoDB Manual, Avoid Unbounded Arrays).
Nên nguyên tắc của tôi rất đơn giản. Cụm dữ liệu nào có số lượng chốt được thì nhúng. Cụm nào lớn không kiểm soát được thì tách ra collection riêng rồi tham chiếu, đúng như cách anh em vẫn làm với khóa ngoại.
Có ba mô hình kinh điển, và ba cái này thật ra là ba nấc của cùng một câu chuyện: một máy chủ, rồi nhiều bản sao, rồi chia nhỏ dữ liệu ra nhiều cụm.
| Mô hình | Cách chạy | Dùng khi nào | Nhược điểm |
|---|---|---|---|
| Standalone | Một máy chủ, một database | Học, dựng thử, môi trường phát triển | Máy chủ chết là ứng dụng chết theo |
| Replication | Một primary nhận ghi, các secondary đồng bộ theo | Hệ thống cần chạy liên tục | Vẫn chỉ một bản dữ liệu đầy đủ trên mỗi máy |
| Sharding | Dữ liệu chia ngang ra nhiều cụm, mỗi cụm lại là một replica set | Dữ liệu lớn, muốn mở rộng ngang | Vận hành phức tạp hơn hẳn |
Mô hình standalone thì đúng như tên. Một con máy chủ, cài MongoDB lên đó, mọi thao tác đọc ghi đều đổ về đấy. Dựng vài phút là xong. Nhược điểm chí mạng là tính sẵn sàng. Database chết thì ứng dụng tính downtime ngay. Anh em làm ngân hàng hay chứng khoán thì không ai dám chịu rủi ro kiểu này.
Replication đẻ ra để chữa đúng chỗ đó. Một ông làm chủ, gọi là primary, chịu mọi thao tác sửa đổi dữ liệu. Ông primary đẩy oplog sang các con secondary để chúng nó cập nhật theo. Primary chết thì một secondary được bầu lên thay, ứng dụng chạy tiếp mà không đứt.
Sharding thì tư tưởng khác hẳn. Thay vì mỗi máy chứa trọn vẹn dữ liệu, dữ liệu bị chặt theo chiều ngang rồi rải ra nhiều cụm.

Nhìn sơ đồ anh em thấy ba tầng. Trên cùng là router, chính là tiến trình mongos, ứng dụng nói chuyện với nó chứ không nói thẳng với từng shard. Dưới đó là các shard. Và bên trong mỗi shard lại là một replica set đầy đủ, một primary kèm hai secondary.
Tôi hay lấy ví dụ dữ liệu cư dân cả nước. Hà Nội đông dân thì cho hẳn hai cụm máy chủ ôm riêng. Thái Bình ít hơn thì một cụm. Cụm chứa Hà Nội hoàn toàn không có dữ liệu tỉnh khác. Được cái gì? Được chỗ này: hết chỗ thì cắm thêm máy chủ vào, dữ liệu tràn ra, hiệu năng tăng theo chiều ngang. Không phải cứ chăm chăm cắm thêm RAM với CPU vào một con máy như cách kinh điển.
Mặc định là không. Ứng dụng gửi mọi lệnh đọc về primary, đây là chế độ primary của read preference. Muốn secondary phục vụ đọc thì phải đổi read preference một cách chủ động.
Và đổi rồi thì phải chấp nhận cái giá của nó. Mọi chế độ khác primary đều có thể trả về dữ liệu cũ, vì secondary đồng bộ theo kiểu bất đồng bộ (MongoDB Manual, Read Preference). Anh em nào tính dùng secondary để giảm tải cho primary thì phải hỏi trước một câu: nghiệp vụ của mình chịu được dữ liệu trễ mấy giây không?
Còn chuyện dựng thử, MongoDB Atlas cho một cụm miễn phí. Cụm này chạy sẵn replica set ba node, không cấu hình được bộ nhớ, không bật được backup, và hiện Atlas chạy MongoDB 8.0 cho gói miễn phí (MongoDB Atlas, Free Cluster Limits). Trong video tôi đăng ký gói này với dung lượng 512 MB. Học thì quá đủ, chạy thật thì không.
Kiến trúc của mọi database đều gồm ba mảng: một vùng bộ nhớ, một tập file nằm trên đĩa, và các tiến trình chuyển dữ liệu qua lại giữa hai chỗ đó. MongoDB cũng vậy thôi.
Người dùng không bao giờ chọc thẳng vào file. Bao giờ cũng phải đi qua bộ nhớ. Nhiều phiên cùng làm việc trên một phần dữ liệu thì lấy luôn trên bộ nhớ, khỏi phải xuống đĩa. Cái việc chạm vào file liên tục mới là thứ giết hiệu năng.
Vậy thứ gì quyết định chuyện đó? Giải thuật. Khi nào đẩy dữ liệu xuống file, khi nào kéo lên, giữ bao lâu thì dọn, ghi với tần suất bao nhiêu. Toàn bộ chuyện đó gói trong một từ khóa duy nhất anh em cần nhớ: storage engine.
Tôi hay ví database như cái xe. Vỏ xe màu gì thì không quan trọng. Quan trọng là cái máy bên trong. Storage engine chính là cái máy đó, và MongoDB cho phép cắm nhiều loại máy khác nhau.

Sơ đồ trên chia đúng hai tầng. Tầng Host Memory là bộ nhớ của máy chủ, trong đó tiến trình mongod giữ các kết nối, còn WiredTiger giữ vùng cache, vùng snapshots và vùng write ahead journal. Bên cạnh còn hai vùng riêng cho việc gộp dữ liệu và sắp xếp dữ liệu. Tầng Disk Storage bên dưới là ba nhóm file: datafiles, journal và temporary files.
Ba cái tên anh em sẽ đọc thấy trong tài liệu:
Dưới đĩa, WiredTiger giữ tập data file cho collection và index, thêm file journal đóng vai nhật ký ghi trước, thêm file tạm. File journal chính là thứ tương đương redo log của Oracle hay WAL của PostgreSQL. Database tắt đột ngột thì nó dựa vào đó khôi phục lại.
Còn tham số bộ nhớ thì lời khuyên của tôi vẫn thế: chưa hiểu sâu thì để mặc định. Kéo bộ nhớ của database lên thật to, chiếm hết bộ nhớ của máy chủ, chưa chắc đã tốt.
Có hai cách, cách nào cũng mất chưa tới một phút.
Cách chính thống là hỏi thẳng server. Cách thứ hai là nhìn tên file trong thư mục data, vì mỗi storage engine đẻ ra bộ file mang tên khác nhau.
// Cach 1: hoi thang server dang chay engine nao
db.serverStatus().storageEngine
// Cach 2: xem thu muc data, thay file mang ten WiredTiger la biet ngay
// Windows: dir E:\MongoDB\data
// Linux: ls -l /var/lib/mongodb
// Doi engine thi sua file cau hinh mongod.cfg, phan storage:
// storage:
// dbPath: E:\MongoDB\data-inmemory
// engine: inMemory
Muốn đổi sang In-Memory thì tạo một thư mục dữ liệu mới, thêm dòng engine vào file cấu hình, bật lại service rồi kiểm tra lại bằng đúng câu lệnh trên. Nhắc lại một lần nữa cho chắc: đường này chỉ mở với bản Enterprise.
Khác ở một điểm gốc: MongoDB không bắt anh em tạo trước. Bên PostgreSQL hay Oracle, muốn insert thì phải có bảng, mà muốn có bảng thì phải khai đủ cột và kiểu dữ liệu. Bên MongoDB, cứ gõ lệnh insert. Chưa có collection thì nó tự tạo. Chưa có database thì cũng tự tạo luôn.
Đây là bảng đối chiếu để anh em tra nhanh.
| Việc cần làm | SQL | MongoDB |
|---|---|---|
| Thêm một bản ghi | INSERT INTO ... VALUES | insertOne() |
| Thêm nhiều bản ghi | INSERT nhiều dòng | insertMany() |
| Lấy tất cả | SELECT * | find() |
| Giới hạn số dòng | LIMIT n | .limit(n) |
| Lọc theo điều kiện | WHERE | tham số thứ nhất của find() |
| Lớn hơn, nhỏ hơn | > < | $gt $lt |
| Kết hợp điều kiện | AND OR | $and $or |
| Sắp xếp | ORDER BY | .sort({ field: 1 }) |
| Sửa một dòng | UPDATE có điều kiện hẹp | updateOne() |
| Sửa nhiều dòng | UPDATE | updateMany() |
| Xóa | DELETE | deleteOne(), deleteMany() |
| Xóa cả bảng | DROP TABLE | .drop() |
Nhìn cột phải anh em thấy quy luật chưa? Thêm, sửa, xóa đều có cặp One và Many. Nhớ đúng một quy luật đó là dùng được cả bộ.
Chỗ đáng để dừng lại lâu nhất là chuyện cấu trúc document. Trong buổi demo tôi thêm một lúc bốn document vào cùng collection mycollection, và cố tình cho chúng nó khác nhau. Ông Trang có đủ name, age, city. Ông Long thì không có age, không có city, lại có thêm trường phone. MongoDB nhận hết, không kêu một câu.

Thêm một chi tiết dễ bỏ qua. Anh em không hề khai trường _id, nhưng document nào cũng có nó, giá trị là một ObjectId do hệ thống tự sinh. Đó chính là mã duy nhất của document, và nó được đánh index sẵn.
use wecommit
// them mot document
db.mycollection.insertOne({ name: "Tran Quoc Huy", age: 25, city: "Ha noi" })
// loc, sap xep, gioi han
db.mycollection.find({ age: { $gt: 30 } }).sort({ age: -1 }).limit(2)
// ket hop dieu kien
db.mycollection.find({ $and: [ { name: "Tran Quoc Huy" }, { age: 25 } ] })
// sua nhieu ban ghi thoa man dieu kien
db.mycollection.updateMany({ name: "Tran Quoc Huy" }, { $set: { city: "Hue" } })
// xoa nhieu ban ghi
db.mycollection.deleteMany({ age: { $gt: 29 } })
Sự tự do này là con dao hai lưỡi. Nó giúp anh em thay đổi mô hình dữ liệu mà không phải dừng hệ thống. Nhưng nó cũng có nghĩa là không ai chặn giúp anh em khi ứng dụng ghi sai tên trường. Chốt lại, chốt lại: MongoDB không có schema cứng, nên kỷ luật phải nằm ở tầng ứng dụng.
Database nào cũng có thể chậm, MongoDB không phải ngoại lệ. Muốn biết vì sao chậm thì phải xem chiến lược thực thi của câu lệnh, tức là xem nó định lấy dữ liệu bằng giải thuật gì.
Tôi giả lập một collection tên customers trong database wecommit, gồm 5 triệu document, mỗi document có name, age, address, email, phone và nghề nghiệp. Đẩy vào theo lô 10.000 document một lần, vì nhồi cả 5 triệu bằng một lệnh insertMany là lỗi bộ nhớ ngay, còn insertOne từng cái thì chậm chết.
Trước khi tối ưu, phải biết mình đang đứng ở đâu. Chạy db.runCommand({ collStats: "customers" }) và chỉ cần nhìn đúng hai con số. Số count là 5.000.000 document. Số size là 762.109.559 byte, tức khoảng 727 MiB nếu nạp trọn lên bộ nhớ. Trên đĩa thì storageSize chỉ 166.916.096 byte, vì WiredTiger nén dữ liệu lại. Các thông số còn lại tạm bỏ qua, nhìn nhiều quá thành loãng.
Câu lệnh cần chạy là tìm những người tên Huy và có age bằng 35. Thêm .explain("executionStats") vào cuối là thấy hết.
db.customers.find({ name: "Huy", age: 35 }).explain("executionStats")
// Ba cho can nhin trong ket qua tra ve:
// [1] executionStats.totalDocsExamined -> quet bao nhieu document
// [2] executionStats.nReturned -> tra ve bao nhieu document
// [3] winningPlan stage -> COLLSCAN hay IXSCAN

Kết quả lần chạy đầu tiên rất xấu. Giải thuật là COLLSCAN, tức là quét cả collection. Hệ thống lục hết 5.000.000 document để trả về đúng 8.169 cái thỏa mãn, totalKeysExamined bằng 0 vì chẳng có Index nào để đọc. Thời gian thực thi 1.767 mili giây.
Anh em thấy vấn đề chưa? Nó làm thừa gấp sáu trăm lần lượng việc cần làm. Và collection càng phình thì càng chậm.
Bây giờ tạo Index trên đúng một trường age, đặt tên là IDX_AGE, rồi chạy lại. Giải thuật đổi thành IXSCAN, tức là nó chịu dùng Index. Số document phải quét rơi từ 5 triệu xuống còn 100.296. Thời gian rơi từ 1.767 xuống 134 mili giây.

Vì thứ tự cột trong Index. Đây là chỗ tôi muốn anh em nhớ nhất trong cả bài.
Tôi thử tiếp một Index ghép hai trường, đặt tên IDX_EMAIL_AGE, với email đứng trước và age đứng sau. Trường age vẫn nằm trong Index đấy chứ, có mất đi đâu. Vậy mà chạy lại thì giải thuật quay về COLLSCAN, quét lại đủ 5.000.000 document, thời gian vọt lên 1.535 mili giây. Index nằm đó mà vô dụng.
Đổi ngược thứ tự hai trường, tạo IDX_AGE_EMAIL cho age lên trước, mọi thứ trở lại bình thường ngay: IXSCAN, quét 100.296 document, thời gian 182 mili giây.
Lý do nằm trong tài liệu chính chủ. MongoDB chỉ dùng được compound index cho các truy vấn chạm vào tiền tố của Index, tức là nhóm trường đứng đầu tính từ trái sang. Trường không nằm ở tiền tố thì Index coi như không giúp gì (MongoDB Manual, Compound Indexes).
Bước cuối, tôi tạo IDX_NAME_AGE đúng lên hai trường mà câu lệnh đang tìm, là name rồi age. Lần này totalDocsExamined rơi xuống 8.169, đúng bằng nReturned. Quét bao nhiêu trả bấy nhiêu, không thừa một cái. Thời gian còn 16 mili giây.

Đây là đúng bộ lệnh tôi chạy, anh em chép về đổi tên collection với tên trường là dùng được.
// tao index tren mot truong
db.customers.createIndex({ age: 1 }, { name: "IDX_AGE" })
// xem cac index hien co
db.customers.getIndexes()
// bo index cu di truoc khi thu phuong an khac
db.customers.dropIndex("IDX_AGE")
// index ghep, thu tu truong quyet dinh tat ca
db.customers.createIndex({ email: 1, age: 1 }, { name: "IDX_EMAIL_AGE" })
db.customers.createIndex({ age: 1, email: 1 }, { name: "IDX_AGE_EMAIL" })
db.customers.createIndex({ name: 1, age: 1 }, { name: "IDX_NAME_AGE" })
// chay lai explain sau moi lan doi index
db.customers.find({ name: "Huy", age: 35 }).explain("executionStats")
Gói cả năm kịch bản vào một bảng cho dễ nhìn.
| Index đang có | Giải thuật | Document phải quét | Document trả về | Thời gian |
|---|---|---|---|---|
| Chưa có Index | COLLSCAN | 5.000.000 | 8.169 | 1.767 ms |
IDX_AGE trên age | IXSCAN | 100.296 | 8.169 | 134 ms |
IDX_EMAIL_AGE trên email rồi age | COLLSCAN | 5.000.000 | 8.169 | 1.535 ms |
IDX_AGE_EMAIL trên age rồi email | IXSCAN | 100.296 | 8.169 | 182 ms |
IDX_NAME_AGE trên name rồi age | IXSCAN | 8.169 | 8.169 | 16 ms |
Từ 1.767 mili giây xuống 16 mili giây, hơn một trăm lần, mà không đụng một dòng code ứng dụng nào. Đó là sức của Index khi đánh đúng.
Nhưng nhìn dòng thứ ba trong bảng đi. Cũng là Index, cũng hai cột đó, chỉ đảo thứ tự thôi mà tệ ngang lúc chưa có gì. Nên tư duy "cứ đánh Index là nhanh" là tư duy hỏng. Câu chuyện thứ tự cột này không riêng gì MongoDB, tôi có phân tích chuyện tương tự trên PostgreSQL ở bài Tầm quan trọng của thứ tự các cột trong Index PostgreSQL, và trên Oracle với SQL Server ở bài Vì sao có Index mà câu SQL vẫn chậm.
Phần này là những thứ tài liệu quảng cáo ít nói, còn người đã vận hành thì gặp suốt.
Một document tối đa 16 mebibyte. Không nới được. Muốn chứa file lớn hơn thì phải dùng GridFS. Nhúng thoải mái nhưng nhúng có biên.
Một collection tối đa 64 Index, một compound index tối đa 32 field (MongoDB Manual, Limits and Thresholds). Con số nghe rộng, nhưng mỗi Index thêm vào là một lần ghi thêm mỗi khi insert hay update. Index không miễn phí.
Schema linh hoạt không có nghĩa là không cần thiết kế. Không ai chặn giúp anh em khi ứng dụng ghi nhầm tên trường. Lỗi kiểu này không nổ lúc ghi, nó nổ lúc đọc, mấy tháng sau.
Giao dịch nhiều document có, nhưng đắt. MongoDB hỗ trợ transaction phân tán trên replica set và sharded cluster. Chính hãng nói thẳng: trong phần lớn trường hợp, transaction phân tán tốn hiệu năng hơn hẳn so với ghi một document, và nó không phải thứ để thay cho việc thiết kế mô hình dữ liệu cho tử tế (MongoDB Manual, Transactions). Ai định bê nguyên thói quen viết transaction dài như bên RDBMS sang đây thì cân nhắc lại.
Secondary trả dữ liệu cũ. Mọi read preference khác primary đều có thể đọc phải dữ liệu chưa đồng bộ kịp. Dùng để giảm tải thì được, dùng cho nghiệp vụ đòi số liệu chuẩn từng giây thì chết.
Cụm miễn phí của Atlas không phải môi trường chạy thật. Không backup, không cấu hình được bộ nhớ, không test được failover.
Số đo trong bài là số của một máy cụ thể. Cùng một câu lệnh chạy hai thời điểm khác nhau trên cùng hệ thống, thời gian đã xê dịch được rồi. Cái đáng mang đi không phải con số 16 mili giây, mà là tỉ lệ giữa số document phải quét và số document trả về.
Video quay cuối 2023, trên MongoDB 7.0.4 bản Community, hệ điều hành Windows. Bản mới nhất đã khác vài chỗ, tôi ghi mốc phiên bản ở từng chỗ có thay đổi trong bài.
mongosh. Mất khoảng 15 phút.db.serverStatus().storageEngine xem hệ thống mình đang chạy engine nào..explain("executionStats"), ghi lại ba con số: totalDocsExamined, nReturned, stage.IXSCAN thì cân nhắc bỏ.Không thay thế, nó giải bài toán khác. MongoDB hợp khi dữ liệu đi theo cụm, cấu trúc thay đổi thường xuyên, và cần mở rộng ngang. Nghiệp vụ mà quan hệ giữa các thực thể chằng chịt và đòi ràng buộc chặt thì cơ sở dữ liệu quan hệ vẫn là lựa chọn đúng.
Cứ để WiredTiger. Đó là mặc định và là loại hãng khuyến nghị cho triển khai mới. In-Memory chỉ có ở bản Enterprise và không giữ dữ liệu sau khi tắt. MMAPv1 thì MongoDB đã gỡ từ phiên bản 4.2.
Phần lớn là do thứ tự trường trong compound index. MongoDB chỉ dùng được Index khi truy vấn chạm vào tiền tố của nó, tức nhóm trường đứng đầu. Kiểm bằng cách chạy explain và nhìn stage. Thấy COLLSCAN nghĩa là Index không được dùng.
Càng ít càng tốt, miễn đủ cho các câu lệnh chạy thường xuyên. Giới hạn cứng là 64 Index cho một collection, nhưng mỗi Index đều làm chậm thao tác ghi. Đánh theo câu lệnh thật, đừng đánh theo cảm giác.
Khi một máy chủ không còn ôm nổi dữ liệu hoặc không còn gánh nổi lượng truy cập, và việc cắm thêm RAM với CPU đã tới hạn. Trước bước đó, replication kèm Index đánh đúng đã giải quyết được phần lớn bài toán rồi.
Nhúng khi cụm dữ liệu con có số lượng chốt được và luôn được đọc cùng document cha. Tách ra collection riêng khi nó lớn không kiểm soát được, hoặc khi nó được đọc và cập nhật độc lập.
Học một database mới nên đi theo đúng bốn nấc: thuật ngữ, mô hình triển khai, kiến trúc, rồi mới tới tối ưu. Nhảy cóc lên nấc cuối thì chỉ chép được câu lệnh chứ không hiểu vì sao nó nhanh.
Với MongoDB, hai thứ đáng nhớ nhất là từ khóa storage engine và thói quen chạy explain trước khi phán. Còn Index thì nhớ đúng một câu: không phải cứ có Index là ngon, Index phải đúng trường và đúng thứ tự.
Anh em cứ dựng thử một cụm miễn phí rồi chạy lại đúng các bước trong bài. Làm đi sai lại sửa, có chết đâu. Ai đang lo phần AI đọc ghi vào database thì đọc thêm bài AI Agent với database, vì sao nó trả sai và cách chữa. Tùy anh em, phần nào thấy hợp thì lấy.