Databases & SQL · پایگاه‌داده و SQL متوسطIntermediate ~62 دقیقه مطالعه~55 min read

MongoDB در عمق: مدل‌سازی سند تا شاردینگMongoDB In Depth: Document Modelling to Sharding

از مدل سند و BSON تا انتخاب shard key: چطور داده را از روی الگوی دسترسی مدل کنی، index و aggregation را درست بسازی، دوام و تراکنش را آگاهانه انتخاب کنی و MongoDB را در Spring Boot به‌شکل حرفه‌ای اجرا کنی.From the document model and BSON to choosing a shard key: how to model data from access patterns, build indexes and aggregations that hold up, pick durability and transactions deliberately, and run MongoDB properly from Spring Boot.


اگر ذهنت رابطه‌ای است — سال‌ها با جدول، کلید خارجی و JOIN فکر کرده‌ای — اولین برخوردت با MongoDB معمولاً یکی از این دو است: یا فکر می‌کنی «این فقط یک JSON store است» و شش ماه بعد با سندهای ۱۲ مگابایتی و کوئری‌های ۴۰ ثانیه‌ای روبه‌رو می‌شوی؛ یا همان مدل نرمال‌شده را عیناً داخل MongoDB می‌سازی و تعجب می‌کنی چرا کندتر از PostgreSQL است.

هر دو خطا یک ریشه دارند: MongoDB دیتابیسِ بدون schema نیست؛ دیتابیسی است که schema را از موتور به کد و به الگوی دسترسی منتقل کرده. چیزی که مصاحبه‌گر واقعاً می‌سنجد هم همین است — نه حفظ‌کردن نام operatorها، بلکه توانِ تصمیم‌گیری دربارهٔ شکل داده.

مبانی ACID در tx-acid، تئوری توزیع‌شده در distributed-systems-theory، Redis و Cassandra و ClickHouse در nosql-specialized و مبانی Spring Data در spring-data-tx آمده‌اند؛ اینجا فقط ارجاعشان می‌دهیم.

نقشهٔ راه

۱. مدل سند: document، collection، BSON، _id و ObjectId؛ سپس اعتبارسنجی schema و CRUD. ۲. مدل‌سازی داده (قلب فصل): embed در برابر reference، سه رابطهٔ کلاسیک، سقف ۱۶ مگابایت، الگوها و ضدالگوها. ۳. Aggregation pipeline مرحله‌به‌مرحله. ۴. Index: compound و قانون ESR، multikey، TTL، partial، wildcard، explain() و covered query. ۵. دوام: write/read concern، read preference، تراکنش چندسندی. ۶. توپولوژی: replica set و failover، سپس sharding و انتخاب shard key. ۷. Change stream و عملیات: profiling، حافظه، backup، Atlas در برابر self-hosted. ۸. Spring Data MongoDB و راهنمای «کِی Mongo، کِی رابطه‌ای».


۱. مدل سند از صفر

پروندهٔ بیمار در برابر قفسه‌های بایگانی

بیمارستان اول اطلاعات هر بیمار را در ده قفسهٔ جدا نگه می‌دارد: مشخصات، آدرس‌ها، نسخه‌ها، آزمایش‌ها. برای دیدن وضعیت یک بیمار باید ده قفسه را باز کنی و برگه‌ها را با شمارهٔ بیمار به هم بچسبانی — این دقیقاً JOIN است.

بیمارستان دوم برای هر بیمار یک پرونده دارد و یک پرونده برمی‌داری و تمام. سریع است — به شرطی که پرونده آن‌قدر قطور نشود که در کشو جا نشود، و به شرطی که نام دکتر که در هر نسخه تکرار شده، وقتی عوض می‌شود دردسر نسازد. MongoDB بیمارستان دوم است، و کل مهارت مدل‌سازی یعنی «چه چیزی داخل پرونده برود و چه چیزی بیرون بماند».

واژه‌ها

  • document: کوچک‌ترین واحد داده؛ ساختار کلید-مقدار که می‌تواند تودرتو و آرایه‌دار باشد. تقریباً «یک سطر» که خودش جدول‌های دیگر را در دل دارد.
  • field: جفت کلید-مقدار داخل سند. ستون خاصیتِ جدول است، اما field خاصیتِ خودِ سند.
  • collection: ظرف سندها (معادل جدول، بدون تعریف ستون از پیش). database: ظرف collectionها.
  • namespace: نام کامل db.collection؛ حداکثر ۲۵۵ بایت.

همان داده، دو شکل

در دنیای رابطه‌ای یک سفارش یعنی دو جدول:

