Процесні та навантажувальні поля: Ефективний запит до напівструктурованих документів MongoDB

pic

Деякі поля, за якими ви шукаєте дані, інші ж потрібно просто зберігати та отримувати

Нещодавно під час огляду проєкту MongoDB мені представили кейс, де клієнт хотів зберігати велику кількість напівструктурованих повідомлень у MongoDB. Крім того, їм потрібно було ефективно отримувати минулі повідомлення на основі конкретних критеріїв запиту.

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

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

Кейс використання:

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

Отримуються різні типи повідомлень, і їх структура залежить від типу. Деякі повідомлення використовують JSON, інші — XML, а інші — Google Protocol Buffers.

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

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

Є кілька випадків, коли додаток повинен запитувати поле, яке присутнє лише в окремих типах повідомлень.

Повідомлення та відповіді можуть бути досить великими (клієнт не мав точних даних, але вони вважали, що вони можуть бути розміром до 1 МБ). Спочатку зберігається вхідне повідомлення, а потім додається оброблена відповідь (зазвичай через частку секунди).

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

Рекомендована модель даних MongoDB

Є вислів, який постійно використовує команда розробників MongoDB:

Дані, до яких звертаються разом, повинні зберігатися разом.

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

Отже, це був мій початковий пункт. Наступний етап полягав у перевірці, чи не викличе це жодних проблем.

