Пошук уроків, статей та іншого контенту
Проєктуємо незалежні сервіси, їхню взаємодію та оцінюємо вартість мікросервісів для різних команд і систем.
Мікросервісна архітектура — це підхід, у якому система складається з невеликих автономних сервісів. Кожен сервіс:
відповідає за окрему бізнес-можливість;
має чіткий API;
може розгортатися незалежно від інших;
володіє власними даними або чітко визначеною частиною даних;
може масштабуватися окремо.
Наприклад, інтернет-магазин можна поділити на такі сервіси:
Catalog — товари та ціни;
Orders — замовлення;
Payments — платежі;
Inventory — залишки товарів;
Notifications — електронні листи та повідомлення.
Мікросервіс — це не просто окремий процес або окрема папка в репозиторії. Його межі мають відповідати відповідальності в предметній області.
У монолітній архітектурі всі частини системи зазвичай працюють як один застосунок:
Клієнт → Моноліт → Одна база данихУ мікросервісній архітектурі компоненти розділені:
Клієнт → API Gateway
├── Orders → база замовлень
├── Payments → база платежів
└── Inventory → база залишківМоноліт часто простіший на початку проєкту:
один процес для запуску;
один pipeline розгортання;
прості виклики функцій замість мережевих запитів;
прості транзакції в одній базі;
менше інфраструктури;
простіше локальне налагодження.
Для невеликої команди моноліт може бути найкращим рішенням. Він не є «поганою» архітектурою сам по собі.
Мікросервіси можуть бути корисними, коли:
різні частини системи мають різне навантаження;
окремі компоненти потрібно розгортати незалежно;
над продуктом працюють кілька автономних команд;
частини системи мають різні вимоги до технологій або масштабування;
помилка в одній бізнес-можливості не повинна зупиняти всю систему.
Однак ці переваги з’являються лише тоді, коли команда здатна керувати розподіленою системою.
Правильний поділ починається не з технічних шарів, а з бізнес-відповідальностей.
Поганий поділ:
UserController
UserService
UserRepositoryЦе може бути лише поділ одного застосунку на технічні шари, а не поділ на мікросервіси.
Інший сумнівний варіант:
DatabaseService
EmailService
LoggingServiceЯкщо сервіс не має самостійної бізнес-відповідальності, його виділення може лише ускладнити систему.
Краще спочатку визначити основні бізнес-можливості:
Замовлення
Оплата
Склад
Доставка
СповіщенняДля кожної можливості варто відповісти на такі питання:
Яку бізнес-відповідальність має цей компонент?
Які дані він створює та змінює?
Які правила предметної області він захищає?
Чи може він змінюватися незалежно від інших частин?
Чи потрібне йому окреме масштабування?
Яка команда буде його підтримувати?
Хороша межа сервісу зазвичай має такі властивості:
зміни в одному сервісі рідко вимагають змін в інших;
сервіс має невеликий, зрозумілий API;
його дані не змінюються напряму іншими сервісами;
сервіс може бути розгорнутий і перевірений незалежно;
команда розуміє його відповідальність.
Межі не обов’язково мають бути ідеальними з першого дня. Їх можна змінювати, але надмірний обмін даними між сервісами часто свідчить про неправильний поділ.
Одна з ключових ідей мікросервісів — сервіс володіє своїми даними.
Наприклад:
Orders володіє замовленнями;
Payments володіє платіжними операціями;
Inventory володіє залишками.
Інші сервіси не повинні напряму змінювати таблиці Inventory. Замість цього вони звертаються до API Inventory або надсилають події.
Кілька сервісів можуть використовувати одну базу, але це створює сильне зв’язування:
зміни схеми впливають на кілька сервісів;
сервіси можуть напряму читати чужі таблиці;
складно визначити відповідального за дані;
незалежне розгортання стає умовним.
У такій ситуації система може називатися мікросервісною лише формально.
Окремі бази не означають, що кожен сервіс обов’язково має використовувати іншу СУБД. Важливіше те, що доступ до даних контролює власник сервісу.
Сервіси взаємодіють через контракти. Найпоширеніші варіанти:
синхронний HTTP-запит;
RPC-виклик;
асинхронне повідомлення;
подія в брокері повідомлень.
Під час синхронної взаємодії один сервіс чекає на відповідь іншого:
Orders → Inventory: зарезервувати товар
Inventory → Orders: товар зарезервованоПереваги:
простий для розуміння сценарій;
результат доступний одразу;
легко побудувати звичайний запит-відповідь.
Недоліки:
недоступність залежного сервісу впливає на викликача;
додається мережева затримка;
потрібно обробляти тайм-аути та повторні спроби;
ланцюжок викликів може стати довгим.
Асинхронний сервіс надсилає повідомлення і не чекає негайної відповіді:
Orders → подія OrderCreated → NotificationsПереваги:
сервіси слабше залежать один від одного в часі;
тимчасова недоступність споживача не завжди блокує операцію;
зручно обробляти фонові задачі.
Недоліки:
результат з’являється не одразу;
складніше відстежувати стан операції;
можливі дублікати повідомлень;
потрібні правила повторної обробки та помилок.
Асинхронні повідомлення не усувають складність, а переносять її в обробку станів, повторів і узгодженості.
Нижче наведено мінімальний приклад із двома HTTP-сервісами:
inventory зберігає залишок товару;
orders приймає замовлення та синхронно просить inventory зарезервувати товар.
Приклад використовує лише вбудовані можливості Node.js.
const http = require("node:http");
const role = process.env.ROLE || "orders";
const port = Number(process.env.PORT || (role === "inventory" ? 3001 : 3000));
const inventoryUrl = process.env.INVENTORY_URL || "http://localhost:3001";
let stock = 10;
const orders = [];
function sendJson(response, statusCode, body) {
response.writeHead(statusCode, {
"Content-Type": "application/json; charset=utf-8",
});
response.end(JSON.stringify(body));
}
function readJson(request) {
return new Promise((resolve, reject) => {
let data = "";
request.on("data", (chunk) => {
data += chunk;
if (data.length > 1_000_000) {
reject(new Error("Занадто великий запит"));
request.destroy();
}
});
request.on("end", () => {
try {
resolve(data ? JSON.parse(data) : {});
} catch {
reject(new Error("Некоректний JSON"));
}
});
request.on("error", reject);
});
}
async function reserveProduct(productId, quantity) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 2000);
try {
const response = await fetch(`${inventoryUrl}/reserve`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ productId, quantity }),
signal: controller.signal,
});
const body = await response.json();
if (!response.ok) {
throw new Error(body.error || "Не вдалося зарезервувати товар");
}
return body;
} finally {
clearTimeout(timeout);
}
}
const server = http.createServer(async (request, response) => {
try {
if (role === "inventory" && request.method === "POST" && request.url === "/reserve") {
const { productId, quantity } = await readJson(request);
if (!productId || !Number.isInteger(quantity) || quantity <= 0) {
return sendJson(response, 400, {
error: "productId і додатна ціла quantity є обов'язковими",
});
}
if (quantity > stock) {
return sendJson(response, 409, {
error: "Недостатньо товару на складі",
available: stock,
});
}
stock -= quantity;
return sendJson(response, 200, {
productId,
reserved: quantity,
remaining: stock,
});
}
if (role === "orders" && request.method === "POST" && request.url === "/orders") {
const { productId, quantity } = await readJson(request);
if (!productId || !Number.isInteger(quantity) || quantity <= 0) {
return sendJson(response, 400, {
error: "productId і додатна ціла quantity є обов'язковими",
});
}
let reservation;
try {
reservation = await reserveProduct(productId, quantity);
} catch (error) {
return sendJson(response, 503, {
error: "Сервіс складу тимчасово недоступний",
details: error.name === "AbortError" ? "Тайм-аут" : error.message,
});
}
const order = {
id: String(orders.length + 1),
productId,
quantity,
status: "created",
reservation,
};
orders.push(order);
return sendJson(response, 201, order);
}
if (request.method === "GET" && request.url === "/health") {
return sendJson(response, 200, {
service: role,
status: "ok",
});
}
sendJson(response, 404, { error: "Маршрут не знайдено" });
} catch (error) {
sendJson(response, 500, {
error: error.message || "Внутрішня помилка",
});
}
});
server.listen(port, () => {
console.log(`${role} service is running on port ${port}`);
});Для запуску потрібен Node.js 18 або новіший, оскільки приклад використовує вбудований fetch.
В одному терміналі запускаємо сервіс складу:
ROLE=inventory PORT=3001 node service.jsВ іншому — сервіс замовлень:
ROLE=orders PORT=3000 INVENTORY_URL=http://localhost:3001 node service.jsСтворюємо замовлення:
curl -X POST http://localhost:3000/orders \
-H "Content-Type: application/json" \
-d '{"productId":"keyboard-1","quantity":2}'Сервіс orders не змінює залишок самостійно. Він просить inventory виконати операцію через API. Це ілюструє володіння даними та явний контракт між сервісами.
У реальній системі додатково потрібні:
постійне збереження даних;
автентифікація між сервісами;
ідемпотентність операцій;
структуровані журнали;
метрики та трасування;
продумана політика повторних спроб.
Мережева взаємодія відрізняється від виклику функції в одному процесі. Запит може:
не дійти до сервісу;
виконатися, але відповідь загубиться;
завершитися із затримкою;
повернути помилку;
бути виконаним повторно через retry.
Кожен синхронний виклик має мати обмежений час очікування. Без тайм-ауту зайняті з’єднання можуть накопичуватися, а сервіс — перестати відповідати на нові запити.
Повторювати запит безпечно лише тоді, коли операція ідемпотентна або має ключ ідемпотентності.
Наприклад, повторне створення замовлення може створити два замовлення. Для таких операцій використовують унікальний ідентифікатор запиту:
Idempotency-Key: request-123Сервіс зберігає результат першої обробки та повертає його під час повторного запиту з тим самим ключем.
Не слід безконтрольно повторювати запити. Це може:
збільшити навантаження;
повторно списати гроші;
створити дублікати;
посилити вже наявний збій.
У розподіленій системі один сервіс може бути доступним, а інший — ні. Тому потрібно визначати:
чи можна прийняти операцію частково;
чи потрібно повернути помилку користувачу;
чи можна завершити дію пізніше;
як відновити незавершену операцію.
Наприклад, замовлення можна створити зі статусом payment_pending, а оплату завершити окремо. Це відрізняється від спроби виконати всі кроки однією розподіленою транзакцією.
У моноліті кілька змін можна виконати в одній транзакції бази даних. Між мікросервісами такої простої транзакції зазвичай немає.
Наприклад:
Orders створив замовлення.
Payments не зміг провести оплату.
Inventory уже зарезервував товар.
Система повинна мати правила компенсації:
скасувати резерв товару;
позначити замовлення як payment_failed;
повторити платіж;
повідомити користувача.
Важливо явно описати життєвий цикл операції:
created → inventory_reserved → payment_pending → paid
└──────→ payment_failedТаку модель іноді реалізують через події та окремі обробники. Головне — не припускати, що всі сервіси змінять свої дані одночасно.
API сервісу — це контракт із його споживачами. Контракт включає:
маршрут або назву повідомлення;
формат запиту;
формат відповіді;
можливі коди помилок;
правила авторизації;
гарантії щодо повторів;
правила сумісності версій.
Зміни контракту мають бути сумісними із клієнтами. Безпечніша послідовність:
додати нове поле або новий формат;
оновити споживачів;
переконатися, що старий формат більше не використовується;
лише після цього прибрати стару поведінку.
Видалення або перейменування поля без узгодження може зламати кілька незалежних сервісів одночасно.
Мікросервіси мають не лише технічні переваги, а й постійну операційну вартість.
Кожен сервіс потребує:
процесу запуску;
конфігурації;
журналів;
метрик;
перевірки працездатності;
розгортання;
резервного копіювання власних даних;
моніторингу помилок.
П’ятдесят сервісів означають більше компонентів, які потрібно оновлювати, захищати та підтримувати.
Мережевий виклик складніший за виклик функції:
він повільніший;
може завершитися помилкою;
потребує серіалізації даних;
має проблеми з тайм-аутами;
вимагає логування кореляції запитів.
Довгі ланцюжки викликів збільшують затримку та кількість можливих точок відмови.
Мікросервіси ефективніші для команд, які можуть володіти сервісами автономно. Команда повинна вміти:
самостійно розгортати сервіс;
підтримувати його в production;
аналізувати інциденти;
працювати з контрактами;
контролювати міграції даних;
розуміти розподілені помилки.
Для маленької команди з кількох розробників десятки сервісів можуть створити більше роботи, ніж користі.
Якщо одна бізнес-зміна потребує одночасної модифікації багатьох сервісів, незалежність не працює. У такому випадку команда отримує:
кілька pull request;
послідовні розгортання;
складніше тестування;
ризик несумісних версій;
більше часу на координацію.
Мікросервіси виправдані не кількістю сервісів, а зменшенням вартості незалежних змін і масштабування.
Мікросервісна архітектура може бути доречною, якщо:
система має чітко відокремлені бізнес-можливості;
частини системи мають різні профілі навантаження;
команди можуть автономно володіти сервісами;
є потреба в незалежних релізах;
організація готова підтримувати спостережуваність та інфраструктуру;
команда розуміє наслідки мережевих помилок і часткової узгодженості.
Моноліт часто є кращим вибором, якщо:
продукт ще перевіряє бізнес-ідею;
команда невелика;
межі предметної області не зрозумілі;
немає потреби в окремому масштабуванні;
система має багато транзакцій між компонентами;
команда не має досвіду експлуатації розподілених систем.
Практичний підхід — почати з модульного моноліту. У ньому бізнес-модулі мають чіткі межі та API всередині одного застосунку. Коли з’являється реальна причина для розділення, окремий модуль можна винести в сервіс.
Під час проєктування мікросервісної системи корисно рухатися в такій послідовності:
Визначити бізнес-можливості.
Описати відповідальність кожного компонента.
Визначити власника кожного набору даних.
Вибрати синхронну або асинхронну взаємодію для кожного сценарію.
Описати тайм-аути, повтори та часткові помилки.
Визначити контракти та правила їх зміни.
Оцінити кількість команд, сервісів і операційної роботи.
Перевірити, чи справді незалежне розгортання дає користь.
Почати з найпростішого поділу, який розв’язує актуальну проблему.
Виділення окремих сервісів для контролерів, репозиторіїв або моделей не створює бізнес-незалежності. Такі сервіси зазвичай постійно залежать один від одного.
Якщо один сервіс напряму читає таблиці іншого, схема бази стає неофіційним контрактом. Зміни в таблицях можуть зламати споживачів без видимої зміни API.
Малі сервіси не завжди кращі. Якщо кожен простий сценарій потребує десятків мережевих викликів, система стає складною для тестування та налагодження.
Очікування відповіді без обмеження часу може заблокувати ресурси сервісу та спричинити каскадний збій.
Повторне виконання платежу, створення замовлення або списання товару може мати побічні ефекти. Для таких операцій потрібні ідемпотентність і контроль повторів.
Після зміни даних в одному сервісі інший сервіс може побачити результат не одразу. Це потрібно враховувати в моделях стану та інтерфейсі користувача.
Якщо немає журналів, метрик, трасування та зрозумілого процесу розгортання, пошук проблем у мікросервісах стає значно складнішим.
Мікросервіс — це автономний компонент із чіткою бізнес-відповідальністю та API.
Межі сервісів слід визначати за бізнес-можливостями, а не за технічними шарами.
Кожен сервіс має контролювати власні дані.
Синхронна взаємодія проста, але створює залежність у часі та потребує тайм-аутів.
Асинхронна взаємодія зменшує часову залежність, але ускладнює обробку станів і повторів.
У розподіленій системі потрібно враховувати часткові помилки та відсутність спільної транзакції.
Мікросервіси мають інфраструктурну, операційну та командну вартість.
Для невеликої команди або системи з нечіткими межами модульний моноліт часто є практичнішим.
Мікросервіси варто вводити тоді, коли незалежне масштабування, розгортання або володіння компонентами компенсує додаткову складність.