Зрештою, вирішено проблему з продуктивністю агрегаційних запитів MongoDB, яка довго залишалася без уваги.

pic

Ми використовуємо MongoDB (6.x) у поточному сервісі. Офіційна документація рекомендує зберігати дані у вигляді піддокументів в одному документі, щоб зменшити кількість агрегацій, але попереднє проєктування бази даних передбачало зберігання даних у окремих колекціях відповідно до старої архітектури.

Тому для отримання інформації про користувачів/групи часто доводиться виконувати більше ніж два запити lookup.

У таких умовах виникли проблеми з продуктивністю, і я вирішив написати статтю про пошук та усунення проблемних запитів.

Нещодавно, в один з післяобідніх годин, несподівано спрацювала попереджувальна система MongoDB. Після перевірки з'ясувалося, що через збільшення навантаження затримка запитів значно зросла.

Це суттєво вплинуло на UX, тому я перевірив дашборд в Atlas та відслідкував проблемні запити.

Atlas дозволяє переглядати різні метрики, такі як час виконання, кількість ресурсних віддач (Num Yields) тощо.

pic

Час виконання буквально вистрілює в космос. Я витираю сльози та починаю шукати причину.

Проблеми виникли переважно в колекціях груп/групових користувачів.

У найгіршому випадку, запити займали до 2 хвилин.

pic

На три документи запит сканує багато більше документів, і кількість ресурсних віддач (Num Yields) виявилась значною.

Ймовірно, час виконання збільшувався через більшу кількість ресурсних віддач, що, у свою чергу, призводило до ще більшого часу виконання 🧐

Після перегляду запитів із найбільшими затримками в часи виникнення проблеми, я виявив, що всі вони належать до одного запиту.

У реальному виконаному запиті я через значення $project визначив, яку частину коду на сервері це стосується.

Проблема полягала в запиті, який намагався завантажити список груп, до яких приєднався користувач, разом з членами цих груп і додатковою інформацією про користувачів.

pic

Переглянувши сумарний час виконання запитів, можна побачити, що за цей період було виконано 8.46 тисячі запитів, і загальний час виконання склав майже 7 годин.

Кожен запит тривав більше ніж 2.5 секунди, що дуже багато для простих запитів на отримання даних 🥹

Винуватець — перший вкладений $lookup

Для отримання додаткової інформації про членів групи використовувався вкладений $lookup.

Розглянемо початковий запит.

{  
 "type": "command",  
 "command": {  
 "aggregate": "{{групова колекція}}",  
 "pipeline": [  
 {  
 "$match": {  
 "organizationId": "{{ідентифікатор організації}}"  
 }  
 },  
 {  
 "$lookup": {  
 "from": "{{колекція групових членів}}",  
 "localField": "id",  
 "foreignField": "groupId",  
 "pipeline": [  
 {  
 "$lookup": {  
 "from": "{{колекція користувачів організації}}",  
 "localField": "userId",  
 "foreignField": "userId",  
 "let": {  
 "organizationId": "$organizationId"  
 },  
 "pipeline": [  
 {  
 "$match": {  
 "$expr": {  
 "$eq": [  
 "$organizationId",  
 "$$organizationId"  
 ]  
 }  
 }  
 }  
 ],  
 "as": "users"  
 }  
 },  
 {  
 "$unwind": "$users"  
 },  
 {  
 "$project": {  
 //...необхідні поля  
 }  
 }  
 ],  
 "as": "members"  
 }  
 },  
 {  
 "$lookup": {  
 "from": "{{колекція групових членів}}",  
 "localField": "id",  
 "foreignField": "groupId",  
 "pipeline": [  
 {  
 "$match": {  
 "$expr": {  
 "$eq": [  
 "$userId",  
 "{{userId}}"  
 ]  
 }  
 }  
 },  
 {  
 "$project": {  
 //...необхідні поля  
 }  
 }  
 ],  
 "as": "user"  
 }  
 },  
 {  
 "$unwind": "$user"  
 },  
 ],  
 },  
 //...  
}

