Пошук уроків, статей та іншого контенту
Навчитеся розподіляти операції читання між репліками, працювати із затримкою реплікації та обробляти застарілі дані.
Read replica — це копія бази даних, яка отримує зміни з основного вузла та використовується переважно для операцій читання.
Типова схема має такий вигляд:
Primary приймає операції запису.
Read replicas отримують зміни від primary.
Запити на читання розподіляються між primary та репліками.
Якщо репліка недоступна або її дані занадто застарілі, запит перенаправляється на іншу репліку чи primary.
Це дає змогу:
збільшити кількість операцій читання;
зменшити навантаження на primary;
розмістити репліки ближче до користувачів;
підвищити доступність читання.
Read replicas не збільшують продуктивність запису автоматично. У більшості архітектур саме primary залишається вузьким місцем для операцій запису.
Найпоширеніший варіант — асинхронна реплікація:
Клієнт надсилає запис на primary.
Primary зберігає зміни.
Зміна потрапляє до журналу реплікації.
Репліка отримує цю зміну.
Репліка застосовує зміну до своєї локальної копії.
За синхронної реплікації primary очікує підтвердження від однієї або кількох реплік перед завершенням запису.
Переваги:
менша ймовірність втрати підтверджених даних;
репліка швидше узгоджується з primary.
Недоліки:
більша затримка запису;
проблеми з доступністю, якщо репліка повільна або недоступна.
За асинхронної реплікації primary не чекає, доки репліка застосує зміну.
Переваги:
швидші записи;
тимчасова недоступність репліки не обов’язково блокує primary.
Недоліки:
репліка може повертати застарілі дані;
у разі аварії primary частина останніх змін може ще не бути скопійована.
Read replicas найчастіше використовують саме з асинхронною реплікацією, тому затримка реплікації є нормальною властивістю системи, а не винятком.
Найпростіше правило маршрутизації:
INSERT, UPDATE, DELETE надсилати на primary;
SELECT надсилати на read replicas.
Однак одного поділу за SQL-командою недостатньо. Деякі операції читання потребують найсвіжіших даних.
Наприклад:
після створення замовлення користувач одразу відкриває його сторінку;
після зміни пароля користувач перевіряє налаштування;
після оплати потрібно показати актуальний статус платежу.
Якщо ці читання потраплять на репліку, яка ще не отримала останню зміну, користувач побачить старий стан.
Логіку вибору вузла можна винести в окремий компонент:
class DatabaseRouter {
constructor(primary, replicas) {
this.primary = primary;
this.replicas = replicas;
this.nextReplica = 0;
}
route(operation) {
if (operation.type === "write") {
return this.primary;
}
const availableReplicas = this.replicas.filter(
(replica) => replica.healthy && replica.lagMs <= operation.maxLagMs
);
if (availableReplicas.length === 0) {
return this.primary;
}
const replica = availableReplicas[this.nextReplica % availableReplicas.length];
this.nextReplica += 1;
return replica;
}
}
const router = new DatabaseRouter(
{ name: "primary" },
[
{ name: "replica-eu", healthy: true, lagMs: 80 },
{ name: "replica-us", healthy: true, lagMs: 120 },
{ name: "replica-asia", healthy: false, lagMs: 0 }
]
);
console.log(router.route({
type: "read",
maxLagMs: 200
}));
// { name: "replica-eu", healthy: true, lagMs: 80 }
console.log(router.route({
type: "read",
maxLagMs: 50
}));
// { name: "primary" }
console.log(router.route({
type: "write"
}));
// { name: "primary" }У реальному застосунку об’єкти вузлів будуть замінені підключеннями до бази даних або пулом підключень.
Маршрутизатор має враховувати:
доступність вузла;
поточну затримку реплікації;
кількість активних з’єднань;
географічне розташування;
тип запиту;
вимоги конкретного сценарію до свіжості даних.
Запити послідовно розподіляються між репліками:
replica-1 → replica-2 → replica-3 → replica-1Це просто реалізувати, але такий підхід не враховує:
різну продуктивність реплік;
поточне навантаження;
затримку реплікації;
кількість помилок.
Новий запит надсилається репліці з найменшою кількістю активних операцій.
Такий підхід краще працює, якщо репліки мають різні характеристики або запити суттєво відрізняються за тривалістю.
Користувача можна спрямувати до репліки в тому самому регіоні:
користувачів з Європи — до європейської репліки;
користувачів зі США — до репліки в США.
Це зменшує мережеву затримку, але не вирішує проблему застарілих даних. Локальна репліка все одно може відставати від primary.
Replication lag — це час або обсяг змін, на який репліка відстає від primary.
Залежно від системи затримку можуть вимірювати як:
час між появою зміни на primary та її застосуванням на репліці;
кількість байтів або записів, які ще не застосовані;
позицію репліки в журналі змін;
різницю між часовими мітками останньої застосованої зміни.
Наприклад:
replica-1: lag = 20 ms
replica-2: lag = 850 ms
replica-3: lag = 12 sРепліка із затримкою 12 секунд може бути неприйнятною для сторінки статусу платежу, але достатньою для звіту, який оновлюється раз на хвилину.
Причини можуть бути різними:
репліка має слабші ресурси, ніж primary;
на репліці виконуються довгі запити;
між вузлами проблеми з мережею;
потік змін на primary перевищує швидкість застосування змін;
репліка тимчасово перевантажена;
відбулася перебудова індексу або інша важка операція.
Затримка не повинна бути лише метрикою для моніторингу. Її потрібно враховувати під час маршрутизації запитів.
Для кожного типу читання можна визначити максимально допустиму затримку.
Наприклад:
стрічка новин: до 10 секунд;
каталог товарів: до 2 секунд;
статус платежу: 0 секунд або читання з primary;
історичний звіт: кілька хвилин.
У маршрутизаторі це можна представити параметром maxLagMs.
Якщо жодна репліка не відповідає вимогам, є кілька варіантів:
прочитати з primary;
повернути помилку;
повернути дані з позначкою, що вони можуть бути застарілими;
повторити запит пізніше.
Вибір залежить від бізнес-вимог. Для платіжного статусу краще прочитати з primary, ніж показати неправильний стан. Для некритичного списку можна використати старі дані.
Read-after-write означає, що після успішного запису наступне читання того самого користувача має побачити цей запис.
Розглянемо послідовність:
1. Запис на primary: створити повідомлення.
2. Читання на replica: отримати список повідомлень.Якщо репліка ще не застосувала запис, список не міститиме щойно створеного повідомлення.
Найпростіша стратегія:
виконати запис на primary;
протягом певного часу всі пов’язані читання спрямовувати на primary;
після завершення цього періоду повернутися до реплік.
Це називають sticky session або прив’язаністю читань до primary. Прив’язка може бути:
для конкретного HTTP-запиту;
для сесії користувача;
для короткого періоду після запису;
для конкретної сутності.
Недолік — збільшення навантаження на primary.
Інший підхід — після запису дочекатися, коли потрібна репліка застосує цю зміну, а потім читати з неї.
Цей варіант потребує механізму, який дає змогу порівняти:
позицію або версію запису на primary;
позицію або версію, до якої дійшла репліка.
Таку стратегію не слід реалізовувати простим setTimeout. Фіксована затримка не гарантує, що репліка вже синхронізувалася: іноді зміна застосовується за 10 мс, а іноді — за кілька секунд.
Застосунок може повертати клієнту версію або часову мітку стану. Під час наступного читання маршрутизатор обирає лише ті репліки, які вже досягли потрібної версії.
Це дає змогу точніше реалізувати read-after-write, ніж просте переключення всіх запитів на primary.
Наступний приклад можна запустити в Node.js. Він показує:
запис на primary;
асинхронне застосування запису реплікою;
читання застарілих даних;
читання з primary для сценарію, де потрібні актуальні дані.
class Primary {
constructor() {
this.users = new Map();
this.version = 0;
}
writeUser(id, name) {
this.version += 1;
const user = {
id,
name,
version: this.version
};
this.users.set(id, user);
return {
...user,
version: this.version
};
}
readUser(id) {
return this.users.get(id) ?? null;
}
}
class ReadReplica {
constructor(primary, lagMs) {
this.primary = primary;
this.lagMs = lagMs;
this.users = new Map();
this.appliedVersion = 0;
}
startReplication() {
this.timer = setInterval(() => {
if (this.appliedVersion >= this.primary.version) {
return;
}
setTimeout(() => {
for (const [id, user] of this.primary.users) {
if (user.version > this.appliedVersion) {
this.users.set(id, { ...user });
}
}
this.appliedVersion = this.primary.version;
}, this.lagMs);
}, 100);
}
stopReplication() {
clearInterval(this.timer);
}
readUser(id) {
return this.users.get(id) ?? null;
}
}
async function main() {
const primary = new Primary();
const replica = new ReadReplica(primary, 500);
replica.startReplication();
primary.writeUser("user-1", "Олена");
console.log("Одразу після запису:");
console.log("Primary:", primary.readUser("user-1"));
console.log("Replica:", replica.readUser("user-1"));
await new Promise((resolve) => setTimeout(resolve, 800));
console.log("\nПісля синхронізації репліки:");
console.log("Replica:", replica.readUser("user-1"));
replica.stopReplication();
}
main();Одразу після запису primary вже повертає користувача, а репліка може повернути null. Це очікувана поведінка для асинхронної реплікації.
Важливо: цей приклад лише демонструє принцип. У промисловій системі стан реплікації та вибір вузла контролюються самою системою бази даних або спеціалізованим маршрутизатором.
Застарілість даних потрібно розглядати на рівні бізнес-сценарію.
Старі дані часто прийнятні для:
публічної стрічки;
лічильників, які не є критичними;
пошукових результатів;
рекомендацій;
аналітичних сторінок;
кешованих каталогів.
Користувач може побачити зміни через кілька секунд, але система залишатиметься доступною та масштабованою.
Краще використовувати primary або механізм підтвердженої узгодженості для:
балансу рахунку;
доступного залишку товару під час оформлення;
статусу платежу;
прав доступу;
результату щойно виконаної команди;
перевірки унікальності перед записом.
Водночас читання з primary саме по собі не замінює транзакцію. Якщо перевірка та запис мають бути атомарними, вони повинні виконуватися в межах відповідного механізму узгодженості бази даних.
Не можна надсилати читання на репліку лише тому, що вона зареєстрована в конфігурації.
Система повинна перевіряти:
чи доступна репліка;
чи відповідає вона на прості запити;
чи не перевищила lag допустимий поріг;
чи не накопичується черга змін;
чи не повертає репліка помилки;
чи має вона достатньо ресурсів.
Якщо репліка не проходить перевірки, маршрутизатор тимчасово виключає її з пулу.
Після відновлення репліку потрібно повертати поступово. Наприклад, спочатку дозволити їй обробляти невелику частину запитів і перевірити помилки та затримку.
Якщо всі репліки перевантажені, автоматичне перенаправлення всіх читань на primary може погіршити ситуацію:
репліки перевантажуються;
запити переходять на primary;
primary також перевантажується;
починають зростати затримки та кількість помилок.
Тому необхідні:
обмеження кількості з’єднань;
тайм-аути;
контроль черги запитів;
обмеження частоти повторних спроб;
окрема політика для критичних і некритичних читань.
Особливо небезпечні безконтрольні retry. Якщо кожна помилка породжує кілька нових запитів, система може швидко перейти в режим каскадного перевантаження.
Для read replicas варто відстежувати:
затримку реплікації;
кількість запитів на кожну репліку;
час відповіді;
частоту помилок;
кількість активних з’єднань;
обсяг черги невиконаних змін;
кількість запитів, перенаправлених на primary.
Корисно також додавати до логів:
обраний вузол;
тип операції;
вимогу до максимальної затримки;
фактичний lag репліки;
причину fallback на primary.
Без цього складно визначити, чому primary раптово отримав більше читань або чому користувач побачив старі дані.
SELECT на реплікиНе кожне читання допускає застарілий результат. Критичні сценарії повинні мати окрему політику.
За асинхронної реплікації запис може бути доступним на primary, але відсутнім на репліці.
sleep після записуОчікування 100 або 500 мс не гарантує синхронізації. Затримка залежить від навантаження, мережі та стану репліки.
Репліка може бути доступною на мережевому рівні, але повертати дані, які застаріли на хвилини.
Якщо кожен невдалий запит повторюється на всіх вузлах, навантаження лише зростає.
Сценарій виду «прочитати з репліки, переконатися, що запису немає, а потім записати на primary» може бути некоректним через застарілі дані. Перевірка, яка впливає на запис, має виконуватися з потрібним рівнем узгодженості.
Для кожного типу читання заздалегідь визначте, що робити, якщо придатних реплік немає:
перейти на primary;
повернути помилку;
показати кешоване значення;
відкласти операцію.
Для кожного read-запиту можна виконувати такий алгоритм:
Визначити, чи потрібні найсвіжіші дані.
Якщо так — використати primary або репліку, яка підтвердила потрібну версію.
Якщо застарілість допустима — знайти здорову репліку в межах maxLag.
Вибрати репліку за навантаженням, регіоном або round-robin.
Виконати запит із тайм-аутом.
За помилки виключити репліку на короткий період.
За відсутності придатних реплік застосувати fallback-політику.
Записати результат маршрутизації та фактичний lag у метрики.
Read replica — це копія бази даних для масштабування операцій читання.
Записи зазвичай виконуються на primary, а читання розподіляються між репліками.
За асинхронної реплікації replica може тимчасово містити застарілі дані.
Маршрутизація повинна враховувати доступність, навантаження та replication lag.
Для read-after-write використовують читання з primary, sticky session або очікування підтвердженої реплікації.
Не всі читання мають однакові вимоги до свіжості даних.
Якщо репліки недоступні або занадто відстають, потрібна явна fallback-політика.
Моніторинг lag, помилок і перенаправлень на primary є обов’язковим для стабільної роботи системи.