<? phpukraine СПІВБЕСІДИ
Пошук по платформі
SQL · SENIOR

Як забезпечити узгодженість при паралельних записах: блокування, версії, обмеження?

Три рівні захисту: песимістичний замок SELECT FOR UPDATE серіалізує доступ до рядка, колонка version із перевіркою кількості оновлених рядків ловить конфлікт без замка, а унікальне обмеження в базі тримає інваріант навіть тоді, коли обидва механізми обійшли.

Два запити одночасно списують гроші з одного рахунку. Що станеться і як це зупинити?
Чим SELECT FOR UPDATE відрізняється від колонки version і що коли обирати?
Ми робимо SELECT, перевіряємо що email вільний, і вставляємо. Навіщо ще унікальний індекс?
Користувач двічі натиснув «Оплатити» і створилось два платежі. Де саме дірка?
блокування транзакції конкурентність

Класична дірка виглядає невинно: код читає баланс, віднімає в PHP сто гривень і записує результат. Дві паралельні транзакції прочитали 500, обидві порахували 400, обидві записали 400 замість очікуваних 300. Це lost update, і жоден рівень ізоляції з тих, які реально вмикають у продакшені, його не помічає: з погляду бази це два законні UPDATE з константою в правому боці. PostgreSQL на READ COMMITTED пропустить обидва, MySQL на REPEATABLE READ теж, бо другий UPDATE після зняття замка просто перезапише рядок своїм значенням. База не знає, що ваше 400 було похідним від прочитаного 500, поки ви їй про це не скажете.

Найдешевший спосіб сказати це - перенести обчислення в сам запит: UPDATE wallets SET balance = balance - 100 WHERE user_id = 42 AND balance >= 100. Читання й запис відбуваються всередині однієї операції під внутрішнім замком рядка, а умова в WHERE робить перевірку достатності коштів частиною тієї ж атомарної дії. Відповіддю стає кількість оновлених рядків: нуль означає, що грошей не вистачило. Там, де логіка зводиться до арифметики над однією колонкою, решта механізмів просто зайва.

Коли між читанням і записом мусить бути код, беруть SELECT ... FOR UPDATE. Замок на рядок ставиться в момент SELECT і тримається до COMMIT або ROLLBACK, паралельні транзакції зупиняються на тому ж SELECT і продовжують уже з актуальним значенням. Тут ховається деталь, яку легко проґавити: на READ COMMITTED PostgreSQL після зняття чужого замка перечитує рядок і повторно перевіряє умову WHERE (механізм EvalPlanQual), тож рядок, який перестав відповідати умові, з результату зникне, і код має бути готовий отримати порожню вибірку там, де щойно щось було. Ціна песимістичного підходу вимірюється тривалістю транзакції: FOR UPDATE плюс виклик платіжного шлюзу всередині того самого BEGIN означає, що всі інші клієнти чекають рівно стільки, скільки відповідає зовнішній сервіс. Пом'якшують це NOWAIT (впасти одразу) і SKIP LOCKED (пропустити зайняте), обидва доступні в PostgreSQL і в MySQL 8.0.

Оптимістичне блокування закриває іншу задачу: конфлікт рідкісний, а пауза між читанням і записом велика, бо в ній сидить людина з відкритою формою. Тримати транзакцію відкритою всі ці хвилини неможливо, тому в таблицю додають version, віддають її клієнтові разом із даними й повертають назад у WHERE: UPDATE ... SET version = version + 1 WHERE id = ? AND version = ?. Якщо оновлено нуль рядків, хтось випередив, і застосунок вирішує, що робити: перечитати й повторити автоматично для машинних операцій або показати користувачеві конфлікт для ручного редагування. Механізм повністю тримається на перевірці affected rows; без неї він перетворюється на декорацію, яка мовчки нічого не робить. Той самий принцип працює і без окремої колонки, якщо в WHERE підставити старе значення поля, що змінюється, але version надійніша, бо не залежить від того, скільки полів у формі.

Унікальні обмеження стоять окремо від обох підходів, бо захищають від іншого класу помилок. Блокування й версії працюють, поки всі пишуть через ваш код і в правильному порядку; обмеження працює завжди - при імпорті CSV, при ручному запиті в консолі, при другому сервісі, який дописали через рік. Перевірка «спочатку SELECT, чи вільний email, потім INSERT» лишає вікно в мілісекунди, і FOR UPDATE його не закриває, бо блокувати неіснуючий рядок нема як. Тому унікальний індекс ставлять завжди, а помилку 23505 у PostgreSQL чи 1062 у MySQL ловлять і перетворюють на нормальну бізнес-відповідь замість п'ятисотки. PostgreSQL дає ще два інструменти, яких немає в MySQL: частковий унікальний індекс з WHERE deleted_at IS NULL для м'яко видалених записів і exclusion constraint для заборони перетину інтервалів. У MySQL ті самі інваріанти або зводяться до звичайного UNIQUE над дискретними слотами, або лишаються на совісті застосунку.

