Not Only SQL: шукаємо альтернативи реляційних баз
Обговорення спонукало мене написати статтю про можливі альтернативи реляційних баз даних і SQL Server.
Так вже сталося, що, коли я навчався в університеті (як і багато моїх колег), у той час ще не існувало толком ніяких альтернатив реляційних баз. Тільки-тільки з'явився алгоритм Map-Reduce (2004), але в продуктах зберігання даних він почав використовуватися приблизно в 2006 і пізніше. Про самому алгоритмі я дізнався в році, напевно, 2010 (як мінімум не раніше — точно не пам'ятаю), до цього моменту я використовував SQL Server файли. Хмарні сховища даних з'явилися ще пізніше — Amazon AWS з'явився в 2006 і став більш-менш на слуху у 2008 році. Microsoft Azure з'явився взагалі в 2010 і набрав популярність ще через пару років.
Раніше всі дані було прийнято зберігати або в файлах, які в реляційної базі даних. Проект без бази даних був чимось з області фантастики (я говорю переважно про класичні «сайти», «сервіси» і «ентерпрайз» рішення). Звичайно ж, були і інші екзотичні альтернативи, але вони використовувалися дуже рідко.
Але час йде, і технології стрімко розвиваються. За останні 10 років з'явилося багато сховищ даних на базі MapReduce, а також на базі інших алгоритмів і структур даних. Зараз нікого не здивуєш довгим списком з існуючих сховищ. Ось тільки деякі з них: Google BigTable, Amazon Dynamo, Azure TableStorage, ElasticSearch, MongoDb, Apache HBase, Neo4j, Amazon S3, Azure DocumentDb, Druid і так далі. Причому деякі з них можна використовувати як сервіс PaaS (Platform As A Service). PaaS дозволяє заощадити купу грошей, часу і нервів на хостингу та обслуговування сховища. Багато хто з них по-справжньому розподілені і масштабовані горизонтально, на відміну від SQL.
Як працює реляційна база даних
В основі реляційних баз даних лежить структура під назвою b-tree, що накладає певні обмеження. Реляційна база даних реалізована за ACID моделі (Atomic, Consistent, Isolated, Durable). Іншими словами, із стандартних Consistency-Availability-Partition Tolerance (стаття на тему CAP) реляційна база підтримує Consistency і Availability. Кожна таблиця — це один або більше індексів (clustered and unclustered indexes). Кожен індекс являє собою окреме b-tree. Це означає, що, якщо у вас в таблиці десять індексів, при кожній запису (видалення, вставлення або зміну) в таблицю, база даних буде робити десять записів (мінімум) на диск в b-trees. Так як база підтримує ACID — всі операції з даними синхронні, а значить, доведеться чекати поки всі операції запису будуть завершені перед комітом» транзакції. Між швидкістю читання і запису в SQL базі даних завжди є компроміс. Неможливо одночасно мати швидкі запис і читання в реляційній базі даних. Хочете швидку запис — ваші таблиці повинні містити мінімум індексів. Хочете швидке читання — навпаки потрібно створити багато індексів, які покриють всі ваші умови в запитах.
Швидка реляційна база даних
А що якщо не можна мати одночасно швидкі читання і запис в реляційній базі? Компроміс між читанням і записом можна обійти за допомогою створення другої бази, яку називають Data Warehouse — її оптимізують під читання даних та створення складних звітів. Таким чином, Operational (OLTP) база підтримує швидку запис і редагування даних Data Warehouse (OLAP) підтримує швидке читання і складні багатоповерхові запити. Але тут вступає в справу CAP теорема — ви не можете мати всі три властивості CAP одночасно у такій конфігурації. Іншими словами, ви не можете транзакционно писати одночасно в Operational базу і в Data Warehouse — транзакція буде розподіленої і дуже повільною. Бази виходять розділені фізично, і дані в Data Warehouse потрапляють з деякою затримкою. Отже, ми приходимо до Eventual Consistency на реляційній базі даних — те, що ми пишемо в Operational базу, неможливо відразу ж прочитати Data Warehouse. Реляційні бази даних не масштабуються горизонтально. І ніякі Shards і Partitions все одно не допоможуть перемогти CAP теорему.
Альтернатива реляційних баз даних
Альтернативні сховища в основному використовують інші структури даних для зберігання, наприклад, LSM, Distributed Hash Table, Inverted Index, Graph або щось ще більш екзотичне. Детальніше про ці структури даних можна почитати на вікіпедії. За великим рахунком більшість альтернатив реляційних баз використовують поділ запису і читання в межах однієї бази. Вони часто реалізують BASE модель (Basically Available, Soft-state, Eventually-consistent) і жертвують C onsistency на користь A vailability і P artition Tolerance. Якщо розглянути Eventually-consistent сховище на прикладі MongoDb — MongoDb виграє в продуктивності запису у SQL, тому що вторинні індекси оновлюються не відразу при записі, а асинхронно — через деякий час. Якщо у вас кілька партишенов і є копії даних (репліки), вони також оновлюються асинхронно через деякий час. Іншими словами, якщо ви робите запис і відразу ж намагаєтесь знайти записаний документ — є шанс, що він не буде знайдений. Або ви знайдете попередню (застарілу) версію документа. Це і є відсутність суворої цілісності (strict C onsistency). Пояснити всі нюанси з P artition Tolerance і C onsistency потягне на окрему статтю — кому цікава тема рекомендую почитати NoSQL Distilled — там дуже добре розкрита тема CAP теореми та її наслідків. Варто згадати, що багато з альтернативних сховищ підтримують транзакционность в межах партишена або глобально на вимогу за рахунок розподіленої транзакції, що дозволяє писати, використовуючи звичний підхід ACID-транзакцій.
Event Sourcing
Дуже потужна альтернатива реляційної моделі — Event Sourcing. У звичній нам реляційної моделі ми зберігаємо об'єкти і зв'язки між ними. При зміні об'єкта ми просто перезаписуємо старий об'єкт новим. Іншими словами, ми завжди зберігаємо тільки один стан об'єкта — останнім збережене. В реляційній моделі, думаю, всім знайомі проблеми з синхронізацією, продуктивністю при зміні одних і тих же даних, deadlocks.
Event Sourcing — це коли кожна зміна в доменній моделі записується як подія (event). Всі події immutable, таким чином, сховище являє собою append-only log, де нічого не видалено і не змінюється. За рахунок цього відразу вирішується купа проблем з синхронізацією і масштабуванням запису. Також, як побічний ефект, з'являється повний лог всіх операцій в системі і можливість реконструювати будь доменний об'єкт на будь-який момент часу в системі. Це може дуже допомогти при налагодженні і відтворенні помилок. Звичайно ж, є і мінуси Event Sourcing — в першу чергу це більший обсяг даних і неможливість виконувати звичні запити select ... where ... у такому сховищі. Проблема з запитами легко вирішується використанням окремого сховища для читання. Проблема з обсягом даних надумана, так як зберігання даних сьогодні коштує копійки в порівнянні з вартістю трафіку або процесорного часу.
Будь-яка операції в моделі Event Sourcing виглядає так:
Given початкова послідовність подій;
When виконуємо операцію (команду);
Then в лог записується одне або більше подій або Exception (помилка).
Як можна помітити — всі обчислення з доменної моделлю можна реалізувати у функціональному стилі без побічних ефектів (side effects).
Тема дуже цікава і дуже велика, тому я згадаю, що Event Sourcing часто використовується разом з Domain Driven Design (DDD) і Command Query Responsibility Segregation (CQRS) і перейду до конкретного прикладу на Azure Table Storage.
Event Sourcing на Azure Table Storage
Я хочу показати приклад, як можна використовувати Azure Table Storage для побудови високонавантаженої системи з Event Sourcing моделлю. Давайте спробуємо змоделювати щось просте — наприклад, рахунок гравця в онлайн-казино. У нас є користувач, який може поповнювати свій рахунок, робити ставки, отримувати виграш і знімати гроші. Коли на рахунку немає грошей — користувач не може робити ставки. Ми можемо змоделювати такі події для користувача: MoneyDeposited, BetPlaced, WinReceived, MoneyWithdrawn.
Azure Table Storage — високопродуктивне хмарне сховище від Microsoft. Воно дуже просте — ви можете створювати таблиці, в яких за замовчуванням є стовпці PartitionKey (string), RowKey (string). Також ви ще можете мати 255 стовпчиків для зберігання довільних даних. Запити підтримуються тільки для PartitionKey і RowKey, для всіх інших випадків запитів продуктивність дуже низька, так як ніякі поля, крім PartitionKey і RowKey, не індексуються. Підтримується транзакционность на рівні Partition в межах 100 записів. Тобто можна атомарно писати до 100 записів з однаковим PartitionKey. У загальному випадку в такому сховищі при використанні Event Sourcing нам потрібно три поля: PartitionKey — aggregate Id, RowKey — event Id Data — event body (довільний JSON).
У випадку з казино наш гравець є aggregate (з термінології DDD) або кордоном транзакції. Гравець взаємодіє тільки з казино, і ми його можемо повністю ізолювати від інших гравців. Таким чином, PartitionKey в нашому сховищі буде aggregate Id — унікальний Id гравця. RowKey буде використовуватися як послідовний номер події в потоці.
У такому рішенні у нас буде кількість партишенов дорівнює кількості гравців. Якщо у нас мільйон гравців — значить буде мільйон партишенов в таблиці. У Azure Table Storage немає обмежень на кількість партишенов, і це дуже зручно. У межах одного партишена продуктивність до 2000 IOPS.
Але все було б занадто просто, якби не дві проблеми — багатопотокове оновлення даних та обчислення поточного стану. Скільки грошей на рахунку гравця? Як підтримувати транзакционность всіх операцій і збереження інваріанти: «Баланс на рахунку не повинен бути негативним»?
З багатопотоковою оновленням даних (точніше, c записом нового події) є як мінімум два рішення. Перше і найочевидніше — не використовуйте багатопоточність. Наприклад, можна реалізувати actor-based рішення і завжди обробляти запис в один потік. Друге рішення — використовуйте optimistic concurrency. Можна додати спеціальну запис StreamHeader, в якій буде зберігатися номер останнього записаного події та інші метадані. StreamHeader ми будемо оновлювати в момент запису подій. В Table Storage у кожного запису є ETag, на базі якого реалізовано optimistic concurrency. Якщо хтось вже перезаписав StreamHeader — ми отримаємо Exception при спробі запису.
Друга проблема — обчислення поточного стану (балансу) гравця вирішується схожим способом. Досить просто додати запис StreamHeader поточний стан рахунку. Як альтернатива — нам потрібно перечитувати весь потік подій? щоб виконати якусь дію (команду).
Так як ми можемо писати до 100 записів транзакционно, і при запису перевіряється був змінений перезаписуваний об'єкт — зі спеціальної записом StreamHeader ми можемо гарантувати цілісність даних в межах партишена.
Уважний читач помітив, що в цьому рішенні немає жодного вторинного індексу. Наприклад, ми не можемо шукати по імені гри або за сумою ставки. Для складних запитів нам потрібно інше сховище — наприклад, DocumentDB, де ми зможемо робити складну аналітику і відповідати на питання «скільки гравців піднімають ставку більш ніж в два рази перед тим, як все програти». Я б назвав Azure TableStorage сховищем, оптимізованим для запису і хорошим вибором для EventSourcing.
Eventual Consistency вирішує проблеми з масштабуванням
Я зрозумів за роки роботи програмістом, що багато реальні бізнес-процеси не вимагають суворої консистентности і транзакционности всіх операцій. Як приклад — продаж товару в інтернет-магазині. Уявіть собі: у вас є 10 000 000 покемонів і весь світ потенційних покупців. Можна взяти sql і реалізувати приблизно таку логіку — при спробі покупки ми робимо запит в базу, якщо покемони ще є — купівлю дозволяємо, інакше говоримо, що покемони закінчилися. Для цього нам знадобиться одна фізична місце (табличка в базі), де ми будемо вести облік проданих покемонів. Або лічильник проданих покемонів, який транзакционно оновлюється при кожному продажу і опитується при кожній спробі купити. Це і є наше вузьке місце в системі.
Можна зробити все по-іншому. Якщо запитати будь-якого продажника — йому все одно, скільки у вас товару на складі. Він буде радий продати будь-яку кількість — чим більше, тим краще. Так влаштована робота продажника, так і багатьох бізнесів: вранці — гроші, ввечері — стільці. І навіть якщо стільців немає, всі будуть раді отримати гроші вранці. =)
Ми не будемо перевіряти наявність покемонів при кожній покупці. Замість цього ми будемо продавати покемонів до моменту, коли вже буде точно зрозуміло, що покемонів більше немає. Ми прибираємо транзакционность з нашого рішення і позбавляємося від проблеми Consistency нашого глобального лічильника. Замість перевірки наявності при кожній спробі купити можна раз в хвилину (в годину або в день) перевіряти, скільки залишилося покемонів, і закрити продаж, як тільки ми точно знаємо, що покемонів більше немає в наявності. Може вийти так, що ми продамо трохи більше товару, ніж реально є на складі. Але це вже буде не наша проблема, а проблема директора (власника), де дістати більше товару. Зазвичай вона вирішується поповненням складу або поверненням грошей клієнтам. Я вас запевняю, краще мати проблеми зі сверхпродажами замість проблеми з гальмами вашого інтернет-магазину або що ви там програмуєте. =)
Context is a king
У контексті сховищ даних дуже важлива модель даних і додатки. Дійсно реляційних даних у світі мало. Реляційна модель (як і будь-яка інша модель) — всього лише наша спроба представлення реальності в цифрах. Є безліч інших моделей, які ігноруються в силу домінування реляційних баз даних. Наприклад, ось цей текст, який ви зараз читаєте, можна розглядати по-різному. З точки зору реляційної моделі тут є слова, речення і абзаци, які складають текст. А можна розглядати як послідовність змін, які я вносив у текст протягом кількох вечорів, поки я його писав. Також його можна розглядати як один цілісний документ — «ляпка», який має посилання в інтернеті і який має сенс тільки як єдине ціле. Чи можна розглядати текст як вектор слів російською мовою. Або як масив символів кирилиці. Ну і так далі — текст один, а безліч моделей. Все залежить від поставленої задачі. Всі завдання вирішувати за допомогою реляційної моделі даних і SQL — погана ідея.
Як підсумок написаного, я пропоную колегам розширювати кругозір, вивчати нові сховища і структури даних. Вчитися моделювати і вибирати правильний інструмент для вирішення завдання. І постаратися перестати бачити все навколо в реляційної моделі з таблицями і зв'язками між ними.
P. S. На моїй останній роботі взагалі немає SQL, і я вважаю це приємним бонусом до всього іншого.
Опубліковано: 21/03/17 @ 08:00
Розділ Різне
Рекомендуємо:
Гід по ІТ-спеціальностями ХНУРЕ
Впали позиції в Яндексі? Як визначити причину і повернути сайт в ТОП
iOS дайджест #16: Core Data
DOU Books: 5 книг для Enterprise Java розробника, які радить Сергій Немчинский
Кар'єра в IT: посада Program Manager