CREATE TABLE orders (
  id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  customer_id bigint      NOT NULL,
  status      text        NOT NULL,
  created_at  timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE order_items (
  order_id bigint NOT NULL REFERENCES orders(id),
  sku      text   NOT NULL,
  qty      integer NOT NULL,
  price    numeric(12,2) NOT NULL
);

در MongoDB همین سفارش یک سند است:

{
  _id: ObjectId("66f0a1b2c3d4e5f601020304"),
  customerId: ObjectId("66f0a1b2c3d4e5f6010202aa"),
  status: "PAID",
  createdAt: ISODate("2026-08-10T09:14:22.000Z"),
  total: NumberDecimal("1450000.00"),
  items: [
    { sku: "KB-87", qty: 1, price: NumberDecimal("1200000.00") },
    { sku: "MP-01", qty: 2, price: NumberDecimal("125000.00") }
  ]
}

یک نمودار، دو فلسفه: Relational splits one concept across tables; the document model keeps it in one place.

flowchart LR
  subgraph Relational["Relational: JOIN at read time"]
    O[(orders row)] --- I1[(order_items row)]
    O --- I2[(order_items row)]
    O --- C[(customers row)]
  end
  subgraph Document["Document: assembled at write time"]
    D["order document<br/>items: [ ... ]<br/>customer: {id, name}"]
  end
  Relational -->|"one read replaces N joins"| Document

تفاوت واقعی در یک جمله: در مدل رابطه‌ای نرمال‌سازی پیش‌فرض است و JOIN هزینهٔ خواندن را می‌پردازد؛ در مدل سندی پیش‌ساختنِ شکل خواندن پیش‌فرض است و دنرمال‌سازی هزینهٔ نوشتن و همگامی را می‌پردازد. هیچ‌کدام ذاتاً بهتر نیستند؛ سؤال این است که در سیستم تو کدام هزینه ارزان‌تر است.

مفهوم رابطه‌ای معادل MongoDB تفاوت مهم
table · row · column collection · document · field بدون تعریف ستون؛ سند تودرتو با سقف ۱۶MB؛ field متعلق به سند است نه collection
primary key _id همیشه هست، همیشه unique، همیشه indexed
foreign key + JOIN reference + $lookup بدون تضمین یکپارچگی ارجاعی از سمت موتور
view view / materialized view $merge نقش materialized view را دارد
GROUP BY و window function aggregation pipeline زبان متفاوت، قدرت مشابه
transaction multi-document transaction ممکن، اما گران‌تر و نادرتر

یک تفاوت را دست‌کم نگیر: MongoDB معادل FOREIGN KEY ... ON DELETE CASCADE ندارد. اگر سندِ ارجاع‌شده حذف شود، ارجاع‌کننده یک شناسهٔ مرده نگه می‌دارد و موتور هشدار نمی‌دهد؛ مسئولیت یکپارچگی به کد تو منتقل شده. اگر گراف داده‌ات پر از ارجاع‌های سختگیرانه است، احتمالاً ابزار اشتباهی انتخاب کرده‌ای.


۲. BSON، _id و ObjectId

MongoDB داده را به‌شکل BSON (Binary JSON) ذخیره و منتقل می‌کند. BSON سه چیز به JSON اضافه می‌کند: نوع‌های واقعی (تمایز int32/int64/double/decimal128 و نوع‌های date و binDataطول‌دار بودن (پیمایش سریع و پرش از روی field ناخواسته) و ترتیب پایدار fieldها.

نوع BSON نام در $type نکته
Double "double" ممیز شناور؛ برای پول استفاده نکن
String · Object · Array "string" · "object" · "array" آرایه، index را multikey می‌کند
Binary · ObjectId "binData" · "objectId" UUID و دادهٔ رمز‌شده · ۱۲ بایت، پیش‌فرض _id
Date "date" میلی‌ثانیه از epoch، بدون timezone
Null · Timestamp "null" · "timestamp" null با «field وجود ندارد» یکی نیست؛ timestamp داخلیِ oplog است
Int32 / Int64 / Decimal128 "int" · "long" · "decimal" decimal128 نوعِ پول است
MinKey / MaxKey "minKey" · "maxKey" مرزهای مقایسه؛ در محدودهٔ chunkها دیده می‌شوند
پول را هرگز در `double` نگذار

0.1 + 0.2 در IEEE-754 برابر 0.30000000000000004 است. مبلغِ double دیر یا زود یک ریال اختلاف می‌سازد و کسی سه روز دنبالش می‌گردد. یا NumberDecimal(...) (همان decimal128، نگاشت‌شده به BigDecimal در Java) استفاده کن یا مبلغ را عدد صحیح در کوچک‌ترین واحد پول (long) نگه دار.

سقف‌هایی که باید حفظ باشی: اندازهٔ سند ۱۶MB · عمق تودرتویی ۱۰۰ سطح · حداکثر ۶۴ index در هر collection · حداکثر ۳۲ field در یک compound index · هر collection حداکثر یک text index. عدد ۱۶ مگابایت در عمل یک سقف طراحی است نه سقف فنی: اگر سندی به یک مگابایت رسید، مدل‌سازی‌ات مشکل دارد.

_id و ObjectId

هر سند دقیقاً یک _id دارد؛ اگر ندهی، درایور یا سرور ObjectId می‌سازد. روی _id همیشه یک unique index حذف‌نشدنی هست و مقدارش پس از درج تغییرناپذیر است. ساختار دوازده‌بایتی‌اش:

| 4 bytes: timestamp (seconds) | 5 bytes: random per process | 3 bytes: counter |

سه نتیجهٔ عملی: زمان درج تقریباً رایگان است ({ $toDate: "$_id" })؛ مرتب‌سازی بر _id تقریباً همان ترتیب زمانی است اما فقط با دقت ثانیه؛ و ObjectId قابل حدس است، پس اگر امنیتت به حدس‌ناپذیری شناسه وابسته است از UUIDv4 در binData استفاده کن (appsec-owasp).

`_id` را می‌توانی کلید کسب‌وکار بگذاری — گاهی باید

اگر کلید طبیعی و تغییرناپذیری داری (شمارهٔ سفارش، یا tenantId + date برای یک سند تجمیعی)، همان را _id کن: یک index کمتر، یکتاییِ رایگان، و upsert بدون کوئری اضافه. فقط بدان در collection شارد‌شده، _id تنها وقتی unique می‌ماند که shard key باشد یا shard key پیشوندش باشد.


۳. «بدون schema» یعنی چه

انعطافِ روز اول، بلای سال دوم است. راه‌حل، schema validation داخل خود دیتابیس است:

db.createCollection("orders", {
  validator: { $jsonSchema: {
    bsonType: "object",
    required: ["customerId", "status", "createdAt", "items"],
    properties: {
      customerId: { bsonType: "objectId" },
      status:     { enum: ["NEW", "PAID", "SHIPPED", "CANCELLED"] },
      total:      { bsonType: "decimal" },
      items: { bsonType: "array", minItems: 1, maxItems: 500,
        items: { bsonType: "object", required: ["sku", "qty", "price"] } }
    } } },
  validationLevel: "moderate",
  validationAction: "error"
})

validationLevel سه حالت دارد: "strict" (پیش‌فرض) همه‌چیز را می‌سنجد؛ "moderate" فقط سندهای جدید و سندهایی که از قبل معتبر بوده‌اند — ابزار دقیقِ مهاجرت تدریجی؛ و "off". validationAction هم یا "error" است که رد می‌کند یا "warn" که فقط log می‌نویسد تا اول ببینی چقدر داده‌ات خارج از قاعده است. برای collection موجود همین‌ها را با collMod اعمال کن.

«MongoDB بدون schema است» را چطور نقد می‌کنی؟

دقیق‌تر این است که MongoDB schema-on-read است در برابر schema-on-write بودنِ دیتابیس رابطه‌ای؛ موتور هنگام نوشتن ساختار را اجبار نمی‌کند، پس مسئولیت به لایهٔ برنامه می‌رود. سودش واقعی است: تغییر شکل داده به ALTER TABLE قفل‌کننده نیاز ندارد و نسخه‌های مختلف سند می‌توانند هم‌زمان زندگی کنند؛ هزینه‌اش این است که کدِ خواندن باید با تنوع کنار بیاید.

رویکرد senior سه‌لایه است: نوع‌های سختگیر در کد به‌عنوان منبع حقیقت؛ $jsonSchema با validationLevel: "moderate" به‌عنوان تور ایمنی؛ و الگوی schema versioning — یک field نسخه در هر سند، خواندنِ سازگار با نسخه‌های قدیمی و نوشتنِ همیشه با نسخهٔ جدید — تا مهاجرت بدون downtime انجام شود.


۴. CRUD و operatorها

db.orders.updateOne({ _id: id },
  { $set: { status: "PAID" }, $currentDate: { paidAt: true } })

// افزودن به آرایه با مهارِ اندازه و ترتیب
db.orders.updateOne({ _id: id },
  { $push: { events: { $each: [{ t: new Date(), type: "PAID" }],
                       $sort: { t: -1 }, $slice: 50 } } })

// گرفتن اتمیکِ یک job در یک رفت‌وبرگشت
db.jobs.findOneAndUpdate(
  { status: "READY", runAt: { $lte: new Date() } },
  { $set: { status: "RUNNING", lockedAt: new Date() } },
  { sort: { runAt: 1 }, returnDocument: "after" })

db.orders.find({ status: "PAID", createdAt: { $gte: ISODate("2026-08-01") } },
               { _id: 0, status: 1, total: 1, "items.sku": 1 })   // projection
         .sort({ createdAt: -1 }).limit(20)
هر عملیات روی «یک سند» اتمیک است

مهم‌ترین تضمین MongoDB: به‌روزرسانی یک سند — حتی اگر ده field و سه آرایهٔ تودرتو را هم‌زمان عوض کند — یا کامل انجام می‌شود یا اصلاً. اگر مدل‌سازی‌ات طوری باشد که مرز تراکنش و مرز سند یکی شوند، عملاً به تراکنش چندسندی نیاز نداری. بیشتر تصمیم‌های خوب این فصل از همین یک واقعیت مشتق می‌شوند.

عملگر کار نکتهٔ index
$eq $gt $gte $lt $lte $in مقایسه و عضویت index‌پذیر؛ فهرست $in خیلی بلند گران است
$ne $nin نامساوی ضدindex: عملاً کل index را می‌خواند
$exists وجود field true با partial index خوب است؛ false معمولاً بد
$regex تطبیق الگو فقط با لنگر ^ و case-sensitive از index استفاده می‌کند
$elemMatch چند شرط روی یک عنصر آرایه بدون آن شرط‌ها روی عناصر مختلف می‌افتند
$size · $expr طول آرایه · مقایسهٔ دو field index‌پذیر نیستند؛ به‌جای $size یک itemCount نگه دار
دو تلهٔ کلاسیک: `$elemMatch` و `skip`

کوئری { "items.qty": { $gt: 5 }, "items.price": { $lt: 1000 } } سفارشی را برمی‌گرداند که یک قلمش تعداد بالای ۵ دارد و قلم دیگری قیمتش زیر ۱۰۰۰ است — تقریباً هیچ‌وقت این را نمی‌خواهی. شکل درست { items: { $elemMatch: { qty: { $gt: 5 }, price: { $lt: 1000 } } } } است.

و find().skip(200000).limit(20) باید دویست‌هزار ورودی index را بخواند و دور بریزد. الگوی درست keyset pagination است: find({ createdAt: { $lt: lastSeen } }).sort({ createdAt: -1 }).limit(20) با _id به‌عنوان شکنندهٔ تساوی. همان مسئلهٔ OFFSET در sql-mastery.


۵. قلب فصل: مدل‌سازی داده

اینجا مصاحبهٔ senior برنده یا بازنده می‌شود، و جواب همهٔ سؤال‌ها یک جمله است: مدل را از روی الگوی دسترسی بساز، نه از روی موجودیت‌ها. در دنیای رابطه‌ای اول نرمال می‌کنی و بعد هر کوئری‌ای لازم شد می‌نویسی؛ نرمال‌سازی هزینهٔ کوئری‌های ناشناخته را می‌پردازد. اینجا برعکس است. سه سؤال را پیش از نوشتن یک خط کد جواب بده: کدام داده‌ها همیشه با هم خوانده می‌شوند؟ نسبت خواندن به نوشتن چیست و کدام طرف بحرانی است؟ و آیا این مجموعه مرز دارد؟

یک آدرس، صد کامنت، یک میلیارد لاگ

سه چیز به یک «کاربر» وصل‌اند: آدرس‌هایش (۱ تا ۵)، کامنت‌هایش (شاید ۵۰۰)، و رویدادهای کلیکش (۱۰ میلیون). آدرس‌ها باید داخل پرونده باشند، کلیک‌ها قطعاً نباید، و کامنت‌ها جای بحث دارند. همین شهود، چارچوب رسمی مدل‌سازی MongoDB است.

one-to-few (کوچک و کراندار: آدرس‌های کاربر، اقلام سفارش) → embed؛ یک خواندن، بدون $lookup، به‌روزرسانی اتمیک. one-to-many (بزرگ ولی کراندار: کامنت‌های یک پست) → reference از فرزند به والد ({ _id: c1, postId: p1, body: ... })، و اگر لازم شد چند مورد اخیر را به‌عنوان subset در والد هم نگه دار. one-to-squillions (رشد نامحدود: لاگ دستگاه) → حتماً reference از فرزند به والد، چون آرایهٔ والد وگرنه بی‌مرز رشد می‌کند؛ اینجا الگوی bucket یا time series collection وارد می‌شود.

این نمودار را می‌توانی در مصاحبه روی تخته بکشی: The embed-versus-reference decision, reduced to four questions.

flowchart TD
  A[Data accessed together?] -->|No| R[Reference]
  A -->|Yes| B[Is the child set bounded?]
  B -->|Unbounded growth| R
  B -->|Bounded| C[Document stays well under 16MB?]
  C -->|No| R
  C -->|Yes| D[Child written far more often than parent is read?]
  D -->|Yes| R
  D -->|No| E[Embed]
  R --> F[Need parent fields on read?]
  F -->|Yes, few and stable| G[Extended reference: duplicate a few fields]
  F -->|No| H[Plain ObjectId reference]
معیار Embed Reference
رفت‌وبرگشت خواندن یک دو یا بیشتر (یا $lookup)
اتمیک بودن به‌روزرسانی رایگان نیازمند تراکنش یا طراحی idempotent
رشد محدود به ۱۶MB نامحدود
دادهٔ تکراری · دسترسی مستقل به فرزند باید همه‌جا اصلاح شود · سخت یک نقطهٔ حقیقت · طبیعی
working set سند بزرگ‌تر، حافظهٔ بیشتر سند کوچک‌تر، I/O بیشتر
مناسبِ one-to-few، دادهٔ «مالکیت‌شده» one-to-many/squillions، دادهٔ مشترک
قاعدهٔ سه‌جمله‌ای که در مصاحبه می‌گویی

«اگر فرزند بدون والد معنا ندارد و کراندار است، embed می‌کنم؛ اگر هویت مستقل دارد یا نامحدود رشد می‌کند، reference می‌کنم؛ و اگر فقط چند field کم‌تغییر از والد را هنگام خواندن لازم دارم، همان‌ها را با extended reference کپی می‌کنم و برای همگام‌سازی‌شان یک مسیر مشخص می‌گذارم.» این ۹۰٪ سؤال‌های مدل‌سازی را پوشش می‌دهد.

دنرمال‌سازی: کپی کردن با چشم باز

کپی کردن نه گناه است نه پیش‌فرض؛ یک معامله است. پیش از هر کپی سه سؤال: چقدر تغییر می‌کند؟ (نام محصول در قلم سفارش هرگز نباید تغییر کند — کپیِ ایده‌آل؛ نام کاربر در ۱۰ هزار کامنت — کپیِ گران). اگر ناهمگام شد چه می‌شود؟ («نام قدیمی در فهرست» قابل تحمل است، «موجودی انبار اشتباه» نیست). مسیر همگام‌سازی چیست؟ (job دسته‌ای، change stream، یا هیچ). دنرمال‌سازیِ بی‌مالک یک بدهی خاموش است، پس حداقل در خودِ سند صراحت بده که این کپی است — customer: { _id, name, snapshotAt }.

یک مدل برای «سفارش و مشتری» طراحی کن و از انتخاب‌هایت دفاع کن.

اقلام سفارش را embed می‌کنم: بدون سفارش معنا ندارند، کراندارند (با maxItems در validator مهارش می‌کنم)، همیشه با سفارش خوانده می‌شوند، و به‌روزرسانی سفارش این‌طور اتمیک می‌ماند. مشتری را reference می‌کنم چون هویت مستقل دارد؛ اما فقط customerId را نگه نمی‌دارم: یک extended reference ذخیره می‌کنم — customer: { _id, name, phone } — چون صفحهٔ فهرست سفارش‌ها همین‌ها را می‌خواهد.

نکته‌ای که امتیاز می‌گیرد: نام و قیمت محصول را داخل قلم سفارش کپی می‌کنم و عمداً همگامشان نمی‌کنم، چون سفارش یک سند تاریخی است و فاکتور دیروز نباید با تغییر قیمت امروز عوض شود. در مقابل customer.name را با change stream یا job دسته‌ای به‌روز می‌کنم و می‌پذیرم چند ثانیه ناهمگام باشد — یعنی برای هر کپی سیاست همگام‌سازی را صریح انتخاب می‌کنم.


۶. الگوهای طراحی schema

الگوها اسم دارند و گفتن اسمشان در مصاحبه امتیاز می‌آورد.

Extended Reference — به‌جای $lookup برای هر ردیف، چند field کم‌تغییر را کنار شناسه کپی کن: customer: { _id, name, tier }.

Subset — سند به‌خاطر آرایهٔ بزرگ باد کرده اما ۹۵٪ خواندن‌ها فقط چند عنصر اول را می‌خواهد: چند عنصر داغ داخل سند، بقیه در collection جدا، و مرزش با $push + $slice خودکار نگه‌داشته می‌شود.

Computed — به‌جای محاسبهٔ هربارهٔ مجموع، مقدار را هنگام نوشتن به‌روز کن:

db.customers.updateOne({ _id: cid },
  { $inc: { "stats.orderCount": 1, "stats.lifetimeValue": 1450000 },
    $max: { "stats.lastOrderAt": new Date() } })

Bucket — یک سند برای هر اندازه‌گیری سنسور یعنی میلیاردها سند کوچک با سربار _id و index؛ به‌جایش اندازه‌گیری‌های یک بازه را در یک «سطل» جمع کن:

{ _id: { sensor: "s-42", hour: ISODate("2026-08-13T09:00:00Z") },
  count: 60, sum: 1342.5, min: 20.1, max: 24.8,
  samples: [ { t: 0, v: 22.4 }, { t: 60, v: 22.5 } ] }

// از ۵.۰ خودِ موتور همین کار را با فشرده‌سازی ستونی انجام می‌دهد:
db.createCollection("readings", {
  timeseries: { timeField: "ts", metaField: "sensor", granularity: "minutes" },
  expireAfterSeconds: 2592000 })

Outlier — ۹۹.۹٪ پست‌ها زیر ۱۰۰ کامنت دارند اما یک پست ویروسی مدل embed را می‌شکند: سند را با hasExtras: true علامت بزن و مازاد را در collection سرریز بگذار.

PolymorphicCreditCard، Wallet و BankTransfer همه «روش پرداخت»اند با ۷۰٪ field مشترک؛ همه در یک collection با یک field تمایز type.

Attribute — محصولات ده‌ها مشخصهٔ متفاوت دارند و سقف ۶۴ index اجازهٔ index جداگانه نمی‌دهد؛ به‌جای { ram: "16GB", weight: "1.2kg" } بنویس { specs: [ { k: "ram", v: "16GB" } ] } تا یک index روی { "specs.k": 1, "specs.v": 1 } همه را پوشش دهد.

Schema Versioning — هر سند field نسخه دارد؛ کد همهٔ نسخه‌های زنده را می‌خواند و همیشه با آخرین نسخه می‌نویسد. همان expand-contract فصل cicd-pipelines، بدون ALTER TABLE.

ضدالگو چرا خراب می‌شود جایگزین
آرایهٔ بی‌مرز · سند بادکرده رسیدن به ۱۶MB؛ working set بزرگ‌تر از RAM reference / bucket / subset
هزاران collection یا index حافظهٔ metadata و کندی startup polymorphic / attribute pattern
جدا کردن دادهٔ همیشه-با-هم $lookup در مسیر داغ embed یا extended reference
بی‌حساس به حروف بدون collation · $ne/$nin COLLSCAN یا index بی‌اثر collation: { strength: 2 } · مدل‌سازی مثبت با $in
آرایه‌ای که «فعلاً کوچک است»

خطرناک‌ترین آرایه آن است که در محیط توسعه سه عنصر دارد. سؤال درست «الان چند تاست؟» نیست، «سقفش کجاست؟» است؛ اگر جواب «سقف ندارد» یا «به رفتار کاربر بستگی دارد» است، embed نکن. جدا از سقف ۱۶MB، آرایهٔ بلند دو هزینهٔ پنهان دارد: هر به‌روزرسانی ممکن است کل سند را دوباره بنویسد، و index روی آن به ازای هر عنصر یک کلید تولید می‌کند — سندی با ۱۰۰۰ عنصر یعنی ۱۰۰۰ ورودی index.


۷. Aggregation pipeline، مرحله به مرحله

خط تولید کارخانه

نوار نقاله‌ای را تصور کن: ایستگاه اول قطعات خراب را کنار می‌گذارد، دومی برچسب‌های اضافی را می‌کَنَد، سومی هم‌نوع‌ها را در جعبه جمع می‌کند، چهارمی جعبه‌ها را مرتب می‌چیند؛ خروجی هر ایستگاه ورودی بعدی است. نتیجه: هرچه زودتر آشغال را از نوار برداری، ایستگاه‌های بعدی کار کمتری دارند — کل بهینه‌سازی pipeline همین جمله است.

$match فیلتر با همان نحو find؛ اگر اولین مرحله باشد از index استفاده می‌کند. $project انتخاب/ساخت field و $set/$addFields افزودن بدون حذف بقیه. $group تجمیع، که در آن _id کلید گروه است و null یعنی «همه در یک گروه»؛ انباشتگرها $sum، $avg، $min، $max، $push، $addToSet. $sort اگر با index هم‌راستا باشد رایگان است، وگرنه blocking sort. $lookup معادل left outer join با خروجی آرایه و $unwind که آرایه را به چند سند باز می‌کند. $facet چند sub-pipeline موازی روی همان ورودی (عالی برای «نتایج + تعداد کل + شمارش دسته‌ها» در یک رفت‌وبرگشت)، $bucket هیستوگرام، $unionWith شبیه UNION ALL، $setWindowFields معادل window function، و $out/$merge نوشتن خروجی که پایهٔ materialized view است.

مثال واقعی: ده مشتری برتر ۳۰ روز اخیر

SELECT o.customer_id,
       SUM(i.qty * i.price) AS revenue,
       COUNT(DISTINCT o.id)  AS orders
FROM orders o
JOIN order_items i ON i.order_id = o.id
WHERE o.status = 'PAID'
  AND o.created_at >= now() - INTERVAL '30 days'
GROUP BY o.customer_id
ORDER BY revenue DESC
FETCH FIRST 10 ROWS ONLY;

و همان در MongoDB — چون اقلام embed شده‌اند، هیچ join‌ای برای اقلام لازم نیست:

db.orders.aggregate([
  { $match: { status: "PAID",
              createdAt: { $gte: new Date(Date.now() - 30*24*3600*1000) } } },
  { $project: { customerId: 1,
                orderRevenue: { $sum: { $map: {
                  input: "$items", as: "it",
                  in: { $multiply: ["$$it.qty", "$$it.price"] } } } } } },
  { $group: { _id: "$customerId",
              revenue: { $sum: "$orderRevenue" }, orders: { $sum: 1 } } },
  { $sort: { revenue: -1 } },
  { $limit: 10 },
  { $lookup: { from: "customers", localField: "_id",
               foreignField: "_id", as: "customer" } },
  { $unwind: "$customer" },
  { $project: { _id: 0, name: "$customer.name", revenue: 1, orders: 1 } }
])
ترتیب مرحله‌ها، خودِ بهینه‌سازی است

$match اول است تا از index استفاده کند؛ $sort و $limit قبل از $lookup آمده‌اند تا join فقط برای ۱۰ سند اجرا شود نه میلیون‌ها — اگر $lookup را بالاتر می‌بردی همان کوئری صدها برابر کندتر می‌شد. بهینه‌ساز بعضی جابه‌جایی‌ها را خودش انجام می‌دهد (مثلاً $match را از پشت $project جلو می‌کشد)، اما هرگز چنین تصمیم معنایی‌ای را برایت نمی‌گیرد.

مرحله معادل SQL نکته
$match WHERE اول بگذار تا index بخورد
$project / $set SELECT field غیرلازم را زود بینداز
$group GROUP BY _id: null یعنی کل مجموعه
$sort ORDER BY بدون index یعنی blocking sort
$lookup · $unwind LEFT JOIN · UNNEST خروجی آرایه؛ بعد از $limit بگذار
$facet · $unionWith چند کوئری موازی · UNION ALL داخل $facet نمی‌شود $out/$merge گذاشت
$setWindowFields · $merge window function · MERGE $merge باید آخرین مرحله باشد
سقف ۱۰۰ مگابایت، `allowDiskUse` و وسوسهٔ `$lookup`

هر مرحله حداکثر ۱۰۰ مگابایت RAM دارد. از MongoDB 6.0 پارامتر allowDiskUseByDefault پیش‌فرض روشن است، یعنی مرحله‌های سنگین به‌جای خطا روی دیسک سرریز می‌کنند — به‌جای شکستن آشکار، کندی خاموش. در profiler و log دنبال نشانگر usedDisk بگرد.

و $lookup را با ذهنیت SQL استفاده نکن: برای هر سند ورودی یک کوئری روی collection مقصد اجرا می‌شود، پس بدون index روی foreignField فاجعه است. اگر در مسیر داغ بیش از یکی داری، معمولاً یعنی مدل‌سازی‌ات رابطه‌ای مانده و جواب واقعی embed یا extended reference است.

چه زمانی aggregation و چه زمانی `find` — و آیا aggregation کندتر است؟

find برای «سندها را همان‌طور که هستند با یک فیلتر بده» است و کمترین سربار را دارد؛ aggregation وقتی لازم است که بخواهی داده را تبدیل کنی: گروه‌بندی، محاسبه، join، reshape. aggregation ذاتاً کند نیست؛ چیزی که کندش می‌کند مرحله‌های blocking است — $group، $sort بدون index، $lookup بدون index — که مجبورند جریان را در حافظه نگه دارند.

قاعده‌ای که در مصاحبه می‌گویم: مرحلهٔ اول باید $matchی باشد که از index استفاده می‌کند؛ بعد با $project fieldهای غیرلازم را دور بریز؛ $sort را با index هم‌راستا کن؛ و $lookup را بعد از $limit عقب بران. در آخر با explain("executionStats") تأیید کن که مرحلهٔ اول واقعاً IXSCAN است.


۸. Index: جایی که کارایی زندگی می‌کند

فهرست انتهای کتاب

بدون فهرست، برای پیدا کردن واژهٔ «شاردینگ» باید ۸۰۰ صفحه را ورق بزنی — این COLLSCAN است. با فهرست مستقیم به صفحهٔ ۴۱۲ می‌روی — این IXSCAN است. و اگر فهرست خودش شمارهٔ فصل را هم نوشته باشد و تو فقط همان را می‌خواستی، اصلاً لازم نیست کتاب را باز کنی — این covered query است.

نوع ساخت کِی
single / compound { status: 1, createdAt: -1 } فیلتر و مرتب‌سازی
multikey خودکار روی field آرایه‌ای کوئری روی عناصر آرایه
text · hashed · geospatial "text" · "hashed" · "2dsphere" جست‌وجوی واژه‌ای · شاردینگ · مکان
TTL · partial · sparse expireAfterSeconds · partialFilterExpression حذف خودکار · index فقط روی زیرمجموعهٔ داغ
unique · wildcard unique: true · { "attrs.$**": 1 } یکتایی · fieldهای غیرقابل پیش‌بینی
db.orders.createIndex({ email: 1 },
  { unique: true, partialFilterExpression: { email: { $type: "string" } } })
db.sessions.createIndex({ lastSeenAt: 1 }, { expireAfterSeconds: 3600 })

قانون ESR

ترتیب fieldها در compound index تصادفی نیست. قاعدهٔ رسمی MongoDB ESR است: Equality اول، Sort وسط، Range آخر. دلیلش این است که شرط تساوی پیمایش را به یک بازهٔ پیوستهٔ باریک محدود می‌کند و درون آن بازه، fieldهای بعدی همچنان مرتب‌اند؛ اگر field بازه‌ای را قبل از field مرتب‌سازی بگذاری، ترتیب داخل بازه به هم می‌ریزد و MongoDB مجبور به blocking sort می‌شود.

db.orders.find({ tenantId: t, status: "PAID", total: { $gte: 100000 } })
         .sort({ createdAt: -1 })

db.orders.createIndex({ tenantId: 1, status: 1, createdAt: -1, total: 1 })
//                     └──── E ─────────────┘  └── S ───┘    └─ R ─┘

دو قاعدهٔ همراه: پیشوند — index روی {a,b,c} کوئری‌های {a}، {a,b} و {a,b,c} را پوشش می‌دهد اما {b} یا {b,c} را نه، پس پیش از ساخت index جدید ببین یکی از indexهای موجود پیشوند مناسبی نیست. و جهت فقط در مرتب‌سازی چندفیلده مهم است: برای sort({ createdAt: -1 }) تنها، { createdAt: 1 } هم کافی است چون index برعکس پیموده می‌شود، اما برای sort({ a: 1, b: -1 }) باید index دقیقاً {a:1,b:-1} یا معکوس کاملش باشد.

covered query و explain()

اگر همهٔ fieldهای شرط و خروجی داخل index باشند و _id از projection حذف شود، سند اصلاً خوانده نمی‌شود:

db.orders.createIndex({ status: 1, createdAt: -1, total: 1 })
db.orders.find({ status: "PAID" }, { _id: 0, createdAt: 1, total: 1 })
         .explain("executionStats")   // verbosity: queryPlanner | executionStats | allPlansExecution
کلید معنا چه چیزی بد است
winningPlan.stage نوع پیمایش COLLSCAN روی collection بزرگ
IXSCAN / FETCH پیمایش index / واکشی سند FETCH زیاد یعنی index پوشا نیست
SORT مرتب‌سازی در حافظه وجودش یعنی index با sort هم‌راستا نیست
nReturned · totalKeysExamined خروجی · کلیدهای خوانده‌شده فاصلهٔ زیاد بین این دو = index بد
totalDocsExamined سندهای خوانده‌شده صفر یعنی covered

نسبت طلایی: totalKeysExamined ≈ totalDocsExamined ≈ nReturned. هر جا این سه از هم فاصله بگیرند، یک index گم‌شده داری.

سه تلهٔ index که در production می‌سوزانند

multikey: یک compound index نمی‌تواند بیش از یک field آرایه‌ای داشته باشد ({ "items.sku": 1, "tags": 1 } با دو آرایه هنگام درج خطا می‌دهد، چون تعداد کلیدها ضرب دکارتی می‌شد)، و کوئری روی field آرایه‌ای هرگز covered نیست.

indexهای بلااستفاده رایگان نیستند: هر index در هر نوشتن نگه‌داری می‌شود و حافظه‌ای را می‌گیرد که می‌توانست به دادهٔ داغ برسد؛ با $indexStats بازهٔ کافی (شامل گزارش‌های ماهانه) را رصد کن.

پیش از حذف، مخفی کن: db.orders.hideIndex("status_created") آن را از دید planner برمی‌دارد ولی نگه می‌دارد؛ اگر چیزی خراب شد unhideIndex در یک ثانیه برمی‌گرداند، در حالی که حذف مستقیم یعنی ساخت دوبارهٔ چندساعته.

چطور یک کوئری کند در MongoDB را عیب‌یابی می‌کنی؟

پنج قدم ثابت. یک: کوئری مقصر را از profiler یا log پیدا می‌کنم (db.setProfilingLevel(1, { slowms: 100 }) و کوئری روی system.profile؛ در محیط پرترافیک sampleRate را کم می‌کنم). دو: explain("executionStats") می‌گیرم و nReturned، totalKeysExamined و totalDocsExamined را کنار هم می‌گذارم. سه: الگو را تشخیص می‌دهم — COLLSCAN یعنی index نیست؛ keysExamined خیلی بیشتر از nReturned یعنی ترتیب یا انتخاب‌پذیری index بد است؛ مرحلهٔ SORT یعنی مرتب‌سازی از index نمی‌آید. چهار: index را با ESR بازطراحی و اگر ممکن است covering می‌کنم. پنج: روی داده‌ای هم‌اندازهٔ production تأیید می‌کنم. اگر باز کند بود، سؤال از سطح index به سطح مدل می‌رود.

قانون ESR را توضیح بده و بگو نقضش چه اثری دارد.

ESR ترتیب پیشنهادی fieldها در compound index است: اول Equality، بعد Sort، آخر Range. شرط تساوی پیمایش را به یک بازهٔ پیوسته محدود می‌کند و درون آن بازه ترتیب fieldهای بعدی دست‌نخورده می‌ماند، پس مرتب‌سازی مستقیماً از index می‌آید. اگر field بازه‌ای را قبل از field مرتب‌سازی بگذاری، پیمایش به چند بازهٔ ناپیوسته تبدیل می‌شود و MongoDB یک SORT مسدودکننده اجرا می‌کند که تا ۱۰۰MB در حافظه نگه می‌دارد و بعد روی دیسک سرریز می‌کند؛ علامتش در explain وجود SORT و بالا بودن totalKeysExamined نسبت به nReturned است.

و اگر پرسیدند covered query چیست: کوئری‌ای که کاملاً از روی index پاسخ داده می‌شود و در explain با totalDocsExamined: 0 دیده می‌شود؛ شرطش این است که همهٔ fieldهای فیلتر و projection در index باشند و _id از projection حذف شود. دو استثنا: روی field آرایه‌ای ممکن نیست، و در sharded cluster اگر index شامل shard key نباشد ممکن است واکشی سند لازم شود.


۹. Replica set: در دسترس بودن

replica set مجموعه‌ای از فرایندهای mongod با همان داده است: primary (تنها پذیرندهٔ نوشتن)، secondary (از oplog می‌خواند و بازپخش می‌کند) و arbiter (بدون داده، فقط رأی‌دهنده).

oplog یک capped collection در دیتابیس local است که هر تغییر را به‌شکل idempotent ثبت می‌کند؛ اندازهٔ پیش‌فرضش ۵٪ فضای آزاد دیسک است (حداقل ۹۹۰MB، حداکثر ۵۰GB) و با replSetResizeOplog قابل تغییر. پنجرهٔ oplog یعنی چند ساعت تاریخچه در آن جا می‌شود — همین عدد تعیین می‌کند یک secondary تا کجا می‌تواند عقب بماند و بدون initial sync برگردد، change stream بعد از قطعی چقدر وقت دارد resume کند، و backup مبتنی بر oplog تا کجا point-in-time می‌دهد.

Failover: when heartbeats stop, eligible secondaries hold an election. — وقتی ضربان قلب قطع شود، secondaryهای واجد شرایط انتخابات برگزار می‌کنند.

stateDiagram-v2
  [*] --> Primary
  Primary --> Unreachable: node crashes / network partition
  Unreachable --> Election: no heartbeat for electionTimeoutMillis (10s)
  Election --> NewPrimary: majority of votes to most up-to-date member
  NewPrimary --> Rollback: old node returns with un-replicated writes
  Rollback --> Secondary: writes rolled back to a file, node rejoins
  NewPrimary --> Primary: steady state

ضربان قلب هر ۲ ثانیه ارسال می‌شود و اگر تا electionTimeoutMillis (پیش‌فرض ۱۰ ثانیه) پاسخی نیاید انتخابات شروع می‌شود. یک replica set حداکثر ۵۰ عضو و ۷ عضو رأی‌دهنده دارد و برنده باید اکثریت آرا را بگیرد — پس تعداد رأی‌دهنده‌ها باید فرد باشد. درایورها failover را می‌بینند و چون retryWrites پیش‌فرض روشن است، نوشتن ناموفق را یک بار خودکار تکرار می‌کنند؛ برنامه معمولاً فقط چند ثانیه تأخیر می‌بیند، نه خطا.


۱۰. Write concern، read concern و read preference

این سه با هم یک چیز را تنظیم می‌کنند: چقدر حاضری برای دوام و تازگی داده تأخیر بپردازی.

write concern («نوشتن کِی تمام است؟»): w: 1 فقط primary تأیید کرده (سریع‌ترین، اما در failover قابل از دست رفتن)؛ w: "majority" اکثریت اعضای داده‌دارِ رأی‌دهنده؛ w: <n> یا w: "<tag>" تعداد یا برچسب مشخص؛ و j: true یعنی پیش از تأیید در journal روی دیسک نوشته شود.

read concern («چقدر مطمئن؟»): "local" (پیش‌فرض) که ممکن است rollback شود؛ "available" مثل local ولی در sharded cluster ممکن است سندهای یتیم برگرداند؛ "majority" فقط دادهٔ ماندگار؛ "linearizable" قوی‌ترین و گران‌ترین؛ "snapshot" برای تراکنش‌ها.

read preference («از کدام گره؟»): primary (پیش‌فرض)، primaryPreferred، secondary، secondaryPreferred، nearest — با tag set به دیتاسنتر خاص و با maxStalenessSeconds (حداقل ۹۰ ثانیه) در برابر گره‌های خیلی عقب‌مانده.

هدف write concern read concern read preference
پول و موجودی majority, j:true majority primary
CRUD معمول · تله‌متری پرحجم majority · w:1 local primary
گزارش سنگین majority secondaryPreferred + maxStalenessSeconds
«خودم را بعد از نوشتن ببینم» majority majority primary یا causal session
دو تلهٔ دوام که بی‌سروصدا داده می‌بلعند

تلهٔ اول — arbiter. آرایش P-S-A وسوسه‌انگیز است چون یک سرور کمتر می‌خواهد؛ اما با از دست رفتن secondary دیگر گرهٔ دومی برای تأیید w: "majority" نداری. دقیقاً به همین دلیل MongoDB در این آرایش پیش‌فرض ضمنی را به { w: 1 } تنزل می‌دهد — یعنی بی‌خبر دوام کمتری می‌گیری. مقدار مؤثر را با db.adminCommand({ getDefaultRWConcern: 1 }) ببین و arbiter را با یک secondary واقعی جایگزین کن، حتی روی سخت‌افزار ضعیف‌تر.

تلهٔ دوم — w:1. برنامه «موفق» می‌گیرد در حالی که داده فقط روی یک گره است؛ اگر همان ثانیه primary بمیرد، آن نوشتن در rollback به یک فایل منتقل می‌شود و از دیتابیس محو می‌شود، بدون هیچ خطایی به کاربر. برای هر داده‌ای که «گم شدنش یعنی پول یا اعتماد»، w: "majority" غیرقابل مذاکره است.

و باور رایج «از secondary بخوان تا بار primary کم شود» معمولاً غلط است: secondary همان بار نوشتن primary را هم اجرا می‌کند پس ظرفیت رایگانی وجود ندارد؛ خواندنش می‌تواند ثانیه‌ها عقب باشد؛ و در sharded cluster با read concern پیش‌فرض ممکن است سندهای یتیم برگرداند. مقیاس‌دهی خواندن کار sharding و caching است (فصل caching)، نه read preference — مگر برای گزارش‌های تحلیلی که تازگی برایشان مهم نیست.

فرق این سه چیست، و چطور اطمینان می‌دهی یک نوشتن گم نمی‌شود؟

write concern می‌گوید نوشتن روی چند گره ثبت شود تا سرور بگوید «تمام شد» — درجهٔ دوام. read concern می‌گوید چه سطحی از قطعیت برای دادهٔ خوانده‌شده لازم دارم، یعنی آیا آنچه می‌بینم می‌تواند rollback شود. read preference می‌گوید از کدام گره بخوانم — مسئلهٔ توپولوژی و تأخیر، نه صحت. ترکیبی که باید بلد باشی: w: "majority" به‌تنهایی تضمین نمی‌کند خواندن بعدی همان داده را ببیند، اگر آن خواندن با read concern local از یک secondary انجام شود؛ برای «read your own writes» یا از primary با majority بخوان یا از causally consistent session استفاده کن.

برای اینکه نوشتن گم نشود w: "majority" را با j: true ترکیب می‌کنم (اولی در برابر failover، دومی در برابر قطع برق همان گره) و retryWrites را روشن نگه می‌دارم. نکته‌ای که از قلم می‌افتد توپولوژی است: در آرایش P-S-A پیش‌فرض ضمنی به { w: 1 } تنزل می‌کند، پس مقدار مؤثر را با getDefaultRWConcern بررسی می‌کنم و برای مسیرهای بحرانی write concern را صریح می‌نویسم.


۱۱. تراکنش‌های چندسندی

از ۴.۰ روی replica set و از ۴.۲ روی sharded cluster، MongoDB تراکنش ACID چندسندی دارد. هست — اما یعنی همیشه باید استفاده کنی.

try (ClientSession session = mongoClient.startSession()) {
    TransactionOptions options = TransactionOptions.builder()
            .readConcern(ReadConcern.SNAPSHOT).writeConcern(WriteConcern.MAJORITY).build();

    session.withTransaction(() -> {   // خطاهای گذرا را خودش دوباره تلاش می‌کند
        accounts.updateOne(session, Filters.eq("_id", "A"), Updates.inc("balance", -100));
        accounts.updateOne(session, Filters.eq("_id", "B"), Updates.inc("balance",  100));
        return null;
    }, options);
}

واقعیت‌ها: نیازمند replica set است (روی mongod تک‌گره کار نمی‌کند — به همین دلیل Testcontainers هم replica set راه می‌اندازد)؛ سقف زمانی پیش‌فرض ۶۰ ثانیه است (transactionLifetimeLimitSeconds)؛ توصیهٔ رسمی این است که بیش از ~۱۰۰۰ سند در یک تراکنش تغییر ندهی؛ برخورد دو تراکنش روی یک سند WriteConflict می‌دهد که withTransaction خودش دوباره تلاش می‌کند؛ و نگه‌داری snapshot روی حافظهٔ WiredTiger هزینه دارد، پس هرگز فراخوانی شبکه‌ای را داخل تراکنش نگذار.

اگر همه‌جا تراکنش لازم داری، مدل‌سازی‌ات اشتباه است

ارزشمندترین جمله‌ای که در این بخش می‌توانی در مصاحبه بگویی. تراکنش چندسندی یک دریچهٔ فرار برای موارد نادر است (انتقال وجه)، نه ابزار روزمره. راهکار طراحی: مرز تراکنش را با مرز سند یکی کن. اگر واقعاً چند سند درگیرند و سازگاری نهایی قابل قبول است، الگوی outbox و saga (فصل ms-data) پاسخ مقیاس‌پذیرتری از تراکنش توزیع‌شده است.

تراکنش‌های MongoDB چه هزینه‌ای دارند و کِی از آن‌ها استفاده می‌کنی؟

هزینه سه بخش دارد: نگه‌داری snapshot در cache موتور WiredTiger که زیر فشار حافظه گران است؛ سقف پیش‌فرض ۶۰ ثانیه؛ و برخورد نوشتن‌ها که WriteConflict می‌دهد و نیاز به تلاش دوباره دارد. روی sharded cluster یک لایهٔ هماهنگی دوفازی هم اضافه می‌شود که تأخیر را بالاتر می‌برد.

من فقط جایی استفاده می‌کنم که چند سند واقعاً باید با هم تغییر کنند و سازگاری نهایی قابل قبول نیست. در بقیهٔ موارد اول مرز تراکنش را با مرز سند یکی می‌کنم؛ اگر نشد سراغ به‌روزرسانی شرطی می‌روم (شرط وضعیت داخل خودِ کوئری به‌روزرسانی، تا گذار اتمیک باشد)؛ و اگر چند سرویس درگیرند، outbox و saga جواب درست‌تری است.


۱۲. Sharding: مقیاس افقی

کتابخانه‌ای که در یک ساختمان جا نمی‌شود

سه ساختمان می‌سازی و کتاب‌ها را تقسیم می‌کنی — اما بر چه اساسی؟ اگر بر اساس حرف اول عنوان، ساختمان «الف» شلوغ می‌شود؛ اگر بر اساس تاریخ خرید، همهٔ کتاب‌های جدید به ساختمان آخر می‌روند؛ و اگر کسی کتابی بخواهد و ندانی کجاست، هر سه را باید بگردی. این دقیقاً مسئلهٔ shard key است — پرریسک‌ترین تصمیم کل فصل.

A sharded cluster: routers, config metadata, and shards that are themselves replica sets. — یک sharded cluster: مسیریاب، متادیتای config، و shardهایی که خودشان replica set هستند.

flowchart TD
  App[Application driver] --> M1[mongos router]
  App --> M2[mongos router]
  M1 --> CFG[(config servers<br/>replica set)]
  M2 --> CFG
  M1 --> S1[Shard A<br/>replica set]
  M1 --> S2[Shard B<br/>replica set]
  M2 --> S3[Shard C<br/>replica set]
  CFG -. chunk map .-> M1
  CFG -. chunk map .-> M2

shard خودش یک replica set کامل است و بخشی از داده را دارد؛ config server یک replica set است که «نقشهٔ داده» را نگه می‌دارد و balancer روی primary همان اجرا می‌شود؛ mongos مسیریاب بدون حالت است و برنامه فقط با او حرف می‌زند.

shard key: چهار معیار

کاردینالیتی بالا (country برای یک سرویس تک‌کشوری یعنی عملاً یک shard) · فراوانی یکنواخت (اگر یک مشتری ۴۰٪ ترافیک است، customerId تنها کافی نیست) · غیریکنوا بودن (کلید صعودی مثل timestamp یا ObjectId همهٔ نوشتن‌های جدید را به آخرین بازه و در نتیجه یک hot shard می‌فرستد) · و معیاری که اغلب فراموش می‌شود: کلید باید در اکثر کوئری‌های خواندن حاضر باشد.

ویژگی ranged hashed
نحو sh.shardCollection("db.c", { userId: 1 }) sh.shardCollection("db.c", { userId: "hashed" })
توزیع نوشتن ممکن است نامتوازن شود تقریباً یکنواخت
کوئری بازه‌ای targeted و کارآمد scatter-gather
کلید یکنوا (زمان) hot shard مشکلی ندارد
zone / data locality پشتیبانی کامل عملاً بی‌معنا

راه‌حل میانه: کلید مرکب که با یک field پرتنوع شروع شود و با یک field ترتیبی ادامه یابد، مثل { tenantId: 1, createdAt: 1 }.

chunk، balancer و zone

داده به chunk (در مستندات جدید «range») تقسیم می‌شود؛ اندازهٔ پیش‌فرض ۱۲۸ مگابایت، و balancer وقتی اختلاف حجم دو shard از آستانه (۳ برابر اندازهٔ range، یعنی ۳۸۴MB) بگذرد مهاجرت را شروع می‌کند. jumbo chunk بازه‌ای است که چون همهٔ سندهایش مقدار shard key یکسانی دارند قابل تقسیم نیست — علامت مستقیم کاردینالیتی پایین. با zone هم (sh.addShardToZone و sh.updateZoneKeyRange) می‌توانی الزام کنی دادهٔ یک منطقه روی shardهای همان منطقه بماند: پاسخ مستقیم به data residency.

اگر کوئری shard key را داشته باشد mongos دقیقاً می‌داند کجا برود (targeted)؛ وگرنه به همهٔ shardها می‌فرستد و نتایج را ادغام می‌کند (scatter-gather) — یعنی تأخیرت برابر کندترین shard است و افزودن shard جدید کمکی نمی‌کند.

تقسیم خودکار chunk دیگر انجام نمی‌شود

از MongoDB 6.0.3 به بعد auto-splitting حذف شده؛ دستورهایش هنوز هستند اما کاری نمی‌کنند و توازن بر اساس حجم داده روی هر shard انجام می‌شود، نه شمارش chunkها. اگر با مستندات قدیمی‌تر کار می‌کنی این تفاوت مهمی است.

تغییر کلید بعد از اینکه اشتباه بود: از ۴.۴ دستور refineCollectionShardKey می‌تواند fieldهایی را به‌عنوان پسوند به کلید موجود اضافه کند (کاردینالیتی را بالا می‌برد ولی توزیع فعلی را فوراً عوض نمی‌کند)؛ از ۵.۰ دستور reshardCollection کلید را کاملاً عوض می‌کند؛ و از ۸.۰ می‌توانی با همان کلید و گزینهٔ forceRedistribution دوباره reshard کنی تا داده روی shardهای تازه‌اضافه‌شده پخش شود.

db.adminCommand({ reshardCollection: "app.orders", key: { tenantId: 1, orderId: 1 } })
شارد کردن آخرین ابزار است، نه اولین — و یکتایی محدود می‌شود

پیش از sharding این‌ها را تمام کن: index درست، مدل‌سازی درست، آرشیو دادهٔ سرد، بزرگ‌کردن عمودی، و cache. علامت درست برای sharding این است که حجم داده یا نرخ نوشتن از ظرفیت یک replica set عبور کرده باشد، نه اینکه «کوئری‌مان کند است».

و یک محدودیت که همه را غافلگیر می‌کند: در collection شارد‌شده هر index یکتا باید shard key را به‌عنوان پیشوند داشته باشد. نمی‌توانی روی email یکتاییِ سراسری بگذاری در حالی که کلید tenantId است؛ یا آن field را وارد shard key کن، یا یک collection «رجیستری یکتایی» با _id برابر همان مقدار بساز.

یک shard key برای سیستم سفارش چندمستأجری انتخاب کن و از آن دفاع کن.

بد: _id که ObjectId است — یکنوا و صعودی، پس همهٔ درج‌ها به یک shard می‌روند؛ createdAt بدتر. ناکافی: tenantId تنها، چون یک مستأجر بزرگ می‌تواند یک shard را اشباع کند و chunkهای jumbo بسازد.

انتخاب من { tenantId: 1, orderId: 1 } است که در آن orderId آنتروپی بالایی دارد: کاردینالیتی ترکیبی بسیار بالاست؛ دادهٔ یک مستأجر مجاور هم می‌ماند پس کوئری‌های «سفارش‌های این مستأجر» targeted هستند؛ و چون field دوم یکنوا نیست، نوشتن‌های یک مستأجر بزرگ هم پخش می‌شود. اگر بار مستأجرها بسیار نامتوازن باشد { tenantId: "hashed" } هم گزینه است، ولی کوئری‌های بازه‌ای scatter-gather می‌شوند؛ و با الزام data residency، region را اولین field می‌گذارم و با zone می‌بندم.

چهار علامت نشان می‌دهد کلید بد بوده: توزیع نامتوازن در sh.status()؛ hot shard هنگام نوشتن (یک shard صددرصد CPU، بقیه بی‌کار)؛ jumbo chunk که به‌خاطر کاردینالیتی پایین قابل تقسیم نیست؛ و نسبت بالای scatter-gather. برای اصلاح اول refineCollectionShardKey را می‌سنجم؛ اگر کلید ذاتاً اشتباه است reshardCollection آن را کاملاً عوض می‌کند، ولی عملیات سنگینی است که به فضای اضافی و پنجرهٔ زمانی نیاز دارد.


۱۳. Change stream

change stream جریان تغییرات یک collection، دیتابیس یا کل cluster را به‌شکل زندهٔ قابل ازسرگیری می‌دهد. زیرش همان oplog است، اما با یک API امن به‌جای خواندن مستقیم.

const cs = db.orders.watch(
  [ { $match: { operationType: { $in: ["insert", "update"] },
                "fullDocument.status": "PAID" } } ],
  { fullDocument: "updateLookup", resumeAfter: savedToken })

// دیدن نسخهٔ پیش از تغییر (از ۶.۰) — اول روی collection فعال کن
db.runCommand({ collMod: "orders", changeStreamPreAndPostImages: { enabled: true } })
db.orders.watch([], { fullDocumentBeforeChange: "whenAvailable" })

resume token در field _id هر رویداد است و باید بعد از پردازش موفق ذخیره شود. رویداد update به‌طور پیش‌فرض فقط دلتا (updateDescription) دارد؛ "updateLookup" سند کاملِ فعلی را می‌آورد، نه لزوماً نسخهٔ لحظهٔ تغییر.

change stream را کجا به‌کار می‌بری و چه ریسک‌هایی دارد؟

برای همگام‌سازی دادهٔ دنرمال‌شده (مثلاً customer.name در سفارش‌ها)، پرکردن index جست‌وجو یا cache، و پل زدن تغییرات به یک broker؛ مزیتش نسبت به polling این است که کم‌تأخیر است، بار کمتری تولید می‌کند و قابل ازسرگیری است.

ریسک‌ها را صریح می‌گویم: تحویل at-least-once است پس مصرف‌کننده باید idempotent باشد؛ اگر مصرف‌کننده بیش از پنجرهٔ oplog قطع بماند resume token نامعتبر می‌شود و به مسیر بازسازی کامل نیاز داری؛ pre-image فضا و هزینه دارد؛ و بار خواندن روی همان cluster تولیدی می‌نشیند. برای انتشار رویداد بین سرویس‌ها معمولاً change stream را با الگوی outbox ترکیب می‌کنم تا شکل و ترتیب رویدادها دست خودم بماند (فصل messaging).


۱۴. عملیات: چیزی که فردا تو را بیدار می‌کند

db.setProfilingLevel(1, { slowms: 100, sampleRate: 0.2 })
db.system.profile.find({ millis: { $gt: 200 } }).sort({ ts: -1 }).limit(20)
db.currentOp({ "secs_running": { $gt: 5 }, "op": { $ne: "none" } })

سطح 0 خاموش، 1 فقط عملیات کندتر از slowms (پیش‌فرض ۱۰۰ میلی‌ثانیه)، 2 همه‌چیز (فقط در توسعه)؛ خروجی در collection سقف‌دار system.profile می‌نشیند. working set هم یعنی مجموعهٔ داده‌ها و indexهایی که مرتب لمس می‌شوند: اگر در RAM جا شود MongoDB سریع است، وگرنه هر کوئری به دیسک می‌رود. کش WiredTiger به‌طور پیش‌فرض max(۵۰٪ × (RAM − 1GB), 256MB) است.

در mongod.conf سه تنظیم پایه را صریح بنویس: storage.wiredTiger.engineConfig.cacheSizeGB، operationProfiling.slowOpThresholdMs و security.authorization: enabled (به‌همراه net.tls.mode: requireTLS).

در container، سقف کش را صریح بگذار

نسخه‌های جدید mongod سقف cgroup را می‌بینند، اما اتکای کورکورانه ریسک دارد: فرمول پیش‌فرض حافظه‌ای برای بافر شبکه، مرتب‌سازی، aggregation و اتصال‌ها باقی نمی‌گذارد. در Kubernetes همیشه cacheSizeGB را صریح بنویس — حدود ۵۰٪ از resources.limits.memory. اگر این کار را نکنی، OOMKill شدنِ متناوب pod دیتابیس را تجربه می‌کنی. (همان بحث برای JVM در containers-jvm.)

برای پشتیبان‌گیری سه گزینهٔ واقعی داری: mongodump/mongorestore (مناسب دیتابیس کوچک و جابه‌جایی داده، اما در مقیاس بزرگ کند)، snapshot سطح volume (استاندارد تولید در مقیاس بزرگ؛ باید journal را هم بگیرد و روی sharded cluster هماهنگ باشد)، و ابزار PITR که snapshot را با oplog ترکیب می‌کند.

mongodump --uri="mongodb://user@rs0/app?replicaSet=rs0" --oplog \
          --archive=/backup/app-$(date +%F).archive --gzip
mongorestore --uri="mongodb://user@rs0/?replicaSet=rs0" \
             --archive=/backup/app-2026-08-13.archive --gzip --oplogReplay

پشتیبانی که بازیابی‌اش تمرین نشده پشتیبان نیست. --oplog فقط روی replica set معنا دارد و برای sharded cluster کافی نیست. مهم‌تر از انتخاب ابزار، اندازه‌گیری RTO و RPO واقعی است: یک بار بازیابی کامل را روی داده‌ای هم‌اندازهٔ production تمرین کن و زمانش را بنویس.

معیار Atlas (مدیریت‌شده) self-hosted
راه‌اندازی · نگه‌داری دقیقه؛ ارتقا و backup خودکار روز تا هفته؛ با تیم توست
Search و Vector Search یکپارچه باید جدا بسازی
کنترل شبکه و سخت‌افزار · داده در داخل کشور محدود؛ وابسته به منطقهٔ در دسترس کامل و کاملاً در اختیار تو
مجوز سرویس نسخهٔ Community با پروانهٔ SSPL

نکتهٔ معماری (نه فنی): نسخهٔ Community از سال ۲۰۱۸ تحت SSPL منتشر می‌شود، نه یک پروانهٔ OSI-approved؛ برای استفادهٔ داخلی معمولاً بی‌دردسر است، اما اگر بخواهی خودِ MongoDB را «به‌عنوان سرویس» بفروشی متن پروانه باید با تیم حقوقی بررسی شود.

چیت‌شیت mongosh

کار دستور
اتصال · فهرست‌ها · آمار mongosh "mongodb://host/app" · show collections · db.orders.stats()
فهرست و مصرف index db.orders.getIndexes() · db.orders.aggregate([{ $indexStats: {} }])
تحلیل کوئری · profiler find(q).explain("executionStats") · db.setProfilingLevel(1, { slowms: 100 })
عملیات جاری · replica set · sharding db.currentOp() · rs.status() · sh.status()
شارد کردن · balancer · پیش‌فرض concern sh.shardCollection("app.orders", { tenantId: 1, orderId: 1 }) · sh.stopBalancer() · db.adminCommand({ getDefaultRWConcern: 1 })

نشانهٔ اینکه working set در RAM جا نمی‌شود را از serverStatus می‌گیری: رشد bytes read into cache، فعال شدن eviction اضطراری، و در سطح سیستم‌عامل افزایش I/O. کنارش اندازهٔ کل indexها را با db.collection.stats().indexSizes بسنج؛ قاعدهٔ سرانگشتی این است که مجموع indexهای فعال باید در حافظه جا شود. درمان به‌ترتیب هزینه: حذف indexهای بلااستفاده، کوچک کردن سندها با subset، آرشیو دادهٔ سرد، افزایش RAM، و در نهایت sharding.


۱۵. Spring Data MongoDB در عمل

spring.data.mongodb:
  uri: mongodb://app:secret@mongo-a:27017,mongo-b:27017/shop?replicaSet=rs0&w=majority
  auto-index-creation: false
@Document(collection = "orders")
@CompoundIndex(name = "tenant_created", def = "{'tenantId': 1, 'createdAt': -1}")
public class Order {
    @Id private String id;                         // نگاشت به _id
    private String tenantId;
    @Field("cust") private CustomerRef customer;   // extended reference
    private OrderStatus status;
    private List<OrderItem> items;                 // embed
    private BigDecimal total;                      // نگاشت به decimal128
    @CreatedDate private Instant createdAt;
    @LastModifiedDate private Instant updatedAt;
    @Version private Long version;                 // قفل خوش‌بینانه
}

public record CustomerRef(String id, String name, String tier) {}
public record OrderItem(String sku, int qty, BigDecimal price) {}

برای فعال شدن فیلدهای auditing یک @Configuration با @EnableMongoAuditing لازم است.

public interface OrderRepository extends MongoRepository<Order, String> {

    List<Order> findByTenantIdAndStatusOrderByCreatedAtDesc(
            String tenantId, OrderStatus status, Pageable pageable);

    @Aggregation(pipeline = {
        "{ $match: { tenantId: ?0, status: 'PAID' } }",
        "{ $group: { _id: '$customer.id', revenue: { $sum: '$total' } } }",
        "{ $sort: { revenue: -1 } }",
        "{ $limit: ?1 }" })
    List<CustomerRevenue> topCustomers(String tenantId, int limit);
}

MongoTemplate برای کارهای دقیق‌تر:

public void markPaid(String orderId, Instant paidAt) {
    Query q = Query.query(Criteria.where("_id").is(orderId)
                                  .and("status").is(OrderStatus.NEW));
    Update u = new Update()
            .set("status", OrderStatus.PAID)
            .set("paidAt", paidAt)
            .push("events").slice(-50).each(new StatusEvent(OrderStatus.PAID, paidAt));

    UpdateResult r = template.updateFirst(q, u, Order.class);
    if (r.getMatchedCount() == 0) {
        throw new IllegalStateException("order not in NEW state: " + orderId);
    }
}

برای pipelineهایی که باید پویا ساخته شوند، Aggregation.newAggregation(match(...), group(...), sort(...), limit(...)) همان کار @Aggregation را با API نوع‌دار انجام می‌دهد و با template.aggregate(agg, "orders", CustomerRevenue.class) اجرا می‌شود.

الگوی «به‌روزرسانی شرطی» به‌جای read-modify-write

در markPaid بالا شرط status = NEW داخل خودِ کوئری به‌روزرسانی است، نه در کد Java بعد از یک findById. یعنی گذارِ وضعیت اتمیک است و دو درخواست هم‌زمان نمی‌توانند هر دو موفق شوند — بدون هیچ تراکنش و هیچ قفلی. همین یک الگو بخش بزرگی از نیاز به تراکنش چندسندی را حذف می‌کند.

تراکنش تا وقتی این bean را تعریف نکنی غیرفعال است؛ همان MongoDatabaseFactory باید به transaction manager و به MongoTemplate داده شود:

@Bean
MongoTransactionManager transactionManager(MongoDatabaseFactory factory) {
    return new MongoTransactionManager(factory);
}

تست با Testcontainers — MongoDBContainer خودش یک replica set تک‌عضوی راه می‌اندازد، پس تراکنش و change stream هم در تست کار می‌کنند، و @ServiceConnection (از Spring Boot 3.1) خودش spring.data.mongodb.uri را تنظیم می‌کند و دیگر به @DynamicPropertySource نیاز نیست (مبانی در فصل testing):

@SpringBootTest
@Testcontainers
class OrderRepositoryTest {

    @Container @ServiceConnection
    static MongoDBContainer mongo = new MongoDBContainer("mongo:8.0");

    @Autowired OrderRepository orders;

    @Test
    void findsPaidOrdersOfTenant() {
        orders.save(newOrder("t1", OrderStatus.PAID));
        assertThat(orders.findByTenantIdAndStatusOrderByCreatedAtDesc(
                "t1", OrderStatus.PAID, PageRequest.of(0, 10))).hasSize(1);
    }
}
دو تنظیم پیش‌فرض که در production می‌سوزانند

ساخت خودکار index از Spring Data MongoDB 3.0 به بعد پیش‌فرض خاموش است و باید خاموش بماند: با روشن بودنش هر بار که برنامه بالا می‌آید ممکن است ساخت index روی یک collection بزرگ آغاز شود، و در استقرار rolling چند instance هم‌زمان این کار را می‌کنند. index را مثل migration در یک مرحلهٔ جداگانهٔ pipeline مدیریت کن.

نگاشت تاریخ و عدد: BigDecimal به decimal128 و Instant به date (میلی‌ثانیه، UTC) می‌رود. اگر داده‌ای از قبل با double نوشته شده باشد مقایسه‌ها نتایج عجیب می‌دهند، و LocalDateTime منطقهٔ زمانی سرور را وارد نگاشت می‌کند. قاعده: همیشه Instant ذخیره کن و تبدیل به زمان محلی را در لایهٔ نمایش انجام بده.

یک نکتهٔ پایانی: @DBRef قدیمی برای هر ارجاع یک کوئری اضافه می‌زند و مستعد N+1 است؛ @DocumentReference جایگزین منعطف‌تری است. اما توصیهٔ سطح senior ساده‌تر است — ارجاع را صریح مدل کن: شناسه را ذخیره کن و بارگذاری را در سرویس یا با $lookup کنترل کن، تا هزینه در کدت دیده شود نه پشت یک annotation.


۱۶. MongoDB یا دیتابیس رابطه‌ای؟

سؤال درست «کدام بهتر است» نیست، شکل غالب دسترسی من چیست؟ است.

معیار MongoDB مناسب‌تر است وقتی… رابطه‌ای مناسب‌تر است وقتی…
شکل داده تودرتو، متغیر، اسنادی جدولی، همگن، پایدار
الگوی کوئری از پیش شناخته‌شده و محدود موقتی، تحلیلی، از هر زاویه
join و تراکنش کم یا هیچ؛ مرز تراکنش = یک سند ذاتی، پرتکرار، چندجدولی
تکامل schema سریع و مکرر، بدون downtime با مهاجرت‌های کنترل‌شده
مقیاس نوشتن نیاز به مقیاس افقی مقیاس عمودی کافی است
یکپارچگی ارجاعی در کد اعمال می‌شود موتور تضمین می‌کند
نمونهٔ کاربرد کاتالوگ، پروفایل، محتوا، رویداد، IoT حسابداری، دفتر کل، قواعد سخت انبار

در بیشتر سیستم‌های واقعی جواب «هر دو» است: سرویس کاتالوگ روی MongoDB و سرویس حسابداری روی PostgreSQL کاملاً منطقی است، به شرط آنکه مرز داده‌ها روشن باشد (polyglot persistence در ms-data). و ضدالگوی بزرگ را نگه دار: اگر مدل‌ات ده collection است که همه با $lookup به هم وصل می‌شوند و هر عملیات کسب‌وکار یک تراکنش چندسندی می‌خواهد، تو یک دیتابیس رابطه‌ایِ ضعیف‌تر ساخته‌ای. عکسش هم درست است: JSON بی‌ساختار در یک ستون jsonb که با هزار عملگر کوئری می‌شود، معمولاً یعنی به دیتابیس سندی نیاز داشتی.

چطور یک تغییر schema را روی collection چندصدمیلیونی بدون downtime انجام می‌دهی؟

با expand-contract به‌کمک schema versioning. مرحلهٔ expand: کد جدید هم شکل قدیم و هم شکل جدید را می‌خواند اما فقط شکل جدید را می‌نویسد. بعد یک job پس‌زمینه با نرخ کنترل‌شده — دسته‌های چندهزارتایی با مکث، معمولاً bulkWrite با فیلتر روی schemaVersion — سندهای قدیمی را ارتقا می‌دهد.

وقتی شمارش نسخهٔ قدیمی صفر شد، مرحلهٔ contract: مسیر خواندن قدیمی حذف می‌شود و $jsonSchema با validationLevel: "moderate" اعمال می‌شود. مزیت MongoDB اینجاست که هیچ ALTER TABLE قفل‌کننده‌ای وجود ندارد؛ اما دام واقعی این است که همان job می‌تواند oplog را پر کند و secondaryها را عقب بیندازد — پس نرخ را محدود و تأخیر replication را رصد می‌کنم.

جمع‌بندی

MongoDB مدل سند است، نه JSON روی دیسک. سند واحد اتمیک بودن، واحد انتقال و واحد قفل است؛ همهٔ تصمیم‌های خوب از همین یک واقعیت مشتق می‌شوند.

مدل را از الگوی دسترسی بساز، نه از موجودیت‌ها. embed وقتی داده با هم خوانده می‌شود، کراندار است و مالکش والد است؛ reference وقتی هویت مستقل دارد یا بی‌مرز رشد می‌کند؛ و برای «چند field از والد در فهرست»، extended reference. هر کپی باید سیاست همگام‌سازی صریح داشته باشد و سقف ۱۶ مگابایت یک هشدار طراحی است، نه یک بودجه.

index جایی است که کارایی زندگی می‌کند. compound index را با ترتیب ESR بساز، covered query را برای مسیرهای داغ هدف بگیر، و هر کوئری کند را با explain("executionStats") و نسبت nReturned به totalKeysExamined و totalDocsExamined تشخیص بده. در aggregation $match را اول بگذار و $lookup را بعد از $limit؛ تکرار $lookup در مسیر داغ نشانهٔ مدل‌سازی اشتباه است.

دوام را صریح انتخاب کن: w: "majority" برای هر داده‌ای که گم شدنش هزینه دارد، و بدان که در آرایش P-S-A پیش‌فرض بی‌سروصدا به w:1 تنزل می‌کند. تراکنش چندسندی هست، اما اگر همه‌جا لازمش داری مدل‌سازی‌ات را دوباره ببین.

shard key پرریسک‌ترین تصمیم است: کاردینالیتی بالا، فراوانی یکنواخت، غیریکنوا، و حاضر در کوئری‌های اصلی. sharding آخرین ابزار است؛ اول index، مدل، آرشیو و cache.

و در آخر: MongoDB جایگزین دیتابیس رابطه‌ای نیست، مکمل آن است. سیستم بالغ معمولاً هر دو را دارد و می‌داند مرزشان کجاست.

If your brain is relational — years of thinking in tables, foreign keys and JOIN — your first encounter with MongoDB usually goes one of two ways. Either you think "it's just a JSON store, this is easy" and six months later you are staring at 12 MB documents and 40-second queries; or you decide it isn't serious, rebuild your normalised model verbatim inside MongoDB, and then wonder why it is slower than PostgreSQL.

Both mistakes share a root cause: MongoDB is not a database without a schema; it is a database that moved the schema out of the engine and into your code and your access patterns. That is also exactly what an interviewer is testing — not whether you memorised operator names, but whether you can make decisions about the shape of data.

ACID fundamentals live in tx-acid, distributed-systems theory in distributed-systems-theory, Redis/Cassandra/ClickHouse in nosql-specialized, and Spring Data basics in spring-data-tx; we reference them here rather than repeating them.

Roadmap
  1. The document model: document, collection, BSON, _id and ObjectId; then schema validation and CRUD.
  2. Data modelling (the heart): embed vs reference, the three classic relationships, the 16 MB ceiling, patterns and anti-patterns.
  3. The aggregation pipeline, stage by stage.
  4. Indexes: compound and the ESR rule, multikey, TTL, partial, wildcard, explain() and covered queries.
  5. Durability: write/read concern, read preference, multi-document transactions.
  6. Topology: replica sets and failover, then sharding and shard-key choice.
  7. Change streams and operations: profiling, memory, backups, Atlas vs self-hosted.
  8. Spring Data MongoDB, and a when-to-use-which decision guide.

1. The document model from zero

One patient file vs ten filing cabinets

The first hospital keeps each patient's information in ten separate cabinets: demographics, addresses, prescriptions, lab results. To see one patient's status you open ten cabinets and staple the sheets together by patient number — literally a JOIN.

The second keeps one file per patient with everything tabbed inside: you pull one file and you're done. Fast — as long as the file never gets too fat for the drawer, and as long as the doctor's name repeated on every prescription doesn't hurt when the doctor changes. MongoDB is the second hospital, and all of modelling is deciding what goes inside the file and what stays outside.

The vocabulary

  • document: the smallest unit of data — a key/value structure that can nest and contain arrays. Roughly "a row", except this row can hold other tables inside it.
  • field: a key/value pair inside a document. A column belongs to the table; a field belongs to the document.
  • collection: a container of documents (a table, but with no column definitions up front). database: a container of collections.
  • namespace: the full name db.collection; maximum 255 bytes.

The same data, two shapes

In the relational world one order means two tables:

CREATE TABLE orders (
  id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  customer_id bigint      NOT NULL,
  status      text        NOT NULL,
  created_at  timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE order_items (
  order_id bigint NOT NULL REFERENCES orders(id),
  sku      text   NOT NULL,
  qty      integer NOT NULL,
  price    numeric(12,2) NOT NULL
);

In MongoDB the same order is one document:

{
  _id: ObjectId("66f0a1b2c3d4e5f601020304"),
  customerId: ObjectId("66f0a1b2c3d4e5f6010202aa"),
  status: "PAID",
  createdAt: ISODate("2026-08-10T09:14:22.000Z"),
  total: NumberDecimal("1450000.00"),
  items: [
    { sku: "KB-87", qty: 1, price: NumberDecimal("1200000.00") },
    { sku: "MP-01", qty: 2, price: NumberDecimal("125000.00") }
  ]
}

One diagram, two philosophies — یک نمودار، دو فلسفه: Relational splits one concept across tables; the document model keeps it in one place.

flowchart LR
  subgraph Relational["Relational: JOIN at read time"]
    O[(orders row)] --- I1[(order_items row)]
    O --- I2[(order_items row)]
    O --- C[(customers row)]
  end
  subgraph Document["Document: assembled at write time"]
    D["order document<br/>items: [ ... ]<br/>customer: {id, name}"]
  end
  Relational -->|"one read replaces N joins"| Document

The real difference in one sentence: relationally, normalisation is the default and JOIN pays the read cost; in the document model, pre-building the read shape is the default and denormalisation pays the write-and-sync cost. Neither is inherently better; the question is which cost is cheaper in your system.

Relational concept MongoDB equivalent The difference that matters
table · row · column collection · document · field no column definitions; documents nest, capped at 16 MB; a field belongs to the document, not the collection
primary key _id always present, always unique, always indexed
foreign key + JOIN reference + $lookup the engine enforces no referential integrity
view view / materialized view $merge plays the materialized-view role
GROUP BY, window functions aggregation pipeline different language, comparable power
transaction multi-document transaction possible, but costlier and rarer

Do not shrug off one of those rows: MongoDB has no equivalent of FOREIGN KEY ... ON DELETE CASCADE. Delete a referenced document and the referrer keeps a dead id, silently. Integrity moved into your code. If your data graph is full of strict references, you may have picked the wrong tool.


2. BSON, _id and ObjectId

MongoDB stores and transports data as BSON (Binary JSON), which adds three things JSON lacks: real types (it distinguishes int32/int64/double/decimal128 and adds date and binData), length prefixes (fast traversal, skip over unwanted fields), and stable field order.

BSON type $type alias Note
Double "double" floating point; never use it for money
String · Object · Array "string" · "object" · "array" an array makes an index multikey
Binary · ObjectId "binData" · "objectId" UUIDs, encrypted data · 12 bytes, the _id default
Date "date" milliseconds since epoch, no timezone
Null · Timestamp "null" · "timestamp" null is not "field absent"; timestamp is oplog-internal
Int32 / Int64 / Decimal128 "int" · "long" · "decimal" decimal128 is the money type
MinKey / MaxKey "minKey" · "maxKey" comparison bounds; you meet them in chunk ranges
Never put money in a `double`

0.1 + 0.2 is 0.30000000000000004 in IEEE-754. Store amounts as doubles and sooner or later an invoice total is off by a cent and someone spends three days hunting it. Either use NumberDecimal(...) (that is decimal128, mapped to BigDecimal in Java) or store an integer count of the smallest currency unit in a long.

Limits worth memorising: document size 16 MB · nesting depth 100 levels · at most 64 indexes per collection · at most 32 fields in a compound index · at most one text index per collection. In practice the 16 MB number is a design ceiling, not a technical one: if a document reaches one megabyte, your model is already wrong.

_id and ObjectId

Every document has exactly one _id; if you don't supply one the driver or the server generates an ObjectId. _id always carries an undroppable unique index and is immutable after insert. Its twelve bytes:

| 4 bytes: timestamp (seconds) | 5 bytes: random per process | 3 bytes: counter |

Three practical consequences: insertion time is essentially free ({ $toDate: "$_id" }); sorting by _id is approximately chronological but only to second precision; and an ObjectId is guessable, so if your security depends on unguessable identifiers use a UUIDv4 in binData instead (appsec-owasp).

You can make `_id` a business key — sometimes you should

If you have a natural, immutable key (an order number, or tenantId + date for a rollup document), make it the _id: one index fewer, uniqueness for free, and upsert without an extra query. Just remember that in a sharded collection _id can only stay unique if it is the shard key or the shard key is a prefix of it.


3. What "schemaless" actually means

Day-one flexibility is year-two chaos unless you contain it. The containment tool is schema validation, inside the database itself:

db.createCollection("orders", {
  validator: { $jsonSchema: {
    bsonType: "object",
    required: ["customerId", "status", "createdAt", "items"],
    properties: {
      customerId: { bsonType: "objectId" },
      status:     { enum: ["NEW", "PAID", "SHIPPED", "CANCELLED"] },
      total:      { bsonType: "decimal" },
      items: { bsonType: "array", minItems: 1, maxItems: 500,
        items: { bsonType: "object", required: ["sku", "qty", "price"] } }
    } } },
  validationLevel: "moderate",
  validationAction: "error"
})

validationLevel has three settings: "strict" (the default) checks everything; "moderate" checks only new documents and existing documents that were already valid — precisely the gradual-migration tool; and "off". validationAction is either "error", which rejects, or "warn", which only logs so you can first measure how much of your data is out of spec. Apply the same options to an existing collection with collMod.

How would you push back on "MongoDB has no schema"?

The precise statement is that MongoDB is schema-on-read where a relational database is schema-on-write: the engine doesn't enforce structure at write time, so responsibility moves to the application. The benefit is real — reshaping data needs no locking ALTER TABLE, and several document versions can coexist — and the cost is that read code must tolerate variety.

The senior approach is three layers: strict types in code as the source of truth; $jsonSchema with validationLevel: "moderate" as a safety net so bad data never lands; and the schema versioning pattern — a version field per document, reads that understand old versions and writes that always emit the newest — so migration happens with no downtime.


4. CRUD and query operators

db.orders.updateOne({ _id: id },
  { $set: { status: "PAID" }, $currentDate: { paidAt: true } })

// append to an array while bounding its size and order
db.orders.updateOne({ _id: id },
  { $push: { events: { $each: [{ t: new Date(), type: "PAID" }],
                       $sort: { t: -1 }, $slice: 50 } } })

// atomically claim a job in a single round trip
db.jobs.findOneAndUpdate(
  { status: "READY", runAt: { $lte: new Date() } },
  { $set: { status: "RUNNING", lockedAt: new Date() } },
  { sort: { runAt: 1 }, returnDocument: "after" })

db.orders.find({ status: "PAID", createdAt: { $gte: ISODate("2026-08-01") } },
               { _id: 0, status: 1, total: 1, "items.sku": 1 })   // projection
         .sort({ createdAt: -1 }).limit(20)
Every operation on a *single document* is atomic

This is MongoDB's most important guarantee: updating one document — even if it changes ten fields and three nested arrays at once — either happens completely or not at all. If you model so that the transaction boundary and the document boundary coincide, you effectively never need multi-document transactions. Most of the good decisions in this chapter derive from that one fact.

Operator Purpose Index note
$eq $gt $gte $lt $lte $in comparison and membership indexable; a very long $in list gets expensive
$ne $nin inequality anti-index: effectively scans the whole index
$exists field presence true pairs well with a partial index; false usually doesn't
$regex pattern match uses an index only when anchored with ^ and case-sensitive
$elemMatch several conditions on one array element without it the conditions may land on different elements
$size · $expr exact array length · compare two fields not indexable; keep an itemCount field instead of $size
Two classic traps: `$elemMatch` and `skip`

{ "items.qty": { $gt: 5 }, "items.price": { $lt: 1000 } } matches an order where one line has qty above 5 and a different line has price below 1000 — almost never what you want. The correct form is { items: { $elemMatch: { qty: { $gt: 5 }, price: { $lt: 1000 } } } }.

And find().skip(200000).limit(20) reads and discards two hundred thousand index entries. The right pattern is keyset pagination: find({ createdAt: { $lt: lastSeen } }).sort({ createdAt: -1 }).limit(20), with _id as tie-breaker — the same OFFSET problem you met in sql-mastery.


5. The heart of the chapter: data modelling

This is where a senior interview is won or lost, and every modelling question has one answer: build the model from the access pattern, not from the entities. Relationally you normalise first and write whatever query you need later; normalisation buys you unknown queries. Here it is the reverse. Answer three questions before writing a line of code: which data is always read together? what is the read/write ratio and which side is critical? and does this set have a bound?

One address, a hundred comments, a billion log lines

Three things hang off a "user": their addresses (1 to 5), their comments (maybe 500), and their click events (10 million). Obviously the addresses belong inside the user's file, the clicks definitely do not, and the comments are the interesting argument. That intuition is MongoDB's formal modelling framework.

one-to-few (small and bounded: a user's addresses, an order's line items) → embed; one read, no $lookup, atomic updates. one-to-many (large but still bounded: a post's comments) → reference from child to parent ({ _id: c1, postId: p1, body: ... }), and if needed keep the few most recent in the parent as a subset. one-to-squillions (unbounded growth: device logs) → always reference from child to parent, because the parent's array would otherwise grow without limit; this is where the bucket pattern or a time series collection enters.

You can draw this on a whiteboard in the interview — می‌توانی این را در مصاحبه روی تخته بکشی: The embed-versus-reference decision, reduced to four questions.

flowchart TD
  A[Data accessed together?] -->|No| R[Reference]
  A -->|Yes| B[Is the child set bounded?]
  B -->|Unbounded growth| R
  B -->|Bounded| C[Document stays well under 16MB?]
  C -->|No| R
  C -->|Yes| D[Child written far more often than parent is read?]
  D -->|Yes| R
  D -->|No| E[Embed]
  R --> F[Need parent fields on read?]
  F -->|Yes, few and stable| G[Extended reference: duplicate a few fields]
  F -->|No| H[Plain ObjectId reference]
Criterion Embed Reference
Read round trips one two or more (or a $lookup)
Update atomicity free needs a transaction or idempotent design
Growth capped at 16 MB unbounded
Duplicated data · independent child access must be fixed everywhere · awkward single source of truth · natural
Working set bigger documents, more memory smaller documents, more I/O
Fits one-to-few, owned data one-to-many/squillions, shared data
The three-sentence rule to say in an interview

"If the child is meaningless without the parent and the set is bounded, I embed; if the child has independent identity or grows without limit, I reference; and if I only need a few slow-changing parent fields at read time, I copy exactly those as an extended reference and define one explicit path for keeping them in sync." That covers 90% of modelling questions.

Denormalisation: copying with your eyes open

Copying is neither a sin nor a default; it is a trade. Ask three questions first. How often does it change? (a product name inside an order line must never change — the ideal copy; a user's name inside 10,000 comments — an expensive one). What happens if it drifts? ("an old name in a list" is survivable; "wrong stock level" is not). What is the sync path? (a batch job, a change stream, or nothing). Ownerless denormalisation is silent debt, so at minimum make the copy explicit — customer: { _id, name, snapshotAt }.

Design a model for "orders and customers" and defend your choices.

I embed the order lines: meaningless without the order, bounded (and I bound them with maxItems in the validator), always read with the order, and embedding keeps the update atomic. I reference the customer because it has independent identity — but not as a bare customerId: I store an extended reference, customer: { _id, name, phone }, because the order-list page needs exactly those fields.

The point that earns marks: I also copy the product name and price into the order line and deliberately never sync them, because an order is a historical document and yesterday's invoice must not change when today's price does. By contrast I refresh customer.name via a change stream or batch job and accept a few seconds of drift — for every copy I choose the sync policy explicitly.


6. Schema design patterns

The patterns have names, and saying the name earns credit in an interview.

Extended Reference — instead of a $lookup per row, copy a few slow-changing fields next to the id: customer: { _id, name, tier }.

Subset — the document is bloated by a big array but 95% of reads only want the first few elements: keep the hot few inside, the rest in a separate collection, and bound them automatically with $push + $slice.

Computed — instead of recomputing a total on every read, update it on write:

db.customers.updateOne({ _id: cid },
  { $inc: { "stats.orderCount": 1, "stats.lifetimeValue": 1450000 },
    $max: { "stats.lastOrderAt": new Date() } })

Bucket — one document per sensor reading means billions of tiny documents each paying _id and index overhead; instead gather one interval's readings into a bucket:

{ _id: { sensor: "s-42", hour: ISODate("2026-08-13T09:00:00Z") },
  count: 60, sum: 1342.5, min: 20.1, max: 24.8,
  samples: [ { t: 0, v: 22.4 }, { t: 60, v: 22.5 } ] }

// since 5.0 the engine does exactly this itself, with columnar compression:
db.createCollection("readings", {
  timeseries: { timeField: "ts", metaField: "sensor", granularity: "minutes" },
  expireAfterSeconds: 2592000 })

Outlier — 99.9% of posts have under 100 comments but one viral post breaks the embedded model: flag the document with hasExtras: true and put the overflow in a separate collection.

PolymorphicCreditCard, Wallet and BankTransfer are all "payment methods" sharing 70% of their fields; keep them in one collection with a type discriminator.

Attribute — products have dozens of different specs and the 64-index ceiling forbids one index each; instead of { ram: "16GB", weight: "1.2kg" } write { specs: [ { k: "ram", v: "16GB" } ] } so that one index on { "specs.k": 1, "specs.v": 1 } covers them all.

Schema Versioning — every document carries a version field; code reads all live versions and always writes the newest. It is the expand-contract pattern from cicd-pipelines, without ALTER TABLE.

Anti-pattern Why it breaks Instead
Unbounded array · bloated documents hits 16 MB; working set larger than RAM reference / bucket / subset
Thousands of collections or indexes metadata memory and slow startup polymorphic / attribute pattern
Splitting always-read-together data $lookup on the hot path embed or extended reference
Case-insensitive without collation · $ne/$nin COLLSCAN or a neutralised index collation: { strength: 2 } · model positively with $in
The array that is "small for now"

The most dangerous array is the one with three elements in your dev environment. The right question is not "how many today?" but "where is the ceiling?" — and if the answer is "there isn't one" or "depends on user behaviour", do not embed. Beyond the 16 MB limit, a long array carries two hidden costs: every update may rewrite the whole document, and an index over it generates one key per element — a document with 1000 elements means 1000 index entries.


7. The aggregation pipeline, stage by stage

A factory conveyor belt

Picture a belt: the first station throws out the broken parts, the second strips off surplus labels, the third gathers like parts into boxes, the fourth arranges the boxes; each station's output is the next one's input. The consequence: the sooner you take the rubbish off the belt, the less work every later station does — that sentence is the whole of pipeline optimisation.

$match filters with find syntax and uses an index when it is the first stage. $project selects or builds fields; $set/$addFields add without removing the rest. $group aggregates, with _id as the grouping key and null meaning "everything in one group"; accumulators are $sum, $avg, $min, $max, $push, $addToSet. $sort is free when aligned with an index, otherwise a blocking sort. $lookup is a left outer join whose output is an array, and $unwind expands an array into multiple documents. $facet runs sub-pipelines in parallel over the same input (perfect for "results + total count + category counts" in one round trip), $bucket builds histograms, $unionWith is UNION ALL, $setWindowFields is the window-function equivalent, and $out/$merge write output — $merge being the basis of materialized views.

A real example: top ten customers of the last 30 days

SELECT o.customer_id,
       SUM(i.qty * i.price) AS revenue,
       COUNT(DISTINCT o.id)  AS orders
FROM orders o
JOIN order_items i ON i.order_id = o.id
WHERE o.status = 'PAID'
  AND o.created_at >= now() - INTERVAL '30 days'
GROUP BY o.customer_id
ORDER BY revenue DESC
FETCH FIRST 10 ROWS ONLY;

And the same thing in MongoDB — note that because the line items are embedded, no join is needed for them at all:

db.orders.aggregate([
  { $match: { status: "PAID",
              createdAt: { $gte: new Date(Date.now() - 30*24*3600*1000) } } },
  { $project: { customerId: 1,
                orderRevenue: { $sum: { $map: {
                  input: "$items", as: "it",
                  in: { $multiply: ["$$it.qty", "$$it.price"] } } } } } },
  { $group: { _id: "$customerId",
              revenue: { $sum: "$orderRevenue" }, orders: { $sum: 1 } } },
  { $sort: { revenue: -1 } },
  { $limit: 10 },
  { $lookup: { from: "customers", localField: "_id",
               foreignField: "_id", as: "customer" } },
  { $unwind: "$customer" },
  { $project: { _id: 0, name: "$customer.name", revenue: 1, orders: 1 } }
])
Stage order *is* the optimisation

$match comes first so it can use an index; $sort and $limit come before $lookup so the join runs for ten documents instead of millions — move the $lookup higher and the same query becomes hundreds of times slower. The optimiser does perform some reordering itself (it will pull a $match ahead of a $project), but it will never make a semantic decision like this one for you.

Stage SQL equivalent Note
$match WHERE put it first so it hits an index
$project / $set SELECT drop unneeded fields early
$group GROUP BY _id: null means the whole set
$sort ORDER BY without an index it is a blocking sort
$lookup · $unwind LEFT JOIN · UNNEST array output; place it after $limit
$facet · $unionWith parallel queries · UNION ALL $out/$merge are illegal inside $facet
$setWindowFields · $merge window function · MERGE $merge must be the last stage
The 100 MB ceiling, `allowDiskUse`, and the `$lookup` temptation

Each stage gets at most 100 MB of RAM. Since MongoDB 6.0 allowDiskUseByDefault is on by default, so heavy stages spill to disk instead of erroring — instead of failing loudly you get silent slowness. Watch for the usedDisk marker in the profiler and logs.

And do not use $lookup with a SQL mindset: it issues a query against the target collection for every input document, so without an index on foreignField it is a disaster. If you need more than one on a hot path, the model has stayed relational and the real answer is embedding or an extended reference.

When do you use aggregation instead of `find` — and is aggregation slower?

find means "give me documents as they are, with a filter", and it has the least overhead; aggregation is for when you need to transform — group, compute, join, reshape. Aggregation is not inherently slow; what makes it slow are blocking stages — $group, an unindexed $sort, an unindexed $lookup — which must hold the stream in memory.

The rule I state in interviews: the first stage must be a $match that uses an index; then drop unneeded fields with $project; align $sort with an index; and push $lookup back behind $limit. Finally confirm with explain("executionStats") that the first stage really is an IXSCAN.


8. Indexes: where performance lives

The index at the back of a book

Without one, finding the word "sharding" means flipping through 800 pages — that is a COLLSCAN. With one you jump straight to page 412 — that is an IXSCAN. And if the index itself also prints the chapter number and the chapter number is all you wanted, you never open the book at all — that is a covered query.

Type How When
single / compound { status: 1, createdAt: -1 } filtering and sorting
multikey automatic on an array field queries over array elements
text · hashed · geospatial "text" · "hashed" · "2dsphere" word search · sharding · location
TTL · partial · sparse expireAfterSeconds · partialFilterExpression auto-expiry · index only the hot subset
unique · wildcard unique: true · { "attrs.$**": 1 } uniqueness · unpredictable field names
db.orders.createIndex({ email: 1 },
  { unique: true, partialFilterExpression: { email: { $type: "string" } } })
db.sessions.createIndex({ lastSeenAt: 1 }, { expireAfterSeconds: 3600 })

The ESR rule

Field order in a compound index is not arbitrary. MongoDB's official guideline is ESR: Equality first, Sort second, Range last. The reason is that an equality predicate narrows the scan to one contiguous, narrow band of the index, and inside that band the following fields are still in order; put a range field before the sort field and the ordering inside the band is destroyed, forcing MongoDB into a blocking sort.

db.orders.find({ tenantId: t, status: "PAID", total: { $gte: 100000 } })
         .sort({ createdAt: -1 })

db.orders.createIndex({ tenantId: 1, status: 1, createdAt: -1, total: 1 })
//                     └──── E ─────────────┘  └── S ───┘    └─ R ─┘

Two companion rules. Prefix: an index on {a,b,c} serves queries on {a}, {a,b} and {a,b,c}, but not {b} or {b,c} — so before creating a new index, check whether an existing one is already a suitable prefix. Direction only matters for multi-field sorts: for sort({ createdAt: -1 }) alone, { createdAt: 1 } is fine because the index can be walked backwards, but for sort({ a: 1, b: -1 }) the index must be exactly {a:1,b:-1} or its full inverse.

Covered queries and explain()

If every field in the filter and the projection lives in the index and _id is excluded from the projection, the document is never read at all:

db.orders.createIndex({ status: 1, createdAt: -1, total: 1 })
db.orders.find({ status: "PAID" }, { _id: 0, createdAt: 1, total: 1 })
         .explain("executionStats")   // verbosity: queryPlanner | executionStats | allPlansExecution
Key Meaning What's bad
winningPlan.stage scan type COLLSCAN on a large collection
IXSCAN / FETCH index scan / document fetch lots of FETCH means the index isn't covering
SORT in-memory sort its presence means the index doesn't match the sort
nReturned · totalKeysExamined output · index keys read a big gap between the two means a bad index
totalDocsExamined documents read zero means covered

The golden ratio is totalKeysExamined ≈ totalDocsExamined ≈ nReturned. Wherever those three diverge, you have a missing index.

Three index traps that burn you in production

multikey: a compound index cannot contain more than one array field ({ "items.sku": 1, "tags": 1 } with two arrays errors on insert, because the key count would be a Cartesian product), and a query on an array field is never covered.

Unused indexes are not free: each is maintained on every write and occupies memory that could hold hot data; monitor with $indexStats over a long enough window.

Hide before you drop: db.orders.hideIndex("status_created") removes it from the planner's view while keeping it maintained, so unhideIndex restores it in a second — whereas a real drop means a multi-hour rebuild.

How do you debug a slow query in MongoDB?

Five fixed steps. One: find the culprit in the profiler or log (db.setProfilingLevel(1, { slowms: 100 }), then query system.profile; on a busy system lower sampleRate). Two: take explain("executionStats") and compare nReturned, totalKeysExamined and totalDocsExamined. Three: recognise the pattern — COLLSCAN means no index; keysExamined far above nReturned means wrong field order or poor selectivity; a SORT stage means the ordering isn't from the index. Four: redesign the index with ESR, covering it if possible. Five: verify on production-sized data. If it is still slow, the question moves from index level to model level.

Explain the ESR rule and what happens when you violate it.

ESR is the recommended field order in a compound index: Equality, then Sort, then Range. An equality predicate confines the scan to a contiguous band, and inside that band the following fields retain their order, so the sort comes straight from the index. Put a range field before the sort field and the scan becomes several disjoint bands; MongoDB then runs a blocking SORT that buffers up to 100 MB and spills to disk beyond that. The symptom in explain is a SORT stage plus totalKeysExamined far exceeding nReturned.

If they follow up with covered queries: a covered query is answered entirely from the index and shows totalDocsExamined: 0; all filter and projection fields must be in the index and _id excluded from the projection. Two exceptions: it is impossible on a multikey field, because the engine must read the document to know the full array; and in a sharded cluster an index lacking the shard key may still force a fetch.


9. Replica sets: staying available

A replica set is a group of mongod processes holding the same data: the primary (the only node accepting writes), secondaries (which read the oplog and replay it), and optionally an arbiter (no data, votes only).

The oplog is a capped collection in the local database recording every change idempotently, so replaying it twice changes nothing. It defaults to 5% of free disk (minimum 990 MB, maximum 50 GB) and is resizable with replSetResizeOplog. The oplog window — how many hours of history fit — decides how far a secondary can fall behind and still catch up without an initial sync, how long a change stream has to resume, and how far back oplog-based backups can recover.

Failover: when heartbeats stop, eligible secondaries hold an election. — وقتی ضربان قلب قطع شود، secondaryهای واجد شرایط انتخابات برگزار می‌کنند.

stateDiagram-v2
  [*] --> Primary
  Primary --> Unreachable: node crashes / network partition
  Unreachable --> Election: no heartbeat for electionTimeoutMillis (10s)
  Election --> NewPrimary: majority of votes to most up-to-date member
  NewPrimary --> Rollback: old node returns with un-replicated writes
  Rollback --> Secondary: writes rolled back to a file, node rejoins
  NewPrimary --> Primary: steady state

Heartbeats go out every 2 seconds, and if none is answered within electionTimeoutMillis (default 10 seconds) an election begins. A replica set has at most 50 members and 7 voting members; the winner needs a majority of votes, which is why the voting count should be odd. Drivers observe the failover and, because retryWrites is on by default, retry a failed write once automatically — your application usually sees a few seconds of latency, not an error.


10. Write concern, read concern and read preference

Together these three tune one thing: how much latency you will pay for durability and freshness.

write concern ("when is a write finished?"): w: 1 means only the primary acknowledged (fastest, but losable in a failover); w: "majority" means a majority of data-bearing voting members did; w: <n> or w: "<tag>" names a count or a tag; and j: true requires the journal to hit disk before acknowledgement.

read concern ("how certain?"): "local" (the default) may still be rolled back; "available" is like local but in a sharded cluster may return orphaned documents; "majority" returns only durable data; "linearizable" is the strongest and most expensive; "snapshot" is for transactions.

read preference ("from which node?"): primary (default), primaryPreferred, secondary, secondaryPreferred, nearest — with tag sets to steer to a data centre and maxStalenessSeconds (minimum 90) to avoid badly lagging nodes.

Goal write concern read concern read preference
Money and stock majority, j:true majority primary
Ordinary CRUD · high-volume telemetry majority · w:1 local primary
Heavy reporting majority secondaryPreferred + maxStalenessSeconds
"See my own write" majority majority primary or a causal session
Two durability traps that quietly eat data

Trap one — the arbiter. A P-S-A layout is tempting because it needs one server less; but lose the secondary and no second node is left to acknowledge w: "majority". For exactly that reason MongoDB downgrades the implicit default to { w: 1 } there — you silently get less durability. Check the effective value with db.adminCommand({ getDefaultRWConcern: 1 }) and replace the arbiter with a real secondary.

Trap two — w:1. The application gets "success" while the data exists on one node only; if the primary dies that second, the write is rolled back into a file on disk and vanishes, with no error reaching the user. For anything whose loss means money or trust, w: "majority" is non-negotiable.

The popular belief that you should "read from secondaries to offload the primary" is usually wrong: secondaries replay the same write load, so there is no free capacity; their reads can be seconds behind; and in a sharded cluster the default read concern may return orphans. Scaling reads is the job of sharding and caching (caching), not read preference — except for analytics where freshness doesn't matter.

What is the difference between the three, and how do you guarantee a write isn't lost?

Write concern says on how many nodes the write must land before the server says "done" — the degree of durability. Read concern says what certainty the data I read must have, i.e. whether it can still be rolled back. Read preference says which node I read from — topology and latency, not correctness. The combination you must know: w: "majority" alone does not guarantee the next read sees that data if the read uses read concern local against a secondary; for read-your-own-writes, read from the primary with majority or use a causally consistent session.

To make sure a write isn't lost I combine w: "majority" with j: true (failover vs. power loss) and keep retryWrites on. The part people forget is topology: in a P-S-A layout the implicit default degrades to { w: 1 }, so I check with getDefaultRWConcern and set write concern explicitly on critical paths.


11. Multi-document transactions

Since 4.0 on replica sets and 4.2 on sharded clusters, MongoDB has multi-document ACID transactions. They exist — which is not the same as saying you should always use them.

try (ClientSession session = mongoClient.startSession()) {
    TransactionOptions options = TransactionOptions.builder()
            .readConcern(ReadConcern.SNAPSHOT).writeConcern(WriteConcern.MAJORITY).build();

    session.withTransaction(() -> {   // retries transient errors for you
        accounts.updateOne(session, Filters.eq("_id", "A"), Updates.inc("balance", -100));
        accounts.updateOne(session, Filters.eq("_id", "B"), Updates.inc("balance",  100));
        return null;
    }, options);
}

The facts: they require a replica set (they do not work on a standalone mongod — which is why Testcontainers starts one); the default time limit is 60 seconds (transactionLifetimeLimitSeconds); the official guidance is not to modify more than about 1000 documents in one transaction; two transactions touching the same document produce a WriteConflict that withTransaction retries for you; and holding a snapshot costs WiredTiger memory, so never put a network call inside a transaction.

If you need transactions everywhere, your model is wrong

This is the most valuable sentence you can offer here in an interview. A multi-document transaction is an escape hatch for rare cases (a funds transfer), not a daily tool. The design fix is to make the transaction boundary coincide with the document boundary. If several documents really are involved and eventual consistency is acceptable, the outbox and saga patterns (ms-data) scale better than a distributed transaction.

What do MongoDB transactions cost, and when do you use them?

The cost has three parts: holding a snapshot in the WiredTiger cache, expensive under memory pressure; the 60-second default limit; and write conflicts that raise WriteConflict and require retries. On a sharded cluster a two-phase coordination layer adds further latency.

I use them only where several documents genuinely must change together and eventual consistency is unacceptable. Otherwise I first make the transaction boundary the document boundary; failing that I use a conditional update (the state predicate inside the update query, so the transition is atomic); and if several services are involved, outbox and saga are the better answer.


12. Sharding: scaling out

A library that outgrew its building

You build three buildings and split the books — but split them by what? By first letter of the title, and building "A" is swamped; by purchase date, and every new book goes to the last building; and if someone asks for a book and you don't know where it is, you must search all three. That is exactly the shard key problem — the highest-stakes decision in this chapter.

A sharded cluster: routers, config metadata, and shards that are themselves replica sets. — یک sharded cluster: مسیریاب، متادیتای config، و shardهایی که خودشان replica set هستند.

flowchart TD
  App[Application driver] --> M1[mongos router]
  App --> M2[mongos router]
  M1 --> CFG[(config servers<br/>replica set)]
  M2 --> CFG
  M1 --> S1[Shard A<br/>replica set]
  M1 --> S2[Shard B<br/>replica set]
  M2 --> S3[Shard C<br/>replica set]
  CFG -. chunk map .-> M1
  CFG -. chunk map .-> M2

Each shard is itself a full replica set holding part of the data; the config servers are a replica set holding the data map, and the balancer runs on their primary; mongos is a stateless router and the only thing your application talks to.

Shard key: four criteria

High cardinality (country for a single-country service means effectively one shard) · even frequency (if one customer is 40% of traffic, customerId alone is not enough) · non-monotonic (an ascending key such as a timestamp or an ObjectId sends every new write to the last range and therefore to one hot shard) · and the criterion people forget: the key must appear in most read queries.

Property ranged hashed
Syntax sh.shardCollection("db.c", { userId: 1 }) sh.shardCollection("db.c", { userId: "hashed" })
Write distribution can become skewed nearly uniform
Range queries targeted and efficient scatter-gather
Monotonic key (time) hot shard no problem
Zones / data locality fully supported effectively meaningless

The middle ground used most often in practice is a compound key starting with a high-cardinality field and continuing with an ordered one, such as { tenantId: 1, createdAt: 1 }.

Chunks, the balancer and zones

Data is split into chunks (called "ranges" in newer docs) with a default size of 128 MB, and the balancer starts a migration once two shards differ in size for a collection by more than the threshold (three times the range size, i.e. 384 MB). A jumbo chunk is a range that cannot be split because all its documents share the same shard-key value — a direct symptom of low cardinality. With zones (sh.addShardToZone and sh.updateZoneKeyRange) you can require a region's data to live physically on that region's shards: the direct answer to data residency.

If a query carries the shard key, mongos knows exactly where to go (targeted); otherwise it broadcasts to every shard and merges the results (scatter-gather) — meaning your latency equals the slowest shard, and adding shards doesn't help.

Automatic chunk splitting no longer happens

Since MongoDB 6.0.3 auto-splitting has been removed; the commands still exist but do nothing, and balancing is now driven by data size per shard rather than chunk counts. If you are working from older documentation this is an important difference.

Changing the key after you got it wrong: since 4.4, refineCollectionShardKey can append field(s) as a suffix to the existing key (raising cardinality without immediately redistributing); since 5.0, reshardCollection replaces the key entirely; and since 8.0 you can reshard on the same key with the forceRedistribution option to spread data onto newly added shards.

db.adminCommand({ reshardCollection: "app.orders", key: { tenantId: 1, orderId: 1 } })
Sharding is the last tool, not the first — and uniqueness gets restricted

Before sharding, exhaust these: correct indexes, correct modelling, archiving cold data, vertical scaling, and caching. The right trigger is that data volume or write rate has passed the capacity of one replica set — not "our queries are slow".

And one restriction that catches everybody: in a sharded collection every unique index must have the shard key as a prefix. You cannot enforce global uniqueness on email while the key is tenantId; either bring that field into the shard key, or build a "uniqueness registry" collection whose _id is that value.

Choose a shard key for a multi-tenant order system and defend it.

Bad: _id as an ObjectId — monotonic and ascending, so every insert lands on one shard; createdAt is worse. Insufficient: tenantId alone, because one large tenant can saturate a shard and create jumbo chunks.

My choice is { tenantId: 1, orderId: 1 } where orderId has high entropy: combined cardinality is very high; one tenant's data stays adjacent so "this tenant's orders" is targeted; and because the second field isn't monotonic, even a large tenant's writes spread out. If tenant load is extremely skewed, { tenantId: "hashed" } is an option at the price of scatter-gather range queries; with a data-residency requirement I make region the first field and pin it with zones.

Four symptoms say the key was wrong: skewed distribution in sh.status(); a hot shard on writes (one shard at 100% CPU, the rest idle); jumbo chunks that can't be split; and a high proportion of scatter-gather queries. To fix it I first consider refineCollectionShardKey; if the key is fundamentally wrong, reshardCollection replaces it, but that is a heavy operation needing extra space and a time window.


13. Change streams

A change stream gives you a live, resumable feed of changes for a collection, a database or the whole cluster. Underneath it is the oplog, but with a safe API instead of reading the oplog directly.

const cs = db.orders.watch(
  [ { $match: { operationType: { $in: ["insert", "update"] },
                "fullDocument.status": "PAID" } } ],
  { fullDocument: "updateLookup", resumeAfter: savedToken })

// see the pre-change version (since 6.0) — enable it on the collection first
db.runCommand({ collMod: "orders", changeStreamPreAndPostImages: { enabled: true } })
db.orders.watch([], { fullDocumentBeforeChange: "whenAvailable" })

The resume token lives in each event's _id field and must be persisted after successful processing. An update event carries only the delta (updateDescription) by default; "updateLookup" attaches the full current document, which is not necessarily the version as of the change.

Where do you use change streams, and what are the risks?

For syncing denormalised data (customer.name inside orders), populating a search index or cache, and bridging changes to a broker; compared with polling it is low-latency, cheaper, and resumable.

The risks, plainly: delivery is at-least-once, so consumers must be idempotent; if a consumer is down longer than the oplog window the resume token is invalid and you need a full rebuild path; pre-images cost space; and the read load lands on the production cluster. For publishing events between services I combine a change stream with the outbox pattern so event shape and ordering stay under my control (messaging).


14. Operations: the part that wakes you up

db.setProfilingLevel(1, { slowms: 100, sampleRate: 0.2 })
db.system.profile.find({ millis: { $gt: 200 } }).sort({ ts: -1 }).limit(20)
db.currentOp({ "secs_running": { $gt: 5 }, "op": { $ne: "none" } })

Level 0 is off, 1 records only operations slower than slowms (default 100 ms), 2 records everything (development only); output lands in the capped system.profile collection. The working set is the set of documents and index entries you touch regularly: if it fits in RAM MongoDB is fast, otherwise every query waits on disk. The WiredTiger cache defaults to max(50% × (RAM − 1 GB), 256 MB).

In mongod.conf, set three basics explicitly: storage.wiredTiger.engineConfig.cacheSizeGB, operationProfiling.slowOpThresholdMs, and security.authorization: enabled (plus net.tls.mode: requireTLS).

In containers, set the cache ceiling explicitly

Recent mongod builds do read the cgroup limit, but relying on that blindly is risky: the default formula leaves nothing for network buffers, sorts, aggregation and connections. On Kubernetes always write cacheSizeGB explicitly — around 50% of resources.limits.memory. Skip this and you will meet the periodically OOMKilled database pod. (The same discussion for the JVM is in containers-jvm.)

You have three real backup options: mongodump/mongorestore (fine for small databases and moving data, but slow at scale), volume-level snapshots (the production standard at scale; must include the journal, and must be coordinated on a sharded cluster), and a PITR tool that combines snapshots with the oplog.

mongodump --uri="mongodb://user@rs0/app?replicaSet=rs0" --oplog \
          --archive=/backup/app-$(date +%F).archive --gzip
mongorestore --uri="mongodb://user@rs0/?replicaSet=rs0" \
             --archive=/backup/app-2026-08-13.archive --gzip --oplogReplay

A backup you have never restored is not a backup. --oplog only makes sense on a replica set and is not sufficient for a sharded cluster. More important than the tool is measuring real RTO and RPO: rehearse a full restore on production-sized data once and write down how long it took.

Criterion Atlas (managed) Self-hosted
Setup · maintenance minutes; upgrades and backups automatic days to weeks; your team owns it
Search and Vector Search integrated build it separately
Network/hardware control · in-country data limited; depends on available regions full, entirely yours
Licence a service Community edition under SSPL

An architectural (not technical) note: the Community edition has shipped under SSPL since 2018, which is not an OSI-approved licence. For internal use it is usually a non-issue, but if you intend to sell MongoDB itself "as a service", have the licence text reviewed by legal.

mongosh cheat sheet

Task Command
Connect · list · stats mongosh "mongodb://host/app" · show collections · db.orders.stats()
List and use of indexes db.orders.getIndexes() · db.orders.aggregate([{ $indexStats: {} }])
Query analysis · profiler find(q).explain("executionStats") · db.setProfilingLevel(1, { slowms: 100 })
Running ops · replica set · sharding db.currentOp() · rs.status() · sh.status()
Shard · balancer · default concern sh.shardCollection("app.orders", { tenantId: 1, orderId: 1 }) · sh.stopBalancer() · db.adminCommand({ getDefaultRWConcern: 1 })

The signs that the working set no longer fits in RAM come from serverStatus: growing bytes read into cache, emergency eviction kicking in, and rising I/O at the OS level. Alongside that, measure total index size with db.collection.stats().indexSizes; the rule of thumb is that the active indexes must fit in memory. Remedies in order of cost: drop unused indexes, shrink documents with the subset pattern, archive cold data, add RAM, and finally shard.


15. Spring Data MongoDB in practice

spring.data.mongodb:
  uri: mongodb://app:secret@mongo-a:27017,mongo-b:27017/shop?replicaSet=rs0&w=majority
  auto-index-creation: false
@Document(collection = "orders")
@CompoundIndex(name = "tenant_created", def = "{'tenantId': 1, 'createdAt': -1}")
public class Order {
    @Id private String id;                         // maps to _id
    private String tenantId;
    @Field("cust") private CustomerRef customer;   // extended reference
    private OrderStatus status;
    private List<OrderItem> items;                 // embedded
    private BigDecimal total;                      // maps to decimal128
    @CreatedDate private Instant createdAt;
    @LastModifiedDate private Instant updatedAt;
    @Version private Long version;                 // optimistic locking
}

public record CustomerRef(String id, String name, String tier) {}
public record OrderItem(String sku, int qty, BigDecimal price) {}

The auditing fields require a @Configuration class annotated with @EnableMongoAuditing.

public interface OrderRepository extends MongoRepository<Order, String> {

    List<Order> findByTenantIdAndStatusOrderByCreatedAtDesc(
            String tenantId, OrderStatus status, Pageable pageable);

    @Aggregation(pipeline = {
        "{ $match: { tenantId: ?0, status: 'PAID' } }",
        "{ $group: { _id: '$customer.id', revenue: { $sum: '$total' } } }",
        "{ $sort: { revenue: -1 } }",
        "{ $limit: ?1 }" })
    List<CustomerRevenue> topCustomers(String tenantId, int limit);
}

MongoTemplate for the precise work:

public void markPaid(String orderId, Instant paidAt) {
    Query q = Query.query(Criteria.where("_id").is(orderId)
                                  .and("status").is(OrderStatus.NEW));
    Update u = new Update()
            .set("status", OrderStatus.PAID).set("paidAt", paidAt)
            .push("events").slice(-50).each(new StatusEvent(OrderStatus.PAID, paidAt));

    if (template.updateFirst(q, u, Order.class).getMatchedCount() == 0) {
        throw new IllegalStateException("order not in NEW state: " + orderId);
    }
}

For pipelines that must be built dynamically, Aggregation.newAggregation(match(...), group(...), sort(...), limit(...)) does what @Aggregation does with a typed API, executed via template.aggregate(agg, "orders", CustomerRevenue.class).

The conditional-update pattern instead of read-modify-write

In markPaid above the status = NEW predicate lives inside the update query, not in Java after a findById. That makes the state transition atomic: two concurrent requests cannot both succeed — with no transaction and no lock. This single pattern removes a large share of the need for multi-document transactions.

Transactions stay disabled until you declare this bean, and the same MongoDatabaseFactory must be given to both the transaction manager and the MongoTemplate:

@Bean
MongoTransactionManager transactionManager(MongoDatabaseFactory factory) {
    return new MongoTransactionManager(factory);
}

Testing with Testcontainers — MongoDBContainer starts a single-member replica set, so transactions and change streams work in tests too, and @ServiceConnection (Spring Boot 3.1+) sets spring.data.mongodb.uri for you so @DynamicPropertySource is no longer needed (basics in testing):

@SpringBootTest
@Testcontainers
class OrderRepositoryTest {

    @Container @ServiceConnection
    static MongoDBContainer mongo = new MongoDBContainer("mongo:8.0");

    @Autowired OrderRepository orders;

    @Test
    void findsPaidOrdersOfTenant() {
        orders.save(newOrder("t1", OrderStatus.PAID));
        assertThat(orders.findByTenantIdAndStatusOrderByCreatedAtDesc(
                "t1", OrderStatus.PAID, PageRequest.of(0, 10))).hasSize(1);
    }
}
Two defaults that burn you in production

Automatic index creation has been off by default since Spring Data MongoDB 3.0 and should stay off: with it on, every application start may kick off an index build on a large collection, and in a rolling deployment several instances do it at once. Manage indexes like migrations, in a separate pipeline step.

Date and number mapping: BigDecimal maps to decimal128 and Instant to date (milliseconds, UTC). Data previously written as double makes comparisons behave strangely, and LocalDateTime drags the server's time zone into the mapping. Always store Instant and convert in the presentation layer.

One last note: the old @DBRef issues an extra query per reference and is highly N+1-prone; @DocumentReference is the more flexible replacement. But the senior advice is simpler — model the reference explicitly: store the id and control loading in your service or with $lookup, so the cost is visible in your code rather than hidden behind an annotation.


16. MongoDB or a relational database?

The right question is not "which is better", it is what is my dominant access shape?

Criterion MongoDB fits when… Relational fits when…
Data shape nested, variable, document-like tabular, homogeneous, stable
Query pattern known in advance and bounded ad-hoc, analytical, from any angle
Joins and transactions few or none; transaction boundary = one document intrinsic, frequent, multi-table
Schema evolution fast and frequent, without downtime via controlled migrations
Write scale needs to scale out vertical scale is enough
Referential integrity enforced in code guaranteed by the engine
Typical use catalogue, profile, content, events, IoT accounting, ledgers, hard inventory rules

In most real systems the answer is "both": a catalogue service on MongoDB and an accounting service on PostgreSQL is entirely sensible, provided the data boundaries are clear (polyglot persistence in ms-data). And keep the big anti-pattern in mind: if your model is ten collections wired together with $lookup and every business operation needs a multi-document transaction, you have built a weaker relational database. The converse holds too: unstructured JSON in a jsonb column queried through a thousand operators usually means you needed a document database.

How do you change the schema of a collection with hundreds of millions of documents without downtime?

With expand-contract, helped by schema versioning. Expand phase: new code reads both the old and the new shape but writes only the new one. Then a rate-controlled background job — batches of a few thousand with pauses, usually bulkWrite filtered on schemaVersion — upgrades the old documents.

Once the old-version count reaches zero, the contract phase: the old read path is deleted and $jsonSchema is applied with validationLevel: "moderate". MongoDB's advantage is that there is no locking ALTER TABLE at all; the real trap is that the same job can flood the oplog and push secondaries behind, so I rate-limit it and watch replication lag.

Summary

MongoDB is a document model, not JSON on disk. The document is the unit of atomicity, of transfer and of locking; every good decision derives from that fact.

Build the model from the access pattern, not the entities. Embed when data is read together, bounded, and owned by the parent; reference when it has independent identity or grows without limit; use an extended reference for "a few parent fields in a list". Every copy needs an explicit sync policy, and 16 MB is a design warning, not a budget.

Indexes are where performance lives. Order compound indexes by ESR, aim for covered queries on hot paths, and diagnose slow queries with explain("executionStats") and the ratio of nReturned to totalKeysExamined and totalDocsExamined. In aggregation put $match first and $lookup after $limit; a repeated $lookup on a hot path is a modelling smell.

Choose durability explicitly: w: "majority" for anything whose loss costs you, and know that a P-S-A layout silently degrades the default to w:1. Transactions exist, but if you need them everywhere, revisit the model.

The shard key is the highest-stakes decision: high cardinality, even frequency, non-monotonic, present in the main queries. Sharding is the last tool; indexes, model, archiving and caching come first.

And finally: MongoDB does not replace the relational database, it complements it. A mature system runs both and knows where the boundary lies.