MongoDB Developer Associate study materials
MongoDB Developer Associate study resources

Product catalog with an unbounded reviews array approaching 16 MB

MongoDB Developer Associate · Put your knowledge to work in context.

All case studies

Product catalog with an unbounded reviews array approaching 16 MB

An e-commerce catalog has 3.5 million product documents, each embedding a reviews array. The median product holds 35 reviews, but the top 0.4% (about 14,000 products) hold more than 20,000 reviews, and 300 documents have already reached the 16 MB BSON limit, causing writes to fail with "object to insert too large". Product pages need only the rating summary and the five most recent reviews; a moderation tool reads individual reviews by reviewId and occasionally flips a review's status to "removed". Updates to popular products now take tens of milliseconds because the whole document is rewritten and sometimes relocated, WiredTiger cache eviction has climbed, and the nightly aggregation that recomputes average rating takes 3 hours. The team wants to keep the product page to a single read where possible.

1. Propose the schema change, show the write that keeps the embedded portion bounded, and discuss how to keep the counters consistent without a transaction on every review.

Read the worked answer

The 16 MB BSON limit is hard and per-document; the array is unbounded by design, so no tuning of the write path fixes this. Use the subset pattern.

Split:

  • products: the page fields, plus ratingSummary: {count, sum, avg} and recentReviews capped at 5 entries.
  • reviews: one document per review, productId, createdAt, status, body, with an index on {productId: 1, createdAt: -1}. reviewId can simply be _id, giving free point lookups for moderation.

Bounded write, single document:

db.products.updateOne(
  {_id: productId},
  {$push: {recentReviews: {$each: [review], $sort: {createdAt: -1}, $slice: 5}},
   $inc: {"ratingSummary.count": 1, "ratingSummary.sum": rating}})

$slice: 5 guarantees the embedded array never grows past five entries, so the product document stops growing, stops relocating, and stops dominating the cache. The nightly job reads recentReviews-free data from a small document instead of loading 20,000 elements.

Consistency. The review insert and the counter update are two documents and cannot both be atomic without a transaction. Two defensible designs:

  • Transaction in a session with readConcern:"snapshot" / writeConcern:"majority". Correct, but adds latency and a failure mode to a very high-volume path.
  • Write then increment, with reconciliation. Insert the review first (idempotent via _id), then $inc the summary - $inc is atomic on a single document. A periodic reconciler recomputes count and sum from reviews for products whose review count changed. At this volume the second option is usually the right trade: the summary is eventually correct within seconds, and page reads never block.

Either way, index {productId: 1, createdAt: -1} on reviews so the nightly average is an index-supported $group, not a collection scan.

2. How should the moderation tool look up and update a single review, and what happens to the embedded copy when a review is marked removed?

Read the worked answer

Moderation is a different access path, so it gets a different design - this is exactly why the reviews were separated.

Lookup: db.reviews.findOne({_id: reviewId}) uses the _id index; no extra index needed.

Update: db.reviews.updateOne({_id: reviewId}, {$set: {status: "removed", removedAt: new Date()}}) is a single-document atomic write with no transaction and no interaction with the 16 MB limit.

The embedded copy. If the removed review happens to be inside the top-5 window, products.recentReviews still shows it. Handle it explicitly:

db.products.updateOne(
  {_id: productId},
  {$pull: {recentReviews: {_id: reviewId}}})

Note the cost difference that motivated the split: $pull against a five-element array is trivial, while against a 20,000-element array it rewrote the whole document and could trigger a move. If the page tolerates a few seconds of staleness, an alternative is to keep the entry but filter status:"removed" in the read projection - cheaper, at the price of the page logic knowing about moderation state.

Do not rely on arrayFilters to update a nested review in the old embedded design: it works, but it still rewrites the entire document, which is precisely the failure mode being fixed.

3. Why can't the team just keep every review embedded and rely on $push with $slice, and what else breaks at 20,000 array elements besides the size limit?

Read the worked answer

$push with $sort and $slice keeps the array bounded, so it is the right tool - but only as half of the subset pattern. Used alone (slice at 20,000) it does not solve anything at these volumes.

What breaks with a 20,000-element array:

  1. Document size. 20,000 reviews of even 600 bytes exceed 16 MB. $slice at a number that keeps you under the limit means discarding real history anyway - at which point you have a separate collection with worse indexing.
  2. Rewrite cost. A $push with $sort reorders the array and rewrites the whole document. At tens of KB to megabytes per update, every review costs a full document write and, when it outgrows its allocated record space, a relocation. That is the source of the tens-of-milliseconds updates and the cache eviction.
  3. Oplog and replication amplification. Every one of those rewrites is replicated in full to every secondary, multiplying the write cost across the replica set and inflating the oplog.
  4. No usable index on array elements for moderation. You cannot efficiently find or update a single review by reviewId inside an embedded array; every moderation action is a full document read-modify-write.
  5. Working set. Large documents push the hot product data out of the WiredTiger cache, so unrelated queries slow down - which is what the eviction climb is telling you.

The nightly aggregation is the same story: it must read and unwind huge arrays instead of doing an index-supported $group over a properly indexed reviews collection. Bounded $push is the correct mechanism; it is not a substitute for separating the unbounded part.