Індекси MongoDB та проблеми з ними. Специфіки Amazon Document DB
Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті
Вітаю, я Олег. Працюю розробником в компанії Easygenerator. У цій статті я зібрав основну інформацію про роботу та оптимізацію індексів в Mongo DB та специфіки Amazon Document DB щодо цього.
Теоретичний матеріал з індексів розбирається на прикладі однієї проблеми, яку довелося вирішувати. А в кінці я зібрав список правил, які варто знати, перед тим як починати довгий процес побудови індексу (звичайно, раджу завчасно перевіряти на колекції з невеликою кількістю записів). Хоч цей матеріал старанно розжовує деякі нюанси для тих, хто хоче розібратися вперше, я впевнений, досвідчені розробники також знайдуть користь, особливо в короткому чеклісті та списку ресурсів, якщо, звичайно, не хочуть власноруч порпатися в обох документаціях, одночасно таких сумісних і ні, баз даних (не маю нічого проти цього).
Так звана передчасна оптимізація (premature optimization) — витрата часу на оптимізацію чогось несуттєвого, яка призводить не тільки до нечитабельного коду, а й до погіршення працездатності програми після зміни вимог. Однак, коли виникає проблема, наприклад, перша сторінка з
Запит сам по собі не виглядає страшним. Той самий Entity Framework під час оптимізації видає ще те дерево запитів, яке можна довго обмізковувати.
Рисунок 1. Проблемний запит
Дослідження проблеми
Коли маєш діло з такого роду проблемою, зразу звинувачуєш індекс, а тому потрібно переглянути план запиту, щоб зрозуміти, що саме не так. Для цього в MongoDB є команда explain(), яка видає ієрархічне зображення етапів виконання. Для того, щоб отримати час виконання кожного етапу, потрібно передати executionStats
як перший параметр. На відміну від DocDB, монга також має третій варіант команди, що повертає усі можливі плани, а не тільки обраний.
Скористуємося командою, щоб отримати більше інформації щодо запиту.
Рисунок 2. Команда отримання плану виконання запиту
Рисунок 3. План проблемного запиту
Отриманий план виконання потрібно дивитися знизу вверх. Першим йде етап сканування COLLSCAN
чи, як і в цьому випадку, вибірки за індексом IXSCAN
. Бачимо, що саме вибірка займає 47 секунд, хоч і використовується індекс, отже, потрібно копати далі. Обраний індекс під назвою courseId_1
— індекс одного поля, що не покриває сортування за lastActivity
.
Виконавши команду db.collection.getIndexes()
, перевіряємо наявні індекси та бачимо, що є більш оптимальний варіант складного індексу courseId_1_lastActivity_1_isArchived_1
, який чомусь не було обрано. Щоб розібратися чому, розглянемо основні критерії щодо яких оптимізатор запитів обирає індекс.
Критерії вибору індексу
Причина, чому деякі індекси не використовуються в окремих запитах, банальна: вони просто неоптимальні, тому можуть тільки сповільнити отримання результату.
- Індекс вказаний за допомогою команди
hint
. В цьому випадку просто обирається вказаний індекс, навіть якщо він неоптимальний. Хоча є виключення про які дізнався в коментарях, наприклад, оператор$regex
не підтримує використання індексу, якщо ми ігноруємо регістр - Індекс не схований. Для того, щоб протестувати швидкодію запитів без індексу (наприклад, щоб замінити чи видалити його пізніше) не потрібно його видаляти, тому що відбудова займе багато часу. Замість цього його можна сховати від планувальника запитів за допомогою команд
hideIndex unhideIndex
. - Він в пам’яті. Потрібно слідкувати, щоб усі індекси поміщалися в оперативну пам’ять. Не забувайте про те, що крім індексів усіх колекцій, також виділяється пам’ять під операції. Для того, щоб дізнатися розмір індексів однієї колекції, потрібно виконати
db.courseResults.stats({ indexDetails: true, scale: 1000000000 })
(scale
для зручності перегляду в Гб).
Рисунок 5. Розмір індексів
- Відповідність правилу ESR (Equality, Sort, Range). Це правило вказує на необхідний порядку полів в складному індексі при його створені. Спочатку йдуть поля, які в запитах фільтруються за допомогою операторів повного збігу, наприклад
$eq
($ne
— оператор діапазону, тому не підходить), далі йдуть поля, за якими відбувається сортування (при чому сортування повинно відповідати сортуванню індексу в прямому або оберненому порядку, дивись наступний пункт), останніми йдуть оператори діапазону$gt
,$gte
,$lt
,$lte
,$ne
. Цей порядок важливий, тому що у разі, коли ми захочемо використати оператор діапазону, а після цього відсортувати, серверу потрібно буде до-сортувати отримані результати в пам’яті, адже друге поле в індексі відсортоване відносно першого (знаю, звучить дуже заплутано). Наприклад, використовуємо оператор діапазону та отримуємо користувачів (дивись рисунок знизу) з idca2
таxyz
, далі сортуємо за оцінкою, але виходить, що для користувачаxyz
запис з балом 90 повинен йти перед записом користувачаca2
з оцінкою 75, отже не вистачає просто взяти документи за індексом, потрібно також виконувати дороге сортування в пам’яті. У випадку, коли ми робимоskip
,limit
, проблема швидкості може з’являтися на сотій сторінці.
Рисунок 6. Візуалізація складного індексу
- Правильний порядок сортування полів індексу. При створенні індексу, окрім полів, ми також вказуємо напрямок сортування за цим полем. Цей напрямок повинен дорівнювати або бути оберненим до того, що вказано в запиті.
Рисунок 7. Правило сортування для складних індексів
- Але не потрібно забувати, з якою звірюкою ми маємо діло.
Рисунок 8. Document DB — емуляція Mongo DB
Специфіки Doc DB
- Відсутність можливості сховати індекс від планувальника запитів.
- Підтримка роботи операторів з індексами. При використанні зі складним індексом, скоріш за все буде виконуватися prefix індексу (про це далі).
Рисунок 9. Використання індексів з операторами діапазону
Розв’язання проблеми
Крім того, що проблемний запит має оператор діапазону $ne
, як виявилося, пізніше назва індексу не відповідає порядку полів (дивись нижче), тобто порушена відповідність правилу ESR. Це дуже підступна проблема, адже збиває з пантелику, яку до того ж неможливо виправити без перестворення індексу (що не є дешевою операцією, особливо в DocDB). Тому краще ніколи не давати ім’я індексу, таким чином воно буде створено автоматично.
{ name: 'courseId_1_lastActivity_1_isArchived_1', key: { courseId: 1, isArchived: 1, lastActivity: 1 }, host: '...',, accesses: { ops: 272, since: 2022-05-08T02:25:57.000Z } }
Фактично, якби порядок полів відповідав назві індексу, то запит підпадав під правило prefix (бо оператори діапазону в DocDB ніколи не використовують індекс). Складні індекси можуть перевикористовуватися в запитах, які використовують частину цього індексу (index prefix). Але в цьому випадку, немає ніякого сенсу триматися за оператор $ne
для булевого поля. Простіше мігрувати базу (створити відсутнє поле), та використовувати оператор $eq
(не потрібно буде навіть перестворювати індекс, хоча шляпа з назвою так і залишиться).
Рисунок 11. Використання префіксу індексу
Після виправлення умови запиту таким чином, щоб можна було використовувати оператор рівності для поля isArchived
, та після встановлення значення для поля isArhived
документів, в який це поле відсутнє, індекс почав відповідати правилу ESR, тому саме він був обраний для запиту, як видно з іншого запиту explain()
.
Рисунок 12. Виправлений запит
Рисунок 13. План виконання виправленого індексу
Далі йде невеличкий теоретичний блок про індекси, варіації, підтримку та чекліст зі створення.
Індекси
У більшості випадків, коли розмовляємо про індекси, ми робимо це абстрактно, без вказування на те, як він побудований. Насправді, в більшості випадків це просте дерево (btree). Приклад індексу з MS SQL:
Рисунок 14. B+ Tree index
Монга підтримує наступні типи індексів:
- Простий індекс
single field
. Покриває операції пошуку та сортування для одного поля. - Складний індекс
compound
. Покриває операції пошуку та сортування як для усієї послідовності полів або його префіксу, при цьому важливі використані операції та напрямок сортування полів запиту. - Hashed. В більшості випадків може бути використаний для шардінга за допомогою послідовного хешування. Знаючи значення поля, наприклад ідентифікатор юзера, можна порахувати хеш та подивитися, що значення хеша попадає в межі третього сервера бази даних, який і зберігає документ користувача. Це є один з варіантів масштабування, за яким ми розподіляємо одну велику колекцію по різним серверам бази (не працює в DocDB, доступна інша методика — primary/secondary реплікація). Для цього індексу скоріше за все (не знайшов в документації) використовується інша структура
hash map
, що не підтримує використання операторів діапазону для запиту (хеш майже однакових значень разюче відрізняється). - Індекси гео-запитів
2dsphere
,2d
. Корисні для пошуку в певному радіусі... - Text, wildcard, regex — спрощена версія повнотекстового пошуку, яка дозволяє обробити текст необхідної мови, прибравши стоп слова (the, a, an), обрізавши слово до кореня (stemming), вказати роздільник. Але, Elastic все ж гнучкіший, адже дозволяє налаштовувати конвеєр обробки тексту під себе, та має інші методи обробки тексту, наприклад додавання синонімів (2 — «two»).
Модифікації індексів
- TTL — видаляє запис після спливання часу. Точність часу видалення не гарантована. Потрібно зменшувати кількість записів, що повинна бути видалена за короткий час. Тобто якщо у нас це лог, в який кожну секунду потрапляють записи, які нам потрібно видалити через 7 днів після створення, потрібно робити групування, наприклад в бакети погодинно або подобово, та створювати індекс для них. В іншому випадку під час видалення буде досить сильне навантаження. Amazon також рекомендує групувати записи в колекції та видаляти їх повністю, бо це не навантажує систему (IO cost) та зменшує ціну. Монга також каже використовувати Time series колекцій, що по суті також групують записи в бакети по часу.
- Unique — забороняє повтори.
- Partial, Sparse (конкретна версія partial індексу) — якщо для більшості запитів не потрібно повертати якусь частину колекції (наприклад, заархівовані записи), ми можемо використати partial індекс та вказати умову, за якої документ буде індексуватися. Це може стати в пригоді, коли потрібно створити unique індекс, але не всі документи мають унікальне поле (наприклад, в деяких воно відсутнє, що також вважається за дуплікацію). Таким чином, також можна створити два індекси для різних типів запитів. Sparse — індексує тільки ті документи, які мають індексоване поле, цього можна досягти вказавши відповідну умову при створенні partial індексу. (Ці індекси не підтримуються в DocDB).
- Індексування масивів (multikey index) — індексується кожен елемент/документ масиву. Після цього ми можемо шукати документи, які мають потрібні нам піддокументи у внутрішньому масиві (наявна можливість вказання декількох умов до одного й того ж елементу масиву за допомогою оператора $elemMatch, який не підтримує індекс в DocDB).
Специфіки Doc DB
Підтримка індексів
Рисунок 15. Підтримка індексів в Document DB
Чекліст зі створенню індексу
- Потрібно створювати індекси, що мають високу вибірковість. Тобто не на полях з декількома можливими значеннями, як, наприклад, boolean, або на тих полях, для яких використовуються оператори діапазону
$gt
,$gte
,$lt
,$lte
. - Перевірити відповідність складних індексів правилу ESR.
- Якщо запит отримує тільки поля документа, що знаходяться в індексі (зазвичай складному), то не буде виконуватися операція отримання самого документа, дані будуть повернуті напряму з індексу (covered queries).
- Індекс — відсортована структура. Тобто створення індексу (в тому числі, складного з правильним напрямком сортування для кожного поля) пришвидшує запити з операцією сортування.
- При створенні складних індексів, краще не давати йому ім’я, в такому випадку ім’я буде згенеровано автоматично і відповідатиме порядку полів та напрямку сортування. Тому воно не буде вводити в оману. Ім’я неможливо буде змінити без видалення індексу.
- Краще перевикористовувати індекси, бо вони займають багато місця та повинні поміщатися в оперативну пам’ять, а також тому що індекси збільшують час створення та оновлення документів.
db.collection.aggregate([{$indexStats:{}}]).pretty()
— для перегляду статистики використання індексів
… { name: 'courseId_1_lastActivity_1_isArchived_1', key: { courseId: 1, isArchived: 1, lastActivity: 1 }, host: '...',, accesses: { ops: 272, since: 2022-05-08T02:25:57.000Z } } { name: '_id_', key: { _id: 1 }, host: '...', accesses: { ops: 411342, since: 2022-05-08T02:25:57.000Z } }
- Якщо запити не мають наміру шукати по всіх документах колекції, наприклад у яких немає певного поля чи які були заархівовані створенням boolean поля, то рекомендується використання partial, sparse індексів, які не індексують всю колекцію (не працює в Doc DB), тому зменшують розмір індексу, а також час модифікації неіндексованих записів.
- Щоб контролювати прогрес побудови індексу, потрібно виконати команду
db.currentOp(true).inprog.forEach(function(op){ if(op.msg!==undefined) print(op.msg) })
(Mongo DB). Щоб зупинити побудову, потрібно виконати командуdropIndex()
. - Є два способи побудови індексу, foreground-версія та background. У той час, коли перша отримує lock на всю колекцію, друга дозволяє працювати з нею під час побудови, хоч це і повільніше. В останніх версіях монги, ці способи об’єднали, тому колекція блокується в початку і в кінці побудови, а під час роботи з колекцією можна працювати як завжди.
- Під час побудови індексу використовується диск, тому потрібно слідкувати, щоб вистачало місця. Також для пришвидшення операції рекомендується заскейлити екземпляр бази на час побудови індексу.
Специфіки DocDB
- Побудова не почнеться, доки не будуть завершені всі запити, що запущені до початку побудови.
- Якщо відбувається побудова background індексу, в Doc DB, не можна розпочинати побудову нового або видалення іншого в тій же колекції, тому що побудова зупиниться з помилкою.
Рисунок 16. Застереження паралельного виконання операцій над індексами в Document DB
10 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів