Bạn thấy thế nào?
Đăng nhập để phản hồi.
Bạn thấy thế nào?
Đăng nhập để phản hồi.
$group nuốt mất thứ tự sort — và tại sao bạn cần sort 2 lầnMình có một API trả về danh sách orders, cho phép user sort theo created_at giảm dần. Chạy ngon. Rồi PM yêu cầu thêm tính năng deduplicate — mỗi customer chỉ hiện order mới nhất.
Mình thêm $group vào aggregation pipeline. Test thử. Kết quả đúng — mỗi customer chỉ còn 1 order.
Nhưng thứ tự? Mỗi lần refresh API, kết quả xếp theo thứ tự khác nhau.
# Lần 1
[Order #142, #139, #137, #128, #124...]
# Lần 2 (cùng params, refresh)
[Order #128, #142, #124, #137, #139...]
Tắt deduplicate? Sort hoạt động hoàn hảo. Bật lên? Thứ tự ngẫu nhiên.
Vấn đề nằm ở một hành vi của MongoDB mà docs không nhấn mạnh đủ: $group phá hủy hoàn toàn thứ tự sort của output.
Bài viết này sẽ đi sâu vào tại sao $group behave như vậy, không chỉ cách fix. Bởi nếu chỉ biết fix mà không hiểu nguyên nhân, bạn sẽ gặp lại bug này ở một dạng khác.
Trước khi nói về $group, mình cần chắc rằng bạn hình dung được aggregation pipeline hoạt động thế nào.
Hãy tưởng tượng một dây chuyền sản xuất trong nhà máy. Nguyên liệu thô (documents trong collection) đi vào đầu dây chuyền, đi qua từng trạm xử lý (stage), và ra thành phẩm ở cuối.
Mỗi stage nhận output của stage trước, xử lý, rồi đẩy kết quả sang stage tiếp theo.
Collection
│
▼
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ $match │────▶│ $sort │────▶│ $group │────▶│ $limit │
│ (lọc) │ │ (sắp xếp)│ │ (gom nhóm)│ │ (cắt) │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
│
▼
Kết quảĐiểm quan trọng: mỗi stage là một phép biến đổi độc lập. Stage sau không biết (và không cần biết) stage trước đã làm gì. Nó chỉ nhận một stream documents và xử lý theo logic của mình.
Đây chính là chìa khóa để hiểu bug.
$sort — sắp xếp documents$sort là stage đơn giản nhất. Nó nhận tất cả documents từ stage trước, sắp xếp theo field bạn chỉ định, rồi đẩy ra theo thứ tự mới.
# Sort theo created_at giảm dần (mới nhất lên đầu)
{"$sort": {"created_at": -1}}Nếu bạn chỉ dùng $match → $sort → $limit, thứ tự output luôn đúng. Không có gì bất ngờ ở đây.
Vấn đề bắt đầu khi bạn thêm $group vào giữa.
$group — kẻ phá hoại thứ tự$group gom documents lại theo một field (gọi là _id của group). Ví dụ: gom tất cả orders theo customer_id, rồi lấy order mới nhất của mỗi customer bằng $first.
{
"$group": {
"_id": "$customer_id",
"doc": {"$first": "$$ROOT"}
}
}Phần $first ở đây phụ thuộc vào thứ tự documents đi vào $group. Nếu documents đã được sort created_at giảm dần, thì $first sẽ lấy đúng document mới nhất mỗi customer.
Đến đây nghe hợp lý. Nhưng đây là phần mà hầu hết mọi người hiểu sai:
$group bảo tồn thứ tự documents đi vào nó (để $first/$last hoạt động đúng), nhưng KHÔNG bảo tồn thứ tự của các groups đi ra.
Nói cách khác: $group nhớ thứ tự bên trong từng nhóm, nhưng quên thứ tự giữa các nhóm.
$group không giữ thứ tự output?Phần này hơi tricky, nhưng hiểu được nó sẽ giúp bạn tránh cả một lớp bugs tương tự.
Hãy nghĩ về cách $group xử lý bên trong. Khi MongoDB nhận được 1000 documents cần group theo customer_id, nó cần:
customer_id là gìcustomer_id đó đã tồn tại chưaBước 3 — “tìm xem nhóm đã tồn tại chưa” — đòi hỏi một hash table (hay tương tự). MongoDB dùng hash table nội bộ để tra cứu nhanh group nào đã có.
Và đây là vấn đề: hash table không có khái niệm thứ tự. Nó được thiết kế để tra cứu nhanh O(1), không phải để giữ thứ tự chèn vào.
Khi $group hoàn thành và cần đẩy kết quả sang stage tiếp theo, nó iterate qua hash table. Thứ tự iterate phụ thuộc vào implementation nội bộ của hash — nó có thể thay đổi giữa các lần chạy, giữa các phiên bản MongoDB, thậm chí giữa các lần restart server.
⚠️ Đây là lý do kết quả “ngẫu nhiên” mỗi lần refresh. Không phải MongoDB random — mà là thứ tự hash table không deterministic từ góc nhìn của bạn.
Quay lại ẩn dụ nhà máy: $group giống như trạm đóng gói. Sản phẩm đi vào theo thứ tự, được phân loại vào từng thùng (mỗi thùng = 1 customer). Bên trong mỗi thùng, sản phẩm vẫn xếp đúng thứ tự. Nhưng khi các thùng được đẩy ra khỏi trạm? Thùng nào ra trước phụ thuộc vào vị trí nó nằm trong kho — không ai sắp xếp thứ tự các thùng cả.
Hãy xem pipeline gốc và trace xem data đi qua từng stage thế nào.
pipeline = [
{"$match": {"user_id": user_id}},
{"$sort": {"created_at": -1}}, # Stage 2
{
"$group": {
"_id": "$customer_id",
"doc": {"$first": "$$ROOT"} # Stage 3
}
},
{"$replaceRoot": {"newRoot": "$doc"}},
# ← THIẾU SORT Ở ĐÂY
{"$skip": skip},
{"$limit": limit}
]Giả sử data đầu vào:
orders = [
{"_id": 1, "customer_id": "Alice", "created_at": "10:00", "amount": 100},
Trace từng stage:
Stage 1 — $match: Lọc theo user_id. Giả sử tất cả 6 records match.
Stage 2 — $sort: Sắp xếp created_at giảm dần:
Alice 10:00 ← đầu tiên
Charlie 09:45
Bob 09:30
Alice 09:00
Alice 08:00
Bob 07:30 ← cuối cùngStage 3 — $group: Gom theo customer_id, lấy $first:
Documents đi VÀO $group theo thứ tự trên. Vì $first lấy document đầu tiên gặp được cho mỗi group:
$first hoạt động đúng! Nhưng 3 groups này ra khỏi $group theo thứ tự nào? Không xác định. Có thể là:
Lần 1: [Bob 09:30, Alice 10:00, Charlie 09:45]
Lần 2: [Alice 10:00, Charlie 09:45, Bob 09:30]
Lần 3: [Charlie 09:45, Bob 09:30, Alice 10:00]Stage 4 — $replaceRoot: Unwrap doc field. Thứ tự không đổi (vẫn ngẫu nhiên).
Stage 5, 6 — $skip, $limit: Cắt trên thứ tự ngẫu nhiên → kết quả sai.
Tổng kết: $first chọn đúng document, nhưng output sai thứ tự.
Fix thực ra rất đơn giản khi đã hiểu nguyên nhân: thêm $sort thứ hai SAU $group.
pipeline = [
{"$match": {"user_id": user_id}},
# SORT #1: Đảm bảo $first lấy đúng document mới nhất
{"$sort": {"created_at": -1, "_id": -1}},
{
"$group": {
"_id": "$customer_id",
"doc": {"$first": "$$ROOT"}
}
},
{"$replaceRoot": {"newRoot": "$doc"}},
# SORT #2: Đảm bảo output cuối cùng đúng thứ tự
{"$sort": {"created_at": -1, "_id": -1}},
{"$skip": skip},
{"$limit": limit}
]Hai $sort này có hai nhiệm vụ hoàn toàn khác nhau:
| Sort | Vị trí | Mục đích | Bỏ thì sao? |
|---|---|---|---|
| Sort #1 | Trước $group | $first chọn đúng document bên trong mỗi nhóm | $first lấy document ngẫu nhiên — sai data |
| Sort #2 | Sau $group | Các nhóm được sắp xếp cho output cuối | Output đúng data nhưng sai thứ tự — UX tệ |
Thiếu bất kỳ sort nào = bug. Hai bugs khác nhau, nhưng đều là bug.
Bạn đã fix xong, test pass, deploy lên staging. Mọi thứ ngon. Rồi trên production, thứ tự lại “lung tung” ở một vài records.
Nguyên nhân: nhiều orders có cùng created_at. Điều này xảy ra thường xuyên hơn bạn nghĩ — bulk import, race conditions khi nhiều request đến cùng lúc, hoặc đơn giản là timestamp chỉ chính xác đến giây.
Khi hai documents có cùng created_at, MongoDB không biết cái nào “đứng trước”. Mỗi lần chạy, nó có thể chọn thứ tự khác.
Fix: thêm _id làm tiebreaker. _id (ObjectId) luôn unique nên đảm bảo thứ tự xác định ngay cả khi created_at trùng.
# Thay vì chỉ sort 1 field:
{"$sort": {"created_at": -1}}
# Thêm _id làm tiebreaker:
{"$sort": {"created_at": -1, "_id": -1}}Khi created_at bằng nhau, MongoDB sẽ sort tiếp theo _id — và vì _id không bao giờ trùng, thứ tự luôn nhất quán.
Sort ổn định (stable sort) = primary field + tiebreaker field. Thiếu tiebreaker, bạn đang để MongoDB tự quyết — và nó không đảm bảo quyết giống nhau mỗi lần.
Đây là phiên bản production-ready, xử lý cả trường hợp user truyền sort params động:
def get_orders_with_deduplication(filter_params):
"""Lấy orders với deduplication, đúng thứ tự, ổn định."""
# Parse sort params từ API request
sort_params = filter_params.get('sort')
sort_fields = parse_sort_params(sort_params) if sort_params else ['-created_at']
# Build sort query dict cho MongoDB
sort_query = {}
for field in sort_fields:
if field.startswith('-'):
sort_query[field[1:]] = -1
else:
sort_query[field] = 1
# Thêm _id tiebreaker — CHỈ KHI user chưa chỉ định _id
if '_id' not in sort_query:
sort_query['_id'] = -1
# Pipeline với 2 sorts
pipeline = [
{"$match": filter_params.get('filters', {})},
{"$sort": sort_query}, # Sort #1: cho $first
{
"$group": {
"_id": "$customer_id",
"doc": {"$first": "$$ROOT"}
}
},
{"$replaceRoot": {"newRoot": "$doc"}},
{"$sort": sort_query}, # Sort #2: cho output
{"$project": filter_params.get('fields', {})},
{"$skip": filter_params.get('skip', 0)},
{"$limit": filter_params.get('limit', 20)}
]
return list(Order.collection.aggregate(pipeline))Chú ý dòng kiểm tra if '_id' not in sort_query — nếu user đã truyền _id trong sort params, mình không override. Tôn trọng API consumer.
$group — những stage khác có phá thứ tự không?Câu hỏi hay mà mình từng tự hỏi sau khi fix bug này.
Không phải mỗi $group. Trong aggregation pipeline, có một nhóm stages mà MongoDB gọi là blocking stages — chúng cần đọc TẤT CẢ documents đầu vào trước khi bắt đầu output. Và thứ tự output phụ thuộc vào implementation nội bộ, không phải thứ tự input.
Các blocking stages cần cẩn thận:
$group — như mình vừa phân tích, output order không xác định$bucket / $bucketAuto — tương tự $group, gom vào buckets, thứ tự output phụ thuộc bucket boundaries$sortByCount — gom + sort theo count, nhưng documents cùng count thì thứ tự không xác định$facet — mỗi facet chạy pipeline riêng, thứ tự trong mỗi facet phụ thuộc pipeline đóNgược lại, các streaming stages bảo tồn thứ tự input:
$match — chỉ lọc, không đổi thứ tự$project / $addFields — chỉ transform fields, không đổi thứ tự$replaceRoot — chỉ unwrap, không đổi thứ tự$skip / $limit — cắt trên thứ tự hiện tại$unwind — explode array nhưng giữ thứ tự document gốcQuy tắc ngón tay cái: nếu stage cần “gom nhóm” hoặc “đếm” → nó có thể phá thứ tự. Luôn re-sort sau nó nếu thứ tự quan trọng.
Bạn có thể thắc mắc: sort 2 lần có chậm không?
Câu trả lời ngắn: có tốn thêm, nhưng thường không đáng lo.
Sort #1 chạy trên dataset đầy đủ (trước $group). Sort #2 chạy trên dataset đã deduplicate (sau $group) — nhỏ hơn nhiều. Nếu bạn có 10,000 orders từ 500 customers, sort #1 sort 10,000 documents nhưng sort #2 chỉ sort 500.
Nếu thật sự cần optimize, đảm bảo field sort có index:
# Compound index cho sort + group
Order.collection.create_index([
("user_id", 1),
("created_at", -1),
("_id", -1),
("customer_id", 1)
])Index này giúp sort #1 tận dụng index scan thay vì in-memory sort. Sort #2 vẫn phải sort in-memory, nhưng trên dataset nhỏ hơn nên hiếm khi là bottleneck.
Đừng optimize sớm. Sort 2 lần trên vài trăm documents sau
$groupnhanh đến mức bạn không đo được sự khác biệt. Fix đúng trước, optimize sau.
Nếu bạn gặp kết quả sort “lung tung” trong aggregation pipeline, check theo thứ tự:
$group (hoặc blocking stage khác) sau $sort không? → Thêm sort thứ 2_id tiebreaker$skip + $limit có nằm SAU sort cuối cùng không? → Nếu không, pagination sẽ saiBốn câu hỏi này cover phần lớn sort bugs mình từng gặp với MongoDB aggregation.
Bug này dạy mình một bài học rộng hơn MongoDB: đừng giả định về thứ tự trừ khi bạn tường minh yêu cầu nó.
Hash tables không giữ thứ tự. Goroutines không chạy theo thứ tự bạn spawn. Network packets không đến theo thứ tự gửi. Kafka partitions không đảm bảo thứ tự across partitions.
Mỗi khi bạn cần thứ tự, hãy nói rõ — bằng $sort, bằng sequence number, bằng timestamp + tiebreaker. Hệ thống không đoán ý bạn.
Và nếu bạn đang dùng $group trong MongoDB aggregation pipeline ngay lúc này — mở code lên kiểm tra xem có sort thứ 2 sau nó chưa. Có thể bạn đang ship một bug mà chưa ai phát hiện.
Tại sao query MongoDB với $group trả về thứ tự ngẫu nhiên dù đã sort trước đó? Deep-dive vào cách aggregation pipeline xử lý thứ tự documents, và pattern sort 2 lần để fix dứt điểm.