Пошук уроків, статей та іншого контенту
Навчитеся поєднувати кілька типів сховищ в одній системі та розподіляти дані між ними за характером навантаження.
Polyglot persistence — це підхід, за якого одна система використовує кілька типів сховищ даних, розподіляючи дані між ними відповідно до характеру навантаження та вимог до доступу.
Замість того щоб зберігати всі дані в одній реляційній базі, система може використовувати:
реляційну базу даних для транзакційних даних;
key-value сховище для кешу або сесій;
документну базу для гнучких структур;
пошуковий рушій для повнотекстового пошуку;
time-series базу для метрик;
об’єктне сховище для файлів;
графову базу для зв’язків між сутностями.
Polyglot persistence — це не правило «використовувати якомога більше баз даних». Це спосіб вибрати сховище під конкретний тип даних і спосіб роботи з ним.
Різні частини системи мають різні вимоги.
Наприклад, інтернет-магазину можуть знадобитися:
атомарне створення замовлення та списання товару;
швидке отримання каталогу;
пошук товарів за назвою та описом;
зберігання зображень;
підрахунок переглядів;
Одна реляційна база технічно може виконувати всі ці завдання, але не обов’язково робитиме це ефективно:
повнотекстовий пошук може створювати значне навантаження;
кешування часто краще реалізувати в пам’яті;
файли не варто зберігати як великі бінарні значення в транзакційних таблицях;
аналітичні запити можуть конкурувати з операційними;
структура подій або документів може бути незручною для нормалізованої схеми.
Polyglot persistence дає змогу оптимізувати кожен тип навантаження окремо.
Уявімо систему замовлень:
PostgreSQL — джерело істини для користувачів, товарів, залишків і замовлень;
Redis — кеш популярних товарів і короткоживучі дані;
Elasticsearch — пошукове представлення каталогу;
об’єктне сховище — зображення товарів;
аналітичне сховище — агреговані дані про продажі.
При цьому одна й та сама інформація може бути представлена в кількох сховищах, але з різними цілями.
Наприклад, товар може мати:
повний нормалізований запис у PostgreSQL;
компактне представлення в Redis;
пошуковий документ в Elasticsearch;
зображення в об’єктному сховищі.
Ці копії не обов’язково є незалежними джерелами істини. Зазвичай одне сховище володіє даними, а інші містять похідні представлення.
Вибір слід починати не з назви технології, а з характеристик навантаження.
Запитайте:
Дані мають чітку схему?
Чи потрібні зв’язки між сутностями?
Чи можуть записи мати різні поля?
Чи потрібні складні транзакції?
Чи є дані великими файлами?
Чи потрібне зберігання часових вимірювань?
Важливі питання:
Читань більше, ніж записів?
Потрібні пошуки за довільними полями?
Потрібен пошук за словами або релевантністю?
Чи відомий ключ кожного запиту?
Потрібні діапазонні запити?
Чи потрібні агрегації за великими обсягами даних?
Для кожного типу даних визначте:
чи потрібна негайна узгодженість;
чи припустима затримка в кілька секунд;
чи можна відновити дані з іншого джерела;
що має статися при тимчасовій недоступності сховища.
Наприклад, залишок товару перед підтвердженням замовлення потребує суворішої узгодженості, ніж пошуковий індекс цього товару.
Помилка в різних даних має різні наслідки:
неправильний залишок може призвести до продажу відсутнього товару;
застарілий результат пошуку зазвичай можна виправити фоновою індексацією;
втрата кешу не повинна означати втрату основних даних;
втрата оригінального платежу може бути критичною.
Сховище треба вибирати не лише за швидкістю, а й за наслідками відмови.
Для кожного набору даних потрібно явно визначити source of truth — джерело істини.
Наприклад:
PostgreSQL є джерелом істини для замовлень;
Redis містить лише кеш замовлення;
пошуковий індекс містить похідні дані для пошуку;
аналітичне сховище містить копії подій для звітів.
Якщо значення в кеші та основній базі відрізняються, система повинна знати, яке значення правильне.
Корисне правило:
Похідне сховище має бути відновлюваним з джерела істини.
Якщо пошуковий індекс можна повністю перебудувати з основної бази, його пошкодження не є втратою бізнес-даних. Якщо ж єдина копія даних знаходиться в індексі, архітектура стає небезпечною.
Транзакція реляційної бази зазвичай не охоплює Redis, пошуковий рушій та об’єктне сховище одночасно.
Наприклад, операція створення товару може виглядати так:
записати товар у PostgreSQL;
завантажити зображення в об’єктне сховище;
додати документ до пошукового індексу;
очистити кеш.
Між цими кроками може виникнути помилка. Якщо крок 3 не виконався, товар уже існує в PostgreSQL, але ще не доступний у пошуку.
Тому для похідних даних часто приймають eventual consistency:
основна операція завершується в головному сховищі;
подія про зміну надходить до споживачів;
споживачі оновлюють свої представлення;
протягом короткого часу різні сховища можуть містити різні версії даних.
Це нормально, якщо така затримка врахована в бізнес-вимогах.
Після зміни назви товару:
сторінка товару може одразу показувати нову назву з PostgreSQL;
пошук може побачити нову назву через кілька секунд;
кеш може містити старе значення до моменту очищення або завершення терміну життя.
Для користувача це може бути прийнятним. Але для залишку товару або статусу платежу така модель може бути неприйнятною на критичному етапі операції.
Основна база містить довговічні дані, а кеш зменшує кількість повторних читань.
Поширений алгоритм:
перевірити кеш;
якщо значення знайдено — повернути його;
якщо значення відсутнє — прочитати основну базу;
записати результат у кеш;
повернути результат.
Це називають cache-aside або lazy loading cache.
Під час зміни даних зазвичай:
оновлюють джерело істини;
видаляють або оновлюють відповідний запис у кеші.
Видалення кешу часто безпечніше за спробу синхронно підтримувати складне кешоване представлення.
Реляційна база добре підходить для транзакцій, але пошуковий рушій оптимізований для:
повнотекстового пошуку;
релевантності;
фасетів;
фільтрації за великою кількістю полів.
Пошуковий документ є проєкцією основних даних. Після зміни товару індекс оновлюється окремо.
Потрібно передбачити:
повторну обробку невдалих операцій;
ідемпотентність оновлення;
відновлення індексу з основної бази;
моніторинг відставання індексу.
Операційна база оптимізована для коротких транзакцій. Аналітичне сховище — для великих сканувань, агрегацій і звітів.
Не слід безконтрольно запускати важкі звіти на тій самій базі, яка обробляє оформлення замовлень.
Дані до аналітичного сховища можуть надходити як:
події про бізнес-операції;
періодичні експорти;
інкрементальні зміни;
потоки змін із транзакційної бази.
Наступний приклад демонструє спрощену схему:
SQLite є довговічним джерелом істини;
словник Python імітує key-value кеш;
читання використовує cache-aside;
після зміни товару кеш інвалідується.
Приклад запускається без зовнішніх бібліотек.
import sqlite3
from dataclasses import dataclass
from typing import Optional
@dataclass
class Product:
product_id: int
name: str
price: int
class ProductRepository:
def __init__(self, connection: sqlite3.Connection):
self.connection = connection
def create_schema(self) -> None:
self.connection.execute(
"""
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
price INTEGER NOT NULL
)
"""
)
self.connection.commit()
def save(self, product: Product) -> None:
self.connection.execute(
"""
INSERT INTO products (id, name, price)
VALUES (?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
price = excluded.price
""",
(product.product_id, product.name, product.price),
)
self.connection.commit()
def find_by_id(self, product_id: int) -> Optional[Product]:
row = self.connection.execute(
"SELECT id, name, price FROM products WHERE id = ?",
(product_id,),
).fetchone()
if row is None:
return None
return Product(product_id=row[0], name=row[1], price=row[2])
class ProductService:
def __init__(self, repository: ProductRepository):
self.repository = repository
self.cache: dict[int, Product] = {}
def get_product(self, product_id: int) -> Optional[Product]:
cached_product = self.cache.get(product_id)
if cached_product is not None:
print("Читання з кешу")
return cached_product
print("Читання з основної бази")
product = self.repository.find_by_id(product_id)
if product is not None:
self.cache[product_id] = product
return product
def save_product(self, product: Product) -> None:
# Спочатку змінюємо джерело істини.
self.repository.save(product)
# Потім видаляємо застаріле похідне значення.
self.cache.pop(product.product_id, None)
def main() -> None:
connection = sqlite3.connect(":memory:")
repository = ProductRepository(connection)
repository.create_schema()
service = ProductService(repository)
service.save_product(Product(product_id=1, name="Клавіатура", price=2500))
print(service.get_product(1))
print(service.get_product(1))
service.save_product(Product(product_id=1, name="Механічна клавіатура", price=3000))
print(service.get_product(1))
if __name__ == "__main__":
main()У реальній системі словник може бути замінений на Redis, але принцип залишається тим самим. Кеш не повинен бути єдиною копією важливого товару.
Також у прикладі є важливе припущення: після успішного запису в основну базу інвалідація кешу має бути виконана надійно. Якщо процес завершується між цими діями, кеш може залишитися застарілим.
Для зменшення такого ризику використовують:
короткий TTL;
повторні спроби інвалідації;
фонові завдання;
версії записів;
події про зміну даних.
Якщо оновлення другого сховища важливе, не варто покладатися лише на виклик у пам’яті процесу.
Небезпечний варіант:
записати замовлення в базу;
викликати API пошукового індексу;
завершити HTTP-запит.
Якщо процес завершується між кроками 1 і 2, зміна залишиться без події.
Один із поширених підходів — transactional outbox:
у межах однієї транзакції записати бізнес-дані;
у тій самій транзакції записати подію в таблицю outbox;
окремий воркер читає події з outbox;
воркер оновлює Redis, пошуковий індекс або аналітичне сховище;
після успішної обробки подія позначається як виконана.
Перевага полягає в тому, що бізнес-зміна та факт необхідності синхронізації фіксуються атомарно в одному сховищі.
Споживачі повинні бути ідемпотентними: повторна обробка тієї самої події не повинна пошкоджувати дані.
Наприклад, оновлення пошукового документа за стабільним ідентифікатором зазвичай безпечніше, ніж безумовне додавання нового документа.
У системі з кількома сховищами важливо розділити відповідальність:
який компонент може змінювати дані;
яке сховище є джерелом істини;
які сховища містять копії;
хто відповідає за синхронізацію;
як відновити похідне сховище.
Погана практика — дозволити різним компонентам незалежно змінювати одну й ту саму сутність у різних базах.
Наприклад, якщо і PostgreSQL, і Elasticsearch приймають незалежні зміни назви товару, система не має визначеного правильного результату.
Краща модель:
команда каталогу змінює товар у PostgreSQL;
зміна породжує подію;
індекс пошуку оновлюється як похідна проєкція;
прямі записи в індекс з боку інших компонентів заборонені.
Для кожного сховища потрібно визначити операційні властивості:
як створюються резервні копії;
як перевіряється відновлення;
скільки часу допустиме відновлення;
яку кількість даних можна втратити;
як виявляється відставання проєкцій;
що відбувається при недоступності сховища.
Особливо важливо відрізняти:
втрату основних даних;
втрату похідного представлення;
тимчасову недоступність кешу;
відставання асинхронної синхронізації.
Кеш зазвичай можна просто заповнити заново. Пошуковий індекс можна перебудувати. Але втрата транзакційної бази потребує зовсім іншої процедури відновлення.
Практична послідовність:
Визначте бізнес-сутності та операції над ними.
Для кожної операції опишіть вимоги до транзакційності й узгодженості.
Розділіть дані на основні та похідні.
Опишіть профіль навантаження: читання, записи, пошук, діапазони, агрегації.
Виберіть найпростіше сховище, яке задовольняє вимоги.
Визначте власника кожного набору даних.
Опишіть процес синхронізації між сховищами.
Передбачте повторні спроби, ідемпотентність і відновлення.
Додайте метрики для затримки та помилок синхронізації.
Перевірте сценарії часткової відмови.
Окремо потрібно відповісти на питання: чи справді потрібне нове сховище? Якщо реляційна база вже задовольняє вимоги, додавання ще однієї технології може збільшити складність без реальної користі.
оптимізація сховища під конкретний тип навантаження;
краща продуктивність спеціалізованих запитів;
ізоляція аналітичного навантаження;
можливість масштабувати компоненти незалежно;
зручне представлення одних даних для різних сценаріїв.
складніша інфраструктура;
більше резервних копій і процедур відновлення;
складніша локальна розробка;
відсутність єдиної транзакції між усіма сховищами;
eventual consistency;
дублювання даних;
потреба в моніторингу синхронізації;
вища вартість експлуатації.
Polyglot persistence виправдана тоді, коли переваги спеціалізації перевищують цю додаткову складність.
Додаткове сховище не робить архітектуру автоматично кращою. Воно має вирішувати конкретну проблему: продуктивність, модель даних, пошук, масштабування або ізоляцію навантаження.
Якщо незрозуміло, де зберігається правильне значення, конфлікти між копіями неможливо вирішити надійно.
Не всі дані повинні оновлюватися одночасно. Для пошуку або аналітики часто достатня eventual consistency. Намагання зробити всі операції синхронними може збільшити затримку та кількість точок відмови.
Успішний запис у PostgreSQL не гарантує успішний запис у Redis або пошуковий індекс. Кожен зовнішній виклик може завершитися помилкою, тайм-аутом або повторним виконанням.
Якщо похідний індекс неможливо відновити з джерела істини, він фактично стає критичною базою даних. Це повинно бути свідомим рішенням, а не випадковим наслідком архітектури.
Повторна доставка події є нормальною ситуацією для асинхронних систем. Обробник повинен коректно переживати повтори.
Небезпечно кешувати дані без визначення:
терміну життя;
правил інвалідації;
поведінки при промаху;
поведінки при недоступності кешу;
допустимого рівня застарілості.
Polyglot persistence — це розподіл даних між різними сховищами відповідно до їхніх моделей, навантаження та вимог до узгодженості.
Ключові принципи:
визначайте джерело істини для кожного набору даних;
використовуйте спеціалізовані сховища лише для конкретних потреб;
розглядайте кеші, індекси та аналітичні копії як похідні дані;
приймайте eventual consistency там, де вона припустима;
проектуйте повторні спроби, ідемпотентність і відновлення;
контролюйте відставання між сховищами;
не додавайте технологію, якщо простіше рішення вже достатнє.
Головна мета polyglot persistence — не кількість баз даних, а відповідність кожного сховища реальному способу роботи з даними.