-- 0. Найдешевше: уся логіка в одному UPDATE, читання й запис атомарні
UPDATE wallets SET balance = balance - 100
WHERE user_id = 42 AND balance >= 100;   -- 0 рядків = не вистачило коштів

-- 1. Песимістично: замок на рядок живе до COMMIT, решта чекає на SELECT
BEGIN;
SELECT balance FROM wallets WHERE user_id = 42 FOR UPDATE;
-- тут можна рахувати складну логіку, але не ходити в зовнішні API
UPDATE wallets SET balance = :new_balance WHERE user_id = 42;
COMMIT;

-- не стояти в черзі: помилка одразу або пропустити зайняті рядки
SELECT id FROM wallets WHERE user_id = 42 FOR UPDATE NOWAIT;
SELECT id FROM jobs WHERE status = 'queued'
ORDER BY id LIMIT 10 FOR UPDATE SKIP LOCKED;   -- PostgreSQL 9.5+, MySQL 8.0+

-- 2. Оптимістично: версія в WHERE, рішення за кількістю оновлених рядків
UPDATE documents
SET title = :title, version = version + 1
WHERE id = 7 AND version = :version_the_user_saw;
-- 0 рядків = документ змінили, поки була відкрита форма: перечитати й показати конфлікт

-- 3. Обмеження: остання лінія, тримає навіть коли логіку обійшли
ALTER TABLE bookings ADD CONSTRAINT bookings_slot_unique UNIQUE (room_id, slot_start);

-- часткова унікальність і перетини діапазонів: лише PostgreSQL
CREATE UNIQUE INDEX users_email_active ON users (lower(email)) WHERE deleted_at IS NULL;
ALTER TABLE bookings ADD CONSTRAINT bookings_no_overlap
    EXCLUDE USING gist (room_id WITH =, during WITH &&);   -- потрібне btree_gist

-- ідемпотентна вставка замість «спочатку перевір, чи вже є»
INSERT INTO bookings (room_id, slot_start, user_id)
VALUES (5, '2026-03-01 10:00', 42)
ON CONFLICT (room_id, slot_start) DO NOTHING;   -- MySQL: ON DUPLICATE KEY UPDATE
Механіку lost update: два процеси читають одне значення, обидва пишуть похідне від нього, друга транзакція мовчки затирає першу, і READ COMMITTED цього не бачить.
Що FOR UPDATE тримає замок до COMMIT, тож усередині транзакції не можна ходити в платіжний шлюз чи інший HTTP.
Що оптимістичне блокування працює лише тоді, коли код перевіряє кількість оновлених рядків і має стратегію на нуль.
Що FOR UPDATE не захищає від вставки: заблокувати можна тільки той рядок, який уже існує.
Що unique-обмеження це інваріант рівня схеми, а 23505 у PostgreSQL і 1062 у MySQL треба перетворювати на бізнес-відповідь, а не на 500.
Схема «SELECT, якщо немає то INSERT» без унікального індексу: між двома запитами інша транзакція вставить те саме значення.
Читати баланс у PHP, віднімати там і писати результат, замість UPDATE ... SET balance = balance - 100 WHERE balance >= 100.
Додати колонку version, оновлювати її, але не дивитись на affected rows: UPDATE не змінює жодного рядка, а код рапортує успіх.
Сподіватись, що REPEATABLE READ сам по собі рятує: у MySQL read-modify-write через застосунок усе одно дає lost update, а PostgreSQL у SERIALIZABLE кидає 40001 і вимагає повтору.
Тримати FOR UPDATE на рядку користувача, поки чекаємо відповідь зовнішнього API: замок живе стільки, скільки триває запит.
Ставити LOCK TABLES або блокувати надто широкий діапазон замість замка на конкретний рядок.
ПОРАДА

Назвіть три рівні одним рухом: атомарний UPDATE з умовою там, де достатньо арифметики; version там, де між читанням і записом сидить людина з відкритою формою; унікальний індекс завжди, бо це єдине, що переживе баг у коді, імпорт даних і другий сервіс, який пише в ту саму таблицю.

оновлено 6 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Оптимістичне блокування без перевірки affected rows нічого не гарантує, а FOR UPDATE не може заблокувати рядок, якого ще немає, тому унікальне обмеження лишається окремим рівнем захисту.