Без додаткових операцій, таких як $project, запит виконує наступне:

  1. Фільтрація груп за допомогою $match для певної організації
  2. Вкладений $lookup для отримання інформації про членів групи та додаткової інформації про користувачів
  3. Наступний етап $lookup фільтрує інформацію для певного користувача групи

В результаті, для конкретного користувача повертається група, і разом із цією групою — перелік членів з додатковою інформацією.

Тепер давайте розглянемо, що саме було не так з цим запитом.

Різниця у точці початку агрегації

Якщо агрегація починається з колекції груп, ми не можемо одразу отримати інформацію про групи, до яких належить користувач, без використання lookup.

Тому на першому етапі запит повертає всі групи, що належать певній організації, і ці групи передаються на наступний етап агрегації.

В результаті, для груп, до яких користувач не належить, все одно виконується необхідний lookup.
Перевірка виконується досить швидко завдяки індексам, але
❗️основною проблемою є те, що великі об'єкти передаються на наступний етап, що є надмірним навантаженням️️❗️.

Це можна перевірити за допомогою плану виконання запиту.

pic

В результаті отримано лише 4 документи, але через 1971 документ пройшло через запит за 1292 мс.
Тепер давайте детальніше розглянемо план виконання.

{  
 "$lookup": {  
 "from": "organizationGroupUsers",  
 "as": "members",  
 "localField": "id",  
 "foreignField": "groupId",  
 "let": {},  
 "pipeline": [  
 {  
 "$lookup": {  
 "from": "organizationUsers",  
 "localField": "userId",  
 "foreignField": "userId",  
 "let": {  
 "organizationId": "$organizationId"  
 },  
 "pipeline": [  
 {  
 "$match": {  
 "$expr": {  
 "$eq": [  
 "$organizationId",  
 "$$organizationId"  
 ]  
 }  
 }  
 }  
 ],  
 "as": "users"  
 }  
 },  
 { "$unwind": "$users" },  
 {  
 "$project": {  
 // ...
필요한 값 추출  
 }  
 }  
 ]  
 },  
 // 중첩된 lookup 파이프라인에서 약 0.9s 소요함  
 // 처리 결과 1971개로, 많은 문서가 aggregate됨  
 "totalDocsExamined": 5369,  
 "totalKeysExamined": 5369,  
 "collectionScans": 0,  
 "indexesUsed": [  
 "groupId_1_userId_1",  
 "organizationId_1_userId_1"  
 ],  
 "nReturned": 1971,  
 "executionTimeMillisEstimate": 916  
 },

План виконання першого вкладеного $lookup.

  1. На етапі FETCH результати повертаються дуже швидко завдяки індексам, але повертається 1971 документ.
  2. Загалом було відскановано 5369 документів.

Проблема полягає в першому етапі. 1971 документ використовується в $lookup.

Як показує executionTimeMillisEstimate, цей етап займає цілих 916 мс. 😱 Це велике навантаження на продуктивність запиту.

З 5369 перевірених документів лише 1971 було повернуто (nReturned), що означає, що лише 37% із всіх оброблених документів було корисними для $lookup.
Якщо покращити фільтрацію, можна зменшити кількість документів у $lookup і значно покращити продуктивність 👍

Тепер давайте розглянемо наступний етап $lookup.

{  
 "$lookup": {  
 "from": "{{групова колекція}}",  
 "as": "user",  
 "localField": "id",  
 "foreignField": "groupId",  
 "let": {},  
 "pipeline": [  
 {  
 "$match": {  
 "$expr": {  
 "$eq": [  
 "$userId",  
 "{{userId}}"  
 ]  
 }  
 }  
 },  
 {  
 "$project": {  
 // ... необхідні значення  
 }  
 }  
 ],  
 // На етапі $unwind, інформація про групи без користувача буде відфільтрована  
 "unwinding": {  
 "preserveNullAndEmptyArrays": false  
 }  
 },  
 // Додається інформація про користувача на другому етапі $lookup  
 "totalDocsExamined": 4,  
 "totalKeysExamined": 4,  
 "collectionScans": 0,  
 "indexesUsed": ["groupId_1_userId_1"],  
 "nReturned": 4,  
 // 1269(накопичений час) - 916(попередній етап) = 353 мс  
 "executionTimeMillisEstimate": 1269  
 },