Хоча MongoDB підтримує документи розміром до 16 МБ, в більшості випадків краще уникати великих документів (будь-які > 200 КБ). Великі документи можуть бути неефективними з двох причин:

  1. Якщо ви вносите невелику зміну до існуючого документа, ваш додаток має надіслати лише змінені дані через мережу в MongoDB — і це ефективно, незалежно від розміру документа. Однак, коли MongoDB записує зміни на диск, він повинен записати повний документ. Невеликі зміни в великих документах можуть спричинити великі витрати на диск IO.
  2. Коли ваш додаток звертається до документа, якого немає в кеші MongoDB, весь документ повинен бути завантажений з диска в кеш (пам'ять).
    Це підходить, якщо додаток зазвичай запитує весь документ, але це витратно для кешу, якщо додаток часто виводить більшість даних документа.

Якщо ваші документи незмінні, і додаток зазвичай потребує всіх даних з документа, то великі документи — це нормально. В іншому випадку краще розділити документ на кілька менших. Для цього прикладу документ може бути великим, але дані фактично незмінні (документ буде доповнюватися під час виконання конвеєра, але ці зміни, ймовірно, відбудуться протягом одного й того ж 60-секундного вікна контрольної точки MongoDB, і тому документ буде записаний на диск лише один раз). Додайте до цього той факт, що додаток завжди хоче отримати всі дані, і я дійшов висновку, що у цьому випадку великі документи допустимі.

При розгляді даних у документі корисно розрізняти «процесні» та «дані» поля. Процесні поля — це ті, за якими додаток буде фільтрувати; це зазвичай кандидати для включення в індекси. При проектуванні схеми важливо враховувати процесні поля, оскільки це може вплинути на продуктивність. Натомість поля з даними потрібно зберігати та повертати, коли додаток запитує документ, але вони ніколи не є частиною умови фільтра запиту та не будуть включені до індексу. Зазвичай можна обробляти всі поля з даними як просто великий блок даних у документі — наприклад, зберігати XML-документ як один рядок.

В цьому додатку я б вважав сирі повідомлення (XML/JSON/Protocol Buffer) полями з даними та просто зберігав їх як рядки в документі. Додаток дійсно має шукати конкретні поля, тому я б копіював ці значення з полів даних у конкретні (процесні) поля в документі.

Наприклад, я міг би уявити наявність полів для типу повідомлення, масиву ID учасників та дати. Ці поля будуть індексовані, щоб забезпечити ефективні запити.

Остаточні документи виглядали б так:

{  
 "_id": "TXN123456789",  
 "messageType": 102,  
 "parties": [  
 "PRTY12345",  
 "PRTY67890",  
 "PRTY13579",  
 "PRTY24680"  
 ],  
 "date": {  
 "$date": "2024-12-12T00:00:00Z"  
 },  
 "piggyBankColour": "Red",  
 "payload": {  
 "request": "\n\n...\n",  
 "response": "\n\n...\n"  
 }  
}

У цьому випадку piggyBankColour присутнє в повідомленні лише тоді, коли messageType дорівнює 102 (приблизно 10% документів).

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

Індексація процесних полів

Згідно з вищезазначеною схемою, додаток може запитувати будь-яку комбінацію _id (ID транзакції), messageType, parties, date та piggyBankColour. На жаль, запити за незіндексованими полями вимагають повного сканування колекції, що є повільним і витратним за CPU та IO. Тому ми перейшли до розгляду, які індекси слід додавати — зокрема, складні індекси.

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

Складні індекси також можуть підтримувати ефективні операції сортування за кількома полями.
Якщо ваш запит включає сортування за field1, а потім за field2, складний індекс може оптимізувати цю операцію.

Складний індекс дозволяє MongoDB повернути результати без доступу до документів, за умови, що запит вимагає лише індексованих полів. Це називається покритим запитом.

MongoDB може використовувати «префікс» складного індексу. Наприклад, якщо є індекс на {a: 1, b: 1, c: 1}, база даних може використовувати індекс для підтримки запитів на:

  • a.
  • a і b.
  • a, b і c.

Однак запит не буде використовувати індекс для підтримки запитів лише на b або c, або для запиту на b і c разом, якщо ви не розширите запит, щоб включити a (наприклад, з використанням діапазонного запиту або $exists).

Другим важливим аспектом при впорядкуванні полів у складному індексі є правило ESR (Equality, Sort, Range). Спочатку мають йти поля, для яких потрібно виконати точні зіставлення, потім будь-яке поле, за яким ви будете сортувати, і після цього будь-яке поле, до якого ви застосовуватимете діапазон (як правило, це те саме поле, за яким ви сортуєте — не включайте його двічі).

Отже, для прикладу полів, які я вибрав, я можу уявити такі складні індекси:

  • messageType, date (в такому порядку).
  • parties, date (в такому порядку).
  • parties, messageType, date (в такому порядку).

Цей JavaScript код додасть ці індекси:

messages.createIndex({messageType: 1, date: -1});  
messages.createIndex({parties: 1, date: -1});  
messages.createIndex({parties: 1, messageType: 1, date: -1});

Зверніть увагу, що ми вказуємо зворотний порядок сортування (-1) для поля date, оскільки зазвичай ми хочемо почати з найновіших дат і працювати назад у часі.

З кількома зразками документів, доданими до бази даних, ми можемо використовувати MongoDB Compass, щоб виконати запит на основі messageType та date:

pic

Потім ми можемо натискати на кнопку "Explain", щоб переглянути план пояснення/виконання:

pic

Цей план показує:

  • Використано індекс на messageType і date.
  • Індекс був використаний для сортування результатів (без сортування в пам'яті).
  • Ключі індексу, що перевірялися == документи, що повертаються == документи, що перевіряються.

Це показує, що ми змогли ефективно навігувати по індексу і що він змусив нас отримувати лише ті документи (можливо з диска), які потрібно було повернути до додатку.

Ми бачимо подібні результати при запитах на parties і date:

pic

pic

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

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

Як приклад, ми могли б додати індекс на parties, messageType, date та _id:

messages.createIndex({parties: 1, messageType: 1, date: -1, _id: 1});

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

pic

pic

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

Згадаємо, що piggyBankColour існує лише для повідомлень типу 102. Якщо ми хочемо запитувати за цим полем, тоді має сенс включити його до індексу. Однак індекс буде містити багато непотрібних записів null (для документів інших типів повідомлень). У такому випадку можна використати частковий індекс.

Частковий індекс у MongoDB — це індекс, який включає лише ті документи, що відповідають вказаній умові фільтра. Це дозволяє створювати індекси, які є меншими, ефективнішими та адаптованими до специфічних запитів, особливо коли індексація всіх документів у колекції є непотрібною.

Ключові характеристики часткових індексів:

  1. Фільтрована індексація: індекс включає лише документи, що відповідають partialFilterExpression (умові, схожій на запит).

  2. Менший розмір індексу: оскільки індексуються лише певні документи, він споживає менше місця для зберігання та зменшує використання пам'яті порівняно з повними індексами.

  3. Покращена продуктивність запису: операції запису виконуються швидше, оскільки індексується менша кількість документів.

  4. Оптимізація запитів: запити, що відповідають умові фільтра, можуть ефективно використовувати індекс.

Зверніть увагу, що частковий індекс буде використовуватися тільки тоді, коли запит включає значення для полів з partialFilterExpression.

Ми можемо додати частковий індекс для piggyBankColour та date:

messages.createIndex(  
 {piggyBankColour: 1, date: -1},  
 {partialFilterExpression: { messageType: 102 }}  
);

Тепер ми можемо перевірити запит (який має включати messageType) та перевірити план виконання, щоб переконатися, що індекс використовується:

pic

pic

Підсумок

Розробники можуть спростити свою роботу, визначивши, які поля застосування необхідно запитувати або проєктувати (поля процесу). Залишкові (поля навантаження) просто потрібно зберігати та повертати разом з документом. Вміст полів навантаження може бути будь-яким — з точки зору бази даних ці поля є непрозорими. Тому поля навантаження є ідеальними для неструктурованих даних, які потрібно зберігати та отримувати разом з більш структурованими даними.

У цій статті ми побачили, як дані навантаження можуть бути XML-рядком, але вони також можуть бути JSON-документом або бінарними даними — або навіть комбінацією всіх трьох.

Дізнайтеся більше про огляди дизайну MongoDB

Огляди дизайну — це можливість отримати пораду від експерта з дизайну MongoDB щодо найкращого використання MongoDB у вашому застосунку. Огляди зосереджені на тому, щоб допомогти вам досягти успіху в роботі з MongoDB. Ніколи не буває занадто рано звернутися за оглядом. Залучаючи нас на ранніх етапах (можливо, ще до того, як ви вирішите використовувати MongoDB), ми можемо надати поради, коли у вас буде найкраща можливість їх реалізувати.

Думаєте, що вашому застосунку може допомогти огляд? Запишіться на огляд дизайну.

Перекладено з: Process vs Payload Fields: Efficiently Querying Semi-structured MongoDB Documents

Leave a Reply

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