Пошук уроків, статей та іншого контенту
Розділяємо моделі читання й запису, оцінюємо складність синхронізації та випадки, коли CQRS не потрібен.
CQRS (Command Query Responsibility Segregation) — це підхід, у якому операції зміни даних і операції читання використовують різні моделі.
Command — команда, яка змінює стан системи.
Query — запит, який лише читає дані.
Write model — модель для виконання бізнес-правил і запису.
Read model — модель, оптимізована для конкретних сценаріїв читання.
У звичайному CRUD одна модель часто використовується і для читання, і для запису:
HTTP-запит → Order model → база данихУ CQRS потік розділяється:
Команда → Write model → подія → Read model
Запит → Read modelЦе не обов’язково означає два мікросервіси або дві фізичні бази даних. Спочатку розділення може бути лише на рівні коду.
Модель, зручна для запису, не завжди зручна для читання.
Наприклад, замовлення може мати таку нормалізовану структуру:
orders
order_items
products
customers
payments
shipmentsДля зміни замовлення така структура корисна: дані не дублюються, а бізнес-правила можна перевіряти централізовано.
Але для сторінки списку замовлень може знадобитися:
ім’я клієнта;
загальна сума;
кількість товарів;
статус оплати;
статус доставки;
дата останньої зміни.
Щоб отримати ці дані, доведеться виконувати складні JOIN, агрегації або кілька запитів.
CQRS дозволяє створити окреме представлення:
order_list_view
----------------
order_id
customer_name
total_amount
items_count
payment_status
shipping_status
updated_atЦя модель може містити дубльовані дані, оскільки її головна мета — швидко відповідати на запити.
Команда описує намір змінити стан системи:
CreateOrder
AddItemToOrder
ConfirmPayment
CancelOrderКоманда не повинна бути просто довільним оновленням полів. Вона передається обробнику, який:
перевіряє вхідні дані;
завантажує потрібний стан;
перевіряє бізнес-правила;
змінює write model;
створює подію про зміну.
Наприклад:
ConfirmPayment(orderId)Обробник може перевірити, що:
замовлення існує;
воно ще не скасоване;
платіж справді можна підтвердити;
повторне підтвердження не порушує правила.
Запит не повинен змінювати стан системи:
GetOrderDetails
ListCustomerOrders
GetSalesReportQuery handler читає read model і повертає дані у форматі, який потрібен конкретному клієнту.
Одна й та сама предметна область може мати кілька read-моделей:
OrderDetailsView
CustomerOrdersView
DailySalesViewВони не зобов’язані повторювати структуру write model.
Після зміни write model система може опублікувати подію:
OrderCreated
PaymentConfirmed
OrderCancelledRead model отримує подію та оновлює власні дані.
Спрощений потік виглядає так:
1. Клієнт надсилає CreateOrder.
2. Write model створює замовлення.
3. Система публікує OrderCreated.
4. Проєкція обробляє подію.
5. Read model стає актуальною.
6. Query повертає замовлення у зручному форматі.Важливо: CQRS і event sourcing — не одне й те саме.
CQRS розділяє читання та запис.
Event sourcing зберігає зміни як послідовність подій.
CQRS можна реалізувати без event sourcing. Наприклад, write model зберігає поточний стан у звичайних таблицях, а після зміни публікується подія для оновлення read model.
Нижче наведено спрощений приклад на JavaScript. Write model і read model зберігаються в пам’яті, а подія передається проєкції асинхронно.
const writeModel = new Map();
const readModel = new Map();
const eventHandlers = {
OrderCreated: async (event) => {
// Імітуємо затримку доставки або обробки події
await new Promise((resolve) => setTimeout(resolve, 100));
readModel.set(event.orderId, {
id: event.orderId,
customerName: event.customerName,
total: event.total,
status: "created"
});
}
};
async function publish(event) {
const handler = eventHandlers[event.type];
if (!handler) {
throw new Error(`Невідомий тип події: ${event.type}`);
}
await handler(event);
}
async function createOrder({ customerName, total }) {
if (!customerName || total <= 0) {
throw new Error("Некоректні дані замовлення");
}
const orderId = crypto.randomUUID();
const order = {
id: orderId,
customerName,
total,
status: "created"
};
// Спочатку змінюємо write model
writeModel.set(orderId, order);
// У реальній системі подія зазвичай передається через чергу
void publish({
type: "OrderCreated",
orderId,
customerName,
total
});
return orderId;
}
function getOrderSummary(orderId) {
return readModel.get(orderId) ?? null;
}
async function main() {
const orderId = await createOrder({
customerName: "Олена Коваль",
total: 2499
});
console.log("Відповідь одразу після команди:");
console.log(getOrderSummary(orderId));
// null: read model ще не встигла обробити подію
await new Promise((resolve) => setTimeout(resolve, 150));
console.log("Відповідь після синхронізації:");
console.log(getOrderSummary(orderId));
}
main().catch((error) => {
console.error(error.message);
});Запустити приклад можна у Node.js, де доступний crypto.randomUUID().
Результат буде приблизно таким:
Відповідь одразу після команди:
null
Відповідь після синхронізації:
{
id: "...",
customerName: "Олена Коваль",
total: 2499,
status: "created"
}У цьому прикладі read model оновлюється не миттєво. Це називають eventual consistency, або узгодженістю зрештою.
Команда може в межах одного запиту:
змінити write model;
одразу оновити read model;
повернути відповідь клієнту.
Переваги:
простіше зрозуміти стан системи;
після успішної команди read model одразу актуальна;
менше затримки між записом і читанням.
Недоліки:
запис стає залежним від read model;
помилка оновлення read model може зірвати всю операцію;
складніше масштабувати частини системи незалежно.
Команда змінює write model і публікує подію. Read model оновлюється окремим обробником.
Переваги:
write і read частини можна масштабувати окремо;
повільне формування read model не обов’язково блокує команду;
складні проєкції можна обробляти у фоні.
Недоліки:
між записом і читанням виникає затримка;
подія може бути оброблена пізніше або повторно;
потрібно обробляти помилки та повторні спроби;
користувач може тимчасово побачити старі дані.
Вибір залежить від вимог. Для фінансового підтвердження платежу важлива надійність запису. Для аналітичного звіту затримка у кілька секунд або хвилин може бути прийнятною.
Коли існує одна модель, легко уявити стан даних:
Запис завершився → читання бачить новий станКоли моделей дві, потрібно відповісти на додаткові запитання:
Що станеться, якщо подія не доставлена?
Що станеться, якщо проєкція впала під час обробки?
Чи можна безпечно обробити подію повторно?
Як відновити read model?
Що побачить користувач одразу після команди?
Як визначити, що проєкція відстала?
Брокер повідомлень або механізм повторних спроб може доставити одну подію більше одного разу. Обробник повинен бути ідемпотентним: повторна обробка не повинна створювати неправильний результат.
Наприклад, небезпечно без перевірки виконувати:
total = total + event.amountЯкщо та сама подія прийде двічі, сума буде збільшена двічі.
Надійніший варіант — зберігати ідентифікатори вже оброблених подій або оновлювати стан на основі унікального ключа події.
Для деяких даних важливий порядок:
OrderCreated
PaymentConfirmed
OrderCancelledЯкщо PaymentConfirmed буде оброблено раніше за OrderCreated, проєкція може не знати, до якого замовлення належить платіж.
Система повинна або гарантувати потрібний порядок, або вміти:
тимчасово відкласти подію;
повторно обробити її пізніше;
відновити проєкцію з повної послідовності подій.
Read model часто можна видалити та побудувати знову, обробивши всі доступні події. Це одна з переваг проєкцій.
Але для цього потрібні:
збережені події або інше надійне джерело даних;
версії проєкцій;
контроль прогресу обробки;
процедура повторної побудови;
спосіб не показувати користувачам частково побудовану модель.
Якщо події ніде не зберігаються, а read model пошкоджена, її відновлення може вимагати спеціального імпорту з write model.
Типова помилка виглядає так:
1. Записати дані в основну базу.
2. Окремо записати подію в брокер.Між цими операціями може статися збій:
база вже змінилася;
подію не вдалося опублікувати;
read model ніколи не оновиться.
Для надійної доставки часто використовують transactional outbox:
зміну write model і подію записують в одну транзакцію бази;
окремий процес читає події з outbox;
процес публікує їх для проєкцій;
після успішної доставки подія позначається як оброблена.
Це не усуває всі проблеми, але прибирає розрив між зміною даних і збереженням події.
CQRS може бути корисним, якщо:
читання та запис мають суттєво різні моделі;
read-запити містять складні агрегації;
потрібно обслуговувати багато читань і небагато записів;
read model потрібно масштабувати окремо;
різні клієнти потребують різних представлень даних;
є природний потік подій між частинами системи;
затримка між записом і появою даних у read model прийнятна;
бізнес-правила запису складніші за структуру читання.
Наприклад, у системі замовлень write model може бути нормалізованою та захищати інваріанти, а read model — містити готові дані для списку замовлень, кабінету клієнта й адміністративної панелі.
CQRS часто буде зайвою складністю, якщо:
застосунок невеликий;
модель читання майже така сама, як модель запису;
достатньо звичайного CRUD;
немає проблем із продуктивністю запитів;
дані повинні бути строго узгодженими одразу після запису;
команда розробки не готова підтримувати асинхронну обробку;
немає зрозумілої потреби в окремому масштабуванні.
Простий CRUD-код може бути кращим за CQRS:
Запит → сервіс → одна модель → одна транзакціяНе варто вводити CQRS лише тому, що це популярний архітектурний підхід. Спочатку потрібно визначити конкретну проблему:
повільні запити;
надто складна модель для читання;
різні вимоги до масштабування;
потреба в окремих проєкціях;
незалежний розвиток команд і запитів.
Якщо проблеми немає, додаткові моделі, події та механізми повторної обробки лише збільшать вартість системи.
CQRS можна реалізувати в одному застосунку, одному процесі та навіть з однією базою даних. Мікросервіси не є обов’язковою частиною підходу.
Команди та запити можна розділити логічно, але оновлювати read model синхронно. Асинхронність — окреме рішення з власними компромісами.
Назва операції не гарантує її поведінку. Query handler не повинен створювати записи, змінювати статуси або запускати приховані побічні ефекти.
Якщо read model оновлюється асинхронно, клієнт може не побачити щойно створений запис. Це потрібно врахувати в API та інтерфейсі.
Повторна доставка події — нормальна ситуація для розподіленої системи. Обробник має безпечно переживати повтори.
Read model призначена для читання. Вона не повинна ставати головним джерелом істини або самостійно приймати рішення, які належать write model.
Перед впровадженням варто пройти короткий список запитань:
Чи справді моделі читання та запису суттєво відрізняються?
Які конкретні запити складно або дорого виконувати зараз?
Чи прийнятна eventual consistency?
Яке джерело істини буде використовуватися?
Як доставлятимуться події?
Як система оброблятиме повтори та помилки?
Як відновлюватиметься read model?
Чи виправдовує отримана користь додаткову інфраструктуру?
Якщо відповіді нечіткі, краще почати з логічного розділення команд і запитів у межах одного застосунку, без окремих сервісів та складної асинхронної інфраструктури.
CQRS розділяє відповідальність за читання та запис.
Command змінює стан і виконує бізнес-правила.
Query читає дані, не змінюючи стан.
Write model оптимізована для коректних змін.
Read model оптимізована для швидких і зручних запитів.
CQRS не вимагає мікросервісів або event sourcing.
Асинхронна синхронізація створює eventual consistency.
Потрібно враховувати повторну доставку, порядок подій, помилки та відновлення проєкцій.
Для надійної публікації подій може використовуватися transactional outbox.
CQRS корисний для систем із різними вимогами до читання та запису.
Для простого CRUD CQRS часто є непотрібною складністю.