На цьому етапі $lookup додається інформація про користувача до вже отриманих 1971 документів.

Через $unwind будуть відфільтровані ті групи, де користувач не є учасником, і повернеться лише список груп, до яких належить користувач.

Через те, що на попередньому етапі не відбулося достатньої фільтрації, запит спочатку виконує $lookup, а потім фільтрує необхідні дані через $unwind.

Згідно з оцінкою часу виконання, тут витрачається 353 мс, а оскільки користувач належить до 4 груп, nReturned становить 4.

Загальний час виконання, включаючи інші операції, склав 1292 мс.

Покращення

☝🏻 Щоб мінімізувати кількість документів, що потрапляють у стадію $lookup, агрегацію варто почати не з колекції груп, а з колекції користувачів груп.

  • У такому випадку на початковому етапі будуть відфільтровані всі непотрібні документи за значенням userId.

✌🏻 Уникайте використання кількох $lookup у межах однієї агрегації, розділіть запит на кілька.

  • Іноді можна обійтися без складних агрегацій і не використовувати додаткові запити.
  • Розподіл запитів і передача частини обчислень на сервер додатків зменшує навантаження на окремий запит і мінімізує збільшення NumYield.

Перший етап дозволяє фільтрувати лише мінімум необхідних документів, тому навіть якщо деякі операції будуть делеговані серверу додатків, це не створює великого навантаження.

Покращений варіант

Агрегація, що раніше об'єднувала запити для [отримання груп користувача]
та [отримання членів групи з додатковою інформацією], була розділена на два окремі запити.

Запит для отримання групи користувача

pic

[  
 {  
 $match: {  
 $expr: {  
 // Фільтрація мінімальних даних перед виконанням $lookup  
 $and: [  
 {  
 $eq: [  
 "$organizationId",  
 "{{організаційний id}}"  
 ]  
 },  
 {  
 $eq: [  
 "$userId",  
 "{{userId}}"  
 ]  
 }  
 ]  
 }  
 }  
 },  
 {  
 $project:  
 {  
 groupId: 1,  
 _id: 0  
 }  
 },  
 // Потрібна лише інформація про групу, тому здійснюється lookup до групової колекції  
 {  
 $lookup:  
 {  
 from: "{{колекція груп}}",  
 localField: "groupId",  
 foreignField: "id",  
 as: "group"  
 }  
 },  
 {  
 $project:  
 {  
 group: 1  
 }  
 },  
 {  
 $unwind:  
 "$group"  
 },  
 // Замінюємо корінь на групову інформацію замість членів  
 {  
 $replaceRoot:  
 {  
 newRoot: {  
 $mergeObjects: ["$group", "$$ROOT"]  
 }  
 }  
 },  
 {  
 $project: {  
 group: 0  
 }  
 }  
]

Виконання цього запиту повертає список груп, до яких належить користувач.

На етапі $replaceRoot групова інформація замінює інформацію про користувачів, щоб результат був більш ефективним.

Порівняно з початком агрегації з колекції груп, почати з колекції групових користувачів і потім застосувати $replaceRoot виглядає більш логічним.

Давайте перевіримо план виконання цього запиту для підтвердження ефективності.
눈여겨볼 부분은 아래 내용입니다.

{   
 "nReturned": 4, // 해당 스테이지에서 반환된 문서 수  
 "executionTimeMillisEstimate": 4, // 추정 수행시간  
 "keysExamined": 2174, // 인덱스 스캔시 검사한 문서 수   

 "keyPattern": {  
 "organizationId": 1,  
 "groupId": 1,  
 "userId": 1  
 },  
 "indexName": "organizationId_1_groupId_1_userId_1", // 사용된 인덱스  
}

