Пошук уроків, статей та іншого контенту
Синхронізуйте роботу кількох екземплярів застосунку за допомогою розподілених lock-механізмів.
Якщо застосунок запущено в одному процесі, для синхронізації можна використати локальні механізми Node.js, наприклад змінну в пам’яті або Mutex. Але після запуску кількох екземплярів кожен процес має власну пам’ять:
Екземпляр A ── локальна пам’ять A
Екземпляр B ── локальна пам’ять B
Екземпляр C ── локальна пам’ять CТому локальне блокування не захищає спільний ресурс. Наприклад, два екземпляри можуть одночасно:
сформувати один і той самий звіт;
виконати періодичну фонову задачу;
обробити одну й ту саму чергу;
змінити спільний запис;
запустити міграцію або очищення даних.
Розподілене блокування — це блокування, стан якого зберігається у спільному зовнішньому сховищі, доступному всім екземплярам застосунку.
Потрібно, щоб операція захоплення блокування була:
атомарною;
обмеженою часом;
безпечною щодо звільнення чужого блокування;
стійкою до завершення або зависання процесу.
Нехай усі екземпляри домовилися використовувати ключ:
locks:daily-reportЕкземпляр, який першим створить цей ключ, отримує право виконувати критичну секцію. Інші екземпляри мають зачекати або пропустити роботу.
Блокування зазвичай містить:
ключ — ім’я спільного ресурсу;
унікальний токен власника — випадкове значення конкретного захоплення;
термін дії — час, після якого блокування автоматично стає недійсним.
Токен потрібен для захисту від такої ситуації:
Екземпляр A отримав блокування.
Термін його дії завершився.
Екземпляр B отримав нове блокування з тим самим ключем.
Екземпляр A завершив роботу й намагається звільнити блокування.
Без токена екземпляр A міг би видалити блокування, яке вже належить B.
Redis підтримує атомарну операцію встановлення ключа лише за умови, що його ще не існує:
SET key value NX PX millisecondsПараметри:
NX — встановити ключ лише тоді, коли його ще немає;
PX — встановити термін дії ключа в мілісекундах.
Наприклад:
SET locks:daily-report 8f...a2 NX PX 15000Якщо команда повернула успішну відповідь, блокування отримано. Якщо ключ уже існує, Redis повертає відсутність результату, і блокування не отримано.
Комбінація встановлення значення та терміну дії має бути однією атомарною операцією. Не слід спочатку створювати ключ, а потім окремою командою встановлювати TTL: між цими операціями процес може завершитися, залишивши блокування назавжди.
SETNX окремоСтарий підхід виглядає приблизно так:
SETNX lock-key token
EXPIRE lock-key 15Він небезпечний, якщо процес завершиться між SETNX і EXPIRE. Ключ залишиться без терміну дії, і жоден екземпляр не зможе отримати блокування.
Одна команда SET ... NX PX ... усуває цю проблему.
Не можна безумовно виконувати:
DEL locks:daily-reportЕкземпляр міг втратити блокування через завершення TTL, а ключ уже міг бути захоплений іншим екземпляром.
Перевірка та видалення мають виконуватися атомарно:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
end
return 0Скрипт видаляє ключ лише тоді, коли його значення збігається з токеном поточного власника.
Окремі команди GET, а потім DEL не гарантують безпеку. Між ними інший екземпляр може отримати блокування.
Для прикладу використаємо пакет redis для Node.js і Redis, запущений на localhost:6379.
Встановлення залежності:
npm install redisСтворіть файл worker.mjs:
import { createClient } from 'redis';
import { randomUUID } from 'node:crypto';
const redis = createClient({
url: process.env.REDIS_URL ?? 'redis://localhost:6379',
});
redis.on('error', (error) => {
console.error('Помилка Redis:', error);
});
await redis.connect();
const RELEASE_SCRIPT = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
end
return 0
`;
const EXTEND_SCRIPT = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("pexpire", KEYS[1], ARGV[2])
end
return 0
`;
class DistributedLock {
constructor(redisClient, key, ttlMs = 15_000) {
this.redis = redisClient;
this.key = key;
this.ttlMs = ttlMs;
}
async acquire({ waitMs = 5_000, retryDelayMs = 200 } = {}) {
const token = randomUUID();
const deadline = Date.now() + waitMs;
do {
const result = await this.redis.set(this.key, token, {
NX: true,
PX: this.ttlMs,
});
if (result === 'OK') {
return token;
}
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) {
return null;
}
const delay = Math.min(retryDelayMs, remainingMs);
await new Promise((resolve) => setTimeout(resolve, delay));
} while (Date.now() < deadline);
return null;
}
async extend(token) {
const result = await this.redis.eval(EXTEND_SCRIPT, {
keys: [this.key],
arguments: [token, String(this.ttlMs)],
});
return Number(result) === 1;
}
async release(token) {
const result = await this.redis.eval(RELEASE_SCRIPT, {
keys: [this.key],
arguments: [token],
});
return Number(result) === 1;
}
}
const workerId = randomUUID().slice(0, 8);
const lock = new DistributedLock(redis, 'locks:daily-report', 15_000);
async function runJob() {
const token = await lock.acquire({
waitMs: 3_000,
retryDelayMs: 250,
});
if (!token) {
console.log(`[${workerId}] Блокування зайняте, роботу пропущено`);
return;
}
console.log(`[${workerId}] Блокування отримано`);
try {
console.log(`[${workerId}] Формування звіту розпочато`);
// Критична секція: одночасно її виконує лише один екземпляр.
await new Promise((resolve) => setTimeout(resolve, 3_000));
console.log(`[${workerId}] Формування звіту завершено`);
} finally {
const released = await lock.release(token);
if (released) {
console.log(`[${workerId}] Блокування звільнено`);
} else {
console.warn(
`[${workerId}] Блокування вже не належить цьому екземпляру`,
);
}
}
}
try {
await runJob();
} finally {
await redis.quit();
}Запустіть кілька копій програми одночасно:
node worker.mjs
node worker.mjs
node worker.mjsЛише один процес отримає блокування. Інші або дочекаються його звільнення протягом визначеного часу, або пропустять роботу.
Термін дії блокування називають lease — орендою. Він потрібен, щоб блокування звільнилося автоматично, якщо власник:
аварійно завершився;
був убитий контейнерною платформою;
втратив мережеве з’єднання;
завис надовго;
зупинився через помилку середовища виконання.
TTL має бути довшим за нормальну тривалість критичної секції. Якщо робота зазвичай триває 3 секунди, TTL у 15 секунд дає запас для коротких затримок.
Водночас TTL не можна вважати доказом, що старий процес припинив виконання. Процес може надовго зупинитися, наприклад через паузу збирача сміття, а потім продовжити роботу вже після завершення TTL.
У такому випадку можливе перекриття:
A отримав блокування.
A призупинився довше за TTL.
B отримав те саме блокування.
A продовжив виконувати критичну секцію.
Токен захищає від видалення блокування B, але не захищає сам спільний ресурс від застарілих операцій A.
Для довгих операцій блокування потрібно періодично продовжувати. Продовження також має перевіряти токен власника атомарно:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("pexpire", KEYS[1], ARGV[2])
end
return 0Метод extend() у прикладі виконує саме цю операцію.
Продовжувати блокування слід істотно раніше завершення TTL. Наприклад, для TTL 15 секунд можна виконувати продовження кожні 5 секунд. Якщо extend() повернув false, екземпляр утратив блокування і повинен:
припинити критичну операцію, якщо це можливо;
не записувати результат як власник ресурсу;
не намагатися безумовно звільнити ключ.
Важливо не продовжувати блокування безмежно. Якщо операція зависла назавжди, оренда має зрештою завершитися або має спрацювати зовнішній механізм скасування.
Розподілене блокування не робить операцію автоматично одноразовою.
Наприклад, процес:
отримав блокування;
записав результат у зовнішню систему;
завершився до звільнення блокування;
після завершення TTL інший процес повторив операцію.
Тому критична операція має бути також ідемпотентною, коли це можливо. Типові підходи:
використовувати унікальний ідентифікатор операції;
створювати унікальний індекс для логічного ключа;
перевіряти, чи результат уже записано;
зберігати статус операції;
повторний запуск робити безпечним.
Блокування зменшує кількість одночасних виконань, але не замінює перевірку стану даних.
Коли критична секція працює з ресурсом, який може приймати застарілі команди, одного випадкового токена блокування недостатньо.
Наприклад:
A отримав блокування і fencing token 41.
A зупинився.
TTL завершився.
B отримав блокування і fencing token 42.
B записав нові дані.
A продовжив роботу й надіслав старий запис.
Зовнішній ресурс має приймати операцію лише тоді, коли її fencing token не менший за вже побачений:
прийняти запис із токеном 42
відхилити пізніший запис із токеном 41Fencing token — це монотонно зростаючий номер кожного успішного захоплення. Його потрібно перевіряти саме на стороні захищеного ресурсу. Просте порівняння випадкових значень або наявність TTL такого захисту не дають.
Код, який використовує розподілене блокування, має враховувати помилки:
Redis може бути тимчасово недоступним;
операція захоплення може завершитися помилкою мережі;
процес може втратити блокування під час роботи;
release() може не виконатися через аварійне завершення;
затримки можуть перевищити TTL.
Практична стратегія:
Захоплювати блокування з обмеженим часом очікування.
Не робити нескінченні повторні спроби без паузи.
Використовувати випадкову затримку, якщо багато екземплярів змагаються за один ключ.
Завжди звільняти блокування у finally.
Ігнорувати результат release() лише тоді, коли зрозуміло, що процес уже не власник.
Після втрати блокування припиняти або безпечно завершувати критичну секцію.
Робити саму операцію ідемпотентною.
Ключ блокування має описувати саме ресурс, який не можна обробляти паралельно:
locks:daily-report
locks:user:123:balance
locks:tenant:acme:billing
locks:reindex:productsНе варто використовувати один глобальний ключ для всього застосунку. Це перетворить незалежні операції на одну чергу й істотно зменшить пропускну здатність.
Водночас надто вузький ключ може не захистити потрібний ресурс. Якщо баланс змінюється для конкретного користувача, ключ на рівні всього процесу буде недостатньо точним, а ключ на рівні користувача дасть змогу паралельно обробляти різних користувачів.
Один Redis-сервер є спільною точкою координації. Якщо він недоступний, екземпляри не зможуть надійно захоплювати або звільняти блокування.
Реплікація Redis сама по собі не робить блокування магічно безпечним: реплікація зазвичай асинхронна, тому після відмови первинного сервера стан блокування може бути відтворено неповністю.
Для критичних сценаріїв потрібно окремо визначити:
що робити, якщо координаційне сховище недоступне;
чи можна продовжувати роботу без блокування;
як уникнути одночасного доступу після перемикання;
який рівень втрати доступності прийнятний;
чи потрібен алгоритм блокування на кількох незалежних вузлах.
У простих випадках Redis із правильною атомарною логікою достатній. Для операцій, де помилка блокування може призвести до значних фінансових або фізичних наслідків, слід оцінювати протокол координації та гарантії всього сховища, а не лише API клієнта.
await redis.del(key);Так можна видалити блокування іншого екземпляра. Звільнення повинно порівнювати токен і видаляти ключ атомарно.
Блокування без терміну дії залишиться після аварійного завершення процесу. Інші екземпляри можуть назавжди втратити доступ до ресурсу.
Якщо критична секція триває довше за TTL, одночасно можуть працювати два власники. Для довгих операцій потрібне продовження lease або інша архітектура.
Послідовність SETNX та EXPIRE може залишити ключ без TTL. Використовуйте одну атомарну операцію SET із NX та PX.
Усі процеси не повинні використовувати однакове значення на кшталт locked. Кожне захоплення має отримувати новий непередбачуваний токен.
Блокування не замінює транзакції, унікальні обмеження, ідемпотентність або fencing token там, де вони потрібні.
Якщо кожен екземпляр безперервно повторює спробу захоплення, збій або довга операція створюють зайве навантаження. Встановлюйте дедлайн очікування й визначайте поведінку при його досягненні.
Розподілене блокування координує кілька екземплярів застосунку через спільне сховище.
Для Redis базова атомарна операція має вигляд SET key token NX PX ttl.
Блокування повинно мати унікальний токен власника та TTL.
Звільняти ключ потрібно атомарним скриптом із перевіркою токена.
TTL є орендою і не гарантує, що старий процес припинив виконання.
Довгі операції потребують продовження lease і перевірки втрати блокування.
Критична операція має бути ідемпотентною, а для захисту від застарілих записів може знадобитися fencing token.
Redis є частиною протоколу координації, тому його доступність і модель відмов потрібно враховувати окремо.