Пошук уроків, статей та іншого контенту
Спроєктуємо distributed lock із lease, fencing tokens і контрольованим звільненням під час мережевих збоїв.
Розподілене блокування потрібне, коли кілька екземплярів сервісу можуть одночасно виконувати операцію, яка має бути серіалізованою:
обробляти одну й ту саму задачу;
оновлювати спільний ресурс;
запускати планувальник у кластері;
виконувати фонову міграцію;
працювати з обмеженим зовнішнім ресурсом.
У локальній програмі для цього достатньо mutex. У розподіленій системі процес може:
завершитися без звільнення блокування;
втратити мережеве з'єднання;
зависнути на невизначений час;
отримати затриману відповідь;
продовжити роботу після того, як інший процес уже отримав блокування.
Тому простого запису «ключ зайнятий» недостатньо.
Надійна схема зазвичай містить три механізми:
Lease — блокування автоматично спливає через певний час.
Fencing token — кожне нове отримання блокування отримує монотонно зростаючий номер.
Контрольоване звільнення — звільнити блокування може лише його поточний власник.
Lease — це блокування, дійсне до певного моменту часу.
Наприклад, клієнт отримує lease на 10 секунд:
resource = "invoice:42"
owner = "worker-a"
expiresAt = 12:00:10Поки lease дійсне, worker-a може виконувати операцію. Якщо він зникне, через 10 секунд інший клієнт зможе отримати блокування.
Без lease аварійний процес може залишити блокування назавжди. Lease обмежує час, протягом якого система може залишатися заблокованою після збою.
Водночас lease має важливе обмеження: клієнт може думати, що lease ще дійсне, хоча сервер блокувань уже вважає його простроченим.
Наприклад:
worker-a отримав lease на 10 секунд.
Через затримку або зупинку процесу він не встиг продовжити lease.
Сервер вважає lease простроченим.
worker-b отримує нове блокування.
worker-a продовжує виконання старої операції.
Саме тому одного lease недостатньо.
Fencing token — це монотонно зростаюче число, яке видається під час кожного успішного отримання блокування.
Послідовність може виглядати так:
worker-a отримав lock → token = 41
worker-a lease сплив
worker-b отримав lock → token = 42Кожен запис до захищеного ресурсу має містити token. Сам ресурс приймає операцію лише тоді, коли її token новіший за всі попередні.
запис із token = 42 → прийнято
запис із token = 41 → відхиленоТак worker-b захищений від запізнілого worker-a.
Fencing token працює лише тоді, коли захищений ресурс сам перевіряє token.
Недостатньо видати token клієнту, але не передати його в базу даних, чергу або API ресурсу. Якщо ресурс приймає будь-який запис без перевірки token, старий власник усе ще може зіпсувати дані.
Перевірка повинна виконуватися якомога ближче до фактичного запису та бути атомарною із цим записом.
Для ключа блокування зручно зберігати:
lock key
current owner
current fencing token
lease expiration time
next tokenЛогічно це може мати такий вигляд:
{
"owner": "worker-a",
"token": 41,
"expiresAt": 1720000010000
}next token або лічильник token-ів має бути довговічним. Після перезапуску сервіс блокувань він не повинен почати видавати старі номери.
Наприклад, якщо раніше було видано token 42, після відновлення не можна знову видати 1 або 42.
Операція acquire повинна бути атомарною.
Алгоритм:
Прочитати стан блокування.
Якщо активний lease належить іншому власнику — відмовити.
Якщо lease відсутній або прострочений:
збільшити глобальний лічильник token-ів;
записати нового власника;
записати новий час завершення lease;
повернути token.
Усі ці дії мають відбутися як одна атомарна операція.
Псевдокод:
acquire(key, owner, ttl):
now = authoritative_time()
atomically:
lock = read(key)
if lock exists and lock.expiresAt > now and lock.owner != owner:
return NOT_ACQUIRED
token = increment_persistent_counter(key)
write(key, owner, token, now + ttl)
return { token, expiresAt: now + ttl }Якщо два клієнти одночасно викликають acquire, лише один із них має отримати результат ACQUIRED.
Є два поширені варіанти:
кожен повторний acquire створює новий token;
операція є ідемпотентною для пари key + owner.
Для простішої моделі кожне нове отримання створює новий token. Якщо клієнт не впевнений, чи попередній запит був успішним, безпечніше не використовувати старий результат без підтвердження від сервісу блокувань.
Власник повинен періодично викликати renew.
Операція продовження має перевіряти:
ключ блокування;
ідентифікатор власника;
fencing token;
те, що lease ще не сплив.
Псевдокод:
renew(key, owner, token, ttl):
now = authoritative_time()
atomically:
lock = read(key)
if lock.owner != owner:
return NOT_RENEWED
if lock.token != token:
return NOT_RENEWED
if lock.expiresAt <= now:
return NOT_RENEWED
lock.expiresAt = now + ttl
write(lock)
return RENEWEDЯкщо renew не вдався, клієнт повинен негайно припинити роботу із захищеним ресурсом.
Не можна продовжувати операцію лише тому, що локальний таймер ще не сплив.
Зазвичай lease продовжують раніше за його завершення, наприклад посередині або ближче до початку строку дії.
Якщо lease триває 30 секунд, клієнт може надсилати renew кожні 10 секунд. Це не гарантує успіху, але залишає час на повторну спробу.
Клієнт має враховувати:
затримку мережі;
час обробки запиту;
паузи збирача сміття;
призупинення віртуальної машини;
перевантаження сервісу блокувань.
Отримання lock не означає, що клієнт може безумовно виконувати операцію.
Клієнт має зберігати стан:
lockState = ACTIVE
lockState = LOST
lockState = RELEASEDУсі небезпечні операції повинні бути можливими лише у стані ACTIVE.
Якщо renew повернув помилку, настав timeout або клієнт отримав ознаки втрати з'єднання, безпечна поведінка така:
Позначити lock як втрачений.
Припинити нові записи до ресурсу.
Не намагатися «дозавершити» критичну операцію старим token.
За можливості виконати умовне звільнення.
Дочекатися повторного отримання нового lock для продовження роботи.
Це особливо важливо для довгих операцій. Якщо операція не може бути перервана, кожен її запис усе одно має передавати fencing token.
Звільнення не повинно бути операцією:
delete lock by keyТакий виклик небезпечний. Сценарій:
worker-a втратив мережу.
Його lease сплив.
worker-b отримав новий lease.
worker-a відновив мережу.
worker-a виконав delete lock by key.
Блокування worker-b було видалено.
Звільнення має бути умовним:
release(key, owner, token)Сервіс видаляє блокування лише якщо поточні owner і token збігаються з переданими.
Псевдокод:
release(key, owner, token):
atomically:
lock = read(key)
if lock.owner != owner:
return NOT_RELEASED
if lock.token != token:
return NOT_RELEASED
delete(key)
return RELEASEDЯкщо відповідь на release втрачена, клієнт не знає, чи звільнення відбулося. У такій ситуації повторний виклик має бути безпечним:
якщо lock усе ще належить цьому owner і token, він буде звільнений;
якщо lock уже належить іншому власнику, операція нічого не змінить;
якщо lock уже відсутній, результат можна трактувати як успішне завершення очищення.
Розглянемо типову послідовність:
1. worker-a отримує token = 10.
2. worker-a втрачає зв'язок із сервісом lock.
3. lease worker-a спливає.
4. worker-b отримує token = 11.
5. worker-a продовжує стару операцію.
6. worker-a надсилає запис із token = 10.
7. Ресурс відхиляє запис, бо останній token уже 11.Це і є призначення fencing.
Неправильна стратегія:
«Якщо клієнт уже отримав lock, він може завершити операцію, навіть якщо зв'язок із lock-сервісом втрачено».
Правильна стратегія:
Клієнт може виконувати записи лише з актуальним lease, а ресурс додатково відхиляє застарілі fencing token-и.
Lease відповідає на питання:
Чи може цей клієнт продовжувати вважати себе власником?
Fencing відповідає на питання:
Чи можна прийняти конкретний запис порівняно з усіма попередніми власниками?
Нижче наведено runnable-приклад на Node.js без зовнішніх бібліотек. Він демонструє:
lease із часом завершення;
монотонні fencing token-и;
умовне звільнення;
відхилення запізнілого запису старого власника.
class LockStore {
constructor() {
this.locks = new Map();
this.counters = new Map();
}
acquire(key, owner, ttlMs, now) {
const current = this.locks.get(key);
const isActive = current && current.expiresAt > now;
if (isActive && current.owner !== owner) {
return null;
}
const token = (this.counters.get(key) ?? 0) + 1;
this.counters.set(key, token);
const lock = {
owner,
token,
expiresAt: now + ttlMs
};
this.locks.set(key, lock);
return { ...lock };
}
renew(key, owner, token, ttlMs, now) {
const current = this.locks.get(key);
if (!current) {
return false;
}
if (current.owner !== owner || current.token !== token) {
return false;
}
if (current.expiresAt <= now) {
return false;
}
current.expiresAt = now + ttlMs;
return true;
}
release(key, owner, token) {
const current = this.locks.get(key);
if (!current) {
return false;
}
// Видаляємо lock лише за точним збігом власника і token.
if (current.owner !== owner || current.token !== token) {
return false;
}
this.locks.delete(key);
return true;
}
}
class FencedResource {
constructor() {
this.lastToken = 0;
this.value = null;
}
write(token, value) {
if (token <= this.lastToken) {
return {
accepted: false,
reason: `застарілий token ${token}, останній token ${this.lastToken}`
};
}
this.lastToken = token;
this.value = value;
return {
accepted: true,
value: this.value,
token: this.lastToken
};
}
}
const locks = new LockStore();
const resource = new FencedResource();
const key = "report:2026-09-02";
const ttlMs = 100;
let now = 0;
const workerA = locks.acquire(key, "worker-a", ttlMs, now);
console.log("worker-a отримав:", workerA);
console.log(
"Запис worker-a:",
resource.write(workerA.token, "дані від worker-a")
);
// Час минув, lease worker-a більше не дійсний.
now = 150;
const workerB = locks.acquire(key, "worker-b", ttlMs, now);
console.log("worker-b отримав:", workerB);
console.log(
"Запис worker-b:",
resource.write(workerB.token, "дані від worker-b")
);
// worker-a мав затриману операцію і намагається записати старі дані.
console.log(
"Запізнілий запис worker-a:",
resource.write(workerA.token, "старі дані від worker-a")
);
// worker-a не може видалити lock, який уже належить worker-b.
console.log(
"Спроба worker-a звільнити lock:",
locks.release(key, workerA.owner, workerA.token)
);
console.log(
"Поточне значення ресурсу:",
resource.value
);Очікуваний результат показує, що:
worker-b отримує більший token;
його запис приймається;
запізнілий запис worker-a відхиляється;
старий власник не може звільнити нове блокування.
У реальній системі LockStore має бути замінений на сховище з атомарними операціями та довговічним зберіганням стану. FencedResource має відповідати реальній базі даних або іншому ресурсу, який атомарно перевіряє token під час запису.
Сервіс, який реалізує lock, повинен гарантувати:
Перевірка lease, збільшення token і запис нового власника не можуть розділятися на незалежні запити.
Інакше два клієнти можуть одночасно побачити вільний lock і обидва вважати, що отримали його.
Для одного ключа має існувати однозначний порядок операцій:
acquire A
acquire B
renew A
release BКлієнти не повинні отримувати суперечливі версії стану.
Fencing token не можна скидати після перезапуску. Якщо сервіс блокувань використовує реплікацію або failover, новий лідер має продовжити видачу token-ів без повторів і зменшення значення.
Рішення про завершення lease має приймати сервіс блокувань або узгоджене сховище. Локальний час клієнта не повинен бути єдиним джерелом істини.
Клієнт може використовувати локальний час для планування renew, але не для доведення того, що lease гарантовано ще активний.
Lock лише координує клієнтів. Він не зупиняє процес, який:
був призупинений;
втратив мережу;
отримав затриману відповідь;
продовжив виконання після завершення lease.
Захищений ресурс має перевіряти fencing token.
TTL автоматично звільняє lock, але не забороняє старому власнику продовжити роботу. Для цього потрібен fencing.
release(key) може видалити lock нового власника. Звільнення повинно перевіряти ідентифікатор власника та token.
Після негативної відповіді renew клієнт не має права вважати себе власником. Повторна спроба може бути виконана лише як нове отримання lock із новим token.
Якщо кожен екземпляр генерує token самостійно, значення можуть повторюватися або порушувати порядок. Лічильник має бути спільним, атомарним і довговічним.
Небезпечна схема:
прочитати lastToken
якщо token новіший — виконати запис
оновити lastTokenДва запити можуть пройти перевірку одночасно. Перевірка token і запис результату мають бути однією атомарною операцією.
Якщо клієнт отримав timeout, запит міг бути виконаний на сервері. Не можна автоматично вважати lock невиданим або звільненим. Повторні операції мають бути умовними та безпечними.
Перед використанням дизайну потрібно перевірити щонайменше такі сценарії:
Два клієнти одночасно отримують один lock.
Власник завершується без release.
renew викликається після завершення lease.
Відповідь на acquire втрачається.
Відповідь на release втрачається.
Старий власник надсилає запис після появи нового token.
Старий власник намагається звільнити lock нового власника.
Сервіс блокувань перезапускається.
Лічильник token-ів відновлюється після відмови.
Запити приходять із різними затримками та в іншому порядку.
Для кожного сценарію має бути зрозуміло:
хто вважається власником;
який token є актуальним;
чи дозволений запис;
чи безпечно повторити операцію;
як система відновлює роботу.
Розподілене блокування з lease, fencing tokens і контрольованим звільненням працює за такою схемою:
Клієнт атомарно отримує lock і fencing token.
Lease має обмежений час дії.
Клієнт регулярно продовжує lease.
Кожен запис до ресурсу містить fencing token.
Ресурс приймає лише token, новіший за попередні.
Після втрати lease клієнт припиняє критичну роботу.
release перевіряє і власника, і token.
Лічильник token-ів зберігається довговічно та не зменшується.
Ключова гарантія полягає не в тому, що старий клієнт фізично зупиниться. Він може продовжити виконання. Гарантія полягає в тому, що його застарілі операції більше не будуть прийняті захищеним ресурсом.