У попередньому запиті на отримання інформації про групи, до яких належить користувач, витрачалося багато часу, але після зміни точки початку агрегації вдалося отримати потрібні дані за 4 мс.

Однак є ще один момент для покращення.

Додаткове покращення: додавання складного індексу

Як видно з indexName, виконується сканування за складним індексом, що включає організаційний ID + груповий ID + користувачевий ID.

Хоча повертається лише 4 документи (nReturned), кількість перевірених документів (keysExamined) становить 2174.

Фільтруючи за організаційним ID + користувачевим ID, можна додати складний індекс на колекцію групових користувачів (організаційний ID + користувачевий ID), що дозволить скоротити діапазон пошуку.

Якщо додати цей складний індекс, кількість перевірених документів (keysExamined) зменшиться до 4, а порівняно з початковим запитом, як час виконання, так і кількість документів, що скануються і обробляються, значно знизяться.

В результаті, основна мета, а саме "отримання групи користувача", буде виконана за 5 мс, і повернуться тільки необхідні дані.

Інформація про членів групи

Якщо потрібно повернути також інформацію про членів групи, можна використати запит, написаний раніше.

Порівнюючи старий та новий запити, можна побачити, що логіка однакова, але порядок операцій змінений.

[До покращення]

  1. Спочатку виконується $lookup для отримання всієї інформації про членів групи
  2. Потім фільтруються тільки ті групи, до яких належить користувач

[Після покращення]

  1. Спочатку фільтруються тільки ті групи, до яких належить користувач
  2. Через окремий запит виконуються $lookup для отримання інформації про членів груп
  3. Результати обох запитів (пункт 1 + пункт 2) об’єднуються

Спочатку ми отримуємо групи, до яких належить користувач, а потім за допомогою запиту $in отримуємо інформацію про членів цих груп.
결과로 그룹 멤버 목록이 반환됩니다.

⚠️ $in 사용시 주의사항

$in쿼리의 경우 배열 크기가 커지면 성능 저하가 발생할 수 있습니다.

가입한 그룹이 가장 많은 경우와 일반적인 경우의 개수를 확인해 $in쿼리를 수행할 때 문제가 될지 확인하였고, 연산에 필요한 리소스는 미미하다 판단되어 $in쿼리를 사용했습니다.

이제 아래와 같이 후속 쿼리를 작성합니다.

[  
 {  
 $match: {  
 $expr: {  
 $and: [  
 {  
 $eq: [  
 "$organizationId",  
 "{{조직 id}}"  
 ]  
 },  
 {  
 $in: [  
 "$groupId",  
 [  
 ...앞선 쿼리에서 반환된 그룹의 id들  
 ]  
 ]  
 }  
 ]  
 }  
 }  
 },  
 {  
 $lookup: {  
 from: "{{조직 유저 컬렉션}}",  
 localField: "userId",  
 foreignField: "userId",  
 let: {  
 organizationId: "$organizationId"  
 },  
 pipeline: [  
 {  
 $match: {  
 $expr: {  
 $eq: [  
 "$organizationId",  
 "$$organizationId"  
 ]  
 }  
 }  
 }  
 ],  
 as: "userInfo"  
 }  
 },  
 {  
 $unwind: "$userInfo"  
 },  
 {  
 $project: {  
 // ...필요한 값만 추출  
 }  
 },  
// 그룹id를 기준으로 그룹핑  
 {  
 $group: {  
 _id: "$groupId",  
 count: {  
 $sum: 1  
 },  
 documents: {  
 $push: "$$ROOT"  
 }  
 }  
 },  
 {  
 $addFields: {  
 groupId: "$_id" // Rename _id to groupId  
 }  
 },  
 {  
 $project: {  
 groupId: "$_id",  
 members: "$documents", // members에 그룹핑된 멤버 목록 삽입  
 _id: 0  
 }  
 }  
]

파이프라인 내 작업 수만 보면 더 많아졌으나, 작업에 드는 코스트는 더 줄어들었습니다.

