Пошук уроків, статей та іншого контенту
Два підходи до захисту від одночасного редагування одного й того самого рядка кількома користувачами.
Уявімо, що два користувачі одночасно відкрили один запис:
поточний баланс рахунку;
картку товару;
профіль клієнта;
документ у системі редагування.
Обидва бачать однакове значення, наприклад 100. Перший користувач змінює його на 120, а другий — на 80. Якщо застосунок просто збереже обидві зміни, результат залежатиме від того, який запит виконається останнім. Одна зі змін буде втрачена.
Цю проблему називають lost update — втрачена зміна.
Блокування допомагає узгодити конкурентний доступ до даних. Найчастіше використовують два підходи:
песимістичне блокування — заздалегідь припускаємо, що конфлікт станеться, і блокуємо рядок;
оптимістичне блокування — припускаємо, що конфлікти рідкісні, і перевіряємо перед збереженням, чи не змінив запис хтось інший.
Песимістичний підхід виходить із припущення:
Поки ми працюємо із записом, інша транзакція може спробувати змінити його.
Тому транзакція встановлює блокування на рядок і не дозволяє іншим транзакціям змінювати його до завершення поточної операції.
У SQL для цього часто використовують SELECT ... FOR UPDATE.
Нехай є таблиця рахунків:
CREATE TABLE accounts (
id BIGINT PRIMARY KEY,
balance NUMERIC(12, 2) NOT NULL
);Операція списання коштів може виглядати так:
BEGIN;
-- Блокуємо рядок до завершення транзакції
SELECT balance
FROM accounts
WHERE id = 1
FOR UPDATE;
-- Виконуємо перевірку та зміну балансу
UPDATE accounts
SET balance = balance - 20
WHERE id = 1
AND balance >= 20;
COMMIT;Поки транзакція не завершиться, інша транзакція, яка також намагається взяти цей рядок через FOR UPDATE, чекатиме.
Важливо, що SELECT ... FOR UPDATE має виконуватися в межах транзакції. Якщо виконати SELECT і одразу завершити транзакцію, блокування зникне ще до зміни даних.
Нехай дві транзакції одночасно намагаються змінити один рахунок:
Транзакція A виконує SELECT ... FOR UPDATE.
Рядок блокується для змін іншими транзакціями.
Транзакція B виконує такий самий запит і переходить у стан очікування.
Транзакція A змінює баланс і виконує COMMIT.
Транзакція B отримує блокування, читає актуальний стан і продовжує роботу.
Таким чином, зміни виконуються послідовно, а не одночасно.
Його варто розглянути, коли:
конфлікти трапляються часто;
операція має бути атомарною;
запис не можна змінювати паралельно;
очікування іншої транзакції прийнятніше за повторне виконання операції;
обробка даних коротка й передбачувана.
Типові приклади:
списання коштів;
резервування останнього товару;
зміна залишку на складі;
розподіл обмеженого ресурсу;
послідовна обробка черги.
Блокування не є безкоштовним. Воно може призвести до таких проблем:
інші транзакції змушені чекати;
збільшується час виконання запитів;
виникають deadlock — взаємні блокування;
довгі транзакції зменшують пропускну здатність системи;
помилки в роботі з транзакціями можуть залишати ресурси заблокованими довше, ніж очікувалося.
Тому не варто брати блокування, а потім виконувати всередині транзакції довгі HTTP-запити, викликати сторонні сервіси або чекати введення користувача.
Оптимістичний підхід виходить із протилежного припущення:
Конфлікти трапляються рідко, тому не потрібно блокувати запис під час читання.
Замість цього до рядка додають поле, яке змінюється під час кожного оновлення. Найчастіше це:
числова версія version;
час останньої зміни updated_at;
спеціальний токен або хеш стану.
Перед збереженням застосунок перевіряє, що запис не змінився після його читання.
Таблиця може мати такий вигляд:
CREATE TABLE documents (
id BIGINT PRIMARY KEY,
content TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1
);Спочатку застосунок читає документ:
SELECT id, content, version
FROM documents
WHERE id = 1;Нехай отримано:
id = 1
content = "Початковий текст"
version = 3Користувач редагує текст, після чого застосунок виконує оновлення:
UPDATE documents
SET content = 'Оновлений текст',
version = version + 1
WHERE id = 1
AND version = 3;Тепер потрібно перевірити кількість змінених рядків:
1 — версія все ще дорівнювала 3, оновлення успішне;
0 — хтось уже змінив документ, тому версія більше не дорівнює 3.
У разі нульової кількості змінених рядків застосунок не повинен мовчки повідомляти про успіх. Він може:
показати повідомлення про конфлікт;
запропонувати завантажити актуальну версію;
об’єднати зміни;
попросити користувача повторити редагування.
WHEREНебезпечний варіант:
SELECT version
FROM documents
WHERE id = 1;
UPDATE documents
SET content = 'Оновлений текст',
version = version + 1
WHERE id = 1;Між SELECT і UPDATE інша транзакція може змінити документ. Якщо UPDATE не перевіряє стару версію, він перезапише чужі зміни.
Безпечніший варіант об'єднує перевірку та зміну в одному SQL-операторі:
UPDATE documents
SET content = 'Оновлений текст',
version = version + 1
WHERE id = 1
AND version = 3;Перевірка версії та оновлення відбуваються атомарно на рівні бази даних.
Замість числової версії можна використовувати поле на кшталт updated_at:
UPDATE documents
SET content = 'Оновлений текст',
updated_at = CURRENT_TIMESTAMP
WHERE id = 1
AND updated_at = '2026-07-29 10:00:00';Цей варіант може працювати, але числова версія зазвичай надійніша:
точність часу залежить від бази даних і драйвера;
різні системи можуть по-різному округлювати часові значення;
час на сервері застосунку та в базі даних може відрізнятися;
інколи поле дати змінюється не під час кожної модифікації.
Якщо використовується updated_at, значення для перевірки потрібно отримувати та порівнювати консистентно.
Песимістичне блокування:
блокує запис до завершення транзакції;
виявляє конфлікт до виконання зміни;
змушує конкурентні операції чекати;
добре підходить для коротких критичних секцій;
може спричинити deadlock і черги очікування.
Оптимістичне блокування:
не блокує запис під час читання;
виявляє конфлікт під час спроби збереження;
не змушує користувачів чекати одне одного на рівні блокування;
добре підходить для рідкісних конфліктів і тривалого редагування;
вимагає сценарію повторної обробки конфлікту.
Поставте собі кілька запитань.
Якщо один і той самий рядок часто змінюють паралельно, оптимістичний підхід може призводити до великої кількості відмов і повторних спроб. У такій ситуації песимістичне блокування може бути ефективнішим.
Якщо конфлікти рідкісні, оптимістичний підхід не створює зайвого очікування.
Для короткої транзакції песимістичне блокування зазвичай прийнятне:
отримати рядок;
перевірити умови;
змінити рядок;
зафіксувати транзакцію.
Для редагування документа користувачем блокувати рядок на весь час відкритої форми не варто. Користувач може залишити сторінку відкритою на кілька годин. Тут краще підходить оптимістичне блокування.
Якщо конфлікт означає, що операцію можна просто повторити, оптимістичний підхід зручний.
Якщо повторення може двічі списати кошти або двічі зарезервувати товар, потрібна дуже обережна транзакційна логіка. Часто в таких сценаріях використовують песимістичне блокування або атомарні умовні оновлення.
FOR UPDATEІноді для простої операції не потрібно окремо читати рядок і блокувати його. Умовне оновлення може виконати перевірку та зміну атомарно:
UPDATE products
SET stock = stock - 1
WHERE id = 10
AND stock > 0;Після виконання перевіряють кількість змінених рядків:
1 — товар успішно зарезервовано;
0 — товару немає або запис із таким ідентифікатором не знайдено.
Це не повний аналог усіх сценаріїв із SELECT ... FOR UPDATE, але для простих інкрементів, декрементів і перевірок часто є хорошим рішенням.
Якщо операція складається з кількох залежних кроків, потрібна транзакція. Наприклад, резервування товару може також створювати запис про замовлення та журналювати подію.
Блокування не існує окремо від транзакцій і рівня ізоляції. База даних визначає, які зміни одна транзакція може бачити та коли блокування повинні чекати.
Навіть якщо застосунок використовує оптимістичне блокування, потрібно правильно оформити сам UPDATE. А якщо використовується песимістичний підхід, важливо знати поведінку конкретної СУБД щодо:
блокування рядків;
блокування індексів або діапазонів;
читання незакомічених даних;
тайм-аутів;
взаємних блокувань.
Назви та деталі можуть відрізнятися між PostgreSQL, MySQL, SQL Server та іншими системами, тому критичний код варто перевіряти на реальній СУБД, а не лише на тестовій абстракції ORM.
З боку застосунку алгоритм має такий вигляд:
Прочитати запис разом із його версією.
Дозволити користувачу або бізнес-логіці змінити дані.
Надіслати UPDATE із перевіркою старої версії.
Перевірити кількість змінених рядків.
Якщо змінено один рядок — операція успішна.
Якщо змінено нуль рядків — повідомити про конфлікт.
Не перезаписувати дані мовчки.
Наприклад, у псевдокоді:
const document = await repository.findById(id);
// Користувач редагує документ поза межами транзакції
const result = await repository.updateIfVersionMatches({
id,
content: newContent,
expectedVersion: document.version
});
if (result.affectedRows === 0) {
throw new ConflictError('Документ уже змінив інший користувач');
}Назви методів у цьому прикладі умовні: конкретна реалізація залежить від драйвера або ORM.
Для песимістичного підходу операція зазвичай виглядає так:
Почати транзакцію.
Прочитати потрібний рядок із блокуванням.
Перевірити бізнес-умови.
Змінити дані.
Зафіксувати транзакцію.
У разі помилки виконати відкат.
Критично важливо, щоб усі операції, залежні від заблокованого значення, перебували в тій самій транзакції.
UPDATEПри оптимістичному блокуванні не можна вважати операцію успішною лише тому, що SQL-запит завершився без помилки.
Потрібно перевіряти кількість змінених рядків. Нуль змінених рядків — це сигнал про конфлікт або відсутність запису.
Окремий SELECT для перевірки версії, а потім безумовний UPDATE створює race condition. Інша транзакція може змінити запис між цими запитами.
Версію потрібно перевіряти в умові самого UPDATE або використовувати блокування в межах транзакції.
Не слід залишати транзакцію відкритою, поки користувач заповнює форму. Це створює довгі блокування, збільшує кількість тайм-аутів і може призвести до недоступності частини системи.
Для такого сценарію використовуйте оптимістичне блокування.
Запит із умовою на ідентифікаторі та версії має виконуватися ефективно. Первинний ключ зазвичай уже індексований, але структуру запиту та план виконання все одно варто перевіряти.
Повторення операції може бути корисним, але нескінченні повторні спроби приховують проблему та створюють додаткове навантаження.
Для retry потрібні:
обмежена кількість спроб;
затримка між спробами;
обробка конфлікту;
гарантія ідемпотентності операції.
Якщо одна транзакція спочатку блокує рядок A, а потім B, а інша — спочатку B, а потім A, можливий deadlock.
Щоб зменшити ризик, усі частини системи повинні блокувати ресурси в однаковому порядку. Також застосунок має вміти коректно повторити транзакцію після помилки deadlock, якщо це безпечно.
Блокування не замінює PRIMARY KEY, UNIQUE, CHECK та інші обмеження.
Якщо правило має бути гарантоване на рівні даних, його слід закріпити в базі даних. Наприклад, унікальність електронної адреси не повинна залежати лише від перевірки в коді застосунку.
У складній системі можуть використовуватися обидва підходи.
Наприклад:
для редагування профілю — оптимістична перевірка версії;
для списання коштів — транзакція з песимістичним блокуванням;
для зменшення залишку — атомарний UPDATE ... WHERE stock > 0;
для унікальних значень — обмеження UNIQUE;
для довгих бізнес-процесів — оптимістичне блокування та окремий процес повторної обробки.
Вибір роблять не для всієї системи одразу, а для конкретної операції та її вимог до узгодженості.
Песимістичне блокування блокує рядок до завершення транзакції та змушує конкурентні операції чекати.
Оптимістичне блокування не блокує рядок під час читання, а перевіряє версію під час оновлення.
Для оптимістичного підходу потрібно перевіряти кількість змінених рядків.
SELECT ... FOR UPDATE має використовуватися в межах транзакції.
Довгі операції з участю користувача не повинні утримувати блокування.
Для простих змін часто достатньо атомарного умовного UPDATE.
Песимістичний підхід доречний за частих конфліктів і коротких критичних операцій.
Оптимістичний підхід добре підходить для рідкісних конфліктів і тривалого редагування.
Незалежно від підходу, потрібно обробляти deadlock, тайм-аути та помилки повторного виконання.