위 쿼리를 수행하면 아래와 같이 그룹id에 따라 그룹핑된 멤버 목록을 반환합니다.

{  
 "groupId": {{그룹 id}},  
 "members": [...그룹 멤버 목록}}  
}

쿼리 실행 계획을 살펴보겠습니다.

pic

"executionStats": {  
 "executionSuccess": true,  
 "nReturned": 73,  
 "executionTimeMillis": 21,  
 "totalKeysExamined": 3655,  
 "totalDocsExamined": 3655,  
 "executionStages": {  
 "stage": "PROJECTION_SIMPLE",  
 "nReturned": 73,  
 "executionTimeMillisEstimate": 2,  
 "works": 3656,  
 "advanced": 73,  
 "needTime": 3582,  
 "needYield": 0,  
 "saveState": 4,  
 "restoreState": 4,  
 "isEOF": 1,  
 "transformBy": {  
 "groupId": 1,  
 "organizationId": 1,  
 "ownerId": 1,  
 "role": 1,  
 "userId": 1,  
 "_id": 0  
 },  
 "inputStage": {  
 "stage": "FETCH",  
 "filter": {  
 "$expr": {  
 "$and": [  
 {  
 "$eq": [  
 "$organizationId",  
 {  
 "$const": "{{조직id}}"  
 }  
 ]  
 },  
 {  
 "$in": [  
 "$groupId",  
 {  
 "$const": [  
 "{{그룹id}}",  
 "{{그룹id}}",  
 "{{그룹id}}",  
 "{{그룹id}}"  
 ]  
 }  
 ]  
 }  
 ]  
 }  
 },  
 "nReturned": 73,  
 "executionTimeMillisEstimate": 2,  
 "works": 3656,  
 "advanced": 73,  
 "needTime": 3582,  
 "needYield": 0,  
 "saveState": 4,  
 "restoreState": 4,  
 "isEOF": 1,  
 "docsExamined": 3655,  
 "alreadyHasObj": 0,  
 "inputStage": {  
 "stage": "IXSCAN",  
 "nReturned": 3655,  
 "executionTimeMillisEstimate": 1,  
 "works": 3656,  
 "advanced": 3655,  
 "needTime": 0,  
 "needYield": 0,  
 "saveState": 4,  
 "restoreState": 4,  
 "isEOF": 1,  
 "keyPattern": {  
 "organizationId": 1,  
 "groupId": 1,  
 "userId": 1  
 },  
 "indexName": "organizationId_1_groupId_1_userId_1",  
 "isMultiKey": false,  
 "multiKeyPaths": {  
 "organizationId": [],  
 "groupId": [],  
 "userId": []  
 },  
 "isUnique": false,  
 "isSparse": false,  
 "isPartial": false,  
 "indexVersion": 2,  
 "direction": "forward",  
 "indexBounds": {  
 "organizationId": [  
 "[\"{{조직id}}\", \"{{조직id}}\"]"  
 ],  
 "groupId": ["[MinKey, MaxKey]"],  
 "userId": ["[MinKey, MaxKey]"]  
 },  
 "keysExamined": 3655,  
 "seeks": 1,  
 "dupsTested": 0,  
 "dupsDropped": 0  
 }  
 }  
 }  
 }  
 },  
 "nReturned": 73,  
 "executionTimeMillisEstimate": 10  
 },

너무 길어 눈이 빠질 것 같습니다.

중요한 부분만 뽑아 확인하겠습니다.

// input stage  
{  
 "nReturned": 3655,  
 "keysExamined": 3655,  
 "executionTimeMillisEstimate": 1,  
}

앞서 본 것처럼 전체 반환 수(nReturned)에 비해 많은 문서를 스캡하고 있습니다.

복합인덱스의 순서에는 문제가 없으므로 $in쿼리가 원인이라고 추측됩니다.

스캔 수는 많아졌으나 적절한 인덱스 사용으로 실행시간은 1ms이며, $in쿼리 리를 쓰는 데에는 문제가 없어 보입니다.

input stage 밖 전체 내용을 보면 조회 외 $project등을 포함하여 아래와 같은 총합 결과를 확인할 수 있습니다.

"nReturned": 73,  
 "executionTimeMillis": 21,  
 "totalKeysExamined": 3655,

두 번째 쿼리에서는 총 21ms가 소요되었습니다.
내가 속한 그룹 조회 및 각 그룹의 멤버 목록을 조회하는데에 두 쿼리를 합쳐 대략 5ms + 21ms가 소요됩니다.

이제 서버에서 각 그룹에-멤버 목록 매핑 과정만 거치면 됩니다!

이 연산은 시간복잡도가 대단히 크지 않으므로, 쿼리 실행 시간에 비해 큰 차이는 없을듯합니다.

Запит на отримання групи, до якої належить користувач, та на отримання списку членів кожної групи в цілому займає близько 5 мс + 21 мс для двох запитів.

Тепер залишилось лише зіставити групи з членами на сервері!

Цей процес не має великої складності в обчисленнях, тому не очікується значного збільшення часу виконання запиту.
모두 합쳐 대략 30ms로 추정한다면 기존 1287ms에 비교했을 때 많이 발전한 속도입니다.

쿼리 성능 개선에 따른 효과 📈

위 쿼리가 사용되는 부분이 여러 곳에 존재했습니다. 전부 꼼꼼하게 확인한 결과, 기존 파이프라인을 재사용하다보니 불필요한 정보를 lookup하는 경우가 빈번하게 발견되었습니다. 😱

쿼리를 분리함으로서 정말 필요한 정보만 조회하도록 하였고, 따라서 자연스레 여러 API에 걸쳐 전반적인 개선이 이루어졌습니다.

aggregate순서 변경과 쿼리 쪼개기를 통해, 개선 전 쿼리의 전체 데이터를 필요로 하는 상황에서도 인덱스 추가 / 캐싱 없이 실행시간을 1287ms에서 약 30ms로 단축할 수 있었습니다.

문제 컬렉션 관련 쿼리들에 성능 문제가 있는 것은 노운 이슈였으나, 갑자기 latency가 심해지는 바람에 한번 정리하는 계기가 되었습니다.

집계 파이프라인은 재사용할 때 반드시 꼼꼼하게 확인하여 성능 문제를 확인해야 한다는 점을 다시금 되새겼습니다. 😅

aggregate의 순서를 조정하고, 쿼리를 쪼개는 등의 작업을 통해 인덱스 추가 없이 광범위하게 사용되는 쿼리의 성능을 유의미하게 개선하니 굉장히 뿌듯합니다 👍

Якщо все це разом займе приблизно 30 мс, порівняно з 1287 мс раніше, це значний прогрес.

Ефект від покращення продуктивності запиту 📈

Цей запит використовувався в кількох місцях. Після детальної перевірки з'ясувалося, що при повторному використанні старого пайплайну часто виникала ситуація, коли потрібно було виконувати зайві $lookup. 😱

Розділивши запит, ми змогли витягнути лише необхідну інформацію, що природно призвело до загального покращення через кілька API.

Змінивши порядок агрегації та розділивши запит, навіть при потребі обробити всі дані, що передбачаються старим запитом, ми змогли скоротити час виконання з 1287 мс до приблизно 30 мс, без додавання індексів або кешування.

Проблеми з продуктивністю запитів до проблемних колекцій були відомі, але раптове збільшення затримки стало приводом для перерахунку.

Цей досвід нагадав, що пайплайни агрегації потрібно ретельно перевіряти при повторному використанні для виявлення проблем із продуктивністю. 😅

Коригування порядку агрегації та розподіл запитів дозволили значно покращити продуктивність широко використовуваних запитів без додавання індексів, і це дуже радує! 👍

Перекладено з: 방치하던 MongoDB Aggregate 쿼리 성능 이슈, 드디어 개선하다

Leave a Reply

Your email address will not be published. Required